前言
有一个比较常见的需求:导出分享视频的时候给视频加上 Logo,或者给录屏素材加上出处标识。
Android 上主流的做法大致有这么几种:
- FFmpeg —— 经典方案,
overlay滤镜,CPU 软件编解码 - Media3 Transformer —— 官方方案,纯 Kotlin/Java,底层走
MediaCodec硬编硬解 + GPU 效果 - 手搓 OpenGL 绘制,写个加水印的 shader,再把渲染出的画面重新编码
其中第一种方案最常用:引入一个预编译好(或者自己编译)的 FFmpeg 库,把加水印的命令丢给它执行。代价是安装包体积增加不少;当然也可以自己编译 FFmpeg、裁掉用不到的功能,做一个平衡。
第二个方案是 Jetpack 提供的视频编辑组件,官方自带添加水印的能力,用起来非常简单,支持文字水印和图片水印。
第三个方案更原始,基本就是第二个方案的手搓版本。
本文介绍 Media3 的水印添加方案,并和 FFmpeg 做一次对比。
Media3 方案
Media3 把”给视频加效果”做成了纯 Kotlin/Java 的 API,硬件编解码和 GPU 渲染都封装在底层,不用碰 JNI。
添加依赖
Transformer 在 media3-transformer 里,水印效果 OverlayEffect 在 media3-effect 里,三个一起加:
implementation(libs.media3.common)
implementation(libs.media3.effect)
implementation(libs.media3.transformer)
水印 Bitmap
两条路线共用同一张 Canvas 画的水印图,这样对比才公平:
fun createWatermarkBitmap(): Bitmap {
val width = 800
val height = 240
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint().apply {
color = Color.argb(230, 255, 255, 255)
textSize = 140f
isAntiAlias = true
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT_BOLD
}
val text = "CHAOSGOO"
val baseline = (height + paint.fontMetrics.descent - paint.fontMetrics.ascent) / 2 - paint.fontMetrics.descent
canvas.drawText(text, width / 2f, baseline, paint)
return bitmap
}
StaticOverlaySettings:锚点坐标系
水印放哪、多大,由 StaticOverlaySettings 控制。它用的是一个归一化锚点坐标系,第一次用容易绕晕,展开说:
backgroundFrameAnchor(x, y):水印锚点落在视频帧的哪个位置overlayFrameAnchor(x, y):水印自身的哪个点去对齐- x/y 取值范围都是
[-1, 1]:-1是左/上,0是居中,1是右/下
所以”右下角贴边”就是背景帧右下角 (1, 1) 对水印右下角 (1, 1):
val overlaySettings = StaticOverlaySettings.Builder()
.setBackgroundFrameAnchor(1f, 1f) // 背景帧的右下角
.setOverlayFrameAnchor(1f, 1f) // 水印自身的右下角
.setScale(0.2f, 0.2f) // 水印尺寸 = 视频宽 * 0.2, 视频高 * 0.2
.setAlphaScale(0.8f) // 半透明
.build()
想放左上角,把两个锚点都换成 (-1f, -1f) 就行;想居中,全换 0f。setScale 是相对视频帧宽高的比例,所以同一套配置放到 1080p 和 4K 上,水印不会忽大忽小——这点比 FFmpeg 按像素写偏移舒服多了。
组装 Effect 并启动
val watermarkOverlay = BitmapOverlay.createStaticBitmapOverlay(watermark, overlaySettings)
val overlayEffect = OverlayEffect(listOf(watermarkOverlay))
val effects = Effects(emptyList(), listOf<Effect>(overlayEffect))
val editedMediaItem = EditedMediaItem.Builder(MediaItem.fromUri(inputUri))
.setEffects(effects)
.build()
val transformer = Transformer.Builder(context)
.addListener(object : Transformer.Listener {
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
// 完成, exportResult 里有耗时/大小等统计
}
override fun onError(
composition: Composition,
exportResult: ExportResult,
exportException: ExportException,
) {
// 失败
}
})
.setVideoMimeType(MimeTypes.VIDEO_H264)
.setAudioMimeType(MimeTypes.AUDIO_AAC)
.build()
transformer.start(editedMediaItem, outputPath)
几个点:
- 视频效果放进
Effects的第二个参数,start()之后异步执行,主线程不阻塞 OverlayEffect是GlEffect,内部就是一个 shader,水印混叠发生在 GPU 上,帧数据不用读回 CPU- 不指定编码器时默认走 H.264;
MediaCodec能找到硬件编码器就用硬件,没有就回退软件 MediaItem.fromUri()直接吃content://Uri,不用先把文件拷出来
进度条
Transformer.getProgress(ProgressHolder) 的返回值就是当前进度百分比,但有个坑:Transformer 的所有方法(包括 getProgress)都必须在创建它的 application thread 上调用,源码里 getProgress 第一行就是 verifyApplicationThread()。所以不能像直觉那样开个子线程轮询,会直接抛 Transformer is accessed on the wrong thread。正确姿势是回到主线程轮询,结束/出错时记得 removeCallbacks 停掉:
val progressHandler = Handler(Looper.getMainLooper())
val progressHolder = ProgressHolder()
progressHandler.post(object : Runnable {
override fun run() {
if (!running) return
val state = transformer.getProgress(progressHolder)
if (state == Transformer.PROGRESS_STATE_AVAILABLE) {
callback.onProgress(progressHolder.progress)
}
progressHandler.postDelayed(this, 100)
}
})
// 在 onCompleted / onError 里:
running = false
progressHandler.removeCallbacksAndMessages(null)
文字水印
BitmapOverlay 之外,还有 TextOverlay,用法几乎一样:
val textOverlay = TextOverlay.createStaticTextOverlay(
SpannableString("CHAOSGOO 2026"),
overlaySettings,
)
注意字体大小内部固定 100px,想调字号只能靠 setScale 放大缩小——别去找 setTextSize,没有这个口子。
FFmpeg 方案
依赖:ffmpeg-kit 已从 Maven Central 下架
对比实现用的是 com.arthenica:ffmpeg-kit-full-gpl。这里有两个坑,第一个是 2025 年之后的:ffmpeg-kit 停更,而且整个包已经从 Maven Central 移除了——直接加依赖会解析失败:
Could not find com.arthenica:ffmpeg-kit-full:6.0-2.LTS.
第二个坑更隐蔽:ffmpeg-kit-full 和 ffmpeg-kit-full-gpl 不是同一个东西——libx264/libx265 是 GPL 编码器,只有 -gpl 后缀的包才带。用 full 包写 -c:v libx264,运行时会直接报:
Unknown encoder 'libx264'
所以要么换 full-gpl,要么别用 x264。代价是 full-gpl 让整个 ffmpeg 二进制受 GPL v3 约束,商用闭源 App 要掂量一下许可问题。
目前还能用的办法:阿里云镜像保留了历史版本,把它加进仓库列表就能解析:
repositories {
google()
mavenCentral()
maven("https://maven.aliyun.com/repository/public") // ffmpeg-kit 残存
}
implementation("com.arthenica:ffmpeg-kit-full-gpl:6.0-2.LTS")
几个注意点:6.0-2.LTS 要求 minSdk 24;full-gpl 的 AAR 比 full 再大一圈(多塞了 x264/x265/xvidcore 几个 GPL 库,full 版 4 个 ABI 的 so 加起来就把 debug 包撑到了 141MB);上面那个 Unknown encoder 'libx264' 就是包选错的典型症状。
overlay 滤镜
ffmpeg-kit 的用法和命令行一模一样,把命令拼成字符串丢进去:
val command = listOf(
"-y",
"-i", inputPath,
"-i", watermarkPath,
"-filter_complex", "overlay=main_w-overlay_w-24:main_h-overlay_h-24",
"-c:v", "libx264",
"-preset", "medium",
"-c:a", "copy",
outputPath,
).joinToString(" ")
FFmpegKit.executeAsync(
command,
{ session -> // 完成回调, session.returnCode 判断成败
if (ReturnCode.isSuccess(session.returnCode)) {
callback.onSuccess(output)
} else {
callback.onError(session.getAllLogs().joinToString("\n") { it.message })
}
},
{ log -> // 日志回调, 进度在这解析
val message = log.message
val match = TIME_REGEX.find(message)
val timeSeconds = timeToSeconds(match.groupValues[1])
callback.onProgress((timeSeconds / durationSeconds * 100).toInt())
},
null, // statistics 回调, 用不到
)
overlay 滤镜的坐标是像素偏移:main_w-overlay_w-24 表示主画面宽减去水印宽再减 24px。和 Media3 的归一化锚点一比,换不同分辨率的视频就得自己算比例。
进度没有现成 API,只能靠正则扒日志里的 time=00:00:12.34:
private val TIME_REGEX = Regex("""time=(\d{2}:\d{2}:\d{2}\.\d{2})""")
总时长用 FFprobeKit.getMediaInformation(path) 拿(6.0-2 是同步 API,直接返回):
val duration = FFprobeKit.getMediaInformation(inputPath)
.mediaInformation?.duration?.toDoubleOrNull() ?: 0.0
还有一点和 Media3 不一样:ffmpeg 只能读文件路径,SAF 选的 content:// Uri 得先拷到缓存目录(这件脏活 ContentUriUtil 里干了)。
对比
两条路线我都跑通了,先看实测耗时——同一段素材、同一台手机,Media3 走硬件编码只花了 5.3s,ffmpeg 软件编码跑了 60s,差了十倍还多:


