相机上传是 Dropbox Android 和 iOS 应用的一项功能,自动将用户移动设备中的照片和视频备份到 Dropbox。该功能于 2012 年首次推出,每天为数十万用户上传数百万张照片和视频。使用相机上传的是我们最忠实、最活跃的一群用户。他们非常重视自己的照片库,期待每次备份都快速可靠。
我们必须提供值得信赖的服务。
直到不久前,相机上传仍建立在 Android 与 iOS 应用共享的 C++ 库之上。这个库多年里上传了数十亿张图片,长期表现不错,但也存在很多问题。共享代码逐渐混入复杂的平台专用变通方案,难以理解,修改风险很高。工具支持不足和公司内部 C++ 专业能力有限,又放大了风险。
投入生产五年多后,C++ 实现也开始显露老化。它不了解各平台对后台进程的限制,某些错误会长时间延迟上传,故障恢复既困难又耗时。
2019 年,我们决定重写该功能,为未来多年提供可靠、可信赖的体验。这次 Android 与 iOS 实现彼此分开,分别采用原生语言 Kotlin 和 Swift,以及原生库,例如 Android 的 WorkManager 和 Room。这样可以分别针对平台优化、独立演进,不再受另一个平台的设计决策限制。
本文介绍我们构建 Android 新版相机上传时的设计、验证和发布决策。它于 2021 年夏季向全部用户推出,顺利发布,没有发生故障或重大问题;错误率降低,上传性能显著改善。如果还未启用相机上传,可以 亲自体验。
为后台可靠性设计
相机上传的核心价值,是在后台静默工作。即使用户数周甚至数月不打开应用,新照片也应该及时上传。
用户拍摄新照片或修改现有照片时,操作系统通知 Dropbox 移动应用。我们称为 scanner(扫描器)的后台任务会仔细找出尚未上传的照片或视频,加入上传队列。另一个后台任务 uploader(上传器)再批量上传队列中的照片。
上传分两步。与许多 Dropbox 系统一样,先把文件分成 4 MB 数据块,计算每块哈希并上传到服务端。所有块上传完成后,再发送最终提交请求,附上文件的全部块哈希列表。服务端据此在用户的 Camera Uploads 文件夹中创建文件,之后可从任何关联设备访问照片和视频。

最大的挑战之一是,Android 严格限制应用在后台运行的频率和能力。例如,如果 Dropbox 最近没有进入前台,App Standby 会限制后台网络访问。这可能意味着每 24 小时只有一次、长达 10 分钟的联网机会。
较新的 Android 版本让这些限制更加严格,而跨平台 C++ 版相机上传无法很好应对。有时它会在没有网络访问权限时尝试注定失败的上传;有时系统提供联网窗口后,它又没能重新启动上传。
重写不会绕过后台限制。除非用户在 Android 设置中主动关闭这些限制,限制仍然适用。但我们尽可能充分利用获准的网络访问,减少延迟。我们通过 WorkManager 处理后台约束,确保只有网络可用时才尝试上传,并在网络可用时执行上传。
与 C++ 实现不同,我们还尽量离线完成准备工作,例如初步检查新照片是否重复,然后才请求 WorkManager 调度网络访问。

