最近,我为 DIALØGUE 的 iOS app 上线了离线下载——就是我在上一篇里只是挥挥手称作"稳健的离线"、却从没解释过到底是什么意思的那部分。这篇就是那个解释。而它的开头,是我差一点做错了东西。
先坦白一下。DIALØGUE 是一个 AI 播客 app:你给它一个主题或者一份 PDF,它就生成一段两位主持人之间的对话。所以当我开始做离线模式时,我的脑子径直跳向了某种"AI 形状"的东西。也许"离线"意味着在设备上预先生成音频。也许是缓存模型的输出,或者搞一条聪明的流水线,能在没有网络的情况下把一集重新拼出来。我脑子里都快成形出一整套架构了。我差点就照那个方向写了设计文档。
然后我停下来,问了一个更无聊的问题:关于这件事,iOS 已经给了我什么?
结果答案是"几乎一切"。而这个功能真正的工作,并不是去造一个聪明的东西。而是克制住那股冲动。
那个无聊的问题,帮我省下了一个月
这是我一篇又一篇地反复学到的事:复杂不是你的朋友。 尤其当你只是一个人,在晚上和周末做东西的时候。
一个 AI 播客,一旦生成出来,就只是一个躺在某个 URL 上的音频文件。"离线"不是把它重新生成一遍。而是把那个文件下载下来,之后再播放。这不是一个 AI 问题。这是一个早在 App Store 出现之前,苹果就一直在解决的问题。
所以,我没有去搞一条聪明的流水线,而是伸手拿起了工具箱里最无聊、最久经考验的那件工具:一个 background URLSession。如果你没接触过它,想法很简单——你把下载交给操作系统,它就会一直跑下去,哪怕你的 app 已经在后台、甚至已经被杀掉,然后它会重新启动你的 app,把下载好的文件交给你。
let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true
差不多两行,现在那些真正困难的部分就归操作系统管了。我没有写下载引擎。我借了苹果的。
我看向哪里,同样的故事就在哪里重演:
- 下载中途断了? iOS 会给你"resume data"——一小块数据,让下载能从断掉的地方接着来,而不是从零重新开始。我只要把它写到磁盘上(写在
Caches文件夹里,因为它是可丢弃的),下次再传回去就行。 - 不想烧掉用户的蜂窝流量? 一行:
request.allowsCellularAccess = !wifiOnly。之后操作系统会自己去等 WiFi。我不监控连接。我不轮询任何东西。 - 需要显示下载占了多少空间?
ByteCountFormatter会把一个字节数变成带有正确本地化单位的"23.4 MB"。这个也不是我写的。
我想诚实地说说这有多舒服,因为它和直觉是反着来的。回头看,正确的一步并不是更聪明的代码。而是承认这是一个已经被解决了的问题,然后把那份工作借过来,而不是想着去比它更聪明。
但"无聊"并不等于"简单"
现在我得承认一件事,因为如果我告诉你标准做法让这件事变得轻而易举,那我是在撒谎——而任何一个读到这里的 iOS 开发者,大概十秒钟就能戳穿我。
标准,跟轻而易举不是一回事。无聊的工具之所以无聊,是因为它们被用得太熟太久了,而不是因为它们毫无摩擦。下面是那些真正花掉我时间的事,万一这份清单能帮你省一点:
1. 临时文件会在你眼皮底下消失。 当一个后台下载完成时,iOS 会回调你,并指向一个临时位置上的文件。那个没人提的坑:在你的回调返回的那一瞬间,那个文件就被删掉了。你必须同步地把它挪到一个永久的地方,就在那个函数里,在做任何别的事情之前。更别扭的是,那个回调跑在主线程之外,而且它只知道是哪个下载完成了,并不知道用我 app 的说法那是哪一集。所以代码做了一个难看的两步走:先立刻把文件挪到一个临时的名字,然后跳到主线程,等知道它是什么了再给它改名。不漂亮。但另一条路,是一个还没等你保存就已经没了的文件。
2. 后台 session 在模拟器里会悄无声息地什么都不做。 我花了一段丢人的时间,笃定是我的代码坏了。它没坏。background URLSession 在 iOS 模拟器上就是表现得不可靠——没有报错,没有崩溃,什么都不下载。修法很难看,我认:
#if DEBUG
// Use a default session in the simulator — background sessions can silently fail
session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
#else
session = URLSession(configuration: .background(withIdentifier: identifier), delegate: self, delegateQueue: nil)
#endif
那个 #if DEBUG 并不优雅。但假装模拟器跟真机表现一致,会比承认它不一致让我付出大得多的代价。
3. Swift 6 逼我说清楚到底谁在哪个线程上跑。 下载回调到来时是在一个后台线程上。我的 UI 状态——那些小小的进度圈、那个绿色的"已下载"对勾——必须在主线程上更新。Swift 6 更严格的并发检查,不肯让我在那条边界上马虎。最后我留下了一个清理用的 commit,标题原话就是 "resolve Swift 6 actor-isolation warnings." 当下烦人,回头看正确。编译器是对的,错的是我。
4. 服务器并不总是把文件名起得干干净净。 音频有时回来是 audio/mpeg,有时是 m4a,偶尔是别的什么。所以有一个小小的、乏味的函数,负责从响应的 content type 里推断出文件扩展名,不行就退回到 URL,再不行就默认 .mp3。不光鲜。但必要。
这些里头没有一个是 AI 问题。每一个都是"计算机实际上是怎么运转的"这类问题。这就是走无聊那条路要交的税。它是实打实的——但比起去维护一个我自己发明的聪明玩意儿的代价,我每一次都愿意交这笔税。
我刻意没有去做的东西
我觉得整个功能里最有用的部分,反倒是我没有去做的那些事的清单:
- 我没写下载引擎。(background
URLSession。) - 我没写连接状态监测。(
allowsCellularAccess。) - 我没发明断点续传协议。(操作系统的 resume data。)
- 我没造任何专门为 AI 设计的东西。(它是个文件。它会被下载。)
我真正花了心思的,是些又小又不光鲜的事:一个小小的队列,让同时跑的下载不超过三个,其余的排队等着;把每集的标题存下来,好让存储页面在冷启动之后显示真实的名字,而不是"剧集、剧集、剧集";把那个页面按文件从大到小排序,这样当你想腾空间时,最大的元凶就排在最上面。而真正的判断——还剩几个空位、列表怎么排——我都抽成了不挂任何 URLSession 的纯函数,好让我能正经地给它们写单元测试。无聊。可测。无聊正是因为可测。
那个教训,大概第一百遍了
到现在,我已经写过这篇文章的好几个版本了——关于重做我的网站,关于一张 54 美元的 API 账单,关于重做眼前这个 app。教训一直是同一个,而我一直需要把它重新学一遍:聪明的方案,通常是昂贵的方案。"平台已经给了我什么?"——这是我在写下一行代码之前,能问出的最有价值的问题。
尤其对一个单干的人来说:你发明的每一个聪明东西,都是一件在最糟糕的时刻坏掉、而只能由你一个人去维护的东西。你借来的每一个无聊、标准的东西,背后都有一家拥有成千上万名工程师的公司在替你维护。从我的经验看,这笔交易几乎总是值得做的——而且说实话,做这笔交易所需要的自律,比它所取代的那点聪明更难。不过,关于这条线到底划在哪儿,我也可能是错的。肯定有些问题,平台什么都不给你,难的那部分你只能自己造——我只是一次又一次地发现,这种情况比我的直觉所坚持的要罕见得多。
用 DIALØGUE 的人里,已经有一些可以下载一集、登上飞机、在没有信号的情况下收听了。这感觉像是一个有意义的功能。而它背后的代码,几乎是带着一股狠劲的平平无奇。我已经跟这一点和解了。我觉得,这才是这份活儿本身。
你也会这样吗——明明无聊的那个就在手边,却抓住自己又伸手去够聪明的那个?还是说只是我太慢,把同一个教训学了一遍又一遍?我是真的很想听听别人都是怎么跟这股直觉较劲的。
祝好, Chandler