测试素材长短、手机芯片不同,具体数字会变,但这个数量级差距是稳定的——硬编的优势在长视频上会进一步放大。差异主要在这些维度:
| Media3 Transformer | ffmpeg-kit (FFmpeg) | |
|---|---|---|
| 处理管线 | GPU(GL shader)+ MediaCodec 硬编 | CPU 软件管线(libavfilter + x264) |
| 水印定位 | 归一化锚点,自动适配分辨率 | 像素偏移,多分辨率要自己算 |
| 输入 | 直接吃 content:// Uri | 只吃文件路径 |
| 依赖体积 | 纯 Java/Kotlin,~几 MB | AAR 65MB(full 版),arm64 单 ABI 约 23MB |
| minSdk | 21 | 24 (LTS 版) |
| 编解码能力 | MediaCodec 支持的格式 | FFmpeg 全家桶(full-gpl 含 x264/x265) |
| 编码速度 | 依赖设备硬编,快 | 软件编码,慢,但结果稳定 |
| API 稳定性 | @UnstableApi,版本间有变动 | 稳定多年,和命令行一致 |
| 维护状态 | Google 官方持续迭代 | 已停更,从 Maven Central 下架 |
| 取消/进度 | cancel() + getProgress() | FFmpegKit.cancel() + 扒日志 |
| 自定义滤镜 | 只能叠效果/简单变换 | 任意 filter 图,能做花活 |
怎么选:
- 要省包体积、设备硬件好、功能简单(水印/裁剪/压缩)→ 选 Media3。官方路线,十几行代码,升级跟着 Google 走
- 要极致的格式兼容、复杂的滤镜处理(批量转码、专业滤镜、webm 之类冷门格式)→ 选 FFmpeg,代价是那 20~60MB 的 so、一个已经停更的依赖和 GPL 许可
- 两条路线不冲突,按需混用也行