一、为什么要在视频上加进度条?

说实话,我第一次接到这个需求时,心里是有点懵的。

那是一个短视频编辑App的项目,用户拍完视频后,想在画面上加一个自定义的进度条——不是播放器那种,而是直接渲染到视频画面里,导出后也带着。说白了,就是把进度条变成视频内容的一部分。

你想想看,这跟播放器UI的进度条完全是两码事。播放器的进度条是播放器控件,只在本地显示;而我们要做的,是用MediaMuxer把进度条画面合成到视频轨道里,最终输出一个带进度条的新视频文件。

嗯,这个场景其实挺常见的:

  • 录屏教程视频,想显示录制时长
  • 运动记录视频,叠加运动进度
  • 倒计时视频,显示剩余时间
  • 直播回放,标记时间轴

我个人习惯把这类需求归类为"视频叠加层渲染"。今天我们就拿进度条这个经典案例,把整个流程走一遍。

二、整体思路:两条轨道,一个合成

要实现视频上叠加进度条,核心思路其实就一句话:

把进度条渲染成连续的图像帧,然后通过MediaMuxer把这些帧合成到原视频上。

具体来说,我们需要做三件事:

  1. 解码原视频 — 用MediaExtractor读出视频帧
  2. 生成进度条帧 — 用Canvas绘制每一帧的进度条
  3. 编码合成 — 用MediaMuxer把叠加后的帧写回新视频

下面这张图可以帮你快速理解整个流程:

原视频文件 .mp4 / .mov MediaExtractor 解码视频轨道 逐帧提取图像 Canvas渲染 绘制进度条 叠加到原帧 生成新Bitmap MediaCodec 编码为H.264帧 生成视频轨道 MediaMuxer 混合视频+音频 输出最终文件 带进度条视频 新.mp4 文件 音频轨道处理 原视频音频轨道 → MediaExtractor提取 → MediaMuxer直接复用(不修改)

看到这个流程,你可能已经发现了:音频轨道我们基本不动,直接复用原视频的音频数据。真正干活的是视频轨道这一路。

三、核心代码实现

好,理论说完了,咱们直接上代码。我会把关键步骤拆开讲,每个部分都附上我在项目中踩过的坑。

3.1 初始化:准备输入和输出

// 初始化提取器
MediaExtractor extractor = new MediaExtractor();
extractor.setDataSource(inputPath);

// 找到视频轨道
int videoTrackIndex = -1;
MediaFormat videoFormat = null;
for (int i = 0; i < extractor.getTrackCount(); i++) {
    MediaFormat format = extractor.getTrackFormat(i);
    String mime = format.getString(MediaFormat.KEY_MIME);
    if (mime != null && mime.startsWith("video/")) {
        videoTrackIndex = i;
        videoFormat = format;
        break;
    }
}
extractor.selectTrack(videoTrackIndex);

// 初始化编码器
MediaCodec encoder = MediaCodec.createEncoderByType(MediaFormat.MIME_TYPE_AVC);
MediaFormat outputFormat = MediaFormat.createVideoFormat(
    MediaFormat.MIME_TYPE_AVC,
    videoFormat.getInteger(MediaFormat.KEY_WIDTH),
    videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
);
outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000);
outputFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
outputFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
encoder.configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
encoder.start();

💡 小提示:编码器的宽高一定要跟原视频一致,否则合成后画面会变形。我刚开始做的时候,随手写了个720p,结果原视频是1080p的,输出画面被拉伸了——嗯,那次被测试小姐姐骂得不轻。

3.2 解码原视频帧

// 解码器配置
MediaCodec decoder = MediaCodec.createDecoderByType(
    videoFormat.getString(MediaFormat.KEY_MIME)
);
decoder.configure(videoFormat, surface, null, 0);
decoder.start();

// 注意:这里用Surface方式解码效率更高
// 但我们需要拿到Bitmap做叠加,所以要用ImageReader
ImageReader imageReader = ImageReader.newInstance(
    width, height, ImageFormat.YUV_420_888, 2
);
surface = imageReader.getSurface();

这里有个关键点:为什么不用Surface直接显示,而是要用ImageReader?

因为我们要在每一帧上画进度条,必须拿到像素数据。Surface方式虽然性能好,但拿不到Bitmap。ImageReader虽然慢一点,但能让我们操作每一帧的像素。

3.3 绘制进度条

这部分是核心中的核心。我直接贴一个我项目里用过的工具方法:

