第28章 短视频应用实战:录制、编辑、发布全流程架构设计、模块化拆分

做短视频应用,说实话,跟做普通播放器完全是两码事。

我记得刚接手这个项目时,团队里有人觉得「不就是录个视频再发出去吗」。结果呢?第一个版本上线后,录制卡顿、编辑闪退、发布失败,用户骂声一片。嗯,后来我们花了整整两个季度重构架构,才把体验拉回来。

这一章,我就把当时沉淀下来的全流程架构设计思路,以及模块化拆分的实战经验,完整地讲给你听。

一、整体架构:三层四模块

先看整体结构。我习惯把短视频应用拆成三层:

  • UI层:负责交互和预览
  • 业务层:负责录制、编辑、发布三大流程
  • 基础层:负责相机、编码、网络、存储等底层能力

业务层内部,又拆成四个独立模块:

  1. 录制模块(Camera + Audio + Encoder)
  2. 编辑模块(裁剪、滤镜、特效、拼接)
  3. 发布模块(上传、转码、封面生成)
  4. 公共模块(权限、缓存、日志、工具类)

为什么要这么拆?说白了,就是为了解耦。你想想看,如果录制和编辑混在一起,改一个滤镜效果,结果录制流程崩了,这谁受得了?

核心原则:每个模块只做一件事,模块之间通过接口通信,不直接依赖实现类。

短视频应用三层四模块架构 UI层(预览、交互、进度控制) 录制模块 Camera + Audio + Encoder 分段录制、实时美颜 编辑模块 裁剪、滤镜、特效 拼接、变速、贴纸 发布模块 上传、转码、封面 断点续传、进度回调 公共模块 权限、缓存、日志 工具类、异常上报 基础层(Camera2 / MediaCodec / OkHttp / SQLite / FFmpeg) 模块间通信方式 录制完成 → 通过 EventBus / 回调 通知编辑模块加载视频 编辑完成 → 生成临时文件路径,发布模块读取并上传

二、录制模块:分段录制与实时美颜

录制模块是整个应用的起点。我建议你用 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% 质量,肉眼几乎看不出区别。

五、模块化拆分的关键点

模块化拆分不是简单地把代码放到不同包下就完事了。我总结了几条实战经验:

  1. 接口先行:先定义模块间的通信接口,再写实现类。比如录制模块暴露 IRecordListener,编辑模块实现它。
  2. 依赖注入:用 Dagger 或 Hilt 管理模块依赖,别手动 new 对象。我见过太多因为 new 错实例导致的诡异 bug。
  3. 独立测试:每个模块都能单独运行和测试。录制模块可以脱离 UI 层,直接输出视频文件到本地。
  4. 版本隔离:每个模块有自己的版本号,升级时互不影响。比如编辑模块从 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