
那周的线上报警我到现在还记得语音房 App 的 native 内存曲线在监控面板上像心率图一样一路往上爬从 80MB 一路爬到 400MBOOM 闪退率直接翻倍。用户反馈很一致——挂房超过一小时后开始卡顿切后台再回来要等好几秒一些中低端机直接白屏重启。语音房这个业务形态本身就比较特殊它的界面层是 Flutter 写的但音频采集、回声消除、混音和网络传输全都跑在 native 层。也就是说Dart 层只是“看得见的壳”真正长期占用内存的是 Flutter Engine 底层的 C 代码和语音 SDK 的原生逻辑。这次排查我从 Dart 层一路挖到 JNI Global Reference最后在 native 侧找到了泄漏点而且整个定位过程大量借助了 AI 编程助手。我觉得这个案例很有代表性所以整理出来分享给大家。1. 线上语音房卡顿从 Dart 层“一切正常”开始的诡异排查1.1 报警曲线与用户体感先说一下这个语音房项目的背景。进入房间后用户除了实时语音连麦还会看到消息弹幕、礼物特效、房间公告、麦位状态变化。房主长时间挂机聊天这是最常见的场景——一个房间的生命周期可能持续几个小时甚至半天。而就是这种“正常的使用方式”把我们最严重的问题暴露了出来。上线新版本后后台监控里 Android 端的 native 内存指标开始缓慢增长。注意不是匿名反馈也不是单个机型问题而是所有复用了某个 Flutter 引擎实例的 Android 端都出现了相似曲线。用户体感就是刚进房间很流畅大概半个小时后开始有点粘手一小时后明显发热切到后台再回来界面恢复要等好几秒钟部分机型出现“点击没反应然后白屏”的闪退。1.2 Flutter 侧内存面板给的“假信号”我的第一反应和大多数人一样用 Flutter DevTools 看 Dart 内存。结果非常“正常”——Dart heap 稳定在 50MB 左右GC 曲线也规律没有明显增长。这时候很容易被带偏觉得“ Flutter 层没问题那问题可能在系统或者其他地方”。这里我要特别提醒一下Flutter 不背所有内存锅。Dart heap 只是整个 Flutter Engine 里的一小块。引擎本身是 C 实现的渲染走 Impeller/Skia文本排版、平台通道、JNI 桥接、图片解码这些数据全部分配在 native heap 里。如果你的业务还嵌了一套实时音视频 SDK那 native 内存占比会远大于 Dart 堆。只看 Flutter DevTools等于只看冰山一角。所以我当时在 Flutter 层翻了十分钟就果断放弃切到 Android Studio Memory Profiler 去看 native 内存。结果一打开就看到了那条长得让人绝望的曲线native heap 从 120MB 一直往上走没有回落趋势。归属类型里有大量byte[]和一部分无法分类的原生分配。1.3 语音房的长生命周期资源为什么集中在 native语音房业务和普通图文页面最大的区别在于它的核心资源生命周期不是“页面栈”级的而是“房间会话”级的。音频采集、回声消除 AEC、降噪、混音、编码、发送这些在 Android 上全部走 native 层。尤其 AEC 模块内部会维护一堆环形缓冲和滤波器状态任何一个回调节点没释放干净内存就只涨不跌。另外类似的问题不只出现在音频场景。低功耗蓝牙的 GATT 回调、外设连接、长连接推送凡是“native 长期持有 业务层按页面创建/销毁”的资源都非常容易出现这种泄漏。说白了native 内存的管理依赖一套严格的对称释放逻辑创建了就必须有对应的销毁点缺一个分支泄漏就产生了。语音房恰好把这种风险放到了最大。2. 全 AI 实战准备把大模型当成排查搭档2.1 我的 AI 协作流程收集、喂数据、验证三步走这次排查和以往不太一样的地方在于我几乎全程在让 AI 参与分析和出方案而不是简单复制报错信息去搜索答案。很多人在工程里用 AI 效果不好是因为期待它“拍板下结论”但 AI 最适合的工作其实是“从大量信息里做模式识别和假设生成”。我给自己定的协作流程是三步先用常规工具把现场数据抓全整理成结构化的文档包括内存曲线、分配栈、相关代码路径、复现步骤和日志。把文档丢给 AI让它分析分配热点、列出候选根因、生成验证脚本和代码思路。拿到 AI 的结论后回工程里逐个对照调用链确认它能被代码路径证明才往下走。这套流程的核心是AI 负责降噪和发散我负责收敛和验证。不要跳到第一步就让 AI 猜也不要到最后一步盲信它的结论。2.2 整理能喂给 AI 的“案件卷宗”AI 再好用给它的数据太碎它也只能给你一堆正确的废话。我习惯把分析材料整理成一份“案件卷宗”格式大概是问题描述native 内存从 120MB 升至 400MBOOM 率翻倍时间线用户进房、挂机、切后台、回前台的时间点数据快照Android Studio Memory Profiler 导出的 native heap 摘要分配热点从 heapprofd 抓到的栈文本后面细说相关代码进房、退房、切换房间时 FlutterEngine 和 channel 的调用路径日志过滤JNI、Audio、Channel 相关的 warning 和 error整理成这种结构后AI 能站在一个相对完整的上下文里看待问题。它给出的结论往往不是“检查代码是否有泄漏”这种废话而是“你注意到没有这个 byte[] 的分配栈全部集中在音频回调里而回调对象可能一直持有 channel 引用”这种有价值的方向。2.3 问大模型问题也有讲究同样是让 AI 帮忙排查提问方式直接决定输出质量。我记得第一次用 AI 的时候甩给它一句“我的 App 内存泄漏了”它回了我一段非常标准的教科书回答什么检查 Bitmap、检查 Handler、检查静态变量对实际工程毫无帮助。当我把问题换成这个句式之后效果差别非常大“我的语音房 App native heap 在 12 小时内从 120MB 升到 400MB分配热点集中在 byte[] 和未分类原生对象。以下是 heapprofd 抓到的调用栈摘要以及进入/离开房间相关的 Kotlin 代码。请基于这些材料列出最可能的三个根因并为每个根因指出对应的代码路径不要泛泛而谈内存泄漏的通用知识。”给 AI 限定范围、限定输出格式、提供数据它就能从“搜索引擎”变成“排查搭档”。后面我甚至让它帮我写脚本去聚合分配栈把整个 trace 里占用最大的 TOP 调用栈提取出来确实省了很多事。3. 定位核心链路从 heapprofd 到 JNI Global Reference3.1 用 heapprofd 抓 native 分配栈的完整过程在 Android 上排查 native 内存问题说实话工具不算多但够用。我选择的是 Android 10 之后系统自带的 heapprofd它是 Perfetto 工具链的一部分可以精准抓取指定进程的 native 内存分配调用栈。具体操作流程我大概说一遍方便你以后直接套用准备一台 Android 10 的设备或者云真机最好是中低端机因为在低内存设备上更容易复现。打开 Perfetto 网页版或者 Android Studio 内置的 Profiler配置 heapprofd填入目标进程包名。设置采样时长 10 到 15 分钟采样频率保持默认。在采样期间手动在测试机上执行典型用户路径进房 → 上麦 → 发消息 → 看礼物 → 退出 → 重新进房反复循环。采集结束后导出 Perfetto trace 文件。heapprofd 抓的是 native 分配栈单位是字节。说实话一次抓取下来数据量很大直接用 Perfetto UI 打开会非常卡。我一般会先用脚本把 trace 里的栈信息导出成文本然后按调用栈聚合。3.2 AI 聚类分配热点嫌疑集中在消息回调上一步导出的文本仍然体量不小几十 MB 都很正常。我当时的操作是写一个简单的聚合脚本按调用栈分组统计每组分配次数和总字节数过滤掉 libc 内部的常规分配和纯音频算法内部的固定缓冲最后输出 TOP 20 个分配热点。这一步的产出交给 AI 分析后它很快就给了一个我非常注意的结论大量的 byte[] 分配并不来自音频数据本身而是来自一个和“房间消息回调”相关的路径。并且它注意到这部分分配的栈里多次出现 Flutter platform channel 相关的帧——也就是说有 Java/Kotlin 层的数据通过 channel 传给 Dart 层而这个过程产生的原生内存没有被及时释放。说真的如果让我自己一行行看几十 MB 的栈我至少要花几个小时才能锁定这个方向。AI 在这里的表现确实像一个能眨眼读完整个案件卷宗的分析员。3.3 顺着调用链挖出真正的元凶拿到 AI 的提示后我回到代码里手动验证。语音房业务的结构大概是这样Dart 层进入房间页面时创建MethodChannel并调用setMethodCallHandler接收原生侧消息。原生 SDK加入房间时SDK 内部会创建一个RoomCallback对象并注册到 native 音频引擎里消息到达后通过 channel 回调给 Dart。FlutterEngine为了省启动时间项目复用了同一个全局 FlutterEngine 实例并没有在页面销毁时销毁引擎。问题就在这个组合上。我们的代码在切换房间时直接走了“重新 enterRoom”的逻辑没有先走完整的 leaveRoom 流程。于是旧房间的RoomCallback对象在 native 侧持有的 JNI 全局引用没有被删除而这个 Java 对象内部又缓存了最近收到的消息数据和音频波形采样。每切换一次房间就残留一个引用每个引用下面还挂着一堆字节数组native heap 自然只涨不跌。从内存快照看Java heap 变化不大因为那些对象都被 native 侧的 Global Reference 强引用着GC 不敢回收它们。但byte[]数量在持续增长这正是“Java 对象被 native 引用泄漏”的典型表现。3.4 为什么 Flutter 引擎没有自动兜底很多人会疑惑flutterEngine 都复用了难道引擎不会自己清理过期的 channel handler 吗答案是不会。FlutterEngine 只知道某个 channel 上挂了一个回调它不知道你的业务什么时候“应该”解绑这个回调。Dart 侧如果销毁了 channel 对象引擎并不会同步通知 native 侧去删除对应的 JNI 全局引用。尤其是StandardMessageCodec在传输大对象时本来就会在 native 层暂存一些数据块正常情况下收发完成后会自动释放但只要回调链上有对象一直被持有这些临时数据就会跟着一直存活。更麻烦的是 Flutter 的 channel 是“以名字为标识”的同一个名字反复注册 handler新注册的确实会覆盖 Dart 层的回调但 native 层持有的旧回调对象不会因此自动释放。这个问题在页面级生命周期中不明显但在语音房这种“复用引擎 重复进房”的场景下就会被无线放大。Impeller 渲染器在内存上确实做了很多优化但这部分和渲染无关纯属于跨语言引用管理没做干净。4. 根因确认channel 回调与 FlutterEngine 复用的耦合4.1 一段还原现场的简化代码先贴一段有问题的代码范例和当时线上的写法基本等价fun enterRoom(roomId: String) { // 问题点每次进房都新建一个 MethodChannel并往同一个 FlutterEngine 上挂 val channel MethodChannel( flutterEngine.dartExecutor.binaryMessenger, com.example.voice_room/$roomId ) channel.setMethodCallHandler { call, _ - when (call.method) { onMessage - handleMessage(call.arguments as ByteArray) } } voiceRoomEngine.joinRoom(roomId, object : RoomCallback { override fun onRoomMessage(data: ByteArray) { // 这里 data 会跨 native/Java/Dart 多层拷贝 channel.invokeMethod(onMessage, data) } }) }这段代码至少有四个问题每次进房都新建 channel旧 channel 的 handler 没有置空。RoomCallback对象通过 JNI 传给 SDK 时SDK 内部持有它的全局引用只有调用leaveRoom才会释放切换房间时如果漏调 leaveRoom引用就泄漏。回调内部持有最近消息的数据数组引用链不断数组就不会被 GC。进房时传ByteArray给 channelNative 层每次都会分配一份新的原生 buffer如果回调对象活着这些 buffer 也被链上。正确的做法应该是先把退房的清理逻辑成对做掉再复用同一个 channel 实例private var messageChannel: MethodChannel? null fun enterRoom(roomId: String) { leaveRoomInternal() // 先强制走完整清理 if (messageChannel null) { messageChannel MethodChannel( flutterEngine.dartExecutor.binaryMessenger, com.example.voice_room/message ) } messageChannel?.setMethodCallHandler { call, _ - when (call.method) { onMessage - handleMessage(call.arguments as ByteArray) } } voiceRoomEngine.joinRoom(roomId, roomCallback) } fun leaveRoomInternal() { voiceRoomEngine.leaveRoom() messageChannel?.setMethodCallHandler(null) }不要小看这个setMethodCallHandler(null)它实际上是把 native 侧的回调引用链断开的关键操作。没有这一行FlutterEngine 上挂的 handler 和 native 全局引用就不会断。4.2 我加了一层引用计数监控代码分析只是推断要证明泄漏存在还得有数据。我借助 AI 快速写了一个 JNI 全局引用计数监控的小工具思路是在测试环境给 JNI 层的NewGlobalRef/DeleteGlobalRef加一层包装统计两者的差值。这个工具不追求像系统 API 一样准确用来观察趋势就够了。代码大概是这个样子#include atomic #include jni.h static std::atomicint g_refLeakCount{0}; static jobject SafeNewGlobalRef(JNIEnv* env, jobject obj) { jobject ref env-NewGlobalRef(obj); if (ref ! nullptr) { g_refLeakCount.fetch_add(1, std::memory_order_relaxed); } return ref; } static void SafeDeleteGlobalRef(JNIEnv* env, jobject obj) { if (obj ! nullptr) { env-DeleteGlobalRef(obj); g_refLeakCount.fetch_sub(1, std::memory_order_relaxed); } }然后在进房、退房各跑 20 轮观察g_refLeakCount是否单调递增。实测下来修复前每轮进房/退房大概会残留 20 到 30 个引用修复后这个数字基本为 0。这种包装层在生产环境不要随便上它会影响性能但在测试环境用来验证泄漏效率极高。我当时只花了几分钟就把这个工具塞进了测试分支。4.3 修复前后对比修复前后的压测数据做一个对比更直观。我们在同型号低端机上模拟用户 2 小时挂机 反复进出房间的场景大致数据如下指标修复前修复后Java Heap99MB → 146MB99MB → 101MBNative Heap126MB → 410MB126MB → 132MBJNI Global Reference 计数200 → 2400200 → 204OOM 闪退率0.23%0.05%用户卡顿反馈大量基本消失不同机型和样本量下数字会有差异但趋势非常一致。数据说明这个根因基本实锤了。5. 修复方案落地与线上验证5.1 三处必须同步改的代码确认根因后修复本身并不复杂但有三处改动必须同时做漏一个都不行。第一处进房前强制走退房清理逻辑。把“leaveRoom 和 channel 解绑”绑定成同一个操作无论什么业务分支进房就是一次完整的清理加注册。第二处在 FlutterEngine 销毁时统一解绑所有已注册的 channel handler。虽然我们项目长期复用同一个引擎但保险起见还是要在onDestroy的时候把所有 handler 置空防止极端情况下引擎销毁顺序异常导致二次泄漏。第三处增加一个“房间会话 ID”的 token 校验。在回调返回时先判断当前回调所属的房间是不是当前活动房间如果不是直接把数据丢掉避免旧回调对象继续往外传数据。这个改动本身不直接解决泄漏但能防止泄漏对象在“半存活”状态下继续干活从而维持整条引用链不活动给 GC 创造更好地回收条件。提示凡是跨语言持有回调节点的场景都要遵循“成对注册与解绑”的原则。原生侧维护一个 MapchannelName, CallbackHandle业务层统一从这里拿回调删除时统一走 unregister是防御这类问题最省心的方法。5.2 灰度数据的检验修完之后我没有直接全量先跑了灰度验证。流程是这样的先在自己的测试环境用低端机跑 24 小时挂机场景确认 native 内存曲线走平然后放 5% 灰度观察线上 native heap 指标和 OOM 率连续观察 48 小时。顺便说一句很多团队用 FVM 管理多个 Flutter SDK 版本这种情况下尤其要在切换 Flutter 版本后重新跑一遍这个验证。不同 Flutter Engine 版本对 JNI 引用管理的细节是有差异的某个版本下解绑行为正常不代表所有版本都一样。灰度阶段指标稳定后我才逐步放量全量。后续两周的监控上native 内存曲线一直保持平稳用户卡顿相关的反馈也明显减少。5.3 让 AI 帮我复查遗漏分支修复完成后我把完整的 diff 丢给 AI 做了一次复查让它专门找“可能漏掉的路径”。结果它确实比我细它注意到如果用户正在语音房里突然来电话系统走的是onHold/onResume分支leaveRoom可能不会被触发导致 channel handler 在挂起期间一直留在引擎上。这个场景我们之前确实没考虑到。用户来电语音房被系统打断原生语音引擎走到了暂停逻辑但我们的退房清理逻辑没有跟着走。于是我们又补了一个onHold时同步解绑 channel handler 的处理通过了完整测试。这里我多说一句AI 辅助 Code Review 最大的价值不是替代程序员思考而是当一个不会累的伙伴把二进制分支、异常分支、中断分支全部过一遍然后人来做最终判断。如果你只让它看主流程它可能给不了太多信息但让它专门找“另类路径”它经常能发现一些你早就忘了的角落。6. 复盘全 AI 实战的边界在哪6.1 AI 真正放大能力的地方这次排查下来我对 AI 在工程场景里的定位更清楚了。它最有用的场景是处理“信息量极大、重复模式极多”的脏活累活。例如分析几十 MB 的 heapprofd 文本人眼去看不现实但 AI 可以快速识别分配热点并生成聚类总结。又比如根据分配栈反推业务场景AI 可以一次性把“可能是 A、B、C”三个方向列出来并且给出对应的代码路径假设省掉了我大量盲试的过程。再比如写监控工具、写聚合脚本、做 diff review这些本来要花几十分钟的事AI 几分钟就搞定了。6.2 哪些结论不能直接信但我也要说清楚 AI 的边界在哪里。第一底层 JNI、JVM 或 Flutter Engine 的内部行为AI 给出的解释有时是“听起来合理但没有验证过的”。我在阅读 AI 关于 StandardMessageCodec 缓存机制的描述时会格外小心回去翻源码或者写一个最小复现工程跑一遍确认它没有编出不存在的行为。第二AI 对版本差异的感知不强。同一个 API 在 Flutter 3.24 和 Flutter 3.27 上行为可能有差异AI 给出的结论往往是“一般情况”下的只有人结合自己的运行环境和 SDK 版本去验证才靠谱。第三AI 不了解你们业务的全部分支。它建议“进房前必须走 leaveRoom”但如果你的产品恰好在某些场景下不允许提前 leaveRoom直接照做就会带来新的问题。AI 的建议是输入不是指令。6.3 给后来者的排查清单最后整理一份排查清单从这次经验里沉淀出来的希望对踩坑的同行有帮助只要 Flutter 页面出现“长时间使用后卡顿、闪退”先分三块看内存Dart heap、Java heap、Native heap不要只盯 Flutter DevTools。优先怀疑长生命周期资源FlutterEngine 复用、platform channel 未解绑、音频设备引用、JNI 全局引用。抓 native 分配优先用 heapprofd 或 malloc debug时机比工具重要一定要复现到典型用户路径。每次实验只改一个变量。多变量同时改AI 也没办法帮你建立正确的因果链。找 AI 排查时给数据、给代码、给限制条件不要只甩一句话问题。这次排查解决了一个线上问题也让我养成了一个新的工作习惯现在不管遇到什么问题第一件事就是按照“现象 时间线 数据快照 相关代码 限制条件”这五要素整理材料再丢给 AI 做分析。这套模板沉淀到团队内部之后新同学也能在半小时内进入状态而不是对着一个“内存泄露”的标题半天无从下手。如果你团队里也在做 Flutter native 混合架构希望这篇复盘能帮你少走一点弯路。有问题可以在评论区交流尤其是语音房、直播、实时音视频相关的内存场景我很想听听你的排查经验。