private Bitmap drawProgressBar(Bitmap originalFrame, float progress, int width, int height) {
    // 创建可编辑的Bitmap
    Bitmap overlay = originalFrame.copy(Bitmap.Config.ARGB_8888, true);
    Canvas canvas = new Canvas(overlay);

    // 进度条参数
    int barHeight = 40;           // 进度条高度
    int barMargin = 60;           // 底部边距
    int barWidth = width - 120;   // 进度条宽度
    int barX = 60;                // 起始X
    int barY = height - barHeight - barMargin;  // 起始Y

    // 绘制背景条(灰色)
    Paint bgPaint = new Paint();
    bgPaint.setColor(Color.parseColor("#80000000"));  // 半透明黑
    bgPaint.setStyle(Paint.Style.FILL);
    canvas.drawRoundRect(barX, barY, barX + barWidth, barY + barHeight, 
                         20, 20, bgPaint);

    // 绘制进度条(蓝色)
    Paint progressPaint = new Paint();
    progressPaint.setColor(Color.parseColor("#FF4A90D9"));
    progressPaint.setStyle(Paint.Style.FILL);
    int currentWidth = (int)(barWidth * progress);
    canvas.drawRoundRect(barX, barY, barX + currentWidth, barY + barHeight,
                         20, 20, progressPaint);

    // 绘制进度文字
    Paint textPaint = new Paint();
    textPaint.setColor(Color.WHITE);
    textPaint.setTextSize(28);
    textPaint.setAntiAlias(true);
    String progressText = String.format("%.0f%%", progress * 100);
    float textX = barX + barWidth / 2f - textPaint.measureText(progressText) / 2f;
    float textY = barY + barHeight / 2f + 10f;
    canvas.drawText(progressText, textX, textY, textPaint);

    return overlay;
}

⚠️ 注意:Bitmap.Config一定要用ARGB_8888,不要用RGB_565。RGB_565不支持透明度,画出来的进度条边缘会有锯齿。我曾经因为这个被UI设计师追着改了三版...

3.4 用MediaMuxer合成输出

最后一步,把编码后的帧和原音频合到一起:

MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);

// 添加视频轨道
int videoMuxerTrack = muxer.addTrack(encoder.getOutputFormat());

// 添加音频轨道(复用原视频的)
int audioMuxerTrack = -1;
// ... 这里需要单独提取音频轨道,代码略长,后面章节会详细讲

muxer.start();

// 循环处理每一帧
while (!isFinished) {
    // 1. 从解码器获取原始帧
    // 2. 调用drawProgressBar()叠加进度条
    // 3. 送入编码器
    // 4. 从编码器取出编码后的数据
    // 5. 写入MediaMuxer
}

muxer.stop();
muxer.release();

四、避坑指南

这部分是我用真金白银换来的经验,你仔细看看:

常见问题 原因 解决方案
输出视频没有声音 只处理了视频轨道,忘了复用音频 用MediaExtractor单独提取音频轨道,通过MediaMuxer的addTrack加入
画面卡顿/掉帧 Bitmap操作太慢,跟不上帧率 使用BitmapPool复用对象,减少GC;或者降低绘制精度
进度条位置不对 不同视频分辨率不同,硬编码坐标 所有坐标都基于视频宽高做百分比计算
内存溢出 每一帧都创建新的Bitmap 复用Bitmap,用Canvas.drawBitmap直接覆盖

我曾经在一个4K视频上做进度条叠加,没做Bitmap复用,结果处理到第30秒直接OOM崩溃了。那次之后,我养成了所有临时Bitmap必须复用的习惯。

五、性能优化建议

如果你只是处理短视频(30秒以内),上面的代码够用了。但如果你想做长视频或者高分辨率,这几个优化点必须注意:

  • 使用Surface + OpenGL渲染:绕过Bitmap,直接在GPU层面叠加,性能提升5-10倍
  • 异步处理:解码、绘制、编码三个环节用HandlerThread分开跑,避免互相阻塞
  • 降低绘制频率:进度条不需要每帧都重绘,每秒更新10-15次就够,肉眼根本看不出区别
  • 预分配内存:启动时就把所有要用到的Bitmap、Buffer一次性分配好,运行时不再申请

💡 我的个人习惯:在正式编码前,先用一个小视频(5秒、720p)跑通整个流程,确认没问题了再上高分辨率。这样调试速度快很多,不用每次等好几分钟。

六、小结

今天这一章,我们把视频叠加进度条的完整流程走了一遍。核心就是三件事:解码原视频 → 绘制进度条 → 编码合成。MediaMuxer在这里扮演的是"组装车间"的角色,把处理好的视频轨道和原音频轨道合并成一个新文件。

说实话,这个功能看起来简单,但真正做起来坑不少。尤其是性能问题,稍不注意就会翻车。不过别担心,后面几章我会专门讲如何用OpenGL做高性能叠加,以及如何处理音频轨道复用的问题。

嗯,今天就到这里。代码你可以直接拿去用,但建议先跑通demo再集成到项目里。有什么问题,咱们评论区见。


公众号:蓝海资料掘金营,微信deep3321