靜默失敗圖鑑:Expo 與 React Native 上那些不報錯的壞法
這篇整理一個 Expo SDK 57 專案兩個月內查出的十幾則生產環境踩雷,共同點是全部不報錯,只能靠使用者回報或人工比對才會被發現。適合已經在用 Expo、React Native、EAS Build/Update 或 SQLite 做正式產品的開發者讀。讀完可以照抄這裡的判準,回頭檢查自己專案裡的降級路徑(fallback,出錯時自動退回某個備用結果的設計)是不是也在悄悄吃掉錯誤。
這是 追追推推 追星族遠征紀錄 App 複盤 的延伸閱讀,上架審核那條線在 App Store 五次退件實錄:條款判讀與回覆紀律。
為什麼把「靜默」當分類軸
這批踩雷真正吃掉時間的不是問題本身多難,而是它們全都不報錯:圖片讀不到就靜靜不畫、OTA 指紋對不上就靜靜忽略更新、多語系載入失敗就靜靜退回舊語言、快取被系統清掉但哨兵檔還在就判定可用。會 crash 的 bug 其實是幸運的 bug,每一條降級路徑都是未來的靜默失敗現場,寫的當下就該想好「如果它被走到了,我怎麼知道」。
flowchart LR A[某個環節出錯] --> B[程式碼裡有設計好的降級路徑] B --> C[畫面看起來正常或退化但不崩潰] C --> D[沒有任何錯誤訊息寫進log] D --> E[只能靠使用者回報或人工比對才會被發現]
一、打包管線:檔案在 git 裡,不代表在 App 裡
@4x 資產被整批丟出 IPA
一版更新後角色圖片大量消失,iOS 打包只允許三種縮放比例,這批資產宣告了不在白名單的 4x,過濾後整批被丟出 IPA,執行期挑圖邏輯仍會去要那張、讀不到就靜靜不畫。
技術細節:為什麼活過三顆 build 和一次送審驗收
OTA(不用重新送審就能推送更新的機制)不會做這層縮放比例過濾,而每次實機驗收用的裝置剛好都套著 OTA,等於從沒驗過內嵌包本身。動手把 430 組資產改成 3x 之前,先證明現有的 4x 逐位元組等於「1x 放大 4 倍」,34 組全數吻合,才敢拿 1x 當事實依據。
可遷移的判準:
git ls-files證明得了「檔案在版控裡」,證明不了「檔案進得了 IPA」。判斷一顆 build 是否真的驗過,要看它的 fingerprint(決定能不能收到某次 OTA 的指紋值)在對應 channel 上有沒有對應的 update,拿裝了 OTA 的裝置驗收,等於沒驗過內嵌包。
快取放錯目錄,哨兵檔讓它永遠不自癒
使用者回報只剩兩種顏色的角色身體還在,一度懷疑是顏色處理問題。真因是快取放在系統會清理的目錄,完整性卻只看一個最容易存活下來的哨兵檔(用來標記「這組快取已完整寫入」的空白檔),哨兵在、實際檔案沒了仍判定可用。
技術細節:排除過程與一個容易忽略的迴圈陷阱
12 個成員色的變色產出逐一拆開比對,置換像素數全同、PNG 標頭與色彩型別全同、alpha 一位元組未動,顏色這條線完全排除。真因是 iOS 對快取目錄採逐檔清理,不是整組清;哨兵檔最小、最晚寫入,最容易在清理下存活。角色的頭是程式即時畫的圓形,所以畫面上只剩頭,「還在的顏色」其實只是那個角色比較常被用到,不是機制本身。
修法是長期資產放 document 目錄,不放系統可以隨時清的 cache 目錄,完整性要逐檔驗證檔案存在且大小大於零,缺一張整組作廢。另外,加
onSourceError退回佔位圖時要用if (!cached) return守門,否則會變成 error 觸發 setState、setState 又觸發 error 的無限迴圈。
光柵化清單漏一項,被寫成了設計意圖
開場畫面票根圖案一直是粉色的,真因是圖示改版時光柵化腳本漏了一個檔案,驗收閘門不看圖片內容,說明文字反而把這次遺漏寫成了刻意的設計決定。修法是新增一個常數統一決定光柵化預設主題與票根色,驗收改成逐像素解析 PNG 取樣。
二、SDK 遷移:墓碑函式與方向錯誤的錯誤訊息
Expo SDK 57 把舊 API 搬進內部 /legacy 路徑,根模組保留同名匯出但函式體直接拋錯,這種「墓碑函式」(名字還在、呼下去必定拋錯的殘留函式)在 media-library 一個套件裡就有 18 顆,這個專案踩了兩次,錯誤提示都誤導成網路問題。
| 案例 | 呼叫 | 真因 |
|---|---|---|
| 儲存到相簿必定失敗 | MediaLibrary.saveToLibraryAsync | 墓碑函式,必定拋錯,被 catch 吃掉後對外顯示成網路錯誤 |
| 遠端圖片可下載但不可複製 | new File(https://...) | 建構子同步做路徑檢查,非 file:// 開頭在建構當下就拋錯,永遠走不到後面「是遠端就先下載」的分支 |
第二則從落地當天就沒成功過,對照組(另一支函式只對本地目錄建 File、遠端一律交給下載函式)證明這不是全面性問題。
技術細節:怎麼分辨墓碑函式與原生模組缺席
SDK 大版本升級後,「函式還在、型別還對、呼下去必爆」是真實存在的形狀,錯誤訊息還會被自己的 catch 改寫成完全不相干的原因。修好之後要在原呼叫點留註解寫明墓碑事由與出處行號,防止下次有人照舊版 API 文件改回去。分辨墓碑函式與原生模組缺席也很重要:後者要重新打包才驗得到,前者用現有的開發用 client 就驗得到,本案是靠確認錯誤提示走的是哪一個分支才分出來的。
三、EAS build 與 OTA:指紋比想像中敏感
expo-updates 用 fingerprint 決定 build 收不收得到 OTA,對不上時裝置靜靜忽略更新、零錯誤訊息,算進指紋的範圍有幾處反直覺,包含 package.json 的 scripts 欄位與檔案換行符號。
技術細節:兩個曾經害專案關掉 OTA 之窗的細節
scripts/目錄本身不算進指紋,但把一個檢查腳本串進package.json的test那一行,就會直接改變指紋、關掉那顆 build 的 OTA 之窗,兩者名字像、意義完全不同,修法是改用測試設定檔的globalSetup掛載。換行符號那條更陰:在 Windows 上
git checkout會把檔案改寫成 CRLF,內容一模一樣但雜湊值會變,要取得原始版本必須用git show <commit>:<path>直接寫檔,不能靠一般檢出流程。
環境變數在 OTA 上靜默消失
測試版天氣與音樂搜尋整組失效,IPA 內嵌版本正常、驗收全綠,套用第一次 OTA 後才壞,因為兩條降級路徑本來就是設計好的行為。
技術細節:兩條互相掩蓋的成因
成因一:
EXPO_PUBLIC_開頭的環境變數在打包時會被寫死成字面值,而 Metro 的轉譯快取只看檔案內容不看環境變數值,久未改動的檔案就沿用了環境變數還沒生效時那份空值,EAS build 在乾淨環境上重新轉譯,不會中這個問題。成因二(逐行讀 CLI 原始碼才查出):
eas update只要指定了環境參數就會關掉本機環境設定檔讀取,改吃伺服器端該環境的變數,而那三個環境當時全是空的;這個參數在非互動模式下還是強制必填。兩條成因疊加時,照「加清除快取參數」這條通則做,只會拿到假安全感,因為成因二根本不受快取影響。可遷移判準:
eas update打包的是目前的工作目錄,不是最新的版本控制節點。在有多個平行工作目錄的情況下,任何「打包工作目錄」的指令,都等於替別人的工作目錄做出了要不要出貨的決定。
eas submit 靜默多建一筆商店紀錄,讓內購三天抓不到商品
App 內購卡在處理中,逐項排除十項常見原因後才發現:商店後台的 App 紀錄建立時就綁錯套件識別碼,執行中的 App 拿自己的識別碼去要商品,對不上就回零筆且不丟錯。
技術細節:怎麼查到與怎麼防呆
不是查 IAP 查到的,是查「為什麼提交指令又多建了一筆 App 紀錄」查到的:提交設定檔案裡沒有指定對應的 App 識別碼,CLI 走了「找不到就直接建立新的」路徑,決定性物證是新紀錄的商品編號用當下時間戳記產生,解出來正好是提交那一秒。
這則留下一個通則:把條件逐項排除到只剩一個時,該懷疑的是清單漏了一項,不是那個剩下的。漏掉的第十一項是「商店後台這筆 App 紀錄實際綁的套件識別碼」,原本清單上的「套件識別碼一致」比對的是設定檔與建置產物,不是後台那個欄位。防呆做法是把對應 App 識別碼直接寫進提交設定檔,從結構上切斷自動建立新紀錄的路徑。
一個不存在的權限連燒三顆 build
連續三顆 build 在同一步驟失敗、錯誤訊息一字不差,真因是宣告了一個根本不是有效權限(entitlement,App 需要向系統聲明的能力清單項目)的項目,官方文件其實列著這是常見的誤加項目。
技術細節:為什麼連燒三顆而不是一顆
第一顆失敗時,這個提示就被誤判成無關的干擾訊息,而這個未經查證的推論被寫進專案的記憶文件當成既定事實,第二、三顆 build 都建立在錯的前提上重複同樣的錯誤,修法只有一行:刪掉整段錯誤的權限宣告。教訓是:任何寫進記憶文件的判斷,尤其是「這個錯誤訊息可以忽略」這種,都該先查證再落筆。
四、React Native、Reanimated 與 React Compiler
entering={FadeIn} 加會變的 key,文字永久消失且不自癒
元件 key 屬性一變就卸載重掛,Reanimated 進場動畫若沒被執行到就永久停在透明狀態、不會自己恢復。修法讓透明度恆由共享數值決定,靜止值恆為完全不透明,這樣動畫沒跑到最壞只是少一次淡入,不是永久消失。
切回前景後,動態粒子擠成一排
App 切回前景那瞬間,所有被中止的動畫在同一幀被判定結束,重複動畫以相同起始值重來,粒子相位全部歸零對齊。
技術細節:用延遲救不了的原因與真正修法
用延遲屬性做時間差救不了,因為那段延遲更早就已經消耗完了。真正修法是把參差效果從排程層搬進數值層:每顆粒子帶一個 0 到 1 之間的相位值,時間值加上相位再取餘數,這樣即使時間軸被歸零重跑也丟不掉參差效果,順帶把一張卡片十六條無限動畫收成一條。
React Compiler 對大量函式靜默放棄最佳化
React Compiler 認定 243 個元件都被自動記憶化的前提,實際只成立八成七:只要函式裡出現一行抑制 ESLint hooks 規則的註解,不論有沒有真的在抑制,整個元件就被跳過最佳化。
技術細節:兩個追加實測
失敗集中在最熱的元件:相簿牆、全 App 共用的資料層 hook、走廊元件、一支一千七百多行的檢視器。第一個實測:偵測器看的是註解文字本身,不是有沒有真的抑制,寫一段「說明不要用抑制」的註解、裡面照字面引了那一行,該元件當場從編譯成功掉成編譯失敗。第二個實測:
try/catch也會導致跳過最佳化,已經編譯成功的元件裡補錯誤處理要一律用.catch(),不要用try/catch。
切語言時某些字沒跟著變,t 必須是顯式依賴
介面切成英文後地區名仍是繁體中文,四道驗收閘門全綠,只有把 App 完全關閉重開才顯示正確。真因是元件記憶化守衛裡沒有把翻譯函式 t 當成依賴,t 其實每次切換語言都是新的參照。
技術細節:靠編譯輸出而非推論找到真因
把元件實跑一次 React Compiler,比對編譯後的記憶化守衛條件,同一支元件相鄰兩行,一行的守衛包含
t(沒壞),另一行不包含(壞了),差別只在有沒有把t明確傳進依賴清單。t能當語言依賴有出處:react-i18next在語言變更時會重建內部快照並重新取得翻譯函式,所以每次切語言t都是新的參照。這條也推翻了專案自己先前的做法:另一支元件曾加一行單純呼叫翻譯 hook(只訂閱不取值)當防護,編譯輸出顯示完全無效。判準只有一個:元件有呼叫翻譯 hook,不能當成「這行會跟著語言重算」的證據,唯一算數的是編譯後的守衛條件裡有沒有語言依賴。
改完函式簽名後用型別檢查工具逐條抓出十八個受影響的來源檔,其中兩處用點自由風格寫的呼叫只有型別檢查抓得到,因為自製的編譯輸出檢查器只認得函式呼叫語法,抓不出這種寫法背後的記憶化結構——兩種工具各有盲點,合起來才算完整清點。
另外兩則多語系的靜默壞法
動態路徑載入的語言檔進不了打包產物,函式庫載入失敗會靜靜退回舊語言;原生日期選擇器不指定語言參數就退回裝置系統語言,介面全繁中卻跳出其他語言月曆。
五、SQLite 與資料層
PRAGMA foreign_keys = OFF 在 migration 裡從未生效
遷移工具把所有 migration 包在同一交易裡執行,SQLite 在交易內會靜默忽略停用外鍵檢查的指令,三支 migration 寫了這行全部沒生效,目前僥倖無害。
schema.ts 宣告的 onDelete,裝置上可能根本不存在
刪除資料時遇到外鍵約束錯誤,schema 明明宣告了刪除規則,真因是當初 migration 只用 ALTER TABLE ADD COLUMN 加欄位、不會附帶刪除規則,資料表也從未整表重建。
技術細節:可遷移判準與驗證方式
刪除規則只在建表當下寫進資料庫結構,開發期反覆改 schema 的裝置,實際結構可能停在任何一個歷史版本,不能只看程式碼裡的宣告。驗證方式是用記憶體資料庫實跑整串 migration 三種情境:全新資料庫、刻意重現壞掉的結構再套修復、以及未修復的舊裝置走新設計的顯式刪除順序。
兩個排序與 join 的靜默陷阱
innerJoin 會讓只掛上層、沒有中間層資料的紀錄在詳情頁完全看不到;SQLite 排序把空值排最前,改 leftJoin 後若直接沿用原排序,未指定那組資料會意外跳到最前面。
金額欄貼上文字讓整個 App 直接終止
貼上帶符號文字讓數字轉換得到非數值,程式判斷條件卻讓它被當成「有變動」而進入轉換函式,函式對非有限數值直接拋錯,整個 App 在正式版下直接終止。
技術細節:為什麼是行程終止而不是白畫面
這行程式碼在元件本體、不是事件處理器,屬於渲染期例外,當時全專案沒有任何路由匯出錯誤邊界元件接住它。拿到當機記錄才發現症狀不是白畫面,是 React Native 在正式版下對沒接住的 JS 例外直接終止整個行程,不是只卸載元件樹。JS 層的錯誤訊息與呼叫堆疊走另一條管道,不會寫進系統的當機記錄檔,拿到記錄也指不出是哪一行 JS。同一射程還有更惡性的一則:另一頁不合格的輸入會走進刪除分支,打錯字再存檔會靜默刪掉原本的資料。
六、iOS 原生行為
- iPad 月曆整個星期六欄消失:格子寬度算式代數上剛好填滿容器、零容錯,次像素捨入方向一變就擠掉最後一欄,修法是無條件捨去寬度。
- 內嵌日期選擇器轉年月滾輪與點選日期在原生層是同一事件,靠日期差值也分辨不出來,最後拿掉自動收合。
- 多選相簿選取器從彈出視窗開啟會無限重跳,比對全 App 呼叫點才找到:只有多選加彈出視窗這個組合會壞,跟自動開啟時機無關。
- 建立播放清單的原生呼叫在使用者移除音樂 App 後永遠不返回,常見的非同步任務取消機制救不了,官方文件寫明任務群組永遠會等所有子任務完成才返回。
技術細節:播放清單呼叫掛住的修法
withThrowingTaskGroup的cancelAll()取消不了已經掛住的呼叫,group 會陪它一起永遠不返回,等於沒加期限,所有自動閘門都抓不到。改用 continuation 加兩個非結構化 Task 競速、搭配 resume-once 閘門。
- Expo 非同步失敗路徑帶不回原生錯誤代碼,追原始碼發現對應的初始化函式本就不設這個屬性,備援的錯誤訊息管道也被正規化函式清空中文。
七、把「靜默」變成讀得到的一行
處理音樂庫匯出功能完全沒反應時,因為每條失敗路徑照理都會跳提示卻一條都沒跳,代表流程卡在某個等待點,原生函式依序做五件事,從 JS 這端完全分不出卡在哪一段。
技術細節:不加記錄、不重新打包的診斷手法
手法是操縱輸入讓原生函式在不同的點提早結束:呼叫授權函式測第一段;建立空清單讓內部迴圈完全不執行、也不連網來測訂閱檢查那一段;建立含一個假編號的清單測目錄請求那一段。三個探針都因為某個前置檢查早於真正的寫入呼叫,對使用者的音樂庫完全沒有副作用。每一段都設了自己的等待上限並留下一行記錄,否則探針本身也會是靜默的,等於拿一把同樣看不見的尺去量看不見的東西。
八、留下的紀律
- 負向驗證是硬性慣例:修法都要改回舊寫法確認斷言真的轉紅,再逐位元組還原,加模擬物件時再加一顆確認模擬物件真的會拋錯的自我檢查。
- 儀器出錯要留紀錄:這個專案記了至少八次統計腳本自己算錯的事故,通則是先拿已知正確的基準跑一次,對不上再往下判斷。
- 工具只能產生線索,判定要人工做:死碼掃描工具在這個專案的誤報率約八成,另一支依賴檢查工具的報告裡七成也是誤報。
- 驗收閘門自己的涵蓋範圍也要稽核:程式碼檢查工具實際只掃過部分檔案,全樹六十多個檔案從未被驗過,包含上架阻斷項的驗證器;順帶查出空字串譯文會靜靜退回來源語言的漏洞。
- 錯誤的指標比空白更危險:一份寫著「上版前最後一步」的操作文件,若照做會造成六項退化(包含移除 OTA 更新機制本身),且出現在專案最沒餘裕重來的時間點。
相關連結
- 追追推推 追星族遠征紀錄 App 複盤
- App Store 五次退件實錄:條款判讀與回覆紀律——其中的 iPad 月曆與內購兩則,就是這裡的踩雷直接變成退件理由
- AI 協作開發:多 Agent 對抗管線——這批問題有好幾則是靠獨立 agent 平行覆核抓出來的
- AI 協作開發:非技術 PM 的驗收閘門——「四道閘門全綠只夠推進到待驗,完成要靠使用者實機回報」