用户选了一段 4K 视频,点击"发布"后开始上传。如果客户端不加判断地重新编码,可能耗时很久、耗电发热,甚至把本来已经很小的视频压得更糊;如果直接上传,又可能撞上网络成本和服务端大小限制。真正的难点不是找到一个"压缩率"参数,而是把输入、转码、上传、失败恢复与清理设计成一条可靠的链路。
本文以现代 Android 应用为背景,使用 Photo Picker / content:// Uri 获取视频,优先考虑 Media3 Transformer 做设备端转码,并用流式 HTTP 上传。示例代码省略常规 import 与业务类型定义;Media3 API 会随版本演进,落地时应以项目锁定的版本为准。
一、先画全链路:压缩只是中间的一步
text
用户选取视频(content Uri)
↓
读取大小、时长、分辨率、旋转、音视频编码信息
↓
判断:原样上传 / 裁剪或重封装 / 重新编码
↓
输出到应用私有文件,校验能否播放与大小
↓
流式上传;大文件使用分片与续传协议
↓
服务端确认、入库 → 清理临时文件
处理每一步都要回答两个问题:用户离开页面后它是否继续?进程被系统回收后它能否恢复?如果没有统一的任务所有者,最常见的结果是页面没了转码还在跑,或上传重试时源 Uri 已经失效。
二、不是所有视频都需要"压缩"
| 处理方式 | 发生了什么 | 适合情况 | 质量与耗时 |
|---|---|---|---|
| 原样上传 | 不改视频内容 | 已符合大小、编码和服务端约束 | 无画质损失,最快 |
| 裁剪 | 只保留用户需要的片段 | 长视频只上传短片段 | 取决于是否需要重编码与精确裁剪 |
| 重封装(remux) | 换容器或组织音视频轨,不重新编码画面 | 编码已合适,仅容器不兼容 | 通常较快,但体积不会神奇大幅下降 |
| 转码(transcode) | 解码并重新编码视频/音频 | 分辨率、码率或编码不符合目标 | 耗时、耗电且有质量损失 |
先定服务端的真实约束:最大文件大小、允许的容器/编码、最长时长、是否会再做服务端转码、是否支持断点续传。若服务端会生成多档清晰度,客户端未必需要追求"把所有源视频统一压到 720p";更重要的可能是控制上传上限和兼容性。
可以制定一个示例策略:小于 15 MB 且编码兼容的视频原样上传;超出限制时再考虑把长边约束到合适分辨率并降低目标码率。这只是产品策略起点,不是 Android 官方推荐阈值。高运动画面、文字录屏、HDR 视频与夜景视频需要不同质量评估。
体积估算只用于规划,不是最终保证
text
预估大小(字节)≈(视频平均码率 + 音频平均码率)× 时长(秒)÷ 8
例如 30 秒视频,目标视频 2 Mb/s、音频 128 kb/s,预估约 2.128 × 30 ÷ 8 = 7.98 MB(十进制),还要考虑容器开销和实际编码波动。可变码率、编码器实现与复杂画面都会让结果偏离估算;上传前要检查真实输出文件大小,不能把公式当作硬性上限承诺。
三、读取输入:用 Uri,不要猜文件路径
Photo Picker 和系统文档选择器通常给应用一个 content:// Uri。Photo Picker 为用户选中的媒体授予范围内的读取访问,无需为了这个选取流程申请整个媒体库的广泛权限。Uri 是内容访问入口,不等于磁盘上的绝对路径。不要依赖已过时的 _data 列把它转换成 /storage/...,也不要假设 uri.path 就是可直接读的文件。
对大小可先尝试查询 OpenableColumns.SIZE,但提供者可以返回未知值;对视频时长、画面尺寸和旋转可用 MediaMetadataRetriever,更深入的轨道信息可用 MediaExtractor。这些读取都要处理异常:来源可能是云端媒体、被删除的文件或损坏的视频。
kotlin
fun videoDurationMs(context: Context, uri: Uri): Long? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull()
} finally {
retriever.release()
}
}
这个函数只说明读取方法;真实业务还应捕获无法打开或无法解析的异常,并把它转换为用户可理解的错误状态。对长时间后台上传,不能把一个可能很快失效的 Uri 字符串塞进 WorkManager 后就认为输入永远可读。若授权方式支持,可显式持久化读取权限;更稳妥的通用方案是先把源流式复制到应用私有持久目录,确认空间足够,并把该文件作为后续任务输入。短期任务可用缓存目录,必须跨进程/重启恢复的任务不要依赖可能被清理的缓存文件。
四、压缩参数怎么选:先保证兼容,再调质量
面向广泛设备和服务端时,MP4 容器、H.264/AVC 视频和 AAC 音频通常是兼容性较好的起点。H.265/HEVC 在相近画质下可能更省空间,但设备编码能力、服务端处理和目标播放器支持要逐一验证。容器是封装格式,编码器决定压缩方法;"MP4 视频"本身不代表它一定是 H.264。
参数选择可按这个顺序:
- 分辨率:只在确有必要时缩小,保持宽高比,避免把竖屏压成横屏、把小视频放大。
- 帧率:不是所有视频都应强行设成 30 fps;高帧率视频降帧会改变运动表现,录屏文字清晰度也要单独检查。
- 视频码率:根据分辨率、运动复杂度和体积预算试验;同一个码率对静态访谈与高速运动的质量不同。
- 音频:确定是否需要保留原声、声道和音量;音频码率也计入最终大小。
- 旋转/HDR:验证方向元数据、色彩空间与 HDR 到 SDR 的处理;"压缩成功"不代表颜色和方向正确。
输出可能比输入更大:原视频已用高效率编码或低码率,而你的目标配置反而更宽松。完成转码后比较大小与可播放性;若输出更大且服务端接受原文件,按产品规则考虑回退原样上传。
五、Media3 Transformer:设备端转码的主线
Media3 Transformer 提供视频编辑和导出能力,底层会结合平台编解码组件。下面仅示意已判断需要转码、且源画面高于目标高度 时的流程:构造 MediaItem、设置缩放效果、创建 Transformer,然后异步导出到应用私有路径。
kotlin
// 结构示意:listener 的完成/失败回调与协程桥接按项目 Media3 版本实现
val edited = EditedMediaItem.Builder(MediaItem.fromUri(inputUri))
.setEffects(
Effects(
emptyList(),
listOf(Presentation.createForHeight(720))
)
)
.build()
val transformer = Transformer.Builder(context)
.setVideoMimeType(MimeTypes.VIDEO_H264)
.setAudioMimeType(MimeTypes.AUDIO_AAC)
.addListener(listener)
.build()
transformer.start(edited, outputFile.absolutePath)
这里的 720 是示例的目标高度,不是所有视频都应固定到 720。实际封装需处理:onCompleted、onError、进度、取消时 transformer.cancel()、输出文件清理,以及设备不支持目标编码/效果时的回退。Transformer API 与效果类构造在不同 Media3 版本中可能变化,因此先锁定版本、参考该版本官方示例,再把异步回调包装成业务层的 suspend 接口。
不要把一次失败直接重试同一个参数十次。先区分输入不支持、编码器不可用、空间不足、用户取消与设备资源压力;再选择原样上传、降低目标、提示用户或交给服务端转码。
六、上传:流式读取文件,不要 readBytes()
几十到几百 MB 的视频不能先 file.readBytes() 全部塞进内存。以 Retrofit + OkHttp 的 Multipart 为例,文件请求体会在发送时从文件读取:
kotlin
interface VideoApi {
@Multipart
@POST("videos")
suspend fun upload(@Part file: MultipartBody.Part): UploadResponse
}
suspend fun uploadVideo(api: VideoApi, file: File): UploadResponse {
val body = file.asRequestBody("video/mp4".toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, body)
return api.upload(part)
}
这里假设输出确实是 MP4,且服务端协议要求字段名 file。如果原样上传的源文件不是 MP4,不能只改请求头冒充 MP4;要按实际类型与服务端协议处理。Retrofit 的挂起接口可与协程取消配合,页面级上传应归属明确作用域,避免离开页面后"幽灵请求"继续执行。
上传进度通常是在 RequestBody.writeTo() 外包一层计数字节的请求体,统计已写入传输通道的字节与文件总字节。它不是服务端已永久保存的证明;只有收到服务端成功确认、必要时再校验资源状态,才能宣布上传完成。转码进度与上传进度属于两阶段,UI 可以分别显示,或按预估耗时加权,但不要把"转码到 100%"显示成"发布成功"。
七、大文件要有续传协议,而不是盲目重试整个包
弱网下 200 MB 视频传到 95% 断线,再从头上传会浪费用户流量。大文件更适合与服务端约定上传会话和分片。下面是协议设计示意,不是通用 HTTP 标准接口:
text
POST /upload-sessions → 会话 ID、分片大小
PUT /upload-sessions/{id}/parts/0 → 上传第 0 片及校验信息
PUT /upload-sessions/{id}/parts/1 → 上传第 1 片及校验信息
GET /upload-sessions/{id} → 查询已确认分片
POST /upload-sessions/{id}/complete → 让服务端合并并确认最终资源
客户端要持久记录会话 ID、源文件标识、分片大小和已确认分片。重试时先与服务端对账,而不是只相信本地"已写出多少字节"。每片可附校验和、序号或幂等键;服务端需要定义重复提交、过期会话、合并失败的行为。分片也不能无限小,片数过多会增加请求与服务端管理开销。
如果服务端只支持单次 Multipart,客户端无法凭空实现真正的断点续传,只能做整包重试、限制输入大小或推动服务端协议升级。
八、任务生命周期:页面内上传还是后台可靠上传?
| 需求 | 更合适的所有者 | 需要注意 |
|---|---|---|
| 用户停留页面时上传,离开即取消 | viewModelScope 等页面相关作用域 |
取消要传到转码器与网络请求;清理半成品 |
| 离开页面仍要完成,可在进程重建后重试 | WorkManager + 持久化输入/会话 | 约束、前台通知、平台配额与超时规则 |
| 用户主动暂停/继续大文件上传 | 持久化会话 + 可取消 Worker/上传器 | 服务端必须支持查询与续传 |
WorkManager 适合需要持久调度的任务,但它不替你保存一个可失效的 Photo Picker Uri,也不替你实现分片协议。长时间运行的工作可能需要前台执行与通知,还要遵守当前 Android 版本对后台工作的限制。输入若放在 cacheDir,系统可能清理缓存;可靠任务可放在应用私有持久目录,并在服务端确认或用户明确取消后清理。
压缩和上传都可能长时间占用 CPU、磁盘与网络。记录阶段状态,例如 Selected → Transcoding → Ready → Uploading → Confirmed/Failed,让用户可以看到失败原因、重试和取消入口。不要只维护一个从 0 到 100 的百分比。
九、发布前的检查清单
- 输入:Photo Picker、系统文件选择器、云端 Uri、无大小信息、损坏文件都能给出可理解的处理结果。
- 视频:横屏/竖屏、旋转、静态/高运动、录屏文字、无音轨、HDR、不同编码的输出都可播放,方向与声音正确。
- 资源:低存储空间、转码取消、App 退后台、进程重建后没有无限增长的临时文件。
- 网络:慢网、断网、401、5xx、分片重复提交、服务端超时都有明确重试或失败策略。
- 安全与隐私:HTTPS、授权上传地址、服务端校验文件类型与大小;视频可能包含定位等元数据,按产品隐私要求处理。
- 完成条件:只有服务端返回并确认资源可用,才标记"发布成功";上传进度不等于业务完成。
十、常见问题
**压缩到 720p 就一定小吗?**不一定。最终大小主要受时长、实际码率、编码器与内容复杂度影响;输出后要读真实文件大小。
**能只换成 MP4 后缀吗?**不能。文件后缀、容器与视频编码是不同层次;不兼容时可能需要重封装或真正转码。
**为什么不对每个视频都跑 FFmpeg?**无条件转码会增加等待、耗电、包体积和维护成本,也可能损失画质。先确认平台能力、目标设备与服务端需求,再决定是否引入额外编解码方案。
**WorkManager 能保证上传"恰好一次"吗?**不能单靠客户端调度保证。任务重试与进程中断可能产生重复请求,重要业务需要服务端幂等与上传会话确认。
**前端进度到 100% 为什么还没完成?**字节已发出后,服务端仍可能校验、转码、合并分片或入库;UI 应区分"传输完成"和"资源可用"。
真正可靠的视频上传链路,不是选一个固定压缩率,而是:以服务端约束决定是否转码,以实际文件校验结果,以持久会话抵抗弱网,以服务端确认作为完成依据。