适用场景:从卡死排查继续追内存

上一篇我们用 ANR 线索和主线程阻塞定位 处理了应用无响应。这一步完成后,如果项目仍出现反复进入页面后越来越慢、图片列表滚动几轮后被系统杀掉、返回首页后内存不下降,或者 Play Console 的低内存终止集中在某些机型,就该把问题拆成独立的内存排查任务。

本文适合已经能用 profile 模式运行 Flutter Android 应用、能稳定复现一个业务路径的团队。目标不是看到一条 GC 曲线就下结论,而是建立一条可复盘证据链:固定设备和构建,记录空闲基线,重复业务动作,比较堆快照,沿 retaining path 找到仍然可达的对象,修复生命周期后再用相同动作验收。

先区分三个现象。泄漏是已经不需要的对象仍被长生命周期引用;内存膨胀是对象仍有业务用途,但图片、缓存或列表保留得过多;Android 的低内存回收则可能在应用退到后台后直接结束进程。三者都可能表现为“应用重新打开了”,修复手段却完全不同。

技术取舍:不要只盯 Dart Heap

DevTools Memory 同时展示 Dart/Flutter Heap、Native、RSS、GC 等信息。Dart 对象受垃圾回收管理,但垃圾回收只能释放不可达对象;如果静态集合、单例、订阅回调或闭包仍持有页面 State,对象就仍然可达。解码后的图片、平台插件缓冲区和部分原生资源还会体现在 external、native 或 RSS,而不只在 Dart Heap。

因此排查顺序应从现象到层级:

  • Dart Heap 随场景轮次阶梯上升,强制 GC 后仍不回落,优先查对象引用链。
  • Heap 较稳定但 external 或 RSS 持续上升,优先查图片解码、文件缓冲和平台插件资源。
  • 只在后台或低内存设备被结束,结合 Android vitals 的 LMK 指标、进程状态和设备维度判断,不把正常进程回收误判成 Dart 泄漏。
  • GC 频繁但每次都能回到相近基线,可能是短命对象分配过多,而不是泄漏;这时再转到分配热点和性能分析。

调试时优先使用 profile 模式接近真实运行开销;若需要完整的对象引用浏览,可补一次 debug 模式对照。最终结论必须回到 release/profile 真机的稳定复现,不能只依赖模拟器的一次截图。

步骤一:建立可重复的内存基线

先固定一台内存较小的 Android 真机、同一份数据和同一构建。关闭自动轮播、后台同步等会干扰曲线的任务,记录测试脚本。例如目标场景是“商品列表进入详情、查看大图、返回”,则每轮动作和等待时间都必须一致。

flutter clean
flutter pub get
flutter run --profile -d <device-id>

打开 DevTools 的 Memory 页面后,等待首页空闲 20 秒,手动触发 GC,再记录 used heap、external、RSS 和关键类实例数。然后执行 10 轮相同动作,每轮返回首页并等待 3 秒。不要边测试边热重载,也不要在不同网络数据量之间比较。

建议把记录写进版本库外的表格:

build: 2.8.0+143 / profile
device: Android 低内存真机 / API level
scenario: list -> detail -> gallery -> back
baseline: used heap / external / RSS
after 1, 5, 10 rounds: 同一组指标
expected: 返回后关键页面对象数量回到 0 或稳定上限

单个数值高不等于泄漏。更有价值的信号是同一动作重复后形成单向增长,而且等待与 GC 后仍保留。基线还要包含冷启动一次和第二次进入页面一次,避免把首次字体、路由、图片缓存初始化当成异常。

步骤二:用快照差异缩小对象范围

在 Diff Snapshots 中按固定节点拍三张快照:A 是进入目标页面前;B 是完成一次操作并返回后;C 是重复 10 次、返回并触发 GC 后。比较 C 与 A,而不是只看 C 的总大小。

先过滤项目包名和高风险类型,例如页面 State、Controller、StreamSubscription、Timer、图片模型、平台通道包装对象。重点查看实例数和 retained size 同时增长的类型。某个 DTO 增加很多但 retained size 很小,可能只是缓存策略;某个页面 State 只多 3 个,却通过字段保留大量图片和列表,优先级更高。

接着在 Trace Instances 中选择可疑类,清空上一轮噪声,再执行一次业务动作,查看分配调用树。分配位置只能说明“在哪里创建”,不能证明“为什么没释放”;最终还要检查实例的 inbound references 或 retaining path,找到从根对象到该实例的最短持有链。

常见链路包括:

static cache -> callback -> page State
service singleton -> StreamSubscription -> listener closure -> State
Timer.periodic -> closure -> controller -> BuildContext
MethodChannel handler -> repository -> page callback

如果引用链停在框架或插件层,先写最小复现:只保留一次注册、一次离开和一次释放。最小复现仍增长,再核对插件公开的 dispose/close/removeHandler 边界;只有证据能稳定复现时才升级依赖或提交问题。

步骤三:收口页面与服务的生命周期

页面最常见的问题不是没有 dispose(),而是释放不对称:注册了两个监听只取消一个,替换 controller 后释放了新实例,或异步回调在页面退出后仍把数据写回长生命周期缓存。把资源创建和释放放在同一类、同一代码审查范围内。