为进一步优化有限的联网机会,我们改进失败上传的处理。C++ 版会激进地无限重试。新版在重试之间加入退避间隔,并按错误类别调整策略:可能是暂时性错误,就多次重试;可能是永久性错误,就不再重试。
结果是总重试次数减少,网络与电池消耗受到控制,用户看到的错误也更少。
为性能设计
用户不仅期待上传可靠,也期待上传迅速且不浪费系统资源。我们在这方面取得明显改进。例如,大型照片库的首次上传,现在最多可以快四倍。新版通过几种方式实现这些改进。
并行上传
首先,增加并行上传支持大幅改善了性能。C++ 版每次只上传一个文件。重写早期,我们与 iOS 和后端基础设施同事合作,设计了支持并行上传的新提交端点。
服务端限制消除后,Kotlin 协程让并发上传更容易实现。Kotlin Flow 通常顺序处理,但现有操作符足够灵活,可以组合成支持并发处理的自定义操作符。这些操作符能以声明式方式串联,让代码比 C++ 中必须采用的人工线程管理更简单、开销更低。
val uploadResults = mediaUploadStore
.getPendingUploads()
.unorderedConcurrentMap(concurrentUploadCount) {
mediaUploader.upload(it)
}
.takeUntil {
it != UploadTaskResult.SUCCESS
}
.toList()
上面是并发上传流水线的简例。unorderedConcurrentMap 是结合内置 flatMapMerge 与 transform 的自定义操作符。
优化内存使用
加入并行上传后,早期测试者的内存不足崩溃明显增加。要达到生产所需的稳定性,我们必须做多项改进。
首先根据可用系统内存动态调整同时上传的数量,让大内存设备获得最快上传速度,又不使旧设备不堪重负。但内存用量仍比预期高得多,于是我们用内存分析器进一步检查。
首先发现所有上传结束后,内存没有回到上传前的基线。原因是 Java NIO API 的一种不理想行为:它会在每个读取文件的线程上创建内存缓存,而该缓存一旦创建就不能销毁。
我们使用由线程池支持的 IO dispatcher 读取文件,因此通常会产生多个缓存,每个用到的 dispatcher 线程各一个。改用不会分配这种缓存的直接字节缓冲区,解决了问题。
接着发现上传时内存会大幅尖峰,尤其是大文件。每次上传都分块读取文件,把每块复制到 ByteArray 进一步处理。前一个数组退出作用域后才创建新数组,因此我们原以为内存中一次只会存在一个。
但短时间分配大量字节数组时,垃圾收集器来不及释放它们,造成暂时性内存尖峰。我们让所有块读取复用同一个缓冲区,解决了这个问题。
扫描与上传并行
C++ 实现必须完成照片库变更扫描后才能上传。为了避免延迟,每次扫描只查看比前一次扫描更新的变更。
这种方法有缺点。某些边缘情况下,时间戳误导的照片可能被完全跳过。如果错误或操作系统变更导致漏掉照片,发布修复还不够;我们必须清除受影响用户保存的扫描时间戳,强制全量重扫。而且首次启用相机上传时,必须检查所有内容才能开始上传,给新用户的第一印象并不好。
新版在每次变更后重新扫描整个照片库,确保正确性。同时将扫描与上传并行化,在旧照片仍被扫描时,新照片就可以开始上传。虽然重扫可能更久,上传本身仍能及时开始和完成。
验证
发布这种规模的重写风险很高。一些危险的失败模式只有大规模运行才会出现,例如每一百万次上传损坏一次。与多数重写一样,我们不可能避免引入新错误,因为并不了解、甚至不知道旧系统处理过的所有边缘情况。
项目开始时,我们就受到提醒:尝试删除一些以为早已不再使用的老相机上传代码,结果反而让 Dropbox 的崩溃报告服务遭遇了类似 DDoS 的请求洪峰。🙃
在生产环境验证哈希
早期开发中,我们让多个底层组件在生产环境与对应 C++ 组件并行运行,再比较输出。这样,在依赖新组件结果之前,就能确认它们工作正确。
其中一个组件是识别照片所用哈希算法的 Kotlin 实现。这些哈希用于去重,即便极小比例照片的哈希变化,也可能产生意外:例如把旧照片当新照片再次上传。Kotlin 与 C++ 并行运行时,哈希几乎总一致,但大约 0.005% 的情况不同。
哪一个实现错了?
我们增加日志来回答这个问题。当两者不一致时,检查服务端是否因哈希不匹配拒绝上传,以及它预期哪个哈希。服务端预期的是 Kotlin 哈希,让我们高度确信 C++ 哈希错误。这是好消息:我们修复了一个原本并不知道的罕见错误。
验证状态转换
相机上传使用数据库跟踪每张照片的上传状态。通常扫描器以 NEW 状态加入照片,再移到 PENDING;若不需要上传,则移到 DONE。上传器尝试上传 PENDING 照片,然后移到 DONE 或 ERROR。
大量工作并行后,多部分同时读写状态数据库很正常。单次读写保证顺序进行,但多个 worker 可能以重复或冲突方式修改状态,带来隐蔽错误。单元测试只孤立覆盖组件,无法捕获这些问题;集成测试也可能漏掉罕见竞态。
新版通过一组允许的状态转换验证每次更新。例如照片不得不经过 PENDING 就从 ERROR 转到 DONE。意外转换可能意味着严重错误,所以一旦发现,我们就停止上传并报告异常。

