那個怎麼都消不掉的警告

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 流程應該長什麼樣:

Mermaid Diagram

關鍵原則:上傳邏輯綁定 build 產物,不綁定部署環境。只要 build 裡有 .somapping.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 的雙重路徑語境

Mermaid Diagram

同一個 ../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__ 是唯一不會變的錨點。

陷阱二:mappingmapping_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 的堆疊追蹤完全無法還原。

Mermaid Diagram

修正:抽出共用方法,用 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 SymbolsR8 Mappingmapping_pathsGoogle Play 警告
2026000034寫死路徑[]兩個警告
2026000036__dir__ + glob[symbols.zip]剩一個警告
2026000037mapping + mapping_paths 衝突build 失敗-
2026000038有,統一用 mapping_paths[symbols.zip, mapping.txt]零警告

從第一版到零警告,改了四個版本。每個版本都是不同性質的問題:路徑解析、API 參數衝突、架構設計。

回顧:三個陷阱的共同本質

三個坑看起來毫不相關,但本質是同一件事:不要假設,要偵測

  • 不要假設路徑 → 用 __dir__ 固定基準
  • 不要假設參數相容 → 統一用 mapping_paths
  • 不要假設哪個環境需要什麼 → 讓 build 產物自己說話

讓 CI 根據實際的 build artifacts 決定行為,而不是根據 lane 名稱或環境變數。這個區別決定了 CI 架構是「能用」還是「能維護」。