反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里 。

背景

在自媒体的时代,音视频播放 俨然已成为内容类型 APP 最基础的能力,对于 Android 开发者而言,无论是 Google 开源的 ExoPlayer ,还是 Bilibili 开源的 ijkplayer , 都是构建应用音视频播放能力时优秀的选择。

虽然上述的三方播放器都自带完善的缓存功能,但对于内容和形式都日新月异的一众互联网产品来说,想要打造完美契合自家产品的用户体验,播放器自身的缓存机制已逐渐无法满足需求。

最具代表性的产品是 抖音。 在播放短视频内容时,保证浏览、上下切换时 无缝链接 般丝滑的用户体验,可以说是重中之重的性能要求,而这对于传统的播放器缓存机制而言是远远满足不了要求的。

因此,构建自定义音视频缓存机制势在必行

本文将针对 Android 音视频缓存解决方案中,先驱级别的 AndroidVideoCache 库进行深入的剖析,读者不应关注具体的代码细节,而应关注整体的设计思想及流程实现。

文章整体结构如下:

现有方案对比

1、文件整体缓存

这是最简单的缓存方案,即 先下再播 ,只有文件完整下载完毕,才能进行播放。

严格来说,在某些特殊的业务场景,这种方案简单、粗暴但却 有效 ,比如微信聊天中的小视频,经过限制时长以及高效的压缩算法,视频整体被压缩的非常小,对于 4G 早已覆盖的用户群体而言,加载、播放视频整个流程中几乎不受影响。

在其他下载成本略高的场景,诸如短视频、播放音乐,这种方案就不太适合,更不论电视剧、电影这类更重的应用场景了。

2、基于源码修改

第二个方案,是基于播放器源码进行修改,优势在于简洁直接,能够以较低开发成本满足业务需求,但缺点有二:

首先,当播放器为三方SDK时,源码修改成本变高,同时,当需要替换播放器SDK时,需针对缓存机制重新开发。

其次,缓存机制不可避免会涉及实际业务,这会提升业务层与底层播放能力之间的耦合。

3、音视频缓存代理方案

由此可见,我们更期望能够在 业务层播放器层 之间搭建一个中间商,由这个新的角色 代理 与业务层的通信,然后 分发执行 播放器层的音视频缓存任务。

4、更多优势

从长远来看,针对现有的架构设计,额外定义一个 缓存层 是很有必要的,这意味着容易实现和扩展 更多细节性的需求;比如同时针对多个音视频进行缓存、针对不同优先级缓存任务速度的限制策略等等,对于月活千万或上亿的头部应用而言,这些都是保证极致用户体验的必要实现。

整体设计

如何构建音视频缓存的代理?首先,我们需要了解 APP 音视频的常规缓存模式及其弊端:

如图所示,常规方式中,缓存相关逻辑是由播放器本身提供的,开发者仅需将视频地址的url交给播放器,播放器会自动进行加载播放。

这种方式下,想要自定义缓存就必须深入播放器源码,对于开源播放器而言,虽然源码上手成本较高,但至少是可针对源码进行定制,但对于部分非开源的播放器而言,开发者几乎无法直接触达内部缓存机制。


我们更希望,无论播放器本身是否开源,都能够在 不涉及理解和修改播放器源码 的情况下,完全控制音视频的缓存机制——我们先将这个中间角色称为 CacheService,整体流程如下:

如图所示,播放器层的播放和加载流程都委托给 CacheService,后者内部实现了包括文件下载、文件读写等一系列的相关逻辑,最终转交给播放器进行播放。

需要注意的是,由于 CacheService 完全是由我们自己定义的,因此我们也可以监听到音视频文件的整个缓存流程,并直接回调通知给最上层的 APP(上图中onCacheStart()等回调),且整个过程完全是 响应式 的。

