适用场景与故障边界

这篇文章面向自己维护 Flutter Android 原生插件、或在业务工程中封装 MethodChannel 的团队。典型故障不是插件完全不能调用,而是相机、文件选择、授权页或第三方 Activity 返回之前发生横竖屏切换、多窗口调整,旧 Activity 被销毁,新 Activity 已创建,插件却仍把结果发给旧引用。表现可能是 Dart 侧 Future 永久等待、第二次调用提示任务仍在进行、日志出现回调到已销毁页面,甚至只在 release 包或部分折叠屏上复现。

本文只处理依赖 Activity 的 Android 插件生命周期,不讨论纯 Dart 插件、后台 Service,也不把保存完整业务状态交给插件。Flutter 官方的 ActivityAware API 明确要求:收到 detach 回调后,旧 binding 及其资源不再有效,必须清理;配置变化完成后会通过 reattach 提供新的 Activity。Android 官方也建议用 ActivityScenario 或 Activity.recreate() 验证系统销毁与重建。前置阅读可参考站内的 Flutter Android R8 发布包规则验证,先保证测试对象与 release 产物一致。

先把可重建状态和 Activity 引用拆开

不要把 ActivityActivityPluginBinding、当前请求和 Dart 回调塞进一个永不清空的字段。更稳妥的边界是:Activity 引用可以失效;请求标识和可恢复参数属于插件状态;一次性 Result 只能完成一次。示例插件提供 openPicker,并用请求 ID 识别回流:

class DocumentPickerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
    private lateinit var channel: MethodChannel
    private var activityBinding: ActivityPluginBinding? = null
    private var pending: PendingCall? = null

    data class PendingCall(
        val requestId: String,
        val mimeType: String,
        val result: MethodChannel.Result
    )

    override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
        channel = MethodChannel(binding.binaryMessenger, "stepnex/document_picker")
        channel.setMethodCallHandler(this)
    }

    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        if (call.method != "openPicker") return result.notImplemented()
        if (pending != null) return result.error("busy", "A picker request is active", null)
        val activity = activityBinding?.activity
            ?: return result.error("no_activity", "Plugin is not attached", null)
        val requestId = call.argument<String>("requestId") ?: UUID.randomUUID().toString()
        val mimeType = call.argument<String>("mimeType") ?: "*/*"
        pending = PendingCall(requestId, mimeType, result)
        launchPicker(activity, mimeType)
    }
}

关键不是这段代码能启动页面,而是它没有把旧 Activity 写进 PendingCall。配置变化期间保留请求元数据,但必须撤销注册在旧 binding 上的 listener;普通 detach 则应明确失败当前请求,避免 Dart 侧无限等待。若原生 SDK 自己能通过稳定 request ID 恢复结果,可以在 reattach 后继续;若 SDK 把回调绑定在旧 Activity 上,就应返回可重试错误,而不是假装任务仍然健康。

对称实现四个 ActivityAware 回调

把注册与注销集中到两个私有方法,可以减少漏清理。onDetachedFromActivityForConfigChanges 与普通 detach 的业务语义不同,但二者都必须先释放旧 binding:

private fun attach(binding: ActivityPluginBinding) {
    check(activityBinding == null) { "Activity binding attached twice" }
    activityBinding = binding
    binding.addActivityResultListener(activityResultListener)
}

private fun detach(failPending: Boolean) {
    activityBinding?.removeActivityResultListener(activityResultListener)
    activityBinding = null
    if (failPending) {
        pending?.result?.error("activity_detached", "Host activity was detached", null)
        pending = null
    }
}

override fun onAttachedToActivity(binding: ActivityPluginBinding) = attach(binding)

override fun onDetachedFromActivityForConfigChanges() {
    detach(failPending = false)
}

override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
    attach(binding)
    // 只恢复能够由 SDK 或持久请求 ID 证明仍有效的操作。
    pending?.let { resumeIfSupported(binding.activity, it.requestId) }
}

override fun onDetachedFromActivity() {
    detach(failPending = true)
}

override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
    channel.setMethodCallHandler(null)
    pending?.result?.error("engine_detached", "Flutter engine was detached", null)
    pending = null
}

代码评审时逐项确认:每个 add...Listener 都存在对应的 remove...Listener;每个 Activity 字段都在两类 detach 中清空;每个 Result 只有 success、error、notImplemented 之一;reattach 不会重复注册;Engine detach 后不再向 channel 发事件。若插件还注册权限、NewIntent 或生命周期观察者,也采用同样的对称规则。

用可观察日志证明绑定发生了切换

只看界面“似乎没崩”不够。给每个 Activity 实例和请求打稳定日志,才能判断回调究竟落到旧实例还是新实例:

private fun activityId(activity: Activity?): String =
    activity?.let { "${it.javaClass.simpleName}@${System.identityHashCode(it)}" } ?: "none"

private fun logBinding(event: String) {
    Log.i(
        "DocumentPickerPlugin",
        "event=$event activity=${activityId(activityBinding?.activity)} " +
            "requestId=${pending?.requestId ?: "none"}"
    )
}

一次正常重建的日志顺序应类似:attached Arequest_started Adetached_for_config Areattached Bresult B,其中 A 与 B 的 identity hash 必须不同,请求 ID 必须相同。失败信号包括 reattach 后仍打印 A、一次结果打印两次、detach 后 listener 仍收到事件、第二次调用一直返回 busy。把这些信号写进测试断言或 CI 日志筛选,比人工旋转一次屏幕可靠得多。

建立三层测试而不是只测 MethodChannel

第一层是 Kotlin 单元测试,使用假的 binding 和 listener 容器验证注册次数。它速度快,适合覆盖重复 attach、普通 detach、配置 detach、Engine detach 等边界。第二层是 example 应用的 Flutter integration_test,验证 Dart API、MethodChannel 和 Android 实现能完整串联。Flutter 插件测试说明指出,纯 Dart 测试不会运行原生代码,因此有原生逻辑时不能只停在 mock channel。

第三层是 Android 仪器测试,直接驱动 Activity 重建。测试版本记录建议固定 Flutter stable、compileSdk、AGP、Kotlin、测试设备 API 和屏幕模式。例如:Flutter stable、Android API 35 模拟器、debug 与 release 各一次。核心测试可用 ActivityScenario:

@RunWith(AndroidJUnit4::class)
class PluginRecreationTest {
    @Test
    fun request_survives_activity_recreation_without_old_binding() {
        ActivityScenario.launch(MainActivity::class.java).use { scenario ->
            scenario.onActivity { activity ->
                TestBridge.startRequest(activity, requestId = "recreate-001")
            }
            scenario.recreate()
            scenario.onActivity { recreated ->
                assertThat(TestBridge.currentActivity()).isSameInstanceAs(recreated)
                assertThat(TestBridge.pendingRequestId()).isEqualTo("recreate-001")
                TestBridge.complete("content://fixture/document.pdf")
            }
            assertThat(TestBridge.successCount()).isEqualTo(1)
            assertThat(TestBridge.listenerCount()).isEqualTo(1)
        }
    }
}

如果 SDK 的真实选择器无法在 CI 自动操作,就把 SDK 适配层抽成接口,仪器测试验证 Activity 绑定与结果状态机,少量真机回归再覆盖真实 UI。不要为了“好测”而跳过系统重建;Android 官方的 Activity 测试文档正是把销毁、重建和后台切换列为需要主动驱动的设备级事件。

执行命令与验收矩阵

在插件仓库根目录依次运行:

flutter test
cd android && ./gradlew testDebugUnitTest
cd ../example && flutter test integration_test -d emulator-5554
cd android && ./gradlew connectedDebugAndroidTest
cd .. && flutter build apk --release

至少覆盖五个场景:未发请求时旋转;请求发出后立即旋转;结果返回瞬间旋转;任务进行时把应用放到后台再回来;普通销毁或 Engine detach。每个场景记录请求完成次数、listener 数量、旧 Activity 弱引用是否可回收、Dart Future 是否在超时前完成。建议设置明确指标:每个请求恰好完成一次,reattach 后 listener 总数为一,detach 后旧 Activity 不再出现在日志,连续重建二十次没有 busy 残留。

release 包也必须执行一次,因为混淆、不同优化配置和插件注册方式可能改变表现。不要把“debug 通过”当成发布结论;将 APK 安装到 API 最低版本和当前主力版本各一台设备,复查选择器回流和进程后台恢复。

把回调做成显式状态机

当插件只有一个布尔值 isBusy 时,很难解释重建发生在启动前、启动后还是结果返回途中。建议将请求状态写成 idlelaunchingwaitingResultreattachingcompletedfailed,并为每次转换记录旧状态、新状态、request ID 与 Activity identity。只有规定好的转换才被接受,例如 waitingResult -> reattaching -> waitingResult;若在 completed 后再次收到结果,只记录重复回调并丢弃,不能第二次调用 Result。

状态机还应规定超时归属。Dart 侧超时只能停止界面等待,不能自动证明 Android 请求已经结束;原生侧仍需清理 pending、listener 和 SDK token。若 Dart 取消后原生结果才返回,应记录 late_result,但不得把结果投递给下一次请求。支付、账号绑定或上传等有副作用操作还要把 request ID 传给服务端做幂等,绝不能因 Activity 重建而自动重放。

测试报告中不要只写“旋转通过”。保存每种场景的状态序列和关键日志,例如重建二十次后 listener 数仍为一、旧 Activity 弱引用在垃圾回收后释放、每个 request ID 只有一条 terminal 记录。若团队无法稳定触发垃圾回收,至少使用 Android Studio Memory Profiler 或 LeakCanary 做辅助观察,并把它标记为人工证据而非确定性的单元测试断言。

还要验证并发入口。用户快速双击按钮、Dart 页面被重复构建、系统返回结果时新页面再次调用插件,都可能绕开单次手工测试。第二个请求应立即得到可识别的 busy 错误,或进入明确队列;前一个请求结束、超时或永久 detach 后,busy 必须释放。把这个行为写进 Dart API 文档,避免业务层把可重试错误当成永久失败。

团队交付与线上监控

合并请求应同时提交插件代码、example 复现入口、自动化测试和一份短日志样本。评审者不必依赖作者的真机,就能从日志看到 A 到 B 的绑定切换。CI 可保存 instrumentation 测试结果和 logcat 作为构建产物;失败时优先展示 request ID、生命周期事件和 listener 数量,而不是只上传整段系统日志。

线上指标保持克制但可关联:plugin_request_startedplugin_activity_reattachedplugin_request_completedplugin_request_failedplugin_late_result。事件不记录文件名、文档内容或用户隐私,只记录插件版本、Android API、构建版本、错误类型和随机请求 ID。按版本比较完成率与超时率,发现新版本异常时能快速关闭入口或回滚插件,而不是等待用户描述“偶尔点了没反应”。

常见错误与回滚策略

最常见错误是把配置变化 detach 当作永久退出,导致请求被过早取消;反过来,把普通 detach 当作可恢复,又会永久保存 Result 和 Activity。第二类错误是 reattach 再注册 listener,却没有从旧 binding 移除,于是一次系统结果被处理两遍。第三类错误是用 lateinit Activity 并假设任意 MethodCall 到来时都已绑定,后台 Engine 或预热 Engine 会直接触发异常。

上线前保留一个开关,让业务暂时回到不依赖原生页面的文件输入或禁用旋转期间的新请求。若监控发现 no_activityactivity_detached、重复完成数上升,先关闭新入口,再收集 request ID 和生命周期序列;不要仅靠延长 Dart 超时掩盖绑定错误。插件无法证明请求可恢复时,明确失败并允许用户重试,比在错误 Activity 上继续执行安全。

发布前复盘清单

  • Activity、binding、listener 是否在两类 detach 中全部清理。
  • 配置变化重建后是否只绑定新 Activity,旧实例能否回收。
  • pending 请求是否有稳定 ID、单次完成保护和明确超时。
  • Dart 单元测试、Kotlin 单元测试、integration_test、仪器测试是否各自覆盖正确层级。
  • debug 与 release、最低 API 与主力 API 是否都有日志证据。
  • 普通销毁、配置变化、Engine detach 是否采用不同恢复策略。

完成这些验证后,插件的目标不再是“旋转屏幕不崩”,而是能用日志、状态机和测试证明:旧引用及时失效,新绑定只注册一次,Dart 侧每个请求都有且只有一个终态。