靜默失敗圖鑑: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更新。
技術細節:為什麼這問題活過了多次驗收
Expo的OTA更新不做這層scale過濾,而每次實機驗收用的裝置剛好都套著OTA,問題只藏在「純內嵌包、沒套OTA」的狀態裡,一直沒被驗到。修法是把資產全部改成
@3x,動手改之前先逐位元組比對,證明現有@4x確實等於「1x放大4倍」才敢拿1x當基準。
哨兵檔在逐檔清理的系統上必然誤判
另一次回報是角色只剩兩種顏色的身體還在。快取放在iOS會逐檔清理的cache目錄,完整性判斷只看一個.done哨兵檔,而哨兵檔最小、寫入最晚,最容易在系統清理下存活,於是「哨兵在、幀檔已被清掉」被誤判成快取可用,且因為哨兵還在,永遠不重烘。
技術細節:症狀的形狀,修法,以及一個容易忽略的迴圈陷阱
角色的頭是程式即時畫的圓形,讀不到身體圖時畫面只剩頭。剩下的兩種顏色只是被用得最頻繁、還沒被清到,跟顏色處理邏輯無關。 長期資產要放document目錄,不受系統LRU清理影響;完整性判斷要逐檔驗證
exists && size > 0,缺一張整組作廢重烘。另外用onSourceError退回佔位圖時要加if (!cached) return守門,不然setState觸發error、error又觸發setState,會變成無限迴圈。
光柵化清單漏一個檔名,漏寫的結果被讀成了設計意圖
圖示改版後,開場畫面的票根圖一直維持粉色沒變。光柵化腳本的輸出清單漏了splash-icon.png,四道驗收閘門都只看流程有沒有跑完,不比對圖片內容,連預覽卡片的說明文字都把這個疏漏描述成「票根維持品牌粉」。
技術細節:修法與新的驗收方式
新增一個常數同時決定光柵化主題與票根色,確保上版時只能指定一次,並補齊輸出清單。驗收方式改成逐像素解PNG取樣,不再只看流程有沒有跑完。
SDK升級後保留同名匯出,呼下去卻直接拋錯
Expo SDK 57把幾個舊API搬進/legacy,根模組保留同名匯出,但函式體直接throw。這種「墓碑函式」單是media-library一個套件就有多個,這個專案踩過兩次,兩次的錯誤提示都被自己的catch改寫成「請確認網路後再試」。
技術細節:兩個實際案例,以及怎麼分辨墓碑函式與原生模組缺席
案例 呼叫 真因 儲存到相簿必定失敗 MediaLibrary.saveToLibraryAsync墓碑函式必throw,被catch吃掉後顯示成網路錯誤 遠端圖片可下載、不可複製 new File(https://...)constructor同步呼叫 validatePath(),非file://路徑建構當下就拋錯,走不到後面「先下載」的分支原生模組缺席要重打build才驗得到,墓碑函式用現有的dev client就驗得到,靠確認Alert實際走的是哪個分支即可區分。修好後在原呼叫點留註解寫明墓碑事由與程式碼出處行號,避免下次有人照舊版文件改回來。
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。指紋對不上時,裝置是靜靜忽略更新,沒有任何錯誤訊息。
技術細節:兩個名字相似、意義完全不同的欄位
scripts/目錄貢獻0,但package.json裡的scripts欄位算進fingerprint。這個專案把一個檢查腳本串進package.json的"test"那行,就此關掉了那顆build的OTA之窗,修法是改掛jest.config.js的globalSetup。換行符號那條更陰:Windows上git checkout會把檔案改寫成CRLF,內容一樣但hash會變,要取原始blob得用git show <commit>:<path>直接寫檔。
環境變數在OTA上消失,是兩個成因互相掩蓋造成的
測試版套用第一顆OTA之後,天氣與音樂搜尋整組失效,IPA內嵌bundle與build驗收都是好的。兩條容錯路徑(天氣退回假資料、音樂收起入口)本來就是設計好的,所以完全沒有錯誤訊息。
eas update打包的對象是工作樹本身,有平行工作環境時,這道指令會替別人未完成的內容決定要不要出貨。
技術細節:Metro快取與eas update的環境變數注入,是兩個獨立成因
成因一:Babel打包時把
EXPO_PUBLIC_*inline成字面值,Metro的transform cache key只看檔案內容不看env值,久未改動的檔案沿用了.env還沒生效時那份undefined。EAS build在乾淨worker上重新transform,不會中這招。 成因二:eas update只要--environment有值就注入EXPO_NO_DOTENV=1,本機.env整個不讀,改吃伺服器端該環境的變數,而那三個環境當時全是空的。--environment在--non-interactive下是必填參數。 兩者會互相掩蓋:照「加--clear-cache」這個常見建議做,只解得掉成因一,會產生一種已經處理好的錯覺,因為成因二根本不受cache影響。
eas submit會靜靜多建一個App紀錄,讓IAP查不到商品
App內購(IAP)停在處理中,診斷顯示連線正常、沒有錯誤、商店回傳0筆。App Store Connect的App紀錄從建立那天起,套件識別碼就綁成了另一個bundle id,StoreKit比對執行中App的bundle id,對不上就回傳0筆且不報錯。
漏掉的排查項是「ASC App紀錄綁的套件識別碼」,清單上原本寫的「Bundle ID一致」只比對設定檔與建置產物。
技術細節:怎麼查到的,以及防呆做法
是查「為什麼eas submit多建了一個App紀錄」才查到的:
eas.json的提交設定是空物件、沒有ascAppId,CLI因此走了「找不到就直接建立」的路徑,決定性證據是新紀錄的SKU格式EX${Date.now()},解出來正是submit當下那一秒。防呆做法是把ascAppId寫進eas.json,從結構上切斷自動建App的路徑,並用npx eas config免費驗證設定檔格式。
一個不存在的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。函式範圍內只要出現一行對應的抑制註解,整個元件就會被跳過記憶化,不會有任何編譯錯誤或警告。
技術細節:偵測器看的是文字,不是行為
這個專案的bail out集中在最熱的元件:相簿牆、全App的資料層hook、走廊元件、以及一個上千行的檢視器。 偵測器看的是「有沒有出現這串抑制註解文字」,不是「有沒有真的在用這個抑制」。寫一段說明「不要用抑制」的註解、裡面照字面引了那一行文字,該元件當場從編譯成功掉成編譯失敗。
try/catch本身也會觸發bail out,已編譯成功的元件裡補錯誤處理要走.catch(),不要走try/catch。
切語言時某些文字沒跟著變,因為t沒被宣告成依賴
介面切成英文後,卡片上的地區名仍顯示繁體中文,程式沒有錯字、翻譯檔齊全,四道驗收閘門全部通過,只有殺掉App重開才顯示正確。編譯輸出裡,同一支元件相鄰兩行的記憶化守衛條件不同,差別只在有沒有把t傳進依賴。t能當語言依賴,是因為react-i18next在語言變更時會重建snapshot並重新取得getFixedT,每次都拿到新的參照。
技術細節:只有編譯後的守衛條件算數
正常那行編譯成
if ($[0] !== event || $[1] !== t),壞掉那行是if ($[3] !== event)。這也推翻一個常見誤解:只訂閱useTranslation()而不取值,並不會讓元件被判定為有語言依賴,編譯輸出的守衛裡沒有任何隨語言變化的值。 改完函式簽名後用tsc逐一找出受影響的來源檔,其中兩處寫成point-free形式的.map(regionName)只有型別檢查抓得到,因為編譯輸出檢查工具只認CallExpression這種語法形式,grep判不出記憶化結構,兩種工具要合起來用才算完整。
技術細節:另外兩則i18n的靜默壞法
dayjs的locale檔不能動態require,因為Metro不做動態require分析,locale檔根本不會進bundle,dayjs對載入失敗的locale是靜靜退回上一個、不報錯,結果是畫面印錯語言的星期名。 原生日期選擇器不傳
locale參數就會跟著裝置語言走,不跟App內語言走,結果是介面全繁中,點開日期欄位卻跳出其他語言的月曆。
key改變觸發動畫重掛,動畫沒被觸發到就永久停在初始值
entering={FadeIn}加上會變化的key,key一變就卸載重掛,reanimated會先把新節點套成opacity: 0再排程動畫推回1。動畫只要沒被實際執行到,就永久停在0且不會自己恢復,因為容器高度固定,症狀是「上下內容都在,中間一塊空白」。修法是讓每一幀的opacity都由shared value決定、靜止值恆為1,動畫沒跑最壞只是少一次淡入。
切回前景時,所有被中止的動畫會在同一幀重新對齊
切回前景後,畫面上的雨滴粒子擠成一橫排。reanimated的timing判斷結束是用now - startTime計算,切回前景那一幀now一次跳過整段背景時間,所有被中止的timing在同一幀被判定結束,repeat隨即以相同起始值重跑,相位全部歸零對齊。用withDelay做參差救不了,延遲時間在背景期間早就被消耗完了。
技術細節:把參差從排程搬進值
修法是每顆粒子帶一個
phase(0到1之間的值),phased(v, phase) = (v + phase) % 1,即使時間軸被歸零重跑,參差效果也不會被吃掉,順帶把每張卡多條無限動畫收成一條。
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。
技術細節:怎麼驗證修復對所有裝置狀態都有效
用
node:sqlite在記憶體裡實跑整串migration,覆蓋三種情境:全新資料庫、刻意重現壞DDL再套修復、以及未修復裝置走新的顯式刪除順序。
innerJoin會讓沒有中間層的資料消失,leftJoin後NULL又會排到最前
innerJoin會讓只掛父層、沒有中間層資料的紀錄在詳情頁完全看不到。SQLite的ORDER BY ... ASC把NULL排最前面,查詢從innerJoin改成leftJoin之後如果沒調整排序邏輯,「未指定」那類資料會跳到清單最前面。
貼上帶貨幣符號的金額,能讓整個App在release build下直接終止
Number("NT$1200")得到NaN,NaN !== 0成立,於是進入轉換函式,該函式對非有限值直接throw,屬於render期例外。
技術細節:這不是白畫面,是行程直接終止
那行寫在元件本體、不在事件處理器內,當時全專案也沒有任何路由export
ErrorBoundary。crash log顯示症狀是reportFatal → abort() → SIGABRT,RN在release build下對未接住的JS例外會直接終止整個行程,不是單純卸載元件樹。JS的錯誤訊息與stack走另一個傳遞管道,不會寫進.ips檔案,拿到crash log也指不出是哪一行JS造成的。同一類輸入驗證缺口在另一頁更嚴重:不合格輸入會走進else分支呼叫刪除函式,打錯字再存檔會靜默刪掉原本的資料。
iOS原生層裡,同一個手勢在系統眼裡常常是同一個事件
iPad月曆版面代數上剛好填滿,一次sub-pixel捨入就少一欄
版面計算式cellW = (寬 - gap × 6) / 7配合flexWrap,代數上剛好填滿容器寬度,等於零容錯,sub-pixel對齊只要往上捨入一點,第七格就被擠到下一行,而下一行被下一週佔住,星期六欄位因此消失。修法是改用Math.floor,代價是右側最多留6pt空白。
轉年月輪與點選日期在原生層是同一個事件
iOS原生inline日期選擇器裡,靠日期變化量分辨這兩種操作行不通,滑動月曆翻頁再點某天,跟直接轉動年月輪,變化量長得一樣。這個問題只能拿掉自動收合行為解決。
多選加modal同時出現才會無限重跳,其餘組合都正常
多選相簿選取器從modal畫面開啟時會無限重跳。比對全App呼叫點:多選加非modal正常,單選加modal也正常,只有兩者同時出現會壞,跟自動開啟的時機無關。
技術細節:MusicKit播放清單卡住不返回,以及Exception.code傳不回來
MusicLibrary.shared.createPlaylist在裝置上的音樂App被移除後永遠不回應,Swift的withThrowingTaskGroup救不了——官方文件明確寫著task group一定會等所有子任務完成才返回,cancelAll()取消不了已卡住的呼叫,整個group會陪著它一起永遠不返回,改用continuation搭配兩個非結構化Task競速、加上resume-once的閘門才解決。 Expo的非同步拒絕路徑帶不回Exception.code:追原生原始碼發現JavaScriptError(runtime, message:)這個initializer本來就不設定code屬性,在原生層顯式指定code也沒有用,問題出在傳輸層,Swift端本來就有值。第二條可用管道(message文字)也被正規化函式的字元過濾規則把中文內容整段刪光。
沒有記錄可看時,操縱輸入讓失敗提早發生來定位
MusicKit匯出功能完全沒反應,該函式每一條失敗路徑照理都會跳出提示,結果一次提示都沒跳,代表流程停在某個await上,但Swift函式依序做五件事,卡在哪一段從JS這端分不出來。
解法是操縱輸入,讓Swift在不同階段提早結束,不必修改原生程式碼、也不必重新build。三個探針都在真正的寫入呼叫之前被guard擋下,對音樂庫沒有副作用,每一段也都設了等待上限並留下一行記錄,否則探針本身也是靜默的。
技術細節:三個探針分別打在哪一段
呼叫授權函式測第一段;建立一份空清單,讓內部迴圈完全不執行、也不連網,用來測訂閱閘門;再建立一份含單一假id的清單,測目錄請求那一段。
任何驗證結果都要留一道人工複查
工具、腳本、測試本身都會出錯。負向驗證是最低成本的自檢:把修法改回舊寫法,確認對應斷言真的轉紅,再逐位元組還原。死碼掃描與依賴檢查只能產生線索,實測誤報率相當高,工具說「這裡沒用到」不能直接刪。
技術細節:其餘三道人工複查
加mock的地方也加一顆「確認mock真的會拋錯」的自檢,避免整組測試變成證明不了任何事的假綠燈。 統計腳本自己算錯的事故不只發生過一次,通則是先拿一個已知正確的基線跑一次,對不上再往下判斷,不直接信任腳本輸出。 閘門自己的涵蓋範圍也要稽核:曾查出lint掃描漏了一部分檔案沒被驗過,也查出某個上架阻斷項的唯一驗證腳本根本不在任何自動閘門裡。
技術細節:一份照做會造成退化的操作指令
一份寫著「build前最後一步」的操作指令文件,如果照做,原本要達成的目的一項都沒達成,反而會造成多項退化,包括移除
expo-updates,等於讓OTA整個失效,而它正好出現在最沒有餘裕重來的時間點。任何牽涉OTA與build的操作指令,都值得先讀一遍程式邏輯再執行。
相關連結
- 追追推推 追星族遠征紀錄 App 複盤
- App Store 五次退件實錄:條款判讀與回覆紀律,iPad月曆與IAP兩則直接變成退件理由
- AI 協作開發:多 Agent 對抗管線,好幾則是靠獨立agent平行覆核抓出來的
- AI 協作開發:非技術 PM 的驗收閘門,四道閘門全綠只夠推進到待驗