Android 视频压缩上传实战:从 content Uri、转码到断点续传

用户选了一段 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。

参数选择可按这个顺序:

  1. 分辨率:只在确有必要时缩小,保持宽高比,避免把竖屏压成横屏、把小视频放大。
  2. 帧率:不是所有视频都应强行设成 30 fps;高帧率视频降帧会改变运动表现,录屏文字清晰度也要单独检查。
  3. 视频码率:根据分辨率、运动复杂度和体积预算试验;同一个码率对静态访谈与高速运动的质量不同。
  4. 音频:确定是否需要保留原声、声道和音量;音频码率也计入最终大小。
  5. 旋转/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 应区分"传输完成"和"资源可用"。

真正可靠的视频上传链路,不是选一个固定压缩率,而是:以服务端约束决定是否转码,以实际文件校验结果,以持久会话抵抗弱网,以服务端确认作为完成依据。

参考资料

相关推荐
冉冉同学5 小时前
AI Agent 开始操作真实手机:移动端自动化的 3 个新考点
android·ai编程
帅得不敢出门6 小时前
Android Framework关闭软件低电关机功能测试PMIC低电保护
android
恋猫de小郭6 小时前
Flutter + EmbeddingGemma 2,谷歌发布完全端侧的 AI Edge Foresight
android·前端·flutter
墨天梦8 小时前
B16_Material主题与可访问交互
android·kotlin·交互
弯_弯8 小时前
企业知识库 RAG 落地:从 Demo 到生产,四条工程链路与参数级自查
android·java·javascript
晚风叙码9 小时前
MySQL 表的约束详解:从空属性到外键
android·数据库·mysql·adb
alexhilton20 小时前
模块化Jetpack Compose架构
android·kotlin·android jetpack
厮年断弦1 天前
AI 辅助开发实录:一周做完一个 Android 本地音乐播放器,这些坑替你踩了
android
hai_android1 天前
InputStage 责任链核心执行流程
android·android studio·android jetpack