最近,我為 DIALØGUE 個 iOS app 上線咗離線下載——就係我上一篇裏面,只係揮一揮手叫做「穩健嘅離線」、但其實從來冇解釋過究竟係乜意思嗰部分。呢篇就係嗰個解釋。而佢嘅開頭,係我差啲整錯咗嘢。
老實坦白。DIALØGUE 係一個 AI 播客 app:你畀一個題目或者一份 PDF 佢,佢就會生成一段兩位主持人之間嘅對話。所以當我開始做離線模式嗰陣,我個腦即刻跳去某種「AI 形狀」嘅嘢。或者「離線」即係喺部機上面預先生成音頻。或者係 cache 個 model 嘅輸出,又或者整一條聰明嘅 pipeline,可以喺冇網絡之下重新砌返一集出嚟。我個腦裏面差唔多已經成形咗成套架構。我差啲就照住嗰個方向寫咗份 design doc。
跟住我停低,問咗一個更悶嘅問題:關於呢件事,iOS 究竟已經畀咗啲乜嘢我?
結果答案係「幾乎全部」。而呢個功能真正嘅工夫,唔係去整一個聰明嘅嘢。係要忍住嗰股衝動。
嗰個悶嘅問題,幫我慳返一個月
呢樣係我一篇又一篇咁不斷學返嘅嘢:複雜唔係你嘅朋友。 尤其當你只係一個人,喺夜晚同週末做嘢嗰陣。
一個 AI 播客,一旦生成咗出嚟,就只係一個瞓喺某個 URL 上面嘅音頻檔案。「離線」唔係將佢重新生成一次。而係將嗰個檔案下載返嚟,遲啲再播。呢個唔係 AI 問題。呢個係一個喺 App Store 出現之前,蘋果就一直喺度解決緊嘅問題。
所以,我冇去整一條聰明嘅 pipeline,而係伸手攞咗工具箱裏面最悶、最久經考驗嗰件工具:一個 background URLSession。如果你未接觸過佢,個諗法好簡單——你將個下載交畀作業系統,佢就會一直行落去,就算你個 app 已經喺背景、甚至已經被 kill 咗,佢之後都會重新啟動你個 app,將下載好嘅檔案交返畀你。
let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true
差唔多兩行,而家嗰啲真正困難嘅部分就歸作業系統管。我冇寫 download engine。我借咗蘋果嗰個。
我望去邊度,同一個故事就喺邊度重演:
- 下載中途斷咗? iOS 會畀你「resume data」——一細嚿數據,等個下載可以喺斷咗嗰度接落去,而唔使由零重新開始。我只要將佢寫落 disk(寫喺
Cachesfolder 裏面,因為佢係可以掉嘅),下次再傳返畀佢就得。 - 唔想燒晒用戶嘅流動數據? 一行:
request.allowsCellularAccess = !wifiOnly。之後作業系統會自己去等 WiFi。我唔監察連線。我唔 poll 任何嘢。 - 要顯示啲下載佔咗幾多空間?
ByteCountFormatter會將一個 byte count 變成帶有正確本地化單位嘅「23.4 MB」。呢個都唔係我寫嘅。
我想老實講吓呢樣有幾爽,因為佢同直覺係相反嘅。回頭睇,正確嗰一步唔係更聰明嘅 code。而係承認呢個係一個已經解決咗嘅問題,然後將嗰份工夫借過嚟,而唔係諗住要叻過佢。
但係「悶」唔等於「易」
而家我要承認一樣嘢,因為如果我同你講標準做法令呢件事變得輕而易舉,咁我就係講大話——而任何一個睇到呢度嘅 iOS 開發者,大概十秒就會拆穿我。
標準,同輕而易舉唔係同一回事。啲悶嘅工具之所以悶,係因為佢哋被用到太熟太耐,而唔係因為佢哋毫無摩擦。下面係嗰啲真係食咗我時間嘅嘢,萬一呢張清單可以幫你慳返少少:
1. 臨時檔案會喺你眼皮底下消失。 當一個背景下載完成嗰陣,iOS 會 callback 你,指住一個臨時位置上面嘅檔案。嗰個冇人提嘅伏:喺你個 callback return 嗰一刻,嗰個檔案就會被刪走。你必須同步咁將佢搬去一個永久嘅位置,就喺嗰個 function 裏面,喺做任何其他嘢之前。仲尷尬嘅係,嗰個 callback 係喺主線程以外行緊,而佢淨係知道係邊個下載完成咗,唔知道用我 app 嘅講法嗰個係邊一集。所以段 code 做咗一個難睇嘅兩步走:即刻將檔案搬去一個臨時嘅名,然後跳去主線程,等知道佢係乜先至幫佢改名。唔靚。但另一條路,係一個未等你 save 就已經唔見咗嘅檔案。
2. 背景 session 喺模擬器裏面會靜雞雞咩都唔做。 我花咗一段好瘀嘅時間,咬定係我嘅 code 壞咗。佢冇壞。background URLSession 喺 iOS 模擬器上面就係表現得唔可靠——冇 error,冇 crash,咩都唔會下載。修嘅方法好難睇,我認:
#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 逼我講清楚究竟邊個喺邊條 thread 上面行。 下載 callback 嚟到嗰陣係喺一條背景 thread 上面。我嘅 UI 狀態——嗰啲細細個嘅進度圈、嗰個綠色嘅「已下載」剔——必須喺主線程上面更新。Swift 6 更嚴格嘅並發檢查,唔肯畀我喺嗰條邊界上面求其。最後我留低咗一個清理用嘅 commit,個 title 原句就係 "resolve Swift 6 actor-isolation warnings." 當下煩,回頭睇啱。Compiler 啱,錯嘅係我。
4. 伺服器唔係次次都會幫個檔案改個乾淨嘅名。 音頻有時返嚟係 audio/mpeg,有時係 m4a,偶爾係第啲嘢。所以有一個細細個、悶悶哋嘅 function,負責由 response 個 content type 推斷出檔案嘅副檔名,唔得就退返去 URL,再唔得就 default 做 .mp3。唔華麗。但有需要。
呢啲入面冇一個係 AI 問題。每一個都係「電腦實際上係點運作」嗰類問題。呢個就係行悶嗰條路要交嘅稅。佢係實實在在嘅——但比起去維護一個我自己發明嘅聰明嘢嘅代價,我每一次都寧願交呢筆稅。
我刻意冇去做嘅嘢
我覺得成個功能裏面最有用嘅部分,反而係我冇去做嗰啲嘢嘅清單:
- 我冇寫 download engine。(background
URLSession。) - 我冇寫連線狀態監測。(
allowsCellularAccess。) - 我冇發明斷點續傳嘅協議。(作業系統嘅 resume data。)
- 我冇整任何專門為 AI 而設嘅嘢。(佢係個檔案。佢會被下載。)
我真正花咗心機嘅,係啲又細又唔華麗嘅嘢:一個細細個嘅 queue,等同時行緊嘅下載唔超過三個,其餘嘅排隊等。將每一集嘅標題存返低,等個儲存頁面喺冷啟動之後顯示真實嘅名,而唔係「集數、集數、集數」。將嗰個頁面按檔案由大到細排,咁當你想騰空間嗰陣,最大嗰個元兇就排喺最上面。而真正嘅判斷——仲剩返幾多個空位、個 list 點排——我都抽咗出嚟,做成唔掛任何 URLSession 嘅純 function,等我可以正正經經咁幫佢哋寫 unit test。悶。可以測。悶正正係因為可以測。
嗰個教訓,大概第一百次喇
到而家,我已經寫過呢篇文章嘅好幾個版本——關於重做我個網站,關於一張 54 美金嘅 API 帳單,關於重做眼前呢個 app。教訓一直都係同一個,而我一直都需要重新學多次:聰明嘅方案,通常就係貴嘅方案。「平台已經畀咗啲乜嘢我?」——呢個係我喺寫低一行 code 之前,可以問到嘅最有價值嘅問題。
尤其對一個單拖嘅人嚟講:你發明嘅每一個聰明嘢,都係一件喺最差嘅時候壞咗、而只能由你一個人去維護嘅嘢。你借返嚟嘅每一個悶、標準嘅嘢,背後都有一間請咗成千上萬個工程師嘅公司幫你維護。以我嘅經驗,呢單交易幾乎次次都值得做——而且老實講,做呢單交易所需要嘅自律,比佢所取代嗰啲聰明仲難。不過,關於條線究竟劃喺邊,我都可能係錯嘅。一定有啲問題,係平台咩都唔畀你,難嗰部分你只能自己整——我只係一次又一次咁發現,呢種情況比我嘅直覺所堅持嘅,罕有得多。
用 DIALØGUE 嘅人入面,已經有啲可以下載一集、上到飛機、喺冇信號之下收聽。呢個感覺似係一個有意義嘅功能。而佢背後嘅 code,就幾乎係帶住一股狠勁咁平平無奇。我已經同呢一點和解咗。我覺得,呢個先至係份工本身。
你又會唔會咁——明明嗰個悶嘅就喺手邊,但你捉到自己又伸手去攞聰明嗰個?定係淨係我太慢,將同一個教訓學咗一次又一次?我係真係好想聽吓其他人都係點同呢股直覺較勁嘅。
祝好, Chandler