第28章 短视频应用实战:录制、编辑、发布全流程架构设计、模块化拆分
做短视频应用,说实话,跟做普通播放器完全是两码事。
我记得刚接手这个项目时,团队里有人觉得「不就是录个视频再发出去吗」。结果呢?第一个版本上线后,录制卡顿、编辑闪退、发布失败,用户骂声一片。嗯,后来我们花了整整两个季度重构架构,才把体验拉回来。
这一章,我就把当时沉淀下来的全流程架构设计思路,以及模块化拆分的实战经验,完整地讲给你听。
一、整体架构:三层四模块
先看整体结构。我习惯把短视频应用拆成三层:
- UI层:负责交互和预览
- 业务层:负责录制、编辑、发布三大流程
- 基础层:负责相机、编码、网络、存储等底层能力
业务层内部,又拆成四个独立模块:
- 录制模块(Camera + Audio + Encoder)
- 编辑模块(裁剪、滤镜、特效、拼接)
- 发布模块(上传、转码、封面生成)
- 公共模块(权限、缓存、日志、工具类)
为什么要这么拆?说白了,就是为了解耦。你想想看,如果录制和编辑混在一起,改一个滤镜效果,结果录制流程崩了,这谁受得了?
核心原则:每个模块只做一件事,模块之间通过接口通信,不直接依赖实现类。
二、录制模块:分段录制与实时美颜
录制模块是整个应用的起点。我建议你用 Camera2 + MediaCodec 的组合,别再用 Camera1 了,延迟和兼容性问题太多。
分段录制是短视频的标配功能。用户按一下开始录,再按一下暂停,最后把所有片段拼起来。实现上,我用了 MediaMuxer 逐段写入,最后合并。
我的经验:分段录制时,每段视频的编码参数(码率、帧率、分辨率)必须完全一致,否则合并后会出现音画不同步。我曾经踩过这个坑,排查了两天才发现是码率不一致导致的。
实时美颜怎么搞?说白了就是在预览 Surface 上挂一个 OpenGL 滤镜链。我习惯用 GPUImage 的思路,把磨皮、美白、瘦脸拆成独立的 Filter,用链表串起来。
// 伪代码:滤镜链管理
class FilterChain {
private val filters = mutableListOf<GLFilter>()
fun addFilter(filter: GLFilter) {
filters.add(filter)
}
fun apply(inputTexture: Int, width: Int, height: Int): Int {
var texture = inputTexture
for (filter in filters) {
texture = filter.process(texture, width, height)
}
return texture
}
}
注意:滤镜链越长,GPU 压力越大。我建议磨皮和美白合并成一个 Filter,减少纹理拷贝次数。实测帧率能提升 30% 以上。
三、编辑模块:裁剪、滤镜、特效与拼接
编辑模块是用户停留时间最长的页面。我把它拆成四个子模块:
| 子模块 | 核心功能 | 技术选型 |
|---|---|---|
| 裁剪 | 设置起止时间,截取片段 | MediaExtractor + MediaCodec |
| 滤镜 | 实时预览 + 应用滤镜 | OpenGL ES 3.0 + GLSurfaceView |
| 特效 | 转场动画、粒子效果 | FFmpeg 滤镜 + 自定义 Shader |
| 拼接 | 多段视频合并 | MP4Parser 或 FFmpeg concat |
裁剪这块,我建议用 seek + 精确帧定位。用户拖动进度条时,先 seek 到关键帧,再逐帧解码到目标位置。别直接 seek 到非关键帧,否则画面会花掉。
拼接呢?如果视频编码格式一致,用 MP4Parser 直接拼接,速度极快,几乎不重新编码。如果格式不一致,那就老老实实用 FFmpeg 重新编码。
避坑指南:我曾经遇到过一个 case,用户录了 5 段视频,分辨率分别是 1080p、720p、1080p、720p、1080p。直接拼接后,播放器在切换分辨率时闪了一下黑屏。后来我强制所有片段统一分辨率再拼接,问题解决。
四、发布模块:上传、转码与封面生成
发布模块的核心就三个字:稳、快、省。
- 稳:断点续传 + 网络切换自动恢复
- 快:分片上传 + 并行转码
- 省:压缩码率 + 降低分辨率(用户可选)
上传这块,我推荐用 OkHttp + 分片上传。每片 512KB,上传成功后服务端返回 offset,客户端记录进度。断网后重连,从 offset 继续传。
// 分片上传核心逻辑
fun uploadChunk(videoPath: String, chunkSize: Long = 512 * 1024) {
val file = File(videoPath)
val totalChunks = Math.ceil(file.length() / chunkSize.toDouble()).toInt()
for (i in 0 until totalChunks) {
val start = i * chunkSize
val end = minOf(start + chunkSize, file.length())
val chunkData = file.readBytes().copyOfRange(start.toInt(), end.toInt())
// 上传分片
val response = uploadChunkToServer(chunkData, i, totalChunks)
if (!response.isSuccess) {
// 重试机制:最多重试3次
retryOrFail(i)
}
}
}
封面生成呢?我习惯取视频中间帧,加上高斯模糊和文字水印。用 MediaMetadataRetriever 获取帧,再用 Canvas 绘制封面。
小技巧:封面图不要太大,建议压缩到 720p 以下,上传速度快,用户等待时间短。我一般用 Bitmap.compress 到 80% 质量,肉眼几乎看不出区别。
五、模块化拆分的关键点
模块化拆分不是简单地把代码放到不同包下就完事了。我总结了几条实战经验:
- 接口先行:先定义模块间的通信接口,再写实现类。比如录制模块暴露
IRecordListener,编辑模块实现它。 - 依赖注入:用 Dagger 或 Hilt 管理模块依赖,别手动 new 对象。我见过太多因为 new 错实例导致的诡异 bug。
- 独立测试:每个模块都能单独运行和测试。录制模块可以脱离 UI 层,直接输出视频文件到本地。
- 版本隔离:每个模块有自己的版本号,升级时互不影响。比如编辑模块从 v2.0 升到 v3.0,录制模块还是 v1.5,完全没问题。
我的习惯:每个模块的 build.gradle 里单独配置 minSdk、targetSdk 和依赖库。这样即使某个模块需要升级依赖,也不会影响其他模块。
六、性能优化与避坑
最后聊几个实战中遇到的性能问题:
- 录制掉帧:检查 Camera2 的 capture session 配置,确保
FPS_RANGE设置正确。我遇到过某款手机默认 15fps,手动设成 30fps 后流畅多了。 - 编辑预览卡顿:GLSurfaceView 的渲染线程和 UI 线程分开,别在 onDrawFrame 里做耗时操作。滤镜参数更新用
queueEvent提交。 - 上传失败率高:加一个上传队列,失败后自动重试,重试间隔指数退避。我设的是 1s、2s、4s、8s,最多重试 5 次。
- 内存泄漏:MediaCodec 和 MediaMuxer 用完后必须 release。我习惯在
finally块里释放,确保万无一失。
曾经踩过的坑:有一次发布版本后,用户反馈录制 10 秒以上的视频会闪退。查了半天,发现是 MediaCodec 的 input buffer 没有及时回收,导致 OOM。后来加了一个 buffer 池,复用已释放的 buffer,问题解决。
好了,这一章的内容就到这里。架构设计、模块拆分、录制编辑发布的核心逻辑,我都讲清楚了。剩下的就是动手去写代码,遇到问题再回来翻翻这一章,应该能帮你少走不少弯路。
公众号:蓝海资料掘金营,微信 deep3321