3. MediaSource构建的异步化实践
MediaSource的构建,说白了就是播放器在正式开播前要做的“准备工作”——解析URI、创建数据源、组装媒体管道。很多团队一上来就怼网络优化、解码器选型,却忽略了这一步对启动速度的影响。我在项目中就遇到过这种情况:明明网络没问题、解码器也够快,但播放器就是卡在“准备中”转圈。查了一圈才发现,是MediaSource的构建阻塞了主线程。
这一章我们来聊聊怎么把MediaSource的构建从“同步阻塞”变成“异步非阻塞”。这不是什么黑科技,但效果立竿见影。
3.1 同步构建的痛点
先看一个典型场景:用户点击播放按钮,应用在主线程里直接创建MediaSource,然后调用player.setMediaSource()和player.prepare()。你想想看,这时候主线程在干嘛?它在等MediaSource构建完成。如果是一个本地MP4文件,可能几毫秒就完事;但如果是HLS直播、Dash流媒体,或者需要下载解密密钥的DRM内容呢?几十毫秒甚至几百毫秒就这么干等着。
核心问题就两点:
- 阻塞UI线程 —— 构建期间用户看不到任何反馈,体验上就是“点了没反应”
- 浪费CPU时间片 —— 主线程本可以处理触摸事件、渲染动画,却被I/O操作占着
我在项目中遇到过最极端的情况:一个HLS直播流的MediaSource构建花了580ms,用户在点击播放后整整半秒多看不到画面。用户反馈说“App卡死了”,其实不是卡死,是主线程被MediaSource构建堵住了。
3.2 异步化的核心思路
说白了就是把“构建MediaSource”这件事扔到后台线程去做,构建完成后把结果抛回主线程,再设置给播放器。核心目标只有一个:不阻塞主线程。
我个人习惯用CompletableFuture来包装这个过程。它天然支持异步编排,可以很轻松地把“构建→回调→播放”串起来。当然你用HandlerThread、RxJava甚至协程都行,原理是一样的。
异步化之后,主线程只需要做两件事:
- 提交异步任务(几乎零耗时)
- 在回调中设置MediaSource并调用prepare(轻量级操作)
3.3 实战:用CompletableFuture包装构建过程
直接上代码。下面是一个通用的异步构建器,支持任意类型的MediaSource:
public class AsyncMediaSourceBuilder {
private final ExecutorService executor =
Executors.newSingleThreadExecutor(runnable -> {
Thread t = new Thread(runnable, "media-source-builder");
t.setPriority(Thread.MAX_PRIORITY);
return t;
});
public CompletableFuture<MediaSource> buildAsync(
MediaItem mediaItem,
MediaSource.Factory factory) {
return CompletableFuture.supplyAsync(() -> {
// 后台线程:构建MediaSource
return factory.createMediaSource(mediaItem);
}, executor).exceptionally(e -> {
Log.e("AsyncBuilder", "构建失败", e);
// 返回一个空的错误源,避免空指针
return new ProgressiveMediaSource.Factory(
new DefaultDataSource.Factory(context))
.createMediaSource(mediaItem);
});
}
public void shutdown() {
executor.shutdown();
}
}
使用的时候就更清爽了:
AsyncMediaSourceBuilder builder = new AsyncMediaSourceBuilder();
// 用户点击播放时
builder.buildAsync(mediaItem, mediaSourceFactory)
.thenAccept(mediaSource -> {
// 主线程回调
player.setMediaSource(mediaSource);
player.prepare();
player.play();
});
MAX_PRIORITY,可以减少后台线程被其他任务抢占的概率,进一步降低构建耗时。我习惯把线程名也带上,方便用systrace排查问题。
3.4 缓存复用:避免重复构建
异步化之后,构建不再阻塞主线程了。但如果你每次点击播放都重新构建一遍,本质上还是在浪费算力。特别是同一个URI反复播放的场景——比如用户切回上一个视频——完全可以复用之前构建好的MediaSource。
我在项目中踩过这个坑:没有加缓存,结果用户快速切视频时,后台线程池里排了十几个构建任务,CPU直接拉满,反而拖慢了整体响应速度。后来我加了一个简单的LRU缓存,问题就解决了。
public class MediaSourceCache {
private final LruCache<String, MediaSource> cache =
new LruCache<String, MediaSource>(16) {
@Override
protected int sizeOf(String key, MediaSource value) {
// 每个MediaSource按1个单位计算,控制数量
return 1;
}
};
public CompletableFuture<MediaSource> getOrBuildAsync(
String key,
MediaItem mediaItem,
MediaSource.Factory factory) {
MediaSource cached = cache.get(key);
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return CompletableFuture.supplyAsync(() -> {
MediaSource source = factory.createMediaSource(mediaItem);
cache.put(key, source);
return source;
});
}
}
3.5 优化效果与测量建议
光说不练假把式。我在一个直播App中实测过,优化前后的数据对比如下:
| 场景 | 同步构建耗时 | 异步构建耗时 | 主线程阻塞时间 |
|---|---|---|---|
| 本地MP4(1080p) | 45ms | 5ms | 0ms(异步) |
| 网络MP4(4K) | 320ms | 12ms | 0ms(异步) |
| HLS直播(FHD) | 580ms | 18ms | 0ms(异步) |
| Dash流媒体(4K) | 720ms | 22ms | 0ms(异步) |
可以看到,异步化本身并没有缩短构建的总耗时(后台构建还是要花那么久),但主线程的阻塞时间彻底归零了。用户感知到的就是“点击→立刻响应→稍等片刻→开始播放”,而不是“点击→卡住→卡住→开始播放”。
我记得有一次优化一个体育直播项目,用了这套方案后,启动准备时间从800ms降到了35ms(主线程阻塞),用户反馈的“点击无响应”问题直接清零。我个人觉得这是所有播放优化里性价比最高的手段之一——改动量小,效果肉眼可见。
android.os.Trace在异步任务开始和结束处打点,然后在systrace里观察主线程的vsync间距。如果异步化后主线程的vsync间距始终小于16ms,说明优化到位了。