上传框架演进复盘:从「每种类型一个分组」到「每次上传一个分组」,以及动态的后台发布

项目 social-app

  • 上传
  • 视频压缩
  • 并发
  • 架构演进
  • AVFoundation

1. 背景与问题

这是一款面向日本市场的语音社交 App。客户端里需要上传文件的地方很多:发动态(图片、视频、音频)、换头像、聊天里的媒体消息、日志打包回传。它们有几个共同点:

  • 都要先向业务服务申请上传凭证,再把文件传到文件存储服务;
  • 都需要失败重试和结果回执,回执要交给业务服务端校验,不能只信客户端给的一个地址;
  • 视频类文件动辄几十 MB,传之前必须先压。

所以上传被抽成了一套公共框架,业务方只关心「传什么」和「传完之后做什么」。这篇复盘这套框架的结构、它的一次重要演进,以及它最重的消费者:动态发布。


2. 整体设计

2.1 三层结构

业务方 动态发布 头像 日志回传上传管理器 按媒体类型持有分组图片分组视频分组音频分组文件或压缩包分组图片任务视频任务音频任务文件任务文件存储服务
  • 任务层:按媒体类型各一个子类,负责准备数据(相册原图、本地文件、视频导出产物)并执行上传。底层失败时,重试次数小于 2 就再试一次。
  • 分组层:一次上传对应的一批任务。维护等待、进行中、成功、失败四个队列;首次上传前申请凭证并在组内复用;全部完成后一次性回调「成功列表 + 失败列表」。
  • 管理器:对外按图片、视频、音频、压缩包分别提供上传入口,管理分组的创建、移除和整体取消。

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 → 中等质量 → 低质量:每一档导出后检查大小,仍然超限就删掉产物、换下一档重新导出。

有没有
否是
否是
否是
选中一个视频本地已有导出产物吗直接使用先按高清档导出仍然超过大小限制吗删除产物 按 640x480 导出仍然超限吗按中等质量导出仍然超限吗按低质量导出 最后一档
// 示意,非原始代码
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 动态发布:点完就走

动态发布页,以及草稿可删除、圈子参数、视频与音频动态这些迭代。

用户在发布页点「发布」后,页面立即关闭,上传和提交在后台进行,列表顶部先出现一条带遮罩的本地动态。

业务服务端上传管理器动态列表发布管理器发布页用户业务服务端上传管理器动态列表发布管理器发布页用户alt[已有任务][空闲]点发布已有任务在发吗提示稍后再发交出稿子 立即关闭页面开始发布通知在对应标签页最前插入本地动态 带遮罩上传媒体进度 已完成张数加当前进度全部成功提交动态成功发布成功通知占位变为正式动态 约 2 秒后去掉遮罩
  • 进度怎么算:多图时按「已完成张数 + 当前这张的进度」折算成总进度;音频动态要音频文件和封面都传完才提交。
  • 进度展示:顶部进度条挂在主窗口上,只在消息首页、广场、动态列表、发布页等白名单页面显示,并据此隐藏状态栏,避免在不相关的页面上突兀出现。
  • 失败:提示「已保存至草稿」,用户可以重新发布或删除草稿。
  • 登出:取消全部上传并复位发布管理器。

4. 边界与失败处理

4.1 并发上限其实是「碰巧正确」

分组里「进行中队列」的设计意图是控制并发:取任务前看进行中有几个,不超过上限才取。但通读实现会发现,任务开始时并没有写入进行中队列,只在完成时尝试移除。

这在上限为 1 时恰好没问题(每完成一个才取下一个);如果把上限调大,完成回调里看到的「进行中」永远是 0,一次会取出多个任务,实际并发会超过设定值。

这类问题可以这样发现和验证:

  1. 怎么复现:把图片分组的并发上限临时改成 2,一次选 9 张图上传,在任务开始和结束处打日志,统计同一时刻的在途任务数。
  2. 怎么定位:搜索进行中队列的所有写入点。只有 remove、没有 append,基本就能确定问题。
  3. 怎么修:开始任务时写入、结束时移除,并把四个队列的读写收到同一个串行队列上。

4.2 其它边界

  • 杀进程:动态草稿只保存在发布管理器的内存里,App 被杀后草稿和进度都会丢失。聊天里的媒体消息把状态写进消息本地扩展字段、能在重进会话后续传,两者的设计差异很明显(见同系列的媒体消息可靠发送一文)。
  • 部分失败:多图中部分失败时的处理分支不够统一,有的分支可能只用已成功的图片发布,有的分支在图片数据准备失败时没有回调,可能让分组一直等下去。
  • 图片编码:图片准备阶段使用 PNG 编码,体积偏大,上传时间和流量都更多。

5. 演进

  • 起步:上传框架建立,每种媒体类型一个单例分组。
  • 扩张:动态支持视频和音频上传。
  • 收敛:更换视频导出方法;分组改为每次上传新建,业务之间互不干扰。
  • 之后发布页继续迭代:草稿可删除、圈子参数等。

动态视频、音频的上传接入,以及视频导出方法的更换。分组从「一个业务、一次上传」变成每次新建,是因为发动态、换头像、日志回传会同时发生。


5.1 结构再看一遍

管理器下面是分组,分组下面是任务。

管理器按类型持有分组取消全部分组凭证四个队列任务准备数据上传与重试

一个任务在分组里的位置:

带回执失败且还能重试重试用尽
等凭证等待上传中成功失败

演进前一个类型只有一个分组,后来每次上传新建一个。

每次一个
动态分组甲头像分组乙
每种类型一个
动态唯一分组头像

聊天视频过大就降一档,直到能发出去。动态视频则是超过阈值才压。

是
否
聊天视频仍然过大?降一档再导出上传

发布页点完就关,列表上的占位跟上传结果走。

提交并关页上传并提交成功失败留在内存
编辑发布中已上屏草稿
起步每种媒体一个分组扩张动态加上视频和音频收敛每次上传新建分组导出方法更换上传框架的几步

6. 复盘与可改进之处

做对了的地方

  • 三层结构让业务方只关心「传什么」,凭证、重试、回执都在框架里统一处理。
  • 聊天视频的逐级降档,把「发不出去」这种最差体验换成了「画质差一点」。
  • 发布页点完就走、列表先上屏,用户感知到的等待时间很短。

还可以做得更好

  1. 草稿落盘:把发布稿和每个媒体的上传状态序列化到磁盘,重启后可续传;思路和聊天媒体消息一致。
  2. 后台上传:换成后台 URLSession,App 挂起甚至被杀后,系统仍能继续传输并在完成时唤醒 App。
  3. 并发控制用现成工具:用 OperationQueue 的 maxConcurrentOperationCount,或者 Swift Concurrency 的 TaskGroup 限流,不再手写队列。
  4. 图片编码:改为 HEIC 或带质量参数的 JPEG,并按长边缩放后再上传。
  5. 部分失败策略显式化:「部分失败时是否允许发布」应该是一个明确的产品决策,并在每条失败分支上保证有且只有一次回调。
现在 内存草稿 手写并发 PNG 编码草稿和上传状态落盘后台 URLSessionOperationQueue 或 TaskGroup 限流HEIC 或 JPEG 压缩 长边缩放可恢复 可控 更省流量的上传链路