适用场景:先把 ANR、崩溃和普通掉帧分开

Flutter Android 项目一旦进入测试、灰度或正式上线阶段,“页面偶尔卡死”“点按钮后几秒没反应”“切后台再回来直接被系统关掉”这类反馈就会出现。很多团队一开始会把它们统称为卡顿,然后顺手去看崩溃日志,最后定位半天发现根本不是同一种问题。ANR 的核心不是异常抛出,而是主线程太久没有响应输入、绘制或系统回调,系统直接判定应用“无响应”。如果你现在连线上崩溃和符号表链路都还没补齐,建议先看站内这篇 Flutter Android 上线后崩溃怎么排查:符号表、logcat 与 Play Console 对照清单;ANR 排查更像它的下一阶段,重点从“为什么崩了”转到“为什么没崩却卡住了”。

这篇文章只处理一个常青问题:Flutter Android 项目出现疑似卡死时,怎样把 Play Console、设备命令、Flutter 帧数据和 Android 退出原因串成一条排查链。验证边界按 Flutter stable、Android 13/14 真机、minSdkVersion 24targetSdkVersion 34+ 来写,目录示例会落到 lib/diagnostics/android/app/src/main/kotlin/.../diagnostics/。如果你的问题只是偶发掉帧、首帧慢但并没有被系统认定为无响应,可以先回到站内的 Flutter Android 冷启动怎么排查:profile 模式、首帧基线与主线程减负清单,先把启动与渲染基线补齐,再继续看这篇。

步骤一:先补两条证据线,别只靠用户口述“卡住了”

ANR 最难受的地方在于,用户描述通常只有一句“点了没反应”,而开发环境里未必可以立刻稳定复现。所以我建议先在项目里补两条轻量证据线,一条留给 Flutter 层,一条留给 Android 层。Flutter 层关注的是主 isolate 附近是否持续出现慢帧;Android 层关注的是系统最后为什么结束了这个进程。两条线一起看,才知道这是普通 jank、主线程阻塞,还是已经演变成真正的 ANR。

目录可以先按下面拆:

lib/
  diagnostics/
    frame_timing_probe.dart
    anr_review_service.dart
android/app/src/main/kotlin/com/example/app/diagnostics/
  AnrDiagnosticsChannel.kt

Flutter 层先记录 FrameTiming,把连续慢帧写进本地日志或你的埋点缓冲区。这里不要贪大,只保留最近几十条即可,目标是让“卡死前 10 秒发生了什么”有痕迹可看。

import 'package:flutter/scheduler.dart';

class FrameTimingProbe {
  final List<Map<String, Object>> samples = <Map<String, Object>>[];

  void start() {
    SchedulerBinding.instance.addTimingsCallback(_onTimings);
  }

  void stop() {
    SchedulerBinding.instance.removeTimingsCallback(_onTimings);
  }

  void _onTimings(List<FrameTiming> timings) {
    for (final timing in timings) {
      final totalMs = timing.totalSpan.inMilliseconds;
      if (totalMs < 32) continue;
      samples.add({
        'buildMs': timing.buildDuration.inMilliseconds,
        'rasterMs': timing.rasterDuration.inMilliseconds,
        'totalMs': totalMs,
        'recordedAt': DateTime.now().toUtc().toIso8601String(),
      });
    }
    if (samples.length > 40) {
      samples.removeRange(0, samples.length - 40);
    }
  }
}

这里的作用不是替代性能平台,而是把 Flutter 层的证据固定下来。你后面看到 buildMs 长时间堆高,通常要先怀疑 Dart 主 isolate 做了重活;如果 rasterMs 更明显,就要回头查图片、阴影、裁剪和平台视图。哪怕最后没有形成 ANR,这条日志也能解释为什么用户在 ANR 前已经先感受到明显掉帧。

步骤二:Android 侧把最近退出原因带回 Flutter,先判定是不是系统真认定为 ANR

第二条证据线要靠 Android 原生层。官方 ApplicationExitInfo 从 Android 11,也就是 API level 30 开始可用,能够告诉你最近一次进程退出的原因。很多 Flutter 团队其实已经有线上“重新打开应用时恢复草稿”的逻辑,这里可以顺手通过 MethodChannel 把最近几次退出原因读出来,应用下次启动时直接写日志或上报。

package com.example.app.diagnostics

