那個怎麼都消不掉的警告
Google Play Console 上掛著一則警告:「App Bundle 含有原生程式碼,而您尚未上傳偵錯符號檔」。
先釐清幾個名詞,因為 Google Play 的警告訊息把不同的東西混在一起講:
| 檔案 | 用途 | 來源 |
|---|---|---|
| Native Debug Symbols (.so) | 還原 C/C++ native crash 的堆疊追蹤 | Flutter 引擎、NDK、第三方 SDK |
| R8 Mapping (mapping.txt) | 還原 Java/Kotlin 被 R8 混淆後的類別名稱 | Gradle build(minifyEnabled true) |
兩者都要上傳,Google Play 才不會抱怨。Flutter 專案兩者都需要:引擎帶了 .so,Gradle 開了 R8。
整體流程:Build → Detect → Upload
在看個別陷阱前,先理解正確的 CI 流程應該長什麼樣:
關鍵原則:上傳邏輯綁定 build 產物,不綁定部署環境。只要 build 裡有 .so 或 mapping.txt,就上傳。不管是 staging 還是 production。
但從這個目標到實際跑通,踩了三個完全不同性質的坑。
陷阱一:File.expand_path 的基準不是你以為的那個
第一版的 Fastfile 寫死了路徑:
# android/fastlane/Fastfile 裡
intermediates = File.expand_path("../build/app/intermediates")
CI log 印出:
Intermediates path: /workspace/android/build/app/intermediates
(exists: false)
路徑多了一層 android/。問題出在 Fastlane 的雙重路徑語境:
同一個 ../build/,在 supply 裡指向專案根目錄的 build/,在 File.expand_path 裡卻指向 android/build/。而且 Fastlane 在不同 action 之間可能用 Dir.chdir 切換工作目錄,.. 的意義會跟著飄。
修正:用 __dir__ 當絕對基準。
__dir__ 是 Ruby 的內建常數,回傳「這個檔案本身所在目錄」的絕對路徑。跟 Dir.pwd(當前工作目錄)不同,__dir__ 不會因為 Dir.chdir 而改變——Fastfile 放在 android/fastlane/,__dir__ 就永遠是 android/fastlane/ 的絕對路徑。
# __dir__ = /workspace/android/fastlane(固定不變)
# ../../ 往上兩層 = /workspace(專案根目錄)
project_root = File.expand_path("../../", __dir__)
intermediates = File.join(project_root, "build/app/intermediates")
不管是 Jenkins、本機、還是其他 CI 環境,__dir__ 是唯一不會變的錨點。
陷阱二:mapping 和 mapping_paths 互斥
供應 Google Play 需要兩種檔案,直覺上分開傳:
# ❌ 直覺但錯誤的寫法
supply(
mapping: "build/.../mapping.txt",
mapping_paths: ["native-debug-symbols.zip"]
)
Fastlane 直接報錯:Unresolved conflict between options: 'mapping' and 'mapping_paths'
mapping 是早期的單檔參數,mapping_paths 是後來為了多檔設計的陣列參數。兩者互斥。
修正:全部統一用 mapping_paths 陣列。
# ✅ 統一用陣列,不管幾個檔案都往裡面塞
upload_paths = []
upload_paths << symbols_zip if File.exist?(symbols_zip)
upload_paths += Dir.glob("#{project_root}/build/**/mapping.txt")
supply(aab: aab_path, mapping_paths: upload_paths)
未來不管加多少 deobfuscation artifacts(NDK symbols、Firebase Crashlytics 等),都往同一個陣列塞,不用改參數型態。
陷阱三:Symbols 邏輯綁在環境,不是綁在產物
最初只在 deploy_prod_to_google_play 加了 symbols 上傳,staging 沒有。邏輯是:「只有正式版需要 crash 分析。」
但 staging 一樣上傳到 Google Play(internal testing track),build 裡一樣含 .so。結果 staging 每個版本都掛著警告,而且 native crash 的堆疊追蹤完全無法還原。
修正:抽出共用方法,用 Dir.glob 動態偵測。
def zip_native_symbols_if_exist
project_root = File.expand_path("../../", __dir__)
so_files = Dir.glob("#{project_root}/build/**/lib/**/*.so")
# 找到就打包,找不到就跳過
end
為什麼用 Dir.glob 而不寫死路徑?因為 .so 檔的目錄結構隨 Android Gradle Plugin(AGP)版本變化。AGP 7.x 和 8.x 的 intermediates 子目錄名稱不同。寫死路徑就是綁死 AGP 版本,下次升級又會壞。
實測驗證
| 版本 | Native Symbols | R8 Mapping | mapping_paths | Google Play 警告 |
|---|---|---|---|---|
| 2026000034 | 寫死路徑 | 無 | [] | 兩個警告 |
| 2026000036 | __dir__ + glob | 無 | [symbols.zip] | 剩一個警告 |
| 2026000037 | 有 | mapping + mapping_paths 衝突 | build 失敗 | - |
| 2026000038 | 有 | 有,統一用 mapping_paths | [symbols.zip, mapping.txt] | 零警告 |
從第一版到零警告,改了四個版本。每個版本都是不同性質的問題:路徑解析、API 參數衝突、架構設計。
回顧:三個陷阱的共同本質
三個坑看起來毫不相關,但本質是同一件事:不要假設,要偵測。
- 不要假設路徑 → 用
__dir__固定基準 - 不要假設參數相容 → 統一用
mapping_paths - 不要假設哪個環境需要什麼 → 讓 build 產物自己說話
讓 CI 根據實際的 build artifacts 決定行為,而不是根據 lane 名稱或環境變數。這個區別決定了 CI 架構是「能用」還是「能維護」。