读者应该理解,之所以 直接回调通知最上层 ,是有这个前提——我们希望整个缓存流程都不会涉及到播放器层的改动,比如 Android 系统的 MediaPlayer,我们无法也不应该修改它。

具体实现

1、逻辑冲突

设计的伊始谈到,为了保证解耦, 我们希望缓存机制 不能修改播放器源码 ,但 MediaPlayer 如何在不改源码的情况下,将自身的缓存加载逻辑交给我们的 CacheService 呢?

如下述代码中所展示的,这种实现似乎无法避免:

public class MyMediaPlayer extends MediaPlayer {public final CacheService mProxy;@Overridepublic void setDataSource(String url) {// super.setDataSource(url);mProxy.setDataSource(url);}
}

必须承认,这也是一种与播放器的耦合,不能修改播放器源码 的设定似乎并不符合常理。

这里体现出了作者本身优秀的创造力,通过创建一个设备的本地代理服务 CacheService,在将视频资源的url交给播放器之前,先进行本地的一次转换,并将初始的url作为参数,拼接在本地代理的url上:

1.建立本地代理:比如 http://127.0.0.1:8090
2.拿到要缓存的视频地址,比如 https://xxx.mp4
3.拼接为新的地址:http://127.0.0.1:8090/https://xxx.mp4

拿到新的 url 并交给任意播放器后,播放器的加载都指向本地服务的新地址——即通过 Socket 连接建立的本地服务 CacheService,后者通过解析出请求中真正的 https://xxx.mp4 地址,创建对应的下载任务,并从下载的文件缓存中,读取 buffer 返回给播放器;同时,监控整个流程的 CacheService 响应式地回调过程中所有大大小小的事件。

经过这样设计,整个流程的调用变得非常简单:

public class MainActivity extends Activity {public final MediaPlayer mPlayer;@Overridepublic void playVideo(String url) {final String proxyUrl = VideoUtils.getProxyUrl(url);// url = https://xxx.mp4// proxyUrl = http://127.0.0.1:8090/https://xxx.mp4mPlayer.setDataSource(proxyUrl);}
}

2、创建代理服务器

接下来,笔者通过伪代码的形式,简单阐述下创建本地代理连接的过程。

上文提到的本地服务 CacheService在创建时,会自动初始化一个本地代理服务器,配置ip和自动分配端口号,这之后,服务完成初步建立,并立即开启一个线程,等待接收客户端的后续连接。

// 实际类名 HttpProxyCacheServer.java
public final class CacheService {private CacheService(Config config) {// 初始化ip和端口号InetAddress inetAddress = InetAddress.getByName("127.0.0.1");this.serverSocket = new ServerSocket(0, 8, inetAddress);this.port = serverSocket.getLocalPort();// 开启新的线程,等待后续接收客户端的连接this.waitConnectionThread = new Thread(new WaitRequestsRunnable());this.waitConnectionThread.start();}
}

3、处理缓存请求

本地服务建立完毕,当用户尝试播放音视频时,播放器实际上访问类似 http://127.0.0.1:8009/https://xxx.mp4 的地址,这时我们的 CacheService 中接到了对应的消息。

针对每一次请求,我们都能解析到真实音视频文件的地址(https://xxx.mp4),为了提高复用性,我们声明一个HttpProxyCache类,为每一个音视频配置一个对应的 HttpProxyCache 以进行管理:

class HttpProxyCache extends ProxyCache {// 视频资源的url地址private final HttpUrlSource source;// 视频资源的本地文件信息private final FileCache cache;
}

实际上还不够,我们还需要针对每个音视频缓存过程的回调进行管理,因此,基于此再封装一层,使用 HttpProxyCacheServerClients 管理一个音视频资源:

final class HttpProxyCacheServerClients {private final String url;       // 视频资源urlprivate volatile HttpProxyCache proxyCache;  // 缓存信息private final List<CacheListener> listeners = new CopyOnWriteArrayList<>(); // 缓存监听
}

简单概括一下,针对一次新的音视频资源加载,会构建一个新的 HttpProxyCacheServerClients,内部除了相关信息的成员,还包含了 HttpProxyCache 对象用于读取和加载缓存。

4、远程加载流程

抽象地看待音视频的源,分为 远程音视频资源本地音视频资源,当不使用缓存时,必然会从远程进行下载,并不断将音视频的流通过 Socket 向播放器传输。

这里我们将 抽象为 Source:

public interface Source {// 建立打开资源void open(long offset) throws ProxyCacheException;// 获取音视频的长度long length() throws ProxyCacheException;// 不断读取音视频数据int read(byte[] buffer) throws ProxyCacheException;// 关闭释放资源void close() throws ProxyCacheException;
}

对于远程加载的完整流程,本质上就是建立、打开、读取和关闭一个远程连接 HttpURLConnection的过程,核心代码如下:

public class HttpUrlSource implements Source {@Overridepublic void open(long offset){HttpURLConnection connection = openConnection(offset, -1);}@Overridepublic int read(byte[] buffer){return inputStream.read(buffer, 0, buffer.length);}// ...
}

5、缓存加载流程

更多的时候,无论音视频资源是否已下载,我们都希望通过缓存统一加载管理:

1、文件已下载:直接读取本地文件,将数据通过Socket不断传回给播放器;

2、文件未下载:新建一个本地文件,并开启远程下载任务,下载过程中,数据流不断涌入本地文件,本地文件大小、下载进度的变更都会响应式通知上层;除此之外,新的音视频流数据会通过Socket不断传回给播放器,播放器也会不断的推进播放进度。

由此可见,无论文件是否下载,缓存流程都是围绕 本地缓存文件 进行的,这也符合软件开发中的 唯一可信源 的概念。


接下来笔者针对部分细节问题进行探讨。

6、自定义缓存策略

缓存所占用的空间往往会成为迫使用户卸载应用的最后一根稻草。

开发者不能无上限对音视频资源进行缓存,通常的维护手法是通过 限制空间大小,比如,用户通常可以接受视频类应用有 1G 左右的缓存空间,即时通信类应用也许会更大些。

因此我们的缓存库也需要提供这样的能力,可通过实现DiskUsage接口,实现不同的缓存策略。

// 缓存空间管理类
public interface DiskUsage {void touch(File file) throws IOException;}

可以预设一些缓存策略供开发选择:

  • TotalCountLruDiskUsage:限制缓存数量
  • TotalSizeLruDiskUsage:限制缓存大小
  • UnlimitedDiskUsage:没有缓存限制

对于这样的诉求,通用的解决方案仍然是经典的 LruCache,通过最近最少算法,缓存达到上限时,清理掉最久远的缓存文件。

7、缓存文件生成策略

类似的还有缓存文件的文件名生成策略,默认是使用的 MD5 方式生成 key,考虑到一些业务逻辑,我们也可以自定义一个 FileNameGenerator 来实现自己的策略:

public interface FileNameGenerator {String generate(String url);}

展望 & 更多问题

看起来,目前 AndroidVideoCache 库已经非常全面——这也正是目前 GitHub 上源码中提供现有的全部功能。

实际上,对于市面上复杂的音视频产品而言,部分功能还有所欠缺,简单列举几条如下:

1、视频文件类型支持不够

针对体量较小的产品系统而言,针对 mp3mp4 这种简单的音视频文件的缓存完全够用,但对于复杂庞大的系统,更多类型的音视频资源一旦涌入,现有实现变得捉襟见肘。

m3u8 格式视频的缓存是典型的场景,由于是切片的数据,因此需要特殊的处理方式,缓存需要考虑切片索引以及视频文件的拼接。

2、Seek功能的缓存支持

缓存加载流程 一节中,笔者针对 文件已下载文件未下载 进行了简单的概括。

事实上,用户真实的交互非常复杂,Seek操作是一个典型的操作:当用户播放时长10分钟的视频时,当缓存预加载到第4分钟时,用户直接操作进度条,从第1分钟切到了第8分钟。

此时,由于缓存尚未预加载到指定的位置,而目前的实现仅仅对一个本地文件进行读和写的操作,因此我们不能直接在现有的缓存文件上(第4分钟)直接追加断裂的(第8分钟)数据。

因此现有实现的方式是,针对这种情况,直接结束缓存过程,直接加载远程数据——即切换到 远程加载流程,不再写入和读取本地缓存文件。

这就导致一个额外的后果,当用户下一次点击播放这个视频,本地的缓存文件中只有4分钟的缓存,用户依然要浪费相当一部分的流量。

3、多任务缓存 & 限速策略

文章开始我们说到,针对部分业务场景,产品需要提供 多任务同时缓存 的能力,依然以 抖音 为例,在播放第一个短视频时,同时针对后续的 1-3 条短视频进行 预加载 是完全有必要的。

除此之外,我们还需要针对不同类型的缓存任务设置不同的 缓存优先级,通过维护一个 任务队列,保证任务能够按需分配,按优先级及时执行。

最后,针对不同优先级的缓存任务,还需要分配不同的 限速策略,保证多个 多任务同时缓存 时,前台的视频不会被后台的任务影响,保证用户的视听体验。

阶段性小结

当然,即使 AndroidVideoCache 目前的实现还有一些不足,我们依然无法否定它在 Android 音视频缓存领域做出的巨大贡献。

换句话说,AndroidVideoCache 现有的实现已经为我们提供了通用性的解决方案,对于小型项目完全可以直接使用;对于大型项目,我们只需在现有的基础上,对自身业务进行补充,也完全可以达到 产品化 的目的。

同时,这种 建立本地代理服务响应式的缓存机制 的思想是非常优秀的,即使是若干年后 Google 推出的 Jetpack Paging 分页组件,也隐约可以看到这种思想的影子,读者可仔细理解这种设计理念,并尝试应用到更多的业务架构设计上去。

针对上一节中几个问题的解决方案,限于篇幅原因,笔者在下一篇文章 Android音视频缓存机制的产品化实现 中进行扩展性的讲述,敬请期待。


参考文章

本文旨在将 Android 音视频缓存机制进行系统性阐述,只有读者学会了理论,针对代码细节才能更高效、更深入地理解,下面是几篇源码分析非常优秀的文章,有兴趣的读者可以选择性参考。

  • AndroidVideoCache-视频边播放边缓存的代理策略 @sheepm

  • 视频缓存AndroidVideoCache攻略 @juexingzhe

  • Android主流视频播放及缓存实现原理调研 @罗拙呓

  • 音视频开发之旅(49)-边缓存边播放之AndroidVideoCache @yabin小站

关于我

Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 GitHub。

  • 我的Android学习体系
  • 关于文章纠错
  • 关于知识付费
  • 关于《反思》系列

反思 | Android 音视频缓存机制的系统性设计相关推荐

  1. 深入理解Android音视频同步机制(二)ExoPlayer的avsync逻辑

    深入理解Android音视频同步机制(一)概述 深入理解Android音视频同步机制(二)ExoPlayer的avsync逻辑 深入理解Android音视频同步机制(三)NuPlayer的avsync ...

  2. Android音视频点/直播模块开发

    前言 随着音视频领域的火热,在很多领域(教育,游戏,娱乐,体育,跑步,餐饮,音乐等)尝试做音视频直播/点播功能,那么作为开发一个小白,如何快速学习音视频基础知识,了解音视频编解码的传输协议,编解码方式 ...

  3. Android音视频点/直播模块开发实践总结-zz

    随着音视频领域的火热,在很多领域(教育,游戏,娱乐,体育,跑步,餐饮,音乐等)尝试做音视频直播/点播功能.那么作为开发一个小白,如何快速学习音视频基础知识,了解音视频编解码的传输协议,编解码方式,以及 ...

  4. Android音视频开发基础(七):视频采集-系统API基础

    前言 在Android音视频开发中,网上知识点过于零碎,自学起来难度非常大,不过音视频大牛Jhuster提出了<Android 音视频从入门到提高 - 任务列表>.本文是Android音视 ...

  5. Android+音视频 全新面试题,求职/跳槽吊打面试官

    每年的金三银四.金九银十都是程序员狂欢的时刻,众多企业在这个时期也都大规模的进行招聘. 就我身边的很多Android开发都有这么一个习惯:跳槽之前都会遍寻各种面试题进行刷题,不过尽管找了很多面试题,面 ...

  6. android音/视频,直播

    流媒体 采用流式传输的方式在Internet / Intranet播放的媒体格式.流媒体的数据流随时传送随 时播放,只是在开始时有些延迟.边下载边播入的流式传输方式不仅使启动延时大幅度地缩短,而且对系 ...

  7. Android音视频学习系列(八) — 基于Nginx搭建(rtmp、http)直播服务器

    系列文章 Android音视频学习系列(一) - JNI从入门到精通 Android音视频学习系列(二) - 交叉编译动态库.静态库的入门 Android音视频学习系列(三) - Shell脚本入门 ...

  8. Android音视频开发--FFmpeg

    音视频的基础知识 视频 静止的画面叫图像(picture): 连续的图像变化每秒超过24帧(frame)画面以上时,根椐视觉暂留原理,人眼无法辨别每付单独的静态画面,看上去是平滑连续的视觉效果,这样的 ...

  9. Android音视频之AudioRecord录音(一)

    Android音视频之AudioRecord录音(一) 在音视频开发中,录音当然是必不可少的.首先我们要学会单独的录音功能,当然这里说的录音是指用AudioRecord来录音,读取录音原始数据,读到的 ...

最新文章

  1. Android小知识-Fragment
  2. 没有c语言基础可以学python吗-必须要有C语言基础才能学python吗
  3. 【实践】简洁大方的summernote 富文本编辑器插件的用发——导入篇
  4. 1.4 matlab数值数据的类型分类
  5. VTK:vtkCompositePolyDataMapper2用法实战
  6. 每日程序C语言18-求分数序列的前20项和
  7. 【C++的深度剖析教程21】类型转换函数下
  8. js数组截取前5个_想用好 Node.js?这 5 个经典国产项目值得细品
  9. js跨域 ajax跨域问题解决
  10. sql server 锁与事务拨云见日(下)
  11. XCODE GDB这个是老版本xcode,新版的是lldb
  12. CSS3 Transform 变形
  13. 前端JavaScript学习小总结
  14. linux的webui服务,Aria2控制前端WebUI客户端安装教程
  15. 云图数字iOS客户端
  16. 简单聊聊FPGA的一些参数
  17. 线上展厅打造视觉亮点
  18. 版权费用外流,中国音乐产业被境外唱片公司收割?
  19. Java获取IP地址和VUE获取IP地址。
  20. Vue实现集成使用第三方Animate.css动画库详细教程(解决鼠标移入移出闪烁问题)

热门文章

  1. 内容平台争夺笔杆子,百家号还向技术大牛伸出了橄榄枝
  2. DirectX9 SDK Samples(26) PixelMotionBlur Sample
  3. 华为od统一考试B卷【敏感字段加密】JavaScript 实现
  4. windows2022远程桌面连接管理员已结束会话解决方法
  5. Python基金投资回测
  6. 虾皮市场中店铺定位是什么,如何做好产品线布局?这些东西你有了解吗?
  7. 开源无疆|京东云参加2019开源年会,助力开源
  8. ios修改hosts文件后访问网址114导航域名无法解析问题
  9. 为啥工资挺高,你还是存不下钱
  10. 过客--三星 s6 edge 照相机出故障了