iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 24

Day 24 - Android 建置與簽名實作:從 AAB 到自動化部署

  • 分享至 

  • xImage
  •  

大家好,歡迎來到第二十四天!在 Day 23,我們建立了完整的 CI/CD 自動化流程。今天,我們要深入 Android 平台的
建置、簽名與自動化部署,並將重點從傳統的 APK 轉向現代化的 AAB (Android App Bundle) 格式。

從專案開發的經驗來看,Android 建置最大的挑戰不是理論,而是實際執行中遇到的各種問題。今天我們將分享在
Crew Up 專案中遇到的真實問題,以及如何一步步解決並建立穩定的建置與部署流程。

🚀 從 APK 到 AAB (Android App Bundle)

在開始之前,最重要的一點是:自 2021 年 8 月起,Google Play 已要求所有新應用程式必須採用 .aab 格式提交。AAB
帶來了許多好處:

  • 更小的應用程式體積:Google Play 會根據使用者的裝置產生最佳化的 APK,使用者只需下載他們需要的資源。
  • 更高效的交付:支援動態功能模組 (Dynamic Feature Modules)。
  • 簡化的發布流程:開發者只需上傳一個 AAB 檔案,Google Play 會處理後續的 APK 生成與簽署。

因此,本篇文章的所有實作都將圍繞 AAB 進行。

🔐 Android 簽名與金鑰管理

1. Play App Signing 與上傳金鑰

在 AAB 的世界裡,簽名流程分為兩部分:

  • 上傳金鑰 (Upload Key):這是你本地持有並用來簽署 AAB 的金鑰。這個金鑰必須妥善保管,因為 Google
    Play 會用它來驗證你的身份。我們接下來建立的 keystore.jks 就是上傳金鑰。
  • 應用程式簽署金鑰 (App Signing Key):這個金鑰由 Google Play 管理。當你上傳 AAB 後,Google Play
    會用這個金鑰來簽署最終分發給使用者的 APK。

這種分離機制提高了安全性,即使你的上傳金鑰洩漏,也可以透過 Google Play Console 進行重設,而不會影響到已安裝應用的更新。

2. Keystore 建立與管理

建立 Keystore (上傳金鑰)

# 建立新的 Keystore
keytool -genkey -v -keystore android/app/keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -alias crew-up-key

# 查看 Keystore 資訊
keytool -list -v -keystore android/app/keystore.jks

重要參數說明

  • -keyalg RSA:使用 RSA 演算法。
  • -keysize 2048:金鑰長度 2048 位元。
  • -validity 10000:有效期 10000 天(約 27 年)。
  • -alias crew-up-key:金鑰別名,用於識別金鑰。

Keystore 資訊記錄

Keystore 資訊:
  檔案名稱: keystore.jks
  金鑰別名: crew-up-key
  金鑰演算法: RSA
  金鑰長度: 2048 bits
  有效期: 10000 days

  ⚠️ 重要提醒:
    - 這是你的「上傳金鑰」,Google Play 透過它來驗證你的身份。
    - 妥善保管 Keystore 檔案與所有密碼。
    - 備份到安全位置(如加密的雲端儲存或硬體裝置)。
    - 絕對不要提交到 Git 版本控制。

3. Gradle 簽名配置

build.gradle.kts 完整配置

// android/app/build.gradle.kts

import java.util.Properties

plugins {
    id("com.android.application")
    id("com.google.gms.google-services")
    id("kotlin-android")
    id("dev.flutter.flutter-gradle-plugin")
}

// 讀取 keystore.properties 檔案
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(keystorePropertiesFile.inputStream())
}

android {
    namespace = "pro.modernwizard.crewup"
    compileSdk = flutter.compileSdkVersion
    ndkVersion = flutter.ndkVersion

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
        isCoreLibraryDesugaringEnabled = true
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }

    defaultConfig {
        applicationId = "pro.modernwizard.crewup"
        minSdk = 21
        targetSdk = flutter.targetSdkVersion
        versionCode = flutter.versionCode()
        versionName = flutter.versionName()
    }

    // 簽名配置
    signingConfigs {
        create("release") {
            if (keystorePropertiesFile.exists()) {
                keyAlias = keystoreProperties["keyAlias"] as String
                keyPassword = keystoreProperties["keyPassword"] as String
                storeFile = file(keystoreProperties["storeFile"] as String)
                storePassword = keystoreProperties["storePassword"] as String
            }
        }
    }

    buildTypes {
        release {
            // 使用 Release 簽名配置
            signingConfig = signingConfigs.getByName("release")
            // 啟用程式碼混淆和優化
            isMinifyEnabled = true
            // 啟用資源縮減
            isShrinkResources = true
            // ProGuard 規則檔案
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
        debug {
            signingConfig = signingConfigs.getByName("debug")
        }
    }
}

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

