上传框架演进复盘:从「每种类型一个分组」到「每次上传一个分组」,以及动态的后台发布
项目 social-app
1. 背景与问题
这是一款面向日本市场的语音社交 App。客户端里需要上传文件的地方很多:发动态(图片、视频、音频)、换头像、聊天里的媒体消息、日志打包回传。它们有几个共同点:
- 都要先向业务服务申请上传凭证,再把文件传到文件存储服务;
- 都需要失败重试和结果回执,回执要交给业务服务端校验,不能只信客户端给的一个地址;
- 视频类文件动辄几十 MB,传之前必须先压。
所以上传被抽成了一套公共框架,业务方只关心「传什么」和「传完之后做什么」。这篇复盘这套框架的结构、它的一次重要演进,以及它最重的消费者:动态发布。
2. 整体设计
2.1 三层结构
- 任务层:按媒体类型各一个子类,负责准备数据(相册原图、本地文件、视频导出产物)并执行上传。底层失败时,重试次数小于 2 就再试一次。
- 分组层:一次上传对应的一批任务。维护等待、进行中、成功、失败四个队列;首次上传前申请凭证并在组内复用;全部完成后一次性回调「成功列表 + 失败列表」。
- 管理器:对外按图片、视频、音频、压缩包分别提供上传入口,管理分组的创建、移除和整体取消。
2.2 分组内部的流转
成功回执里除了文件地址,还带上 MIME、内容 MD5 等字段,编码成一段结构化字符串交给业务服务端。服务端据此校验文件确实存在、内容一致,防止客户端随便填一个地址冒充上传结果。
3. 关键实现
3.1 一次重要演进:分组从单例变成「每次一个」
最早的结构是每种媒体类型全局只有一个分组对象。这在只有一个业务上传时没问题,但发动态、换头像、日志回传可能同时发生:同一个图片分组的等待队列和回调代理会被后来者覆盖,带来结果串台、回调丢失的风险。
演进后,管理器按类型持有的是分组数组:每次调用上传入口都新建一个分组,完成或取消后从数组移除;取消全部时遍历取消并清空。
// 示意,非原始代码
func uploadImages(_ items: [ImageItem], completion: @escaping GroupResult) {
let group = ImageUploadGroup(items: items)
imageGroups.append(group)
group.start { [weak self] success, failed in
self?.imageGroups.removeAll { $0 === group }
completion(success, failed)
}
}
func cancelAll() {
allGroups.forEach { $0.cancel() }
allGroups.removeAll()
}
3.2 视频压缩链路
视频压缩这条链路:动态视频的压缩策略、聊天视频的逐级降档,以及后来更换的导出方法。
两类视频的目标不同,所以策略也不同:
| 场景 | 触发条件 | 导出规格 | 目标 |
|---|---|---|---|
| 动态视频 | 文件大于 10MB 才压 | 960×540,MP4,针对网络传输优化 | 控制体积,画质够看 |
| 聊天视频 | 默认先导出 | 1280×720 起,过大则逐级降档 | 无论如何都要发得出去 |
聊天视频的降档顺序是 1280×720 → 640×480 → 中等质量 → 低质量:每一档导出后检查大小,仍然超限就删掉产物、换下一档重新导出。
// 示意,非原始代码
let presets = [.p1280x720, .p640x480, .medium, .low]
func exportForChat(asset: AVAsset, to url: URL, level: Int = 0, done: @escaping (URL?) -> Void) {
export(asset, preset: presets[level], to: url) { ok in
guard ok else { return done(nil) }
if fileSize(url) <= chatLimit || level == presets.count - 1 {
return done(url)
}
removeFile(url)
exportForChat(asset: asset, to: url, level: level + 1, done: done)
}
}
另外几个细节:导出任务按输出文件名去重,同一个视频不会被并行导出两次;相册里的视频允许从云端相册下载原始资源,避免因为资源不在本地而导出失败;动态视频输出到临时目录、随机文件名,上传完即可清理。
3.3 动态发布:点完就走
动态发布页,以及草稿可删除、圈子参数、视频与音频动态这些迭代。
用户在发布页点「发布」后,页面立即关闭,上传和提交在后台进行,列表顶部先出现一条带遮罩的本地动态。
- 进度怎么算:多图时按「已完成张数 + 当前这张的进度」折算成总进度;音频动态要音频文件和封面都传完才提交。
- 进度展示:顶部进度条挂在主窗口上,只在消息首页、广场、动态列表、发布页等白名单页面显示,并据此隐藏状态栏,避免在不相关的页面上突兀出现。
- 失败:提示「已保存至草稿」,用户可以重新发布或删除草稿。
- 登出:取消全部上传并复位发布管理器。
4. 边界与失败处理
4.1 并发上限其实是「碰巧正确」
分组里「进行中队列」的设计意图是控制并发:取任务前看进行中有几个,不超过上限才取。但通读实现会发现,任务开始时并没有写入进行中队列,只在完成时尝试移除。
这在上限为 1 时恰好没问题(每完成一个才取下一个);如果把上限调大,完成回调里看到的「进行中」永远是 0,一次会取出多个任务,实际并发会超过设定值。
这类问题可以这样发现和验证:
- 怎么复现:把图片分组的并发上限临时改成 2,一次选 9 张图上传,在任务开始和结束处打日志,统计同一时刻的在途任务数。
- 怎么定位:搜索进行中队列的所有写入点。只有 remove、没有 append,基本就能确定问题。
- 怎么修:开始任务时写入、结束时移除,并把四个队列的读写收到同一个串行队列上。
4.2 其它边界
- 杀进程:动态草稿只保存在发布管理器的内存里,App 被杀后草稿和进度都会丢失。聊天里的媒体消息把状态写进消息本地扩展字段、能在重进会话后续传,两者的设计差异很明显(见同系列的媒体消息可靠发送一文)。
- 部分失败:多图中部分失败时的处理分支不够统一,有的分支可能只用已成功的图片发布,有的分支在图片数据准备失败时没有回调,可能让分组一直等下去。
- 图片编码:图片准备阶段使用
PNG编码,体积偏大,上传时间和流量都更多。
5. 演进
- 起步:上传框架建立,每种媒体类型一个单例分组。
- 扩张:动态支持视频和音频上传。
- 收敛:更换视频导出方法;分组改为每次上传新建,业务之间互不干扰。
- 之后发布页继续迭代:草稿可删除、圈子参数等。
动态视频、音频的上传接入,以及视频导出方法的更换。分组从「一个业务、一次上传」变成每次新建,是因为发动态、换头像、日志回传会同时发生。
5.1 结构再看一遍
管理器下面是分组,分组下面是任务。
一个任务在分组里的位置:
演进前一个类型只有一个分组,后来每次上传新建一个。
聊天视频过大就降一档,直到能发出去。动态视频则是超过阈值才压。
发布页点完就关,列表上的占位跟上传结果走。
6. 复盘与可改进之处
做对了的地方
- 三层结构让业务方只关心「传什么」,凭证、重试、回执都在框架里统一处理。
- 聊天视频的逐级降档,把「发不出去」这种最差体验换成了「画质差一点」。
- 发布页点完就走、列表先上屏,用户感知到的等待时间很短。
还可以做得更好
- 草稿落盘:把发布稿和每个媒体的上传状态序列化到磁盘,重启后可续传;思路和聊天媒体消息一致。
- 后台上传:换成后台
URLSession,App 挂起甚至被杀后,系统仍能继续传输并在完成时唤醒 App。 - 并发控制用现成工具:用
OperationQueue的maxConcurrentOperationCount,或者 Swift Concurrency 的TaskGroup限流,不再手写队列。 - 图片编码:改为
HEIC或带质量参数的JPEG,并按长边缩放后再上传。 - 部分失败策略显式化:「部分失败时是否允许发布」应该是一个明确的产品决策,并在每条失败分支上保证有且只有一次回调。