
你的模擬器在騙你:真機才能暴露的事
模擬器說它沒問題。手機當機了。 那道落差——從綠燈測試到磚頭手機——正是「在裝置上運作的軟體」真正被打造出來的地方。而這道落差幾乎沒人寫:活下來的人忙著出貨沒空寫部落格,而寫「我打造了一個 AI 語音玩意」的人,多半停在那個只跑在模擬器上的 demo。 這篇談的是模擬器之所以騙你的那些具體、物理的原因,還有為什麼在真機上出貨,是一門跟「通過測試」不一樣的手藝。 第一個謊言:模擬器無法重現的當機 把語音介面接上一個語言模型很快。一個 WebSocket、一個開放協定、一點壓縮音訊,一個週末就有一個會說話的 demo。在模擬器上一切正常,因為模擬器不是手機,它是你的筆電穿著手機的戲服。 然後你把它裝到真機上、按下按鈕,跑出一個執行緒斷言的當機:一段本該在主執行緒執行的程式,卻在即時音訊回呼上被叫用。模擬器永遠不會丟出這個錯誤,因為它根本沒跑一顆帶有即時排程器的真實音訊引擎。 解法只有一行註記,把那段 closure 標記為可安全用於該執行緒。就一行。但找到它,需要去符號化裝置的當機日誌,一路讀到音訊 tap 內部的那個 messenger。一旦知道該往哪看,解法是簡單的;難的是知道怎麼看。而只有模擬器願意帶你去的地方,你才看得到。 第二個謊言:什麼都沒發生,也沒有報錯 接著是回音迴圈:助理一說話,它自己的輸出就被麥克風收走,再轉錄成胡言亂語。解法不是補丁,而是架構:麥克風與語音播放必須共用同一顆音訊引擎,並以伺服器的回合界線來把關。 「顯而易見」的設計——第二顆專屬引擎、乾淨的關注點分離——反而才是 bug。兩顆引擎爭奪同一個音訊 session,結果是回覆解碼了卻沒聲音,麥克風也聽不到接下來的追問。同時發生,而且完全不當機。 模擬器也無法重現這個,因為它不像真機的作業系統那樣強制執行音訊 session 的獨佔。症狀是「什麼都沒發生」——這是最難察覺的 bug,因為沒有錯誤訊息,只有一片沉默。 第三個謊言:本來好好的,突然就不行 接著是藍牙。接上無線耳機後,App 開始抖動、掉音;「解法」是一個能乾淨收音、卻把語音播得幾乎聽不見的音訊 session 模式。這種取捨只存在於有真實音訊路由的實體硬體上。然後是進入背景:切走、切回來,App 就「不再收聽」了,因為作業系統搶走了 session,並在 App 暫停時掛起 socket。每一種都是一類只會在真機上遇到的 bug,只要一次拆掉重連,就會在任何自動化執行裡隱形。 「在裝置上」作為一門手藝,到底意味什麼 教訓不是「模擬器沒用」。對那 90% 與硬體無關的邏輯,它不可或缺。真正的教訓是:那最後的 10%——碰觸即時音訊、藍牙、背景生命週期的部分——無法用代理來測試。 它們只能靠出貨到真機、讀當機日誌、並且相信硬體告訴你的話勝過測試套件宣稱的結果,才找得到。 這是在真機面前還能存活的紀律: 能 mock 的就 mock,不能的就出貨。 按住說話的狀態機,用 mock 的 socket 與麥克風做單元測試:十幾條測試,沒有真實音訊、沒有網路。音訊引擎本身無法 mock,所以它被出貨,然後被盯著看。 相信裝置日誌,不是測試執行。 裝置的當機日誌是地面真相;模擬器通過是假設。兩者相衝突時,永遠是裝置贏。 「在我機器上明明可以」是行動開發最危險的一句話。 你的機器是模擬器。顧客的機器是口袋裡那支手機,戴著耳機,正走進一個訊號死角。 對獨立開發者而言,真正重要的區別是:一個只跑在模擬器上的 demo,是假設;一個經得起真實硬體考驗的東西,是你可以推理的事實。那道落差,寬得足以成為「週末專案」與「值得出貨的東西」之間的全部差異。 來源 Apple 開發者文件,即時音訊。 安裝在音訊引擎上的 tap,是在即時音訊執行緒上被叫用,而非主執行緒;造成執行緒斷言當機的隔離假設,是並行的產物,在模擬器中不存在。 Apple 開發者文件,音訊 session 分類與模式。 Session 的擁有權在真機上是獨佔的;第二顆引擎在已被擁有的 session 上啟動,是有紀錄的「無聲或音訊飢餓」成因,也是「解碼了卻沒聲音」的根源。 開放原始碼的語音助理協定生態系。 成熟、MIT 授權的語音協定參考實作(不少擁有數萬顆 GitHub 星)展示了協定本身與一層免費的客戶端。客戶端本身沒有商業護城河;可遷移的技能是「在裝置上的加固」,不是協定。 這篇是〈完成了是一個主張〉(done-is-a-claim)的裝置端姊妹篇。後者談的是驗證儀式:相信自己的眼睛,勝過代理的一句話。那篇談的是你該信任誰;這篇談的是你該信任什麼:硬體,勝過模擬。