Flutter包发布避坑指南:从审核被拒到成功上架

Flutter包发布避坑指南:从审核被拒到成功上架

Flutter 项目开发完成后,打包上架是最后一步,但往往也是问题最多的一步。iOS 的 App Store 审核、Android 各应用市场的合规要求,打包过程中的证书、签名、权限配置,每一个环节都可能出岔子。

整理一下从打包到上架的完整流程,以及常见的坑和解决方案。

iOS 打包

证书和描述文件

iOS 打包需要三种证书/文件:

  • 开发证书ios/development_certificate.p12,用于真机调试
  • 发布证书ios/distribution_certificate.p12,用于上架
  • 描述文件ios/*.mobileprovision,关联证书和 App ID

常见问题:

问题 1:证书过期

iOS 开发证书有效期一年,描述文件有效期一年且不能跨证书使用。证书过期后打包会失败:

Code Signing Failed: No certificate found in the default keychain

解决方案:到 Apple Developer 后台重新创建证书和描述文件。创建后需要重新配置 Xcode 的 Signing & Capabilities。

问题 2:Bundle Identifier 冲突

每个 App 的 Bundle Identifier 必须唯一,包括不同团队账号下的 App。如果提示:

The bundle identifier "com.example.app" is already taken

解决方案:

  1. 到 Apple Developer 后台检查是否已有这个 Bundle ID
  2. 如果有但不是你的,联系原所有者转移或删除
  3. 如果是自己的,检查 Xcode 里的 Team 配置是否正确

问题 3:Team 选错了

Xcode Signing 里有个 "Team" 下拉框,如果选错了团队或者个人账号,会导致签名不一致。打包前确认:

  • App Store Connect 上的 App 和 Xcode 里的 Team 是同一个
  • Development 和 Distribution 的 Team 配置要分别检查

Xcode 构建

用命令行打包 iOS:

# 清理旧的构建
flutter clean

# 构建 iOS release
flutter build ios --release --no-codesign

--no-codesign 是关键:如果不加这个参数,Flutter 会尝试自动签名,在 CI 环境里很容易失败。

构建成功后产物在 build/ios/iphoneos/Runner.app

上传到 App Store Connect

上传用 xcrun altool

xcrun altool --upload-app \
  -type ios \
  -file build/ios/ipa/Runner.ipa \
  -username "your@email.com" \
  -password "app-specific-password"

注意:从 2021 年开始,上传 App Store Connect 必须用 App-Specific Password,不能用 Apple ID 密码。

创建 App-Specific Password:

  1. 登录 appleid.apple.com
  2. 安全 → App-Specific Passwords → 生成
  3. 记住这个密码,一次性使用

iOS 审核被拒的常见原因

原因 1:隐私权限描述缺失或不清晰

如果 App 使用了定位、相机、相册、麦克风、通讯录等权限,必须在 Info.plist 里添加对应的用途描述:

<key>NSLocationWhenInUseUsageDescription</key>
<string>我们需要获取您的位置来提供附近的服务</string>

<key>NSCameraUsageDescription</key>
<string>我们需要使用相机来拍摄商品照片</string>

<key>NSPhotoLibraryUsageDescription</key>
<string>我们需要访问相册来选择商品图片</string>

<key>NSMicrophoneUsageDescription</key>
<string>我们需要使用麦克风来录制语音</string>

审核被拒的典型提示:We noticed that your app requests the user's permission to access their [camera], but the purpose you provided in the usage string does not clearly explain why the app needs this information.

原因 2:第三方 SDK 收集用户数据未披露

App Store Connect 的 "Privacy" 部分要求披露所有第三方 SDK 收集的数据类型:

  • 位置
  • 联系人
  • 健康
  • 财务
  • 等等

如果使用了第三方 SDK(如友盟统计、极光推送),需要确认这些 SDK 会收集哪些数据,并在 App Store Connect 隐私页面如实填写。

原因 3:应用截图尺寸不对

App Store Connect 要求不同机型的截图尺寸:

  • iPhone 6.5 英寸:1284 x 2778 像素
  • iPhone 5.5 英寸:1242 x 2208 像素
  • iPad Pro 12.9 英寸:2048 x 2732 像素

截图必须是真实设备截图,不能用模拟器截图(会被检测拒绝)。

原因 4:IPv6 网络支持

App Store 要求 App 必须支持 IPv6-only 网络环境。Flutter 的 HTTP 库默认支持 IPv6,但如果有自定义网络代码,需要检查。

Android 打包

签名配置

Android 的签名比 iOS 简单,但更容易出问题的是签名密钥管理

签名密钥生成:

keytool -genkey -v -keystore ~/release.jks \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -alias release

android/app/build.gradle 配置签名:

signingConfigs {
  release {
    storeFile file('path/to/release.jks')
    storePassword 'your_store_password'
    keyAlias 'release'
    keyPassword 'your_key_password'
  }
}

buildTypes {
  release {
    signingConfig signingConfigs.release
    minifyEnabled true
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
  }
}

重要:不要把 keystore 文件和密码提交到代码仓库。建议用环境变量或者 gradle.properties 配置:

# gradle.properties
KEYSTORE_FILE=release.jks
KEYSTORE_PASSWORD=xxx
KEY_ALIAS=release
KEY_PASSWORD=xxx
signingConfigs {
  release {
    storeFile file(KEYSTORE_FILE)
    storePassword KEYSTORE_PASSWORD
    keyAlias KEY_ALIAS
    keyPassword KEY_PASSWORD
  }
}

多渠道打包

Android 通常需要按渠道打包(华为、小米、应用宝等),可以用 productFlavors 配置:

flavorDimensions "store"

productFlavors {
  huawei {
    dimension "store"
    applicationIdSuffix ".huawei"
    manifestPlaceholders["CHANNEL_NAME"] = "huawei"
  }
  xiaomi {
    dimension "store"
    applicationIdSuffix ".xiaomi"
    manifestPlaceholders["CHANNEL_NAME"] = "xiaomi"
  }
  official {
    dimension "store"
    // 官方渠道不加 suffix
  }
}

Manifest 里的渠道标识:

<meta-data
  android:name="CHANNEL"
  android:value="${CHANNEL_NAME}" />

Flutter 代码获取渠道:

const channel = String.fromEnvironment('CHANNEL', defaultValue: 'official');

打包命令:

flutter build apk --release -t flavorName

Android 11 (API 30+) 适配

Android 11 引入了更严格的权限管理,有几个常见问题:

问题 1:包可见性

如果 App 使用了自定义 ContentProvider,但 Android 11 上找不到,检查 AndroidManifest.xml

<!-- 如果需要查询其他 App 的包 -->
<queries>
  <package android:name="com.other.app" />
</queries>

问题 2:后台位置权限

Android 11 要求后台位置权限必须单独申请:

<!-- 精确位置(前台)-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<!-- 模糊位置(前台)-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

<!-- 后台位置(需要额外申请)-->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

用户必须先授予前台权限,才能申请后台权限。

Android 应用市场审核

华为应用市场

华为是审核最严格的市场之一,常见被拒原因:

  1. 隐私政策缺失或不合规:必须有独立的隐私政策页面,且页面内容要覆盖所有权限用途
  2. 应用截图包含敏感信息:截图里不能有电话号码、微信号等联系方式
  3. 应用名称或图标和他方商标近似:有品牌侵权风险

小米应用市场

小米对角标(热门、推荐等)使用有严格要求,不能在应用内自行添加这些角标。

应用宝

应用宝要求所有游戏必须申请版号,非游戏应用也需要提供软件著作权证书。

Flutter 特有的打包问题

资源文件路径

Flutter 的资源文件在 pubspec.yaml 里声明:

flutter:
  assets:
    - assets/images/
    - assets/data/

  fonts:
    - family: MyFont
      fonts:
        - asset: assets/fonts/MyFont.ttf

资源路径是相对 pubspec.yaml 的路径,不是 Dart 文件的路径。如果资源找不到,检查:

  1. 路径是否正确
  2. 是否有尾随斜杠(assets/images/ vs assets/images
  3. 资源文件是否在项目根目录下(不能在 lib/ 子目录里)

原生库依赖

Flutter 插件如果有原生 iOS/Android 代码,需要注意:

iOS:检查 Podfile 的 platform 版本,确保不低于插件要求的最低版本:

platform :ios, '12.0'  # 不能低于 12.0

Android:检查 android/app/build.gradleminSdkVersion

defaultConfig {
  minSdkVersion 21  # 不能低于 21
}

热更新和代码推送

Flutter 不支持真正的热更新(代码替换),但可以通过以下方案实现动态更新:

  1. flutter_update:托管差量包,用户下载后替换 Dart 代码
  2. 应用内跳转:如果只是运营活动,可以用 WebView 或 RN/小程序方案

如果有人告诉你 Flutter 可以热更新 iOS App Store 应用,那是在说谎或者混淆概念。App Store 禁止任何形式的代码更新,所有动态代码执行都会被检测并下架。

总结

打包上架的核心避坑点:

  1. 证书管理:证书和描述文件要提前检查有效期,过期前及时更新
  2. 权限声明:所有权限都要有对应的 Info.plist 用途描述,审核时才不会被拒
  3. 签名安全:keystore 文件和密码不要提交到代码仓库
  4. 渠道适配:Android 多渠道配置要测试每个渠道的包是否正常
  5. 隐私合规:隐私政策页面必须真实有效,覆盖所有数据收集行为
  6. 资源路径:资源文件路径要正确,pubspec.yaml 声明要完整

上架只是开始,后续的版本更新、证书续期、隐私合规都是持续的工作。建议把打包流程写成自动化脚本,减少手动操作带来的出错概率。

最后更新 4/20/2026, 4:48:48 AM