移动端 CI/CD 与 Web 的最大差异在于:代码签名与设备矩阵。iOS 需要证书与描述文件,Android 需要 keystore,且都需要在 CI 的无状态 Runner 上安全重建;而测试需要在模拟器/真机上运行,才能捕获 Web 测试无法暴露的原生问题。本文以 Flutter 为主线,同时覆盖 iOS/Android 双端,给出从「提交 → 测试 → 签名构建 → 商店发布」的完整流水线。
一、移动端 CI/CD 架构总览
1.1 双端流水线差异
| 维度 | iOS | Android | Flutter(双端) |
|---|---|---|---|
| 构建平台 | 必须 macOS | Linux/macOS/Windows 均可 | 按目标平台 |
| 签名材料 | 证书 + Provisioning Profile | keystore(.jks/.keystore) | 随目标平台 |
| 分发渠道 | App Store Connect / TestFlight | Google Play / 内测渠道 | 两者 |
| 测试设备 | iOS Simulator / 真机 | Android Emulator / 真机 | 两者 |
1.2 推荐的流水线分层
L1 静态检查:analyze + format + lint
L2 单元测试:flutter test / xcodebuild / gradlew test
L3 设备测试:emulator / simulator 上的 widget/instrumentation 测试
L4 签名构建:生成 apk / aab / ipa
L5 商店发布:fastlane deliver / supply 上传与元数据
二、Flutter 测试与构建
2.1 Flutter Action 配置
name: Flutter CI
on:
push:
branches: [main]
pull_request:
jobs:
flutter-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable # 或指定 flutter-version: 3.24.0
flutter-version: 3.24.0
cache: true # 缓存 pub 依赖与 Flutter SDK
- run: flutter pub get
- run: flutter analyze # 静态分析,作为质量门禁
- run: flutter test --coverage --reporter expanded
2.2 构建多平台产物
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: flutter pub get
- run: flutter build apk --release --split-per-abi
- run: flutter build appbundle --release
build-ios:
runs-on: macos-14 # iOS 构建必须 macOS
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: flutter pub get
- run: flutter build ipa --release --no-codesign # 先无签名构建验证
一句话:iOS 构建必须在 macOS runner 上执行,且代码签名材料只在「发布 job」中注入——CI 的常规验证构建保持
--no-codesign,避免每次 PR 都触碰私钥。
三、iOS 证书与描述文件管理
3.1 为什么需要 fastlane match
iOS 的代码签名需要 Apple 证书(Development/Distribution)与描述文件(Provisioning Profile)。在 CI 中手动上传这些 .p12 文件既不安全也不可复现。fastlane match 将证书加密存储在私有仓库或云存储中,按需解密注入——这是 iOS CI 的事实标准。
3.2 match 仓库结构
certs/
├── certs/
│ └── distribution/
│ └── XXXXXXXXXX.p12 # 加密的私钥
└── profiles/
├── appstore/
│ └── AppStore_com.example.app.mobileprovision
└── development/
└── Development_com.example.app.mobileprovision
3.3 在 CI 中配置 match
jobs:
build-ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
# 下载加密证书仓库(用独立只读 token)
- run: |
git clone --depth 1 \
https://x-access-token:${{ secrets.MATCH_REPO_TOKEN }}@github.com/org/mobile-certs.git \
~/certs
- name: Install match certificates
run: |
bundle install
bundle exec fastlane match appstore \
--git_url "https://github.com/org/mobile-certs.git" \
--app_identifier "com.example.app" \
--readonly true
- run: flutter build ipa --release
3.4 match 的密钥与安全参数
| 参数 | 说明 | 安全要求 |
|---|---|---|
MATCH_PASSWORD | 证书加密口令 | 存 Secrets,绝不入库 |
MATCH_REPO_TOKEN | 访问证书仓库的 token | 独立最小权限,只读 |
--readonly | 只拉取不修改 | CI 中避免并发写冲突 |
--type appstore | 证书类型 | distribution 用于发布 |
一句话:match 的核心价值是「证书不进代码库」——证书仓库用独立只读 token 拉取,加密口令放 Secrets,CI 每次按需解密重建签名环境。
四、Android 签名与 keystore 加密
4.1 keystore 的安全注入
Android 签名材料是 keystore 文件(.jks)+ 两个口令。在 CI 中最安全的做法是:keystore 以 Base64 存为 Secrets,构建时解码写入,用完即丢。
jobs:
sign-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
# 从 Secrets 解码 keystore(不进代码库)
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" >> android/key.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=upload" >> android/key.properties
echo "storeFile=upload-keystore.jks" >> android/key.properties
- name: Build signed APK
run: flutter build apk --release
4.2 build.gradle 签名配置
// android/app/build.gradle.kts
import java.util.Properties
android {
signingConfigs {
create("release") {
val props = Properties().apply {
val f = rootProject.file("key.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
if (props["storeFile"] != null) {
storeFile = rootProject.file(props["storeFile"] as String)
storePassword = props["storePassword"] as String
keyAlias = props["keyAlias"] as String
keyPassword = props["keyPassword"] as String
}
}
}
buildTypes {
getByName("release") {
signingConfig = signingConfigs.getByName("release")
}
}
}
4.3 签名安全对比
| 方案 | 存储位置 | 泄露风险 | 推荐度 |
|---|---|---|---|
| keystore 明文入库 | 代码仓库 | 极高 | ❌ |
| Base64 Secrets 解码 | 仓库级 Secrets | 低(需 actions 权限) | ✅ |
| OIDC + 密钥托管服务 | 云 KMS / Vault | 极低 | ✅✅ 大型团队 |
一句话:keystore 永远以 Base64 形式存在于 Secrets 中,构建时解码、构建后丢弃——
key.properties与.jks文件必须写进.gitignore。
五、设备矩阵:模拟器与真机
5.1 Android Emulator 测试
reactivecircus/android-emulator-runner 是 Android 设备测试的事实标准:
jobs:
android-instrumented-tests:
runs-on: ubuntu-latest
strategy:
matrix:
api-level: [29, 34] # 多 Android 版本矩阵
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
arch: x86_64
profile: pixel_5
script: ./gradlew connectedDebugAndroidTest
5.2 iOS Simulator 测试
iOS 模拟器测试在 macOS runner 上使用 xcodebuild 与 xcrun simctl:
jobs:
ios-simulator-tests:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_15.4.app
- name: Run tests on simulator
run: |
xcodebuild test \
-workspace ios/Runner.xcworkspace \
-scheme Runner \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=17.5'
5.3 设备矩阵策略
| 层级 | 方式 | 覆盖率 | 成本 | 适用 |
|---|---|---|---|---|
| 单元测试 | JVM/原生 | 高(逻辑) | 低 | 每次 commit |
| 模拟器测试 | Emulator/Simulator | 中(UI) | 中 | PR / nightly |
| 真机云测试 | Firebase Test Lab / BrowserStack | 高(真实硬件) | 高 | 发布前 / 回归 |
一句话:CI 中用模拟器矩阵覆盖「每次提交」,用云端真机测试覆盖「发布候选」——性价比与风险平衡的黄金组合。
六、App Store / Play 发布
6.1 用 fastlane 统一发布
jobs:
publish-ios:
needs: [build-ios, tests]
runs-on: macos-14
if: github.ref == 'refs/heads/main'
environment:
name: production
steps:
- uses: actions/checkout@v4
- run: |
bundle install
bundle exec fastlane match appstore --readonly
- run: flutter build ipa --release
- name: Upload to App Store Connect
run: |
bundle exec fastlane deliver \
--api_key_path ~/fastlane/AppStoreConnect.json \
--ipa "./build/ios/ipa/Runner.ipa"
publish-android:
needs: [build-android, tests]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment:
name: production
steps:
- uses: actions/checkout@v4
- run: flutter build appbundle --release
- name: Upload to Google Play
run: |
bundle exec fastlane supply \
--json_key ~/fastlane/play-service-account.json \
--aab ./build/app/outputs/bundle/release/app-release.aab \
--track production
6.2 App Store Connect 与 Play 的凭证安全
| 渠道 | 凭证 | 存储建议 |
|---|---|---|
| App Store | AppStoreConnect.json(API Key) | 环境级 Secrets Base64 解码 |
| Play 商店 | 服务账号 .json | 环境级 Secrets Base64 解码 |
| 证书口令 | MATCH_PASSWORD | Secrets,独立最小权限 |
6.3 发布门禁
发布 job 应挂在 production environment 下,叠加审批与分支限制:
jobs:
publish:
runs-on: macos-14
environment:
name: production
url: https://app.example.com
steps:
- run: ./upload.sh
七、代码签名安全加固
7.1 签名材料的威胁模型
| 攻击面 | 风险 | 防护 |
|---|---|---|
| 证书仓库泄露 | 私钥被窃取 | 独立只读 token + match 加密口令 |
| CI 日志回显 | 口令被打印 | 一律 env: 注入,禁止 ${{ secrets }} 拼入命令 |
| 共享 Runner 残留 | keystore 残留磁盘 | 构建后立即删除敏感文件 |
| fork PR 触发 | 恶意代码窃取证书 | 发布 job 绝不响应 fork PR 事件 |
7.2 安全构建步骤
- name: Sign and build
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
flutter build apk --release
# 构建完成立即清理敏感文件
rm -f android/app/upload-keystore.jks android/key.properties
7.3 fork PR 与签名隔离
# 只允许受信来源触发签名构建
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize]
jobs:
# 普通 PR 只跑无签名测试
test:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- run: flutter test
# 发布仅在 main 分支执行
publish:
if: github.ref == 'refs/heads/main'
environment: production
steps:
- run: ./sign-and-upload.sh
一句话:签名材料只在受信分支的发布 job 中出现——fork PR 永远只能跑无签名测试,这是移动端 CI 的安全底线。
八、移动端 CI 最佳实践清单
- Flutter Action 开启
cache: true,避免每次重下 SDK - 常规验证构建使用
--no-codesign,签名只在发布阶段 - match 证书仓库用独立只读 token,口令放 Secrets
- keystore 以 Base64 存 Secrets,构建后立即删除
-
key.properties、*.jks、*.p12全部加入.gitignore - 模拟器矩阵覆盖多次提交,真机云测试覆盖发布候选
- 发布 job 挂 production 环境 + 审批 + 分支限制
- fork PR 永远无权访问签名材料
总结
移动端 CI/CD 的复杂度集中体现在「签名」与「设备」两个维度。
| 环节 | 核心工具 | 关键决策 |
|---|---|---|
| Flutter 构建 | subosito/flutter-action | 固定版本、开启缓存 |
| iOS 签名 | fastlane match | 证书仓库隔离 + 只读 token |
| Android 签名 | keystore Base64 Secrets | 用完即删、不进仓库 |
| 设备测试 | emulator-runner / xcodebuild | 模拟器高频 + 真机低频 |
| 商店发布 | fastlane deliver / supply | 环境门禁 + 受信分支 |
| 安全底线 | 签名材料与 fork 隔离 | 只在发布 job 注入证书 |
一句话:用「常规 job 无签名验证 + 发布 job 注入签名」把安全与效率分开,用「模拟器矩阵 + 真机云测」把覆盖与成本分开——移动端 CI/CD 的工程化即是对这两组平衡点的持续打磨。
延伸阅读:
- GitHub Actions 环境保护与部署门禁 — 发布环境审批
- GitHub Actions 缓存优化完全指南 — Flutter SDK 与 pub 依赖缓存
- GitHub Actions 矩阵构建策略 — API level / 设备矩阵
- GitHub Actions 安全与 Secret 管理 — 签名材料保护
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。