Flutter Android 构建配置:为 R8 收紧规则并验证发布包

Flutter 项目接入 Android 原生 SDK 后,最难复现的一类问题往往只出现在 release 包:flutter run 和 debug APK 都正常,安装 AAB 后点进某个功能却报 ClassNotFoundExceptionNoSuchMethodException,或者 SDK 静默失败。不要立刻把它归因于 Flutter。Dart 的 --obfuscate 处理的是 Dart 符号;Android release 变体中的 R8 会压缩、优化、改名甚至移动 Java/Kotlin 字节码。直接调用通常能被 R8 识别,但字符串反射、JNI 回调和注解扫描属于间接使用,必须明确描述给构建系统。

这篇只解决一个核心问题:Flutter 通过 MethodChannel 调用一个由固定类名装配的 Android 启动任务,如何在不关闭 R8、不保留整个包的前提下,修复 release-only 崩溃并验证发布产物。它建立在你已经完成 Flutter Android 的 AGP/Kotlin 构建迁移 的基础上;本次新增能力是把动态入口、最小规则、设备测试和符号归档收进同一条发布链路。

适用范围与发布验收

示例环境为 Flutter stable、Dart 3、JDK 17、Android Gradle Plugin 8.12+,Android 模块使用 Kotlin DSL。规则语法可用于较旧插件,但 keep-rule 文件位置和资源压缩行为会随 AGP 演进,因此发布前应核对项目实际版本。本文针对应用自身按类名装配的 Kotlin 入口;第三方 AAR 是否需要规则,应优先读取其文档与 consumer rules,不要从论坛复制整段配置。

验收目标不是“Gradle 成功”,而是四项同时成立:release AAB 或 APK 能在清洁设备安装;Flutter 页面触发原生入口得到预期结果;logcat 没有缺类或缺成员日志;configuration.txtmapping.txt 与 versionCode 一起归档。Android 官方的 R8 keep rules 概览 明确说明,反射与 JNI 等间接调用可能在优化时丢失可达性。

先让入口和复现路径可见

把页面、Channel、原生装配和规则分开,代码审查时才能看清哪一处真正依赖动态加载:

lib/features/bootstrap/bootstrap_page.dart
lib/platform/startup_bridge.dart
android/app/src/main/kotlin/com/example/app/StartupChannel.kt
android/app/src/main/kotlin/com/example/app/bootstrap/StartupTask.kt
android/app/src/main/kotlin/com/example/app/bootstrap/RemoteConfigStartupTask.kt
android/app/proguard-rules.pro

Flutter 端只调用稳定协议,绝不把服务器下发的任意类名交给 Android 反射:

const channel = MethodChannel('com.example.app/startup');
Future<String> runTask() async {
  final value = await channel.invokeMethod<String>('runRemoteConfig');
  if (value == null || value.isEmpty) throw StateError('empty native result');
  return value;
}

原生层把允许的实现固化为常量。下面故意使用 Class.forName,模拟配置型 SDK 的动态入口;这不是为了炫技,而是让规则有可追溯的证据。

interface StartupTask { fun run(): String }
private const val REMOTE_TASK =
    "com.example.app.bootstrap.RemoteConfigStartupTask"
fun createRemoteTask(): StartupTask {
    val type = Class.forName(REMOTE_TASK)
    return type.getDeclaredConstructor().newInstance() as StartupTask
}

先在 release 变体复现,再写规则。保存完整命令输出和日志;Windows 将 ./gradlew 替换为 ./gradlew.bat

flutter clean
flutter pub get
flutter build appbundle --release --obfuscate --split-debug-info=build/symbols/1.8.0
cd android && ./gradlew :app:assembleRelease --stacktrace --info
adb install -r app/build/outputs/apk/release/app-release.apk
adb logcat -c && adb logcat AndroidRuntime:E Flutter:E *:S

在清除应用数据的设备上打开页面并触发一次功能。若日志准确指向 RemoteConfigStartupTask 缺失,才是 R8 可达性证据;如果是 401、ABI 安装错误、Channel 名称不一致或后端返回空值,应沿各自边界排查,不能用 Keep Rule 把真实问题藏掉。

用最小规则表达运行时契约

确认 release 变体实际载入规则文件:

android {
  buildTypes {
    release {
      isMinifyEnabled = true
      isShrinkResources = true
      proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
    }
  }
}

这里的契约只有一件事:按精确类名加载,并调用无参构造器。规则就只保留类和构造器,allowoptimization 仍允许 R8 优化代码:

# Loaded by the fixed string in createRemoteTask().
-keep,allowoptimization class com.example.app.bootstrap.RemoteConfigStartupTask {
    <init>();
}

不要无证据保留 bootstrap.**、接口所有实现或 { *; }-keepnames 也不够,因为它只约束命名,类仍可能被移除。若运行机制确实是注解扫描,可把注解和接口作为边界:

-keep,allowoptimization @com.example.app.bootstrap.NativeEntry class * implements com.example.app.bootstrap.StartupTask {
    <init>();
}

Android 的 规则示例 把类名反射列为需要规则的场景;Kotlin 的 internalsuspend 不是规则关键字,匹配的是编译后的 JVM 名称和签名。对于库作者提供的规则,先确认其版本与生效范围,不能在应用里重复铺开包级白名单。

将规则变成 release 回归测试

规则不是测试。第一层是 Kotlin 单元测试,保证注册表仍能构造入口;第二层必须在真实 release 包里穿过 Flutter 到 Android 的完整调用链。

