靜默失敗圖鑑:Expo與React Native上那些不報錯的壞法

這篇整理一個Expo SDK 57專案裡的一批問題,共同點是沒有一個在log留下錯誤訊息:圖片讀不到就不畫、OTA對不上就悄悄跳過更新、資料庫查不到就退回預設值。涵蓋打包、OTA、Reanimated動畫、React Compiler、SQLite各自的靜默壞法,以及怎麼把它們變成看得到的訊號。

這是追追推推 追星族遠征紀錄 App 複盤的延伸閱讀,上架審核那條線收在App Store 五次退件實錄:條款判讀與回覆紀律。

每一條容錯路徑,都是一個未來的靜默失敗現場

這批問題共同的形狀是:程式裡都寫了合理的容錯,而容錯做得太乾淨,乾淨到沒人看得出這裡出過錯。寫容錯的當下,要同時想好如果它真的被走到,要怎麼知道。

flowchart LR
    A[資源存在] --> B[讀取成功,畫面正常]
    C[資源缺失] --> D[容錯機制啟動]
    D --> E[畫面看起來正常,但沒有錯誤訊息]

打包管線把「檔案在版控裡」誤看成「檔案在App裡」

@4x資產進得了git,進不了iOS的IPA

一次更新後使用者回報角色圖案整批消失。Expo打包端對iOS只允許[1, 2, 3]這幾種scale,資產宣告的[1, 4]過濾後只剩1,@4x檔案被整批丟出IPA;執行期的取檔邏輯卻仍會去要@4x那個檔,讀不到就靜靜不畫,連onError都不觸發。

裝了OTA的裝置驗收等於從沒驗過內嵌包,要看的是這顆build的fingerprint在對應channel上有沒有OTA更新。

哨兵檔在逐檔清理的系統上必然誤判

另一次回報是角色只剩兩種顏色的身體還在。快取放在iOS會逐檔清理的cache目錄,完整性判斷只看一個.done哨兵檔,而哨兵檔最小、寫入最晚,最容易在系統清理下存活,於是「哨兵在、幀檔已被清掉」被誤判成快取可用,且因為哨兵還在,永遠不重烘。

光柵化清單漏一個檔名,漏寫的結果被讀成了設計意圖

圖示改版後,開場畫面的票根圖一直維持粉色沒變。光柵化腳本的輸出清單漏了splash-icon.png,四道驗收閘門都只看流程有沒有跑完,不比對圖片內容,連預覽卡片的說明文字都把這個疏漏描述成「票根維持品牌粉」。

SDK升級後保留同名匯出,呼下去卻直接拋錯

Expo SDK 57把幾個舊API搬進/legacy,根模組保留同名匯出,但函式體直接throw。這種「墓碑函式」單是media-library一個套件就有多個,這個專案踩過兩次,兩次的錯誤提示都被自己的catch改寫成「請確認網路後再試」。

OTA的fingerprint比想像中敏感,改錯地方會靜靜關掉更新之窗

