Android 音视频缓存机制的系统性设计

背景

在自媒体的时代,音视频播放 俨然已成为内容类型 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;
  
  @Override
  public 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;
  
  @Override
  public void playVideo(String url) {
    final String proxyUrl = VideoUtils.getProxyUrl(url);
    // url = https://xxx.mp4
    // proxyUrl = http://127.0.0.1:8090/https://xxx.mp4
    mPlayer.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;       // 视频资源url
    private 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 {
    @Override
    public void open(long offset){
      HttpURLConnection connection = openConnection(offset, -1);
    }
    
    @Override
    public 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干货,交流Android技术。 对文章有何见解,或者有何技术问题,欢迎在评论区一起留言讨论!最后给大家分享一些Android相关的视频教程,感兴趣的朋友可以去看看。

本文转自 https://juejin.cn/post/7011881370968408071,如有侵权,请联系删除。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容