import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Build
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class AnrDiagnosticsChannel(
    private val context: Context,
    flutterEngine: FlutterEngine,
) {
    private val channel = MethodChannel(
        flutterEngine.dartExecutor.binaryMessenger,
        "stepnex/anr_diagnostics",
    )

    fun register() {
        channel.setMethodCallHandler { call, result ->
            if (call.method == "recentExitReasons") {
                result.success(readExitReasons())
            } else {
                result.notImplemented()
            }
        }
    }

    private fun readExitReasons(): List<Map<String, Any?>> {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return emptyList()
        val manager = context.getSystemService(ActivityManager::class.java)
        return manager
            .getHistoricalProcessExitReasons(context.packageName, 0, 6)
            .map { info ->
                mapOf(
                    "reason" to info.reason,
                    "reasonLabel" to reasonLabel(info.reason),
                    "description" to info.description,
                    "timestamp" to info.timestamp,
                    "importance" to info.importance,
                )
            }
    }

    private fun reasonLabel(reason: Int): String = when (reason) {
        ApplicationExitInfo.REASON_ANR -> "anr"
        ApplicationExitInfo.REASON_CRASH -> "crash"
        ApplicationExitInfo.REASON_CRASH_NATIVE -> "native_crash"
        ApplicationExitInfo.REASON_LOW_MEMORY -> "low_memory"
        ApplicationExitInfo.REASON_USER_REQUESTED -> "user_requested"
        else -> "other"
    }
}

Flutter 侧只保留一个读取入口就够了:

import 'package:flutter/services.dart';

class AnrReviewService {
  static const _channel = MethodChannel('stepnex/anr_diagnostics');

  Future<List<Map<String, dynamic>>> recentExitReasons() async {
    final raw = await _channel.invokeListMethod<dynamic>('recentExitReasons');
    return raw == null
        ? const <Map<String, dynamic>>[]
        : raw.cast<Map>().map((item) => Map<String, dynamic>.from(item)).toList();
  }
}

这一步的价值很高:你终于能把“用户说打开前卡住了”和“系统最后一次把进程记成 REASON_ANR”对上号。要注意两个版本边界。第一,ApplicationExitInfo 只在 API 30+ 可用,所以 Android 10 及以下设备只能靠日志、bugreport 和 Play Console。第二,官方 getAnrInfo() 是 API 37 新增能力,如果你的目标设备还没到这个级别,不要把它当成基础依赖,先靠 reasondescription 和时间戳建立最小闭环。

步骤三:复现时先跑 profile、logcat 和 bugreport,再去猜是哪段代码堵住了主线程

真正开始排查时,顺序一定不要乱。Flutter 官方性能文档强调,性能诊断应该优先在真机的 profile 模式上做,而不是在 debug 模式或模拟器里猜。Android 官方 ANR 文档也明确指出,主线程做慢 I/O、长计算、同步 Binder 调用或等待锁,都是高频原因。所以复现时先固定环境,再抓证据。

先跑应用:

flutter run --profile --dart-define=ANR_REVIEW=true

然后单独开一份日志窗口,只看跟输入分发、ActivityManager 和 Flutter 相关的关键字:

adb logcat -v time ActivityManager:I InputDispatcher:I flutter:V *:S

如果你手上是开发真机或模拟器,还可以在疑似卡死后抓 bugreport:

adb bugreport .\reports\flutter-anr-case

Android 官方文档说明了 bugreport 里会包含 dumpsysdumpstatelogcat 等诊断信息。它的好处是不用假设“日志刚好没丢”,而是把当时系统服务、线程状态和错误输出一起打包。只有在 root 可用的设备上,你才考虑直接拉 /data/anr/ 下的 anr_* 文件;零售机和普通测试机更现实的路径还是 bugreport

如果你在 profile 模式里已经看见 UI 卡住前有一串连续慢帧,再去 DevTools 的 Performance view 看 frame chart 和 timeline。官方文档给的基线很清楚:Flutter 以 60fps 为目标时,每帧大约只有 16ms;明显超过这个预算的连续帧,往往就是 ANR 前夜。这里建议把复现步骤刻意做成可重复的测试动作,比如:

  1. 打开订单列表后连续滚动并点入详情。
  2. 在详情页同步拉取大图、解析大 JSON,并立即触发一个 MethodChannel 请求。
  3. 连续返回首页再进入,观察卡死是否总落在同一操作链上。

有了固定测试动作,你看到的日志、帧数据和 bugreport 才能相互验证,而不是每次都在不同页面上“偶尔卡一下”。

步骤四:日志、测试和指标要三方对齐,别只凭一个截图就下结论

真正适合长期维护的 ANR 排查,不是一次性手工救火,而是把日志、测试和指标对齐。Play Console 的 Android vitals 会给你三组很关键的指标:ANR rate、user-perceived ANR rate、multiple ANR rate。其中 user-perceived ANR rate 直接关系到 Google Play 可发现性,官方当前给出的坏行为阈值是整体至少 0.47%,单机型至少 8%。这意味着只看“有没有个别用户抱怨卡死”还不够,你还要看问题是不是已经集中到某个 Android 版本、某个品牌机型或某个 release。

我在项目里一般会要求排查记录里同时出现下面三类证据:

  • 日志:recentExitReasons() 是否出现 anrlogcat 是否有 input dispatch timeout、broadcast timeout 或 service 超时线索。
  • 测试:profile 模式下是否能用固定步骤稳定复现,DevTools timeline 是否能看到主线程长时间被占用。
  • 指标:Play Console 的设备模型、Android 版本、版本号维度是否集中,是否只在新 release 后抬头。

如果三类证据只有一类成立,不要急着把问题命名为 ANR。比如只有用户口述卡,但 ApplicationExitInfo 里全是 crash 或 low memory,就更像崩溃或系统回收;如果只有慢帧,没有系统无响应记录,那是性能问题但未必已经达到 ANR 级别;如果只在 Play Console 有 ANR cluster,本地完全复现不出来,那就优先怀疑设备差异、后台广播、前台服务或厂商系统负载,而不是先改 Flutter 页面代码。

避坑边界:这些写法最容易把 Flutter 项目拖进主线程阻塞

真正落到代码层面,Flutter Android 项目出现 ANR,常见根因通常绕不开下面几类。

第一类是生命周期里塞重活。比如把数据库迁移、同步解密、大 JSON 解析、首屏接口拼装都堆进 onCreate()onResume() 或 Flutter 首页初始化,用户一点击就把主线程吃满。第二类是 MethodChannel 滥用:Flutter 发起一个看似简单的原生调用,Android 侧却同步读磁盘、等网络或拿锁,结果表面上是“Flutter 页面没反应”,实质是原生主线程堵住了。第三类是后台组件超时,比如 BroadcastReceiver、前台服务启动后没有及时调用 startForeground(),或者 JobService 在主线程里做重活。Android 官方文档已经把这些列成 ANR 触发条件,不需要再靠经验猜。

开发阶段可以额外开 StrictMode,先把主线程误用 I/O 的问题揪出来。它不能替代线上诊断,但特别适合提前发现“这段磁盘读写本来就不该在主线程里”。另外还有两个容易漏掉的边界。一个是后台 ANR 不一定弹窗,所以“我没看到系统对话框”不能说明没有 ANR;另一个是 ANR 和普通卡顿并不是二选一,很多线上 ANR 前面往往先有一段持续 jank,只是团队没把那部分日志留住。

验证与复盘:每次卡死都留同一份记录,下次才能少走弯路

只要你的项目已经进入维护期,就不要把 ANR 排查做成一次性聊天记录。更稳的做法是固定一份复盘模板,每次都按同样字段补齐:

  • 记录测试环境:Flutter 渠道、应用版本、targetSdkVersion、Android 版本、机型。
  • 记录目录和配置:哪个页面、哪个 MethodChannel、哪段初始化代码参与了本次测试。
  • 记录命令:是否执行 flutter run --profileadb logcatadb bugreport,输出文件放在哪。
  • 记录日志:最近退出原因、关键 logcat 片段、是否出现 timeout 或锁等待。
  • 记录测试结果:能否稳定复现,FrameTiming 是 buildMs 高还是 rasterMs 高。
  • 记录指标:Play Console 哪个 cluster、哪类设备、哪个 release 波动最大。
  • 记录结论:这次属于主线程慢 I/O、长计算、锁竞争、原生回调阻塞,还是后台组件超时。

当你把 FrameTimingApplicationExitInfo、bugreport、logcat 和 Play Console 指标都固定到一条步骤里后,Flutter Android 的“卡死”就不再是玄学词,而是一条能验证、能复盘、能持续收紧的工程问题。下一篇如果继续往后走,最自然的方向就是把这套排查结果接进发布后的可观测性或回归门禁,让问题在正式扩散前就暴露出来。