靜默失敗圖鑑: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,執行期挑圖邏輯仍會去要那張、讀不到就靜靜不畫。

快取放錯目錄,哨兵檔讓它永遠不自癒

使用者回報只剩兩種顏色的角色身體還在,一度懷疑是顏色處理問題。真因是快取放在系統會清理的目錄,完整性卻只看一個最容易存活下來的哨兵檔(用來標記「這組快取已完整寫入」的空白檔),哨兵在、實際檔案沒了仍判定可用。

光柵化清單漏一項,被寫成了設計意圖

開場畫面票根圖案一直是粉色的,真因是圖示改版時光柵化腳本漏了一個檔案,驗收閘門不看圖片內容,說明文字反而把這次遺漏寫成了刻意的設計決定。修法是新增一個常數統一決定光柵化預設主題與票根色,驗收改成逐像素解析 PNG 取樣。

二、SDK 遷移:墓碑函式與方向錯誤的錯誤訊息

Expo SDK 57 把舊 API 搬進內部 /legacy 路徑,根模組保留同名匯出但函式體直接拋錯,這種「墓碑函式」(名字還在、呼下去必定拋錯的殘留函式)在 media-library 一個套件裡就有 18 顆,這個專案踩了兩次,錯誤提示都誤導成網路問題。

案例呼叫真因
儲存到相簿必定失敗MediaLibrary.saveToLibraryAsync墓碑函式,必定拋錯,被 catch 吃掉後對外顯示成網路錯誤
遠端圖片可下載但不可複製new File(https://...)建構子同步做路徑檢查,非 file:// 開頭在建構當下就拋錯,永遠走不到後面「是遠端就先下載」的分支

第二則從落地當天就沒成功過,對照組(另一支函式只對本地目錄建 File、遠端一律交給下載函式)證明這不是全面性問題。

三、EAS build 與 OTA:指紋比想像中敏感

expo-updates 用 fingerprint 決定 build 收不收得到 OTA,對不上時裝置靜靜忽略更新、零錯誤訊息,算進指紋的範圍有幾處反直覺,包含 package.jsonscripts 欄位與檔案換行符號。

環境變數在 OTA 上靜默消失

測試版天氣與音樂搜尋整組失效,IPA 內嵌版本正常、驗收全綠,套用第一次 OTA 後才壞,因為兩條降級路徑本來就是設計好的行為。

eas submit 靜默多建一筆商店紀錄,讓內購三天抓不到商品

App 內購卡在處理中,逐項排除十項常見原因後才發現:商店後台的 App 紀錄建立時就綁錯套件識別碼,執行中的 App 拿自己的識別碼去要商品,對不上就回零筆且不丟錯。

一個不存在的權限連燒三顆 build

連續三顆 build 在同一步驟失敗、錯誤訊息一字不差,真因是宣告了一個根本不是有效權限(entitlement,App 需要向系統聲明的能力清單項目)的項目,官方文件其實列著這是常見的誤加項目。

四、React Native、Reanimated 與 React Compiler

entering={FadeIn} 加會變的 key,文字永久消失且不自癒

元件 key 屬性一變就卸載重掛,Reanimated 進場動畫若沒被執行到就永久停在透明狀態、不會自己恢復。修法讓透明度恆由共享數值決定,靜止值恆為完全不透明,這樣動畫沒跑到最壞只是少一次淡入,不是永久消失。

切回前景後,動態粒子擠成一排

App 切回前景那瞬間,所有被中止的動畫在同一幀被判定結束,重複動畫以相同起始值重來,粒子相位全部歸零對齊。

React Compiler 對大量函式靜默放棄最佳化

React Compiler 認定 243 個元件都被自動記憶化的前提,實際只成立八成七:只要函式裡出現一行抑制 ESLint hooks 規則的註解,不論有沒有真的在抑制,整個元件就被跳過最佳化。

切語言時某些字沒跟著變,t 必須是顯式依賴

介面切成英文後地區名仍是繁體中文,四道驗收閘門全綠,只有把 App 完全關閉重開才顯示正確。真因是元件記憶化守衛裡沒有把翻譯函式 t 當成依賴,t 其實每次切換語言都是新的參照。

另外兩則多語系的靜默壞法

動態路徑載入的語言檔進不了打包產物,函式庫載入失敗會靜靜退回舊語言;原生日期選擇器不指定語言參數就退回裝置系統語言,介面全繁中卻跳出其他語言月曆。

五、SQLite 與資料層

PRAGMA foreign_keys = OFF 在 migration 裡從未生效

遷移工具把所有 migration 包在同一交易裡執行,SQLite 在交易內會靜默忽略停用外鍵檢查的指令,三支 migration 寫了這行全部沒生效,目前僥倖無害。

schema.ts 宣告的 onDelete,裝置上可能根本不存在

刪除資料時遇到外鍵約束錯誤,schema 明明宣告了刪除規則,真因是當初 migration 只用 ALTER TABLE ADD COLUMN 加欄位、不會附帶刪除規則,資料表也從未整表重建。

兩個排序與 join 的靜默陷阱

innerJoin 會讓只掛上層、沒有中間層資料的紀錄在詳情頁完全看不到;SQLite 排序把空值排最前,改 leftJoin 後若直接沿用原排序,未指定那組資料會意外跳到最前面。

金額欄貼上文字讓整個 App 直接終止

貼上帶符號文字讓數字轉換得到非數值,程式判斷條件卻讓它被當成「有變動」而進入轉換函式,函式對非有限數值直接拋錯,整個 App 在正式版下直接終止。

六、iOS 原生行為

  • iPad 月曆整個星期六欄消失:格子寬度算式代數上剛好填滿容器、零容錯,次像素捨入方向一變就擠掉最後一欄,修法是無條件捨去寬度。
  • 內嵌日期選擇器轉年月滾輪與點選日期在原生層是同一事件,靠日期差值也分辨不出來,最後拿掉自動收合。
  • 多選相簿選取器從彈出視窗開啟會無限重跳,比對全 App 呼叫點才找到:只有多選加彈出視窗這個組合會壞,跟自動開啟時機無關。
  • 建立播放清單的原生呼叫在使用者移除音樂 App 後永遠不返回,常見的非同步任務取消機制救不了,官方文件寫明任務群組永遠會等所有子任務完成才返回。
  • Expo 非同步失敗路徑帶不回原生錯誤代碼,追原始碼發現對應的初始化函式本就不設這個屬性,備援的錯誤訊息管道也被正規化函式清空中文。

七、把「靜默」變成讀得到的一行

處理音樂庫匯出功能完全沒反應時,因為每條失敗路徑照理都會跳提示卻一條都沒跳,代表流程卡在某個等待點,原生函式依序做五件事,從 JS 這端完全分不出卡在哪一段。

八、留下的紀律

  • 負向驗證是硬性慣例:修法都要改回舊寫法確認斷言真的轉紅,再逐位元組還原,加模擬物件時再加一顆確認模擬物件真的會拋錯的自我檢查。
  • 儀器出錯要留紀錄:這個專案記了至少八次統計腳本自己算錯的事故,通則是先拿已知正確的基準跑一次,對不上再往下判斷。
  • 工具只能產生線索,判定要人工做:死碼掃描工具在這個專案的誤報率約八成,另一支依賴檢查工具的報告裡七成也是誤報。
  • 驗收閘門自己的涵蓋範圍也要稽核:程式碼檢查工具實際只掃過部分檔案,全樹六十多個檔案從未被驗過,包含上架阻斷項的驗證器;順帶查出空字串譯文會靜靜退回來源語言的漏洞。
  • 錯誤的指標比空白更危險:一份寫著「上版前最後一步」的操作文件,若照做會造成六項退化(包含移除 OTA 更新機制本身),且出現在專案最沒餘裕重來的時間點。

相關連結