14. 字幕与文本渲染:字幕格式支持、时间轴同步、文本渲染引擎
字幕这件事,说实话,在多媒体框架里经常被当成「小透明」。大家更关心视频解码顺不顺、音频有没有杂音。但真到了做播放器的时候,字幕出问题,用户骂得比卡顿还凶——毕竟「听不懂」比「看不清」更让人抓狂。
我早期接手过一个项目,字幕总是慢半拍。用户嘴型都对不上,产品经理天天站我身后。后来才发现,不是渲染慢,是时间轴同步的逻辑写错了。嗯,今天我们就来聊聊字幕这块的坑。
14.1 字幕格式支持
Android 支持的字幕格式,其实比很多人想象的多。Stagefright 内部有一套字幕解析框架,叫 TextTrack,它不直接处理文件格式,而是通过 MediaExtractor 把字幕轨道提取出来,再交给对应的解析器。
常见的格式有这些:
| 格式 | 类型 | Stagefright 支持情况 |
|---|---|---|
| SRT | 文本字幕 | 原生支持,通过 SRTParser |
| ASS/SSA | 高级字幕 | 支持基础渲染,特效部分有限 |
| VTT | WebVTT | Android 4.4+ 原生支持 |
| PGS | 位图字幕 | 蓝光常用,通过 BitmapSubtitleDecoder |
| TTML | XML 字幕 | 部分设备支持,依赖厂商实现 |
我个人习惯把字幕分成两类:文本字幕和位图字幕。文本字幕像 SRT、ASS,解析出来是一段字符串加时间戳。位图字幕像 PGS,直接就是图片帧,渲染起来简单,但没法调样式。
核心思路:Stagefright 不自己做字幕解析,它把字幕当成一种特殊的媒体轨道。提取出来的数据通过 NuPlayer::CCDecoder 或 TextRenderer 往下走。
14.2 时间轴同步
字幕同步,说白了就是让文字出现的时间点和音频/视频对上。你想想看,如果演员张嘴说「我爱你」,字幕三秒后才飘出来,那观众得多出戏。
Stagefright 里字幕同步的核心机制,是基于 MediaClock 的。每个字幕样本都有一个 presentationTimeUs,表示它应该在哪个时间点显示。
// 字幕样本的时间戳处理
sp<MediaSource> source = getSource();
MediaBufferBase *buffer;
while (source->read(&buffer) == OK) {
int64_t timeUs;
buffer->meta_data()->findInt64(kKeyTime, &timeUs);
// timeUs 就是字幕应该显示的时间
// 和 MediaClock 的当前时间对比
int64_t nowUs = mMediaClock->getRealTimeUs();
if (timeUs <= nowUs) {
renderSubtitle(buffer);
} else {
// 还没到时间,等一等
waitForTime(timeUs - nowUs);
}
}
这里有个坑。我曾经遇到过一个问题:某些视频文件的字幕时间戳是乱的,不是严格递增。比如第1条字幕在 10 秒,第2条在 8 秒。Stagefright 默认假设字幕是按时间顺序排列的,遇到乱序就会出 bug。
避坑指南:我曾经在解析一个用户上传的 MKV 文件时,发现字幕跳来跳去。后来定位到是 MatroskaExtractor 没有对字幕样本做排序。解决方案是在读取后手动按 presentationTimeUs 排一次序。
还有一个常见问题:字幕提前或延迟。有些播放器提供「字幕偏移」功能,这个在 Stagefright 里怎么实现?很简单,在比较时间戳的时候加一个偏移量:
int64_t adjustedTime = timeUs + mSubtitleOffsetUs;
if (adjustedTime <= nowUs) {
renderSubtitle(buffer);
}
偏移量可以是正数(延迟显示)或负数(提前显示)。这个逻辑我建议放在 TextRenderer 层,不要污染下层的数据流。
14.3 文本渲染引擎
文本渲染,就是把字幕字符串变成屏幕上的像素。Android 里做这件事,绕不开 Canvas 和 Paint。但 Stagefright 不走普通 View 路线,它用的是 SurfaceFlinger 直接合成。
具体流程是这样的:
TextRenderer收到字幕文本和时间戳- 调用
TextLayoutCache做文本布局(换行、对齐) - 用
Skia引擎渲染到GraphicBuffer - 把
GraphicBuffer传给SurfaceFlinger合成到屏幕上
这里有个性能关键点:不要每帧都重新渲染字幕。如果字幕内容没变,只是时间在走,那就复用之前的 buffer。我见过有些实现,每 16ms 都去调一次 Canvas.drawText(),CPU 直接拉满。
优化技巧:我个人习惯在 TextRenderer 里维护一个缓存。只有字幕文本或样式变化时,才重新渲染。否则直接复用上一次的 GraphicBuffer。
ASS 字幕的渲染比较特殊。它支持各种特效:滚动、渐入渐出、字体阴影。Stagefright 的 ASSRenderer 用的是 libass 库,但 Android 原生系统里不一定带这个库。很多厂商会自己实现一个简化版。
我建议的做法是:如果检测到 ASS 字幕,先判断有没有复杂特效。没有特效的,直接用 Skia 渲染文本。有特效的,再走 libass。这样可以避免不必要的性能开销。
14.4 字幕渲染的整体架构
说了这么多,我们来看一张图。这是 Stagefright 字幕渲染的核心流程:
从这张图可以看得很清楚:数据从解析器出来,经过时间同步,最后到渲染引擎。每一步都是独立的模块,这也是 Stagefright 设计上比较优雅的地方——你可以替换任意一个环节而不影响其他部分。
14.5 实战中的几个建议
- 字幕缓存:不要重复渲染相同内容。我习惯用
LRUCache缓存最近 30 秒的字幕位图。 - 字体回退:中文用户经常遇到字幕显示成方框。这是因为缺少对应字体。建议在
Typeface加载时设置 fallback 列表。 - 性能监控:字幕渲染的耗时,我建议打点输出到 log。如果单帧渲染超过 8ms,就要考虑优化了。
一句话总结:字幕渲染不是简单的「画文字」,它涉及格式解析、时间同步、渲染引擎三个环节。任何一个环节出问题,用户看到的都是「字幕不对」。
好了,这一章就到这里。字幕这块内容其实挺细碎的,但每个细节都可能成为线上事故的导火索。希望今天的分享能帮你少踩几个坑。