这些检查在上线早期帮助我们发现了棘手错误。日志中大量异常来自照片从 DONE 转到 DONE,让我们意识到某些照片被重复上传。根因是 WorkManager 的一个出乎意料的行为:unique worker 可以在前一个实例完全取消之前重新启动。
服务端拒绝重复文件,因此没有创建重复文件,但重复上传浪费了带宽与时间。修复后,上传吞吐量大幅提高。
逐步推出
即使做完验证,发布仍需谨慎。完整系统比单个部分复杂,还有内部用户测试未覆盖的长尾设备类型。我们也必须继续达到或超过所有依赖相机上传的用户期望。
为提前降低风险,我们确保能够从新版回滚到 C++ 版。例如,新版修改的所有用户偏好,也能应用于旧版。最终没有回滚,但灾难发生时能有退路,这些投入仍然值得。
我们首先面向自愿参与的 Beta 用户推出,他们通过 Play Store 抢先体验计划,每周收到新版 Dropbox Android 应用。该群体足够大,能暴露罕见错误,并收集上传成功率等关键指标。我们监测数月,确认可以广泛发布。
期间发现不少问题,但快速的 Beta 发布节奏让我们能够迅速迭代和修复。
我们也监测可能预示未来问题的指标。为确保上传器不会逐渐落后,观察待上传照片是否持续积压;按错误类型跟踪重试成功率,用于细调重试算法;最后还认真关注用户反馈和支持工单,发现指标未能捕获的错误。
最终向全部用户发布时,数月 Beta 投入的价值很明显。指标在发布期间保持稳定,没有重大意外,发布伊始就表现出更高可靠性与更低错误率。我们甚至提前完成了推出。
大量质量改进已在每周更新的 Beta 期完成,因此没有出现等待关键修复进入稳定版、延迟数周的情况。
这次重写值得吗?
重写大型遗留功能并不总是正确决定。它极其耗时:仅 Android 版就花了两人整整两年,也很容易导致重大回归或故障。值得做的重写,必须通过改善用户体验、长期节省工程时间精力,或二者兼有,交付实质价值。
对准备启动类似项目的人,我们建议:
- 定义目标与衡量方法。开始时确认收益能支撑投入,结束时判断是否得到预期结果。有些目标,例如未来抵御操作系统变更的能力,无法量化,这没关系,但应明确哪些可量化、哪些不可量化。
- 降低风险。识别一旦失败影响最大的组件或系统交互,从一开始就防范。先构建关键组件,尽可能不等完整系统完成,就在生产环境测试。提前投入以支持回滚,也很值得。
- 不要急。用户已经依赖旧功能按预期工作,发布重写可能比发布新功能更危险。先面向规模刚好足以提供成功评估数据的群体,再观察、等待并修复,直到数据支持继续推进。用户规模较小时处理问题,长期看更快、压力更小。
- 限制范围。重写时很容易顺手处理新功能、UI 清理与其他积压工作。考虑这是否真的比先发布重写、再迅速跟进其余事项更快、更容易。我们只处理核心架构相关问题,例如底层数据模型固有的崩溃,把其他改进推迟。
功能变化过多,不仅实现更久,也更难发现回归或回滚。
这次我们认为重写是正确选择。它立即改善可靠性,也为未来保持可靠奠定基础。iOS 与 Android 向不同方向演进,共享 C++ 库迟早会严重失效,需要根本性系统改变。
重写完成后,我们能更快构建和迭代相机上传,也能为用户提供更好体验。
另外:我们在招聘!
如果你是希望构建长期可靠、可维护软件的移动工程师,Dropbox 欢迎你!空缺职位见 招聘页面。
来源与版权
作者 Sarah Tappon、Andrew Haigh;原文 Making camera uploads for Android faster and more reliable,2022 年 3 月 16 日,Dropbox.Tech。本中文版本依据转载授权翻译。结果、平台行为及性能数字保留原文时间范围,未独立复现实验。











暂无评论内容