expo-updates用fingerprint判斷一顆build收不收得到某次OTA更新。會算進fingerprint的:app.json、eas.json、modules/*/ios整個目錄、package.json的scripts欄位、以及檔案的換行符號。不算的:devDependencies、package-lock.json、scripts/目錄本身、jest.config.js。指紋對不上時,裝置是靜靜忽略更新,沒有任何錯誤訊息。

環境變數在OTA上消失,是兩個成因互相掩蓋造成的

測試版套用第一顆OTA之後,天氣與音樂搜尋整組失效,IPA內嵌bundle與build驗收都是好的。兩條容錯路徑(天氣退回假資料、音樂收起入口)本來就是設計好的,所以完全沒有錯誤訊息。

eas update打包的對象是工作樹本身,有平行工作環境時,這道指令會替別人未完成的內容決定要不要出貨。

eas submit會靜靜多建一個App紀錄,讓IAP查不到商品

App內購(IAP)停在處理中,診斷顯示連線正常、沒有錯誤、商店回傳0筆。App Store Connect的App紀錄從建立那天起,套件識別碼就綁成了另一個bundle id,StoreKit比對執行中App的bundle id,對不上就回傳0筆且不報錯。

漏掉的排查項是「ASC App紀錄綁的套件識別碼」,清單上原本寫的「Bundle ID一致」只比對設定檔與建置產物。

一個不存在的entitlement,靠複製前一次的錯誤推論連燒三顆build

連續三顆build都在同一個簽章步驟報同一則錯誤。com.apple.developer.musickit根本不存在這個entitlement,MusicKit在Apple後台列在App Services欄,官方文件甚至列出這一項是常被誤植的假entitlement。這個未經查證的推論被寫進專案記憶檔當成既定事實,後續的build都建立在錯誤前提上重複失敗。修法只有一行:刪掉整塊entitlements宣告。

一行抑制註解,能讓React Compiler整個跳過一個元件的記憶化

React Compiler的預設抑制清單包含react-hooks/exhaustive-deps與react-hooks/rules-of-hooks。函式範圍內只要出現一行對應的抑制註解,整個元件就會被跳過記憶化,不會有任何編譯錯誤或警告。

切語言時某些文字沒跟著變,因為t沒被宣告成依賴

介面切成英文後,卡片上的地區名仍顯示繁體中文,程式沒有錯字、翻譯檔齊全,四道驗收閘門全部通過,只有殺掉App重開才顯示正確。編譯輸出裡,同一支元件相鄰兩行的記憶化守衛條件不同,差別只在有沒有把t傳進依賴。t能當語言依賴,是因為react-i18next在語言變更時會重建snapshot並重新取得getFixedT,每次都拿到新的參照。

key改變觸發動畫重掛,動畫沒被觸發到就永久停在初始值

entering={FadeIn}加上會變化的key,key一變就卸載重掛,reanimated會先把新節點套成opacity: 0再排程動畫推回1。動畫只要沒被實際執行到,就永久停在0且不會自己恢復,因為容器高度固定,症狀是「上下內容都在,中間一塊空白」。修法是讓每一幀的opacity都由shared value決定、靜止值恆為1,動畫沒跑最壞只是少一次淡入。

切回前景時,所有被中止的動畫會在同一幀重新對齊

切回前景後,畫面上的雨滴粒子擠成一橫排。reanimated的timing判斷結束是用now - startTime計算,切回前景那一幀now一次跳過整段背景時間,所有被中止的timing在同一幀被判定結束,repeat隨即以相同起始值重跑,相位全部歸零對齊。用withDelay做參差救不了,延遲時間在背景期間早就被消耗完了。

SQLite宣告的規則,裝置上不一定真的照做

PRAGMA foreign_keys = OFF寫在migration的交易裡,從未真正生效

drizzle的migrator把全部migration包在同一個BEGIN交易裡,SQLite在交易內會靜默忽略這個PRAGMA。這個專案有三支migration寫了這行,全部沒生效,目前僥倖無害。

schema.ts宣告的onDelete,裝置上的實際DDL可能沒有

onDelete規則只有在建表當下才會寫進實際DDL,ALTER TABLE ADD COLUMN REFERENCES加的欄位不會帶ON DELETE規則。某張表從未被整表重建過,裝置上的實際DDL與schema.ts宣告不一致,刪除資料時直接拋出FOREIGN KEY constraint failed。

innerJoin會讓沒有中間層的資料消失,leftJoin後NULL又會排到最前

innerJoin會讓只掛父層、沒有中間層資料的紀錄在詳情頁完全看不到。SQLite的ORDER BY ... ASC把NULL排最前面,查詢從innerJoin改成leftJoin之後如果沒調整排序邏輯,「未指定」那類資料會跳到清單最前面。

貼上帶貨幣符號的金額,能讓整個App在release build下直接終止

Number("NT$1200")得到NaN,NaN !== 0成立,於是進入轉換函式,該函式對非有限值直接throw,屬於render期例外。

iOS原生層裡,同一個手勢在系統眼裡常常是同一個事件

iPad月曆版面代數上剛好填滿,一次sub-pixel捨入就少一欄

版面計算式cellW = (寬 - gap × 6) / 7配合flexWrap,代數上剛好填滿容器寬度,等於零容錯,sub-pixel對齊只要往上捨入一點,第七格就被擠到下一行,而下一行被下一週佔住,星期六欄位因此消失。修法是改用Math.floor,代價是右側最多留6pt空白。

轉年月輪與點選日期在原生層是同一個事件

iOS原生inline日期選擇器裡,靠日期變化量分辨這兩種操作行不通,滑動月曆翻頁再點某天,跟直接轉動年月輪,變化量長得一樣。這個問題只能拿掉自動收合行為解決。

多選加modal同時出現才會無限重跳,其餘組合都正常

多選相簿選取器從modal畫面開啟時會無限重跳。比對全App呼叫點:多選加非modal正常,單選加modal也正常,只有兩者同時出現會壞,跟自動開啟的時機無關。

沒有記錄可看時,操縱輸入讓失敗提早發生來定位

MusicKit匯出功能完全沒反應,該函式每一條失敗路徑照理都會跳出提示,結果一次提示都沒跳,代表流程停在某個await上,但Swift函式依序做五件事,卡在哪一段從JS這端分不出來。

解法是操縱輸入,讓Swift在不同階段提早結束,不必修改原生程式碼、也不必重新build。三個探針都在真正的寫入呼叫之前被guard擋下,對音樂庫沒有副作用,每一段也都設了等待上限並留下一行記錄,否則探針本身也是靜默的。

任何驗證結果都要留一道人工複查

工具、腳本、測試本身都會出錯。負向驗證是最低成本的自檢:把修法改回舊寫法,確認對應斷言真的轉紅,再逐位元組還原。死碼掃描與依賴檢查只能產生線索,實測誤報率相當高,工具說「這裡沒用到」不能直接刪。

相關連結