大家好,歡迎來到第二十四天!在 Day 23,我們建立了完整的 CI/CD 自動化流程。今天,我們要深入 Android 平台的
建置、簽名與自動化部署,並將重點從傳統的 APK 轉向現代化的 AAB (Android App Bundle) 格式。
從專案開發的經驗來看,Android 建置最大的挑戰不是理論,而是實際執行中遇到的各種問題。今天我們將分享在
Crew Up 專案中遇到的真實問題,以及如何一步步解決並建立穩定的建置與部署流程。
在開始之前,最重要的一點是:自 2021 年 8 月起,Google Play 已要求所有新應用程式必須採用 .aab
格式提交。AAB
帶來了許多好處:
因此,本篇文章的所有實作都將圍繞 AAB 進行。
在 AAB 的世界裡,簽名流程分為兩部分:
keystore.jks
就是上傳金鑰。這種分離機制提高了安全性,即使你的上傳金鑰洩漏,也可以透過 Google Play Console 進行重設,而不會影響到已安裝應用的更新。
建立 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 版本控制。
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
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 主要提供三大功能:壓縮 (Shrinking) 移除未使用的程式碼、優化 (Optimization)
改善程式碼執行效率,以及 混淆 (Obfuscation) 將類別、方法和欄位名稱改為簡短無意義的名稱,增加反編譯的難度。
# 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(...);
}
程式碼大小優化:
優化前 (APK):
APK 大小: ~50 MB
程式碼大小: ~20 MB
優化後 (AAB 交付):
AAB 大小: ~35 MB
使用者下載大小: ~15-25 MB (根據裝置)
優化效益: 應用程式下載體積減少約 50-70%
安全性提升:
在正式上架前,我們需要一個方便的方式來分發測試版本。Firebase App Distribution 提供了:
步驟 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)
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 }}
遇到的錯誤:
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 }}
遇到的錯誤:
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
遇到的錯誤:
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.**
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 建置與部署流程,我們實現了:
✅ 已完成的功能
versionCode
自動化策略。🎓 關鍵學習
明天(Day 25),我們將探討 iOS 部署完整實作,學習 Apple Developer 帳戶設定、憑證與 Provisioning
Profile 管理、App Store Connect 配置,以及 Fastlane 自動化部署。
期待與您在 Day 25 相見!