@Test fun remoteTask_is_constructible() {
    val task = createRemoteTask()
    assertTrue(task is RemoteConfigStartupTask)
}

设备验证至少覆盖一个低支持 API 和当前 target API,记录登录态、测试数据和结果。集成测试可以驱动 Flutter 页面;当 SDK 依赖 Play 服务、账号或硬件时,保留一个可重复的 adb 脚本同样重要。Flutter 的 Android 发布文档 可核对 AAB、签名与构建命令,但不能替代实际安装后的功能测试。

还要检查 R8 产物。常见路径如下,具体以项目输出为准:

android/app/build/outputs/mapping/release/mapping.txt
android/app/build/outputs/mapping/release/configuration.txt
android/app/build/outputs/mapping/release/missing_rules.txt

configuration.txt 搜索类名与 proguard-rules.pro,验证规则已合并;missing_rules.txt 只是诊断线索,不能逐行盲贴。CI 可增加简单检查:

grep -q "RemoteConfigStartupTask" android/app/build/outputs/mapping/release/configuration.txt
test -s android/app/build/outputs/mapping/release/mapping.txt
test -s build/app/outputs/bundle/release/app-release.aab

PowerShell 可用 Select-StringTest-Path 实现同样校验。把 AAB、mapping、Flutter split-debug-info、Git commit、versionName、versionCode、设备日志和测试结果按同一次构建归档;日后崩溃需要同时区分 Dart 符号和 Android R8 符号。

失败边界与发布复盘

debug 正常不证明 release 正常,因为 debug 通常没有开启缩减。-keep class ** { *; }-dontshrink-dontoptimize 可能让故障暂时消失,却损失压缩与优化,也让团队忘记真正的动态依赖。Manifest 声明的组件、XML 直接引用的 View 常已被构建工具识别,不应无证据加入全局规则。若只有一个 flavor 失败,比较该 flavor 的 proguardFiles、manifest placeholder、依赖图、开关和测试数据,而非修改所有变体。

发布前复盘:每个动态入口要有代码位置、触发条件和负责人;每条规则都能对应反射、JNI 或注解扫描证据;release 包必须在设备完成关键路径;configuration.txtmapping.txt、Dart 符号和 AAB 必须可按版本回查;临时包级规则要在根因确认后删除。这样交付的不是越来越大的 ProGuard 文件,而是一条可审计的 Flutter Android 发布验证链路。

用数据决定规则是否过宽

规则收紧后还需要观察两类指标。第一类是正确性指标:release 设备测试中动态入口成功次数、缺类和缺成员异常次数、启动任务耗时,以及失败时是否能从日志关联到 buildId。不要只记录成功;把入口名、版本、flavor、API level 和错误类别输出为结构化日志,排查时才能区分新规则漏保留、网络超时和服务端数据异常。第二类是构建产物指标:AAB 大小、DEX 方法数、mapping 文件是否生成、R8 配置中匹配到的类数量。它们不需要承诺固定下降比例,却能在一次宽泛规则误入主分支时给出预警。

可在 CI 为每个 release 任务保存报告:构建命令返回码、AAB 的 SHA-256、安装设备型号、自动化测试名称、logcat 关键字计数和 mapping 文件路径。若把规则从单类扩大到注解扫描,审查说明必须写清新增实现数量与触发处。对于 size 波动,只在同一依赖集、ABI 和资源压缩开关下比较,避免把图标、语言包或依赖升级误判成规则影响。

回滚与线上故障处理

上线后若收到 release 崩溃,先锁定 versionCode 和对应 AAB,再使用同一批 mapping.txt 对 Android 堆栈还原;Dart 堆栈则使用同批 split-debug-info。两份符号文件不能互换。确认异常来自反射入口后,先用测试设备复现,再提交只覆盖缺失类或成员的规则和回归测试。紧急回滚应优先选择已验证的上一构建或关闭功能开关;不要在没有复现和日志的情况下把全局逃生开关推到全量发布。

当规则删除后仍能通过 release 测试,也应删除它并在变更记录中说明原因。R8 配置是代码的一部分:需要代码评审、可验证假设和可回退版本。这样每次加入 Flutter 原生插件时,团队都能沿着入口证据、最小规则、发布测试、指标归档的路径前进,而非累积无法解释的例外。

团队协作中的最小约定

为了让这条链路长期可用,建议把原生动态入口登记在模块 README:写明它由哪个 Flutter 功能调用、类名为何不能随意改动、对应的 Keep Rule、设备测试入口和归档位置。依赖升级的 PR 只要触及反射、序列化、JNI 或注解处理,就应主动跑一次 release 验证,而不是等线上日志提醒。测试同学不必理解所有 R8 语法,但需要知道“debug 正常、release 失败”是独立状态,并在缺类、缺成员、闪退和静默失败之间提供可复现步骤与日志时间段。

代码评审时可以问三个问题:这条规则保留的对象是谁;运行时从哪里间接调用它;删除或收紧后哪个测试会失败。三个问题都答不出时,先不要合入。若实际项目使用多模块,应用模块的规则和库的 consumer rules 要分开维护,避免库把应用范围的全局开关带给所有接入方。通过这种小约定,规则数量增加时仍能被阅读、验证和安全迁移。此外,任何发布失败都应保留原始构建与日志,修复后只重试同一版本链路,不为了赶节奏另起一份未经验证的发布包。