keystore.properties 檔案

# android/keystore.properties

storeFile=keystore.jks
storePassword=your_store_password
keyAlias=crew-up-key
keyPassword=your_key_password

⚠️ 安全提醒

# android/.gitignore

# Android Keystore
keystore.properties
app/keystore.jks
app/keystore.jks.base64
app/upload-keystore.jks

4. CI/CD 簽名自動化

GitHub Actions 配置

# .github/workflows/ci-cd.yml

- name: Setup Android Keystore
  run: |
    echo "Setting up Android Keystore for signing..."
    echo "$ANDROID_KEYSTORE" | base64 -d > android/app/keystore.jks
    echo "Keystore file created"

    # 創建 keystore.properties 檔案
    cat > android/keystore.properties << EOF
    storeFile=keystore.jks
    storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
    keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
    keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
    EOF
    echo "Keystore properties configured"
  env:
    ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}

- name: Build Android App Bundle (AAB)
  run: flutter build appbundle --flavor ${{ matrix.flavor }} --release
  env:
    FLAVOR: ${{ matrix.flavor }}
  timeout-minutes: 20

- name: Verify AAB Size
  run: |
    echo "Verifying AAB size..."
    AAB_PATH="build/app/outputs/bundle/${{ matrix.flavor }}Release/app-${{ matrix.flavor }}-release.aab"
    if [ -f "$AAB_PATH" ]; then
      echo "AAB found: $AAB_PATH"
      AAB_SIZE_BYTES=$(stat -c%s "$AAB_PATH")
      AAB_SIZE_MB=$(echo "scale=2; $AAB_SIZE_BYTES / 1024 / 1024" | bc)
      echo "AAB size: ${AAB_SIZE_MB} MB"

      # 大小檢查
      if (( $(echo "$AAB_SIZE_MB > 150" | bc -l) )); then
        echo "⚠️ AAB size is very large: ${AAB_SIZE_MB} MB. Google Play's limit is 150MB for the initial install."
      else
        echo "✅ AAB size is good: ${AAB_SIZE_MB} MB"
      fi
    else
      echo "❌ AAB not found at $AAB_PATH"
      exit 1
    fi

必要的 GitHub Secrets

ANDROID_KEYSTORE:
  用途: Android 上傳金鑰的 Base64 編碼
  取得方式: base64 -i android/app/keystore.jks | pbcopy
  重要性: ⭐⭐⭐⭐⭐ (極度重要)

ANDROID_KEYSTORE_PASSWORD:
  用途: Keystore 密碼
  格式: 純文字字串
  重要性: ⭐⭐⭐⭐⭐ (極度重要)

ANDROID_KEY_ALIAS:
  用途: 金鑰別名
  格式: crew-up-key
  重要性: ⭐⭐⭐⭐⭐ (極度重要)

ANDROID_KEY_PASSWORD:
  用途: 金鑰密碼
  格式: 純文字字串
  重要性: ⭐⭐⭐⭐⭐ (極度重要)

安全性最佳實踐

GitHub Secrets 會被加密儲存,並且只在 Workflow 執行期間提供給 Runner。絕對不要將金鑰內容或密碼以明文形式寫在
YAML 檔案中,也不要在 CI/CD 的日誌中將它們印出來,以防敏感資訊外洩。

🏗️ ProGuard/R8 程式碼混淆

ProGuard/R8 主要提供三大功能:壓縮 (Shrinking) 移除未使用的程式碼、優化 (Optimization)
改善程式碼執行效率,以及 混淆 (Obfuscation) 將類別、方法和欄位名稱改為簡短無意義的名稱,增加反編譯的難度。

ProGuard 規則

# android/app/proguard-rules.pro

# Android ProGuard/R8 規則
# 用於 Release 建置的程式碼混淆和優化

# Flutter 引擎需要的核心類別,防止被 R8 移除
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.**  { *; }
-keep class io.flutter.util.**  { *; }
-keep class io.flutter.view.**  { *; }
-keep class io.flutter.**  { *; }
-keep class io.flutter.plugins.**  { *; }
-keep class io.flutter.embedding.** { *; }

# Firebase SDK 需要反射,保留其類別以確保功能正常
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
-dontwarn com.google.firebase.**
-dontwarn com.google.android.gms.**

# Google Play Core 相關規則
-keep class com.google.android.play.core.** { *; }
-keep class com.google.android.play.core.splitcompat.** { *; }
-keep class com.google.android.play.core.splitinstall.** { *; }
-keep class com.google.android.play.core.tasks.** { *; }
-dontwarn com.google.android.play.core.**

# 若專案使用 Gson,需保留序列化/反序列化相關的類別
-keepattributes Signature
-keepattributes *Annotation*
-dontwarn sun.misc.**
-keep class com.google.gson.** { *; }
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer

# 保留專案的模型類別,防止 JSON 解析出錯
-keep class com.example.crew_up.models.** { *; }

# 保留自定義 View
-keep public class * extends android.view.View {
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
    public void set*(***);
}

# 保留 Parcelable 實現
-keepclassmembers class * implements android.os.Parcelable {
    static ** CREATOR;
}

# 保留 Serializable 實現
-keepclassmembers class * implements java.io.Serializable {
    static final long serialVersionUID;
    private static final java.io.ObjectStreamField[] serialPersistentFields;
    private void writeObject(java.io.ObjectOutputStream);
    private void readObject(java.io.ObjectInputStream);
    java.lang.Object writeReplace();
    java.lang.Object readResolve();
}

# 在 Release 建置中移除所有 Log 輸出
-assumenosideeffects class android.util.Log {
    public static boolean isLoggable(java.lang.String, int);
    public static int v(...);
    public static int d(...);
    public static int i(...);
    public static int w(...);
    public static int e(...);
}

ProGuard 效益

程式碼大小優化

優化前 (APK):
  APK 大小: ~50 MB
  程式碼大小: ~20 MB

優化後 (AAB 交付):
  AAB 大小: ~35 MB
  使用者下載大小: ~15-25 MB (根據裝置)
  優化效益: 應用程式下載體積減少約 50-70%

安全性提升

  • 程式碼混淆,增加反編譯難度。
  • 移除未使用的程式碼,減少攻擊面。

📱 Firebase App Distribution 自動發布

1. Firebase App Distribution 設定

在正式上架前,我們需要一個方便的方式來分發測試版本。Firebase App Distribution 提供了:

  • 快速分發:支援 AAB 和 APK,無需等待商店審核。
  • 測試群組管理:可以建立不同的測試群組。
  • 安裝追蹤與回饋:整合了安裝追蹤和測試人員回饋機制。

2. Firebase Console 設定

步驟 1:啟用 App Distribution

# 前往 Firebase Console > 選擇專案 > App Distribution > 啟用

步驟 2:建立測試群組

測試群組設定:
  群組名稱: testers
  成員: [ 內部開發人員, QA 測試人員, Beta 測試者 ]

步驟 3:取得 Firebase CI Token

# 安裝 Firebase CLI
npm install -g firebase-tools
# 登入 Firebase 並取得 CI Token
firebase login:ci
# 將產生的 Token 複製到 GitHub Secrets (名稱: FIREBASE_TOKEN)

步驟 4:取得 Firebase App ID

# 在 Firebase Console > 專案設定 > 一般 > 你的應用程式
# 複製 App ID (格式: 1:123456789:android:abc123def456)
# 存入 GitHub Secrets (名稱: FIREBASE_APP_ID_STAGING)

3. CI/CD 自動上傳配置

GitHub Actions 配置

# .github/workflows/ci-cd.yml

deploy-firebase-distribution:
  needs: build-android-multi-env
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/develop'

  steps:
    - uses: actions/checkout@v5

    # 下載 Staging AAB
    - name: Download Staging AAB
      uses: actions/download-artifact@v4
      with:
        name: android-aab-staging # <-- 改為下載 AAB
        path: ./bundle

    # 上傳到 Firebase App Distribution
    - name: Upload to Firebase App Distribution
      uses: wzieba/Firebase-Distribution-Github-Action@v1
      with:
        appId: ${{ secrets.FIREBASE_APP_ID_STAGING }}
        token: ${{ secrets.FIREBASE_TOKEN }}
        groups: testers
        file: ./bundle/app-staging-release.aab # <-- 改為上傳 AAB
        releaseNotes: |
          Branch: ${{ github.ref_name }}
          Commit: ${{ github.sha }}
          Author: ${{ github.actor }}

          Changes:
          ${{ github.event.head_commit.message }}

🚨 Android 建置實際問題與解決方案

問題 1:Google Services 配置問題

遇到的錯誤

File google-services.json is missing.
No matching client found for package name 'pro.modernwizard.crewup.staging'

解決方案:在 CI 環境中,透過 GitHub Secrets 動態生成 google-services.json 檔案,並在
flavorizr.gradle.kts 中統一 applicationId,避免不同 flavor 的 package name 不匹配問題。

# CI 中動態生成 google-services.json
- name: Setup Google Services
  run: |
    echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > android/app/google-services.json
  env:
    GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_PRODUCTION_BASE64 }}

問題 2:Flutter 編譯錯誤 (缺少生成程式碼)

遇到的錯誤

Error: Can't use 'lib/...g.dart' as a part, because it has no 'part of' declaration.

解決方案:在建置前,務必執行 build_runner 來生成 Riverpod 和 JSON 序列化所需的檔案。

# 在建置前加入程式碼生成步驟
- name: Get dependencies
  run: flutter pub get

- name: Generate code
  run: flutter packages pub run build_runner build --delete-conflicting-outputs

- name: Build Android App Bundle (AAB)
  run: flutter build appbundle --flavor ${{ matrix.flavor }} --release

問題 3:R8/ProGuard 混淆錯誤

遇到的錯誤

ERROR: Missing class com.google.android.play.core.splitcompat.SplitCompatApplication

解決方案:在 proguard-rules.pro 檔案中加入 -keep 規則,保留 Google Play Core 等必要套件的類別,防止它們被
R8 移除。

# android/app/proguard-rules.pro
-keep class com.google.android.play.core.** { *; }
-dontwarn com.google.android.play.core.**

問題 4:版本號管理

versionName vs versionCode

  • versionName (例如 1.0.3): 這是顯示給使用者看的版本字串,應遵循語義化版本 (SemVer)。
  • versionCode (例如 4): 這是內部的整數版本號。Google Play 要求每次上傳的版本,其 versionCode
    都必須比前一個高。

遇到的錯誤

flutterVersionCode must be an integer.

問題分析pubspec.yaml 中的 build number (即 versionCode) 使用了時間戳記格式(如
202510081332),超過了 Android 的 32-bit 整數限制(2,147,483,647)。

解決方案:使用一個簡單且保證遞增的整數。一個很好的 CI/CD 實踐是使用 Git commit 的總數。

# 在 CI/CD 中獲取 commit 總數作為 versionCode
VERSION_CODE=$(git rev-list --count HEAD)
flutter build appbundle --build-number=$VERSION_CODE

這樣既能保證 versionCode 的唯一性和遞增性,也與程式碼的變更有直接關聯。

🎯 總結

透過建立完整的 Android AAB 建置與部署流程,我們實現了:

✅ 已完成的功能

  1. 🔐 簽名管理:理解 Play App Signing,並安全地管理上傳金鑰。
  2. 🏗️ 建置優化:從 APK 轉向 AAB,實現更小的應用程式體積。
  3. 🔧 問題解決:提供了建置過程中常見問題的完整解決方案。
  4. 📱 自動化部署:整合 Firebase App Distribution 進行測試版本分發。
  5. 🤖 CI/CD 整合:在 GitHub Actions 中實現了完整的自動化建置、簽名與發布流程。
  6. 📊 版本管理:採用了更穩健的 versionCode 自動化策略。

🎓 關鍵學習

  1. AAB 優先:現代 Android 開發應以 AAB 作為首選交付格式。
  2. 簽名機制:理解「上傳金鑰」和「應用程式簽署金鑰」的分離機制。
  3. 自動化價值:自動化部署不僅提升效率,更能確保每次建置的品質與一致性。
  4. 問題導向:解決實際問題是學習部署流程最快的方式。

下一步

明天(Day 25),我們將探討 iOS 部署完整實作,學習 Apple Developer 帳戶設定、憑證與 Provisioning
Profile 管理、App Store Connect 配置,以及 Fastlane 自動化部署。

期待與您在 Day 25 相見!


📋 相關資源

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 24 - Android 建置與簽名實作:從 AAB 到自動化部署
  • 文章日期: 2025-10-08
  • 技術棧: Android, AAB, Gradle, Flutter, Firebase App Distribution, GitHub Actions, ProGuard

上一篇
Day 23 - GitHub Actions CI 實作:你的第一個自動化工作流
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言