class ProductGalleryState extends State<ProductGallery> {
  late final ScrollController _scrollController;
  StreamSubscription<GalleryEvent>? _subscription;
  Timer? _prefetchTimer;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _subscription = widget.events.listen(_onGalleryEvent);
    _prefetchTimer = Timer.periodic(
      const Duration(seconds: 10),
      (_) => widget.prefetcher.tick(),
    );
  }

  @override
  void dispose() {
    _prefetchTimer?.cancel();
    _subscription?.cancel();
    _scrollController.dispose();
    super.dispose();
  }
}

不要把 BuildContext 保存进单例或长期存活的闭包。需要主题、文案或路由参数时,在页面仍有效时提取小而稳定的值传下去。异步操作返回页面状态前使用 mounted 保护只能避免退出后调用 setState,并不会自动取消订阅、Timer、下载任务或原生回调。真正的边界仍是显式取消和所有权清晰。

服务层也要定义所有者。应用级服务可以长期存在,但它保存的监听者必须支持注销;页面级 repository 则应随路由或依赖注入作用域销毁。静态缓存应有数量或字节上限、淘汰策略和测试入口,而不是把 Map 当作无限仓库。

步骤四:单独验证图片与原生内存

如果 Dart Heap 能回落但 external 或 RSS 不回落,先缩小图片路径。检查是否直接把相机原图解码到屏幕尺寸之外,列表缩略图是否复用原图,以及页面退出后是否仍保留完整 Uint8List。服务端缩略图、cacheWidth/cacheHeight 和有上限的缓存通常比在客户端长期保留原图更可靠。

对平台插件,记录“打开资源—使用—关闭资源”的完整动作,如相机 controller、视频播放器、数据库 cursor、文件流和事件通道。Dart 包装对象被 GC 不代表原生资源已经及时关闭;应调用插件声明的 close/dispose,并在 Android Studio Memory Profiler 或系统命令中交叉观察。

adb shell dumpsys meminfo <your.package.name>
adb shell am force-stop <your.package.name>

dumpsys meminfo 适合记录同一进程在固定节点的 PSS/RSS 分类,不适合拿不同设备的绝对值硬比较。应用 force-stop 后系统资源应释放;若进程内场景退出不释放,而 force-stop 后恢复,说明资源所有权仍可能在应用或插件生命周期内。

验证方式:修复前后使用同一条脚本

修复后不要只看“曲线更平”。重新安装相同构建类型,按 A/B/C 快照节点执行 10 轮场景,并同时验证功能:返回后不再收到旧页面事件,重新进入仍能正常订阅,相机或播放器可再次打开,缓存命中率没有因粗暴清空而失效。

通过条件可以写成工程门槛:

  1. 目标页面 State、controller 和 subscription 在离开并 GC 后回到 0 或设计上限。
  2. 第 5 到第 10 轮的 used heap、external 与 RSS 不再持续单向增长。
  3. Android 低内存真机上完成 30 分钟回放,没有 OOM、明显卡顿或意外进程重启。
  4. dumpsys meminfo、DevTools 快照与应用日志指向同一结论。
  5. 修复包含自动化生命周期测试或最小回归脚本,避免下一次重构恢复旧监听。

线上再按版本、设备内存档位和前后台状态观察 Android vitals。LMK 是系统在内存压力下终止进程的信号,可以作为内存治理线索,但不能单独定位某个 Dart 类。

避坑点:五种看似正确的误判

第一,只看到 RSS 不下降就认定泄漏。操作系统和运行时可能保留已申请内存以便复用,应结合对象实例、快照差异和重复趋势。第二,看到 GC 频繁就强制清缓存;频繁 GC 也可能来自大量短命对象,需要定位分配热点。第三,在 dispose() 里只加 mounted 判断;mounted 不是资源取消器。

第四,为了让曲线下降,每次返回都清空全局图片和业务缓存。这可能把泄漏变成网络抖动与重复解码,应先给缓存设置边界。第五,只在高端模拟器跑一次。真实低内存设备、后台切换和长时间重复操作更容易暴露泄漏与膨胀。

还有一个版本边界:Android 平台不断增加生产环境分析能力,但新 API 不应直接替代现有兼容方案。先按项目的 minSdk、目标设备覆盖率和隐私要求评估,再接入生产 profiling;常规项目仍应把 DevTools、系统内存信息和 Play Console 指标作为分层证据。

复盘清单:把一次修复变成团队能力

  • 是否记录了设备、构建、数据集、动作轮次和等待时间。
  • 是否区分 Dart Heap、external/native、RSS 与 LMK。
  • 是否保存修复前后的快照差异和关键 retaining path。
  • 每个 controller、subscription、Timer、handler 是否都有明确所有者和对称释放。
  • 静态集合与缓存是否有容量、淘汰和测试边界。
  • 图片是否按显示尺寸解码,原图字节是否被页面长期持有。
  • 插件资源是否按文档调用 close/dispose,并在重复进入后复验。
  • 是否在低内存真机执行长期回放,并观察线上版本指标。

完成这套流程后,团队得到的不只是一次内存修复,而是一套能复用到图片列表、相机、视频、地图和原生插件场景的排查模板。下一阶段可以把关键页面的内存回放纳入发布前设备回归,并为高风险资源补充统一所有权与可观测日志。