
Android 原生工程里嵌 Flutter 页面这个组合现在太常见了主 App 用原生写某些业务模块用 Flutter 快速迭代。但很多人第一次把 Flutter 模块塞进 Android 工程之后会发现一个很别扭的事——热重载没了。改一行 Dart 代码得重新跑一遍flutter build、重新装 APK、重新点进那个页面一轮下来两三分钟没了一天下来光等编译就能耗掉小半天。这篇就专门聊这件事在 Android 原生工程集成 Flutter 的场景下怎么把热重载这个能力找回来并且用得顺手。先说清楚适合谁看。如果你是把整个 App 都用 Flutter 写的那热重载是默认就有的flutter run一跑按个r就完事这篇对你参考价值不大。但如果你面对的是原生壳 Flutter 模块的混合工程尤其是用add-to-app方式把 Flutter 以 aar 或者源码依赖的形式嵌进 Android 项目那你大概率正卡在改完代码看不到效果的阶段。这篇会从热重载在混合工程里为什么失效讲起把几种可行的接法、参数配置、常见报错和排查链路都过一遍最后给一套我自己长期在用的工作流。需要提前说明一点热重载不是万能的它有自己的边界比如涉及原生侧改动、涉及 Flutter 引擎初始化参数的改动热重载是覆盖不到的。这些边界我会在文中标清楚避免你花了半天时间以为是配置问题其实是能力本身不支持。1. 混合工程里热重载为什么会消失很多人对热重载的理解停留在Flutter 自带的功能好像只要跑起来就有。实际上热重载是一套需要宿主进程 Flutter 引擎 开发工具链三方配合的机制缺一环都不行。在纯 Flutter 工程里flutter run这个命令同时干了三件事编译 Dart 代码、把产物推到设备、启动一个 VM Service 并和它保持长连接。热重载的按键之所以有效是因为这个长连接一直在工具链能通过它向运行中的 Dart VM 注入新的代码。1.1 纯 Flutter 工程和混合工程的根本差异纯 Flutter 工程里flutter run是总指挥它自己负责装应用、起进程、连服务。而混合工程里App 的启动入口在 Android 原生侧Flutter 只是被原生代码叫起来的一个页面。你平时用 Android Studio 点 Run 装的是原生 APKFlutter 引擎是在原生代码执行到某个点比如点击跳转 Flutter 页面时才被初始化的。这时候没有任何工具去连它的 VM Service热重载自然无从谈起。打个比方纯 Flutter 工程像是你自己开车油门刹车都在手上混合工程像是你坐在副驾车是别人开的你想调座椅得先跟司机建立联系。热重载要做的就是在这个别人开的车上重新建立一条开发工具到 Dart VM 的通道。1.2 热重载依赖的三个条件把机制拆开看热重载能工作必须同时满足三个条件Dart 代码以 JIT 模式运行。热重载本质是把新的 kernel 文件增量推给运行中的 VM这要求 VM 处于 JIT 编译模式。Release 包是 AOT 编译的代码已经变成机器码没法热替换。所以热重载只在 Debug 构建下有效这一点在混合工程里同样成立。VM Service 处于开启状态并可被连接。Flutter 引擎在 Debug 模式下会启动 VM Service 并监听一个端口开发工具通过这个端口和 VM 通信。混合工程里这个服务默认是开的但端口信息需要被发现。有一个客户端持续连着这个服务并发送重载指令。纯 Flutter 里这个客户端是flutter run进程混合工程里需要你手动指定或者用工具去连。三个条件里第一个和第二个在混合工程的 Debug 构建下通常是满足的真正缺的是第三个——没有一个现成的客户端去连。这就是问题的核心也是后面所有方案的出发点。1.3 一个常见误解以为装了 Debug 包就有热重载我见过不少人以为只要我打的是 Debug 包热重载就应该自动生效。这个理解偏差很普遍。Debug 包只是让引擎跑在 JIT 模式、开了 VM Service它不会自动帮你连上去发指令。你可以把 Debug 包理解成这台机器支持远程调试但遥控器还没配对。配对这件事得你自己做或者借助工具做。还有一个更隐蔽的坑有些团队为了图省事在原生工程里依赖的是 Flutter 模块打出来的aar而不是源码目录。aar 里的 Dart 代码是编译后的产物你改源码目录里的 Dart 文件aar 根本不会变热重载就算连上了也看不到变化。这个坑后面会单独讲。2. 把 Flutter 模块以源码方式挂进原生工程想让热重载有意义第一步是保证你改的 Dart 源码和实际运行的代码是同一份。这一步没做对后面所有配置都是白费。所以先把工程结构理顺。2.1 为什么必须用源码依赖而不是 aaradd-to-app官方支持两种集成方式一种是 Flutter 模块打成 aar原生工程通过implementation引入另一种是原生工程的settings.gradle里通过include_flutter.groovy把 Flutter 模块作为子工程包含进来直接依赖源码。前者适合 CI 出包和团队协作后者才是开发调试该用的方式。原因很直接aar 是构建产物是冻结的。你在 Flutter 模块目录里改 Dart 代码aar 不会自动重新打包原生工程拿到的还是旧产物。而源码依赖方式下Gradle 构建时会实时调用 Flutter 的构建脚本把最新的 Dart 代码编译进去。热重载虽然不走 Gradle但它注入的代码和运行时加载的代码必须来自同一个源码树否则你重载了 A 文件运行的却是 B 产物效果对不上。我一般的做法是开发阶段用源码依赖出包阶段切到 aar。两套配置分开用 Gradle 的 flavor 或者简单的注释切换都行别在开发时用 aar 折磨自己。2.2 settings.gradle 里的关键几行源码依赖的接入点主要在settings.gradle新版 Gradle 也可能是settings.gradle.kts。核心是引入 Flutter 提供的脚本把模块目录包含进来。典型写法是这样// settings.gradle def flutterProjectRoot new File(rootDir, ../flutter_module) def flutterIncludeScript new File(flutterProjectRoot, android/include_flutter.groovy) if (flutterIncludeScript.exists()) { apply from: flutterIncludeScript }这里有几个细节值得说。flutterProjectRoot指向你的 Flutter 模块目录路径按实际结构调整。include_flutter.groovy这个脚本是 Flutter 模块创建时自动生成的它内部会做两件事把 Flutter 模块的 android 子工程 include 进来同时把 Flutter 引擎依赖也配好。所以只要这个脚本被 apply 了原生工程就能通过implementation project(:flutter)依赖到 Flutter 模块。注意include_flutter.groovy的路径是相对于 Flutter 模块根目录的android/子目录不是模块根目录本身。写错路径会导致脚本找不到Gradle 同步直接报错。2.3 app 模块的依赖声明settings.gradle配好之后app 模块的build.gradle里加一行依赖dependencies { implementation project(:flutter) // 其他依赖... }到这一步原生工程就能正常编译并调用 Flutter 页面了。但注意这只是让源码依赖成立热重载还没接上。不过这是必要前提——只有源码依赖成立你后续通过热重载注入的代码才和运行时加载的代码一致。2.4 验证源码依赖是否真的生效配完之后别急着往下走先验证一下。方法很简单在 Flutter 模块里改一段明显的 UI 文案比如把某个按钮文字从提交改成提交测试然后重新用 Android Studio 跑一次原生 App进到 Flutter 页面看文字变没变。如果变了说明源码依赖生效Gradle 确实在构建时编译了最新的 Dart 代码。如果没变八成是还在用旧的 aar 或者缓存没清先解决这个再谈热重载。这个验证步骤看起来笨但能帮你排除掉一大类热重载连上了却没效果的伪问题。我踩过这个坑当时折腾了半天热重载连接最后发现是 Gradle 缓存里还是旧 aar白忙一场。3. 让开发工具连上运行中的 Dart VM工程结构理顺之后进入正题怎么让开发工具连上那个已经在跑的 Flutter 引擎。这里有几条路各有适用场景我按推荐程度从高到低说。3.1 方案一flutter attach最省心的接法flutter attach是官方为混合工程量身定做的命令。它的作用和flutter run类似但不负责安装应用只负责附着到一个已经在运行的 Flutter 引擎上建立 VM Service 连接然后你就能按r热重载、按R热重启。用法上先在 Android Studio 里正常 Run 原生 App等 App 起来、进到 Flutter 页面或者停在能触发 Flutter 引擎初始化的地方然后在 Flutter 模块目录下开一个终端执行flutter attach正常情况下它会自动发现设备上正在运行的 Flutter 引擎并连上终端会打印出类似Syncing files to device...的提示之后按r就能热重载。如果自动发现失败可以显式指定设备flutter attach -d device_iddevice_id用flutter devices查。这个方案的好处是零配置、官方维护、和纯 Flutter 的体验几乎一致。我日常开发基本都用这个。3.2 方案二手动指定 VM Service 端口有些情况下flutter attach自动发现会失灵比如引擎初始化时机比较特殊、或者设备连接状态不稳定。这时候可以手动指定 VM Service 的地址。Flutter 引擎在 Debug 模式下启动时会在 logcat 里打印一行类似这样的信息I/flutter: The Dart VM service is listening on http://127.0.0.1:xxxxx/yyyy/拿到这个地址后用flutter attach的--debug-url参数直接连flutter attach --debug-url http://127.0.0.1:xxxxx/yyyy/这个方式更硬绕过了自动发现逻辑只要端口对就能连上。缺点是每次重启 App 端口可能变得重新从 logcat 里捞。适合自动发现反复失败时的兜底。3.3 方案三DevTools 里的连接入口Flutter 自带的 DevTools 也提供了连接运行中应用的能力。启动 DevTools 后在连接页面填入 VM Service 地址连上之后可以在里面做性能分析、widget 检查部分版本也支持触发重载。不过说实话日常改代码用 DevTools 触发重载不如命令行按r顺手它更适合做性能排查。我一般把它当辅助工具不作为主力热重载入口。3.4 三种方案的取舍把三种方案放一起对比选择逻辑就清楚了方案配置成本稳定性适用场景flutter attach 自动发现极低较高日常开发首选flutter attach 指定 debug-url低高自动发现失灵时兜底DevTools 连接中中性能排查为主重载为辅我的建议是默认用方案一遇到连不上再降级到方案二方案三当补充。别一上来就折腾手动端口那是给自己找麻烦。3.5 attach 成功后的实际操作连上之后终端会进入交互模式常用的几个按键r热重载保留当前状态注入新代码。R热重启重置状态重新跑main()。h查看所有可用命令。d断开连接不影响 App 运行。q退出 attach。这里有个经验改完代码按r之后如果终端提示Reloaded 1 of N libraries说明重载成功如果提示有编译错误会直接打印出来修完再按一次r即可不用重新 attach。这个反馈循环比重新构建快太多了。4. 热重载失效时的排查链路配置看起来都对但按r没反应或者报错这是混合工程里最常见的状态。这一节我把排查顺序理清楚你按这个链路走基本能定位到问题。4.1 第一步确认当前是不是 Debug 构建热重载只对 JIT 模式有效。如果你不小心装的是 Release 或者 Profile 包VM Service 压根没开attach 会直接失败或者连上后无法重载。确认方法看 Android Studio 的 Build Variant 是不是 debug或者看 logcat 里有没有 VM Service 的监听日志。没有那行日志基本就是构建类型不对。4.2 第二步确认 Flutter 引擎真的初始化了VM Service 是在 Flutter 引擎初始化时才启动的。如果你的 App 起来了但还没进过 Flutter 页面引擎可能还没初始化这时候 attach 自然连不上。解决办法是先手动进一次 Flutter 页面触发引擎初始化再执行 attach。有些团队会把 Flutter 引擎做成预热在 Application 里提前初始化那种情况下 App 一启动引擎就在了attach 会更早成功。4.3 第三步看 attach 的报错信息对症下药flutter attach失败时终端会给提示不同提示对应不同问题提示找不到设备检查adb devices是否能看到设备USB 调试是否授权。提示找不到运行中的应用确认 App 进程还活着且引擎已初始化。提示连接被拒绝端口可能变了改用--debug-url手动指定。提示版本不匹配Flutter SDK 版本和模块依赖的引擎版本不一致统一版本后重试。这一步的关键是别看到报错就慌逐条对照。我遇到最多的是连接被拒绝基本都是端口变化导致的手动指定就好。4.4 第四步排查连上了但重载没效果这是最让人抓狂的情况attach 成功按r也提示重载成功但界面纹丝不动。可能原因有这么几个改的是原生代码。热重载只作用于 Dart 代码你改了 Kotlin/Java 或者 AndroidManifest必须重新构建安装热重载管不了。改的是 Flutter 引擎初始化参数。比如FlutterEngine的构造参数、DartEntrypoint的配置这些在引擎启动时就固定了热重载改不了。还在用 aar 依赖。前面说过aar 是冻结产物改源码不生效。回去检查settings.gradle是不是真的走了源码依赖。改的是main()里的初始化逻辑。热重载不会重新执行main()如果你改的是main()里的代码得用热重启R而不是热重载r。状态被保留了。热重载保留 State如果你改的是initState里的逻辑已经创建的 State 不会重新执行initState看起来就像没生效。这种情况用热重启。把这几条对照一遍基本能覆盖 90% 的重载无效问题。剩下 10% 可能是缓存或者工具链版本问题清一下flutter clean再试。4.5 一个容易被忽略的细节多引擎场景有些 App 会创建多个 Flutter 引擎实例比如不同业务模块各用一个引擎做隔离。这种情况下flutter attach可能会连到其中一个你改的代码如果在另一个引擎里跑重载就看不到效果。排查方法是看 logcat 里有几个 VM Service 监听地址确认 attach 连的是不是你要调试的那个。多引擎场景建议显式用--debug-url指定目标引擎避免连错。5. 把热重载用出效率的实战习惯配置通了只是开始真正拉开效率差距的是日常使用习惯。这一节分享几个我长期用下来觉得值的做法。5.1 保持 attach 终端常开别反复启停很多人习惯改完代码就关掉终端下次再重新 attach。其实 attach 连接是持久的只要 App 进程不重启、引擎不销毁连接就一直有效。我一般会单独开一个终端窗口专门跑flutter attach一整天都挂着改完代码切过去按个r就完事。这样省掉了反复 attach 的时间也避免了每次重新连的等待。5.2 区分热重载和热重启的使用场景这两个键用错了会浪费很多时间。简单记改 UI 布局、改文案、改方法体逻辑用r改main()、改initState、改全局初始化、改 State 的初始字段用R。热重载保留状态所以快但状态保留有时恰恰是看不到变化的原因热重启重置状态所以慢一点但能保证初始化逻辑重新跑。判断标准就是你改的代码是不是只在初始化时执行一次。是就用R。5.3 原生侧改动别硬等热重载热重载管不了原生代码这是能力边界不是 bug。改了 Kotlin/Java、改了 Gradle 配置、改了 AndroidManifest老老实实重新 Run 原生 App。我见过有人改完原生代码按了半天r没反应最后发现是方向错了。把这条边界记牢能省下不少无效操作。5.4 用 logcat 辅助定位引擎状态调试混合工程时logcat 是你的好朋友。Flutter 引擎初始化、VM Service 启动、Dart 异常都会在 logcat 里留痕。我习惯在 attach 之前先扫一眼 logcat 里有没有 VM Service 的监听地址有就说明引擎起来了attach 成功率会高很多。另外热重载过程中的编译错误有时终端显示不全logcat 里能看到更完整的堆栈。5.5 团队协作时的配置同步如果团队多人开发同一个混合工程settings.gradle里的 Flutter 模块路径很容易因为各人目录结构不同而对不上。我的做法是把路径抽成local.properties或者gradle.properties里的变量各人本地配自己的路径settings.gradle里读变量。这样提交代码时不会因为路径冲突互相覆盖新人拉下来也只需要改一个本地配置。// gradle.properties 里加一行 flutter.module.path../flutter_module // settings.gradle 里读取 def flutterProjectRoot new File(rootDir, project.property(flutter.module.path))这个做法看起来多了一步但团队规模一上来省下的沟通成本很可观。6. 几个真实踩过的坑和对应解法前面讲的偏方法论这一节说几个具体踩过的坑都是配置看起来没问题但实际卡住的情况供你对号入座。6.1 Gradle 缓存导致的改了没变最典型的一个源码依赖配好了热重载也连上了按r提示成功但界面就是不变。查了半天发现是 Gradle 构建缓存里还是旧的 Flutter 产物。解法是执行flutter clean清掉 Flutter 模块的构建产物再在原生工程里Build Clean Project然后重新 Run。这个坑的迷惑性在于热重载本身是好的坏的是底层产物没更新。判断方法热重载后如果连热重启R都看不到变化基本就是产物问题不是热重载问题。6.2 引擎预热导致的 attach 时机问题有些工程为了减少 Flutter 页面首次打开的白屏会在 Application 里预热 Flutter 引擎。这种工程里引擎初始化得很早flutter attach有时会在引擎还没完全就绪时尝试连接导致失败。解法是等 App 完全起来、logcat 里出现 VM Service 地址之后再 attach或者直接用--debug-url指定地址。别在 App 刚启动的瞬间就急着 attach。6.3 多 flavor 构建下的变体混淆工程配了多个 flavor比如 dev、staging、prod每个 flavor 可能对应不同的 Flutter 入口或者不同的依赖配置。如果你 attach 的时候 App 装的是 dev flavor但 Flutter 模块当前构建配置指向的是另一个入口热重载注入的代码可能和运行的不匹配。排查方法是确认当前 Build Variant 和 Flutter 模块的构建配置一致必要时在 attach 前先确认 App 的实际构建变体。6.4 端口被占用或转发异常偶尔会遇到 VM Service 端口在设备上正常监听但宿主机连不上。这通常是端口转发的问题。可以手动做一次端口转发adb forward tcp:xxxxx tcp:xxxxx把设备上的端口映射到宿主机再用--debug-url连本地映射后的地址。这个操作不常用但在网络环境复杂或者多设备调试时能救急。6.5 版本不一致引发的诡异报错Flutter SDK 版本、Flutter 模块依赖的引擎版本、原生工程里引用的 Flutter 库版本这三者不一致时热重载可能连得上但注入失败报一些看不懂的 kernel 相关错误。解法是统一版本Flutter 模块的pubspec.yaml里声明的 SDK 约束、原生工程依赖的 Flutter 库版本都对齐到同一个 SDK。团队里最好在文档里写死推荐版本避免各人环境漂移。7. 热重载之外混合工程的调试配套热重载解决了改 Dart 代码快速看效果但混合工程的调试不止这一件事。顺手把配套的几件事说清楚整体效率会更高。7.1 Dart 侧断点调试热重载连上之后其实也建立了调试通道。在 Android Studio 或者 VS Code 里配置 Flutter 的 attach 调试可以在 Dart 代码里打断点、看变量。配置方式是在 IDE 的 Run Configuration 里选 Flutter Attach指定设备后启动效果和命令行 attach 类似但多了图形化断点能力。改复杂逻辑时断点比 print 高效得多。7.2 原生与 Dart 之间的调用排查混合工程里经常有原生调 Dart、Dart 调原生的场景通过 MethodChannel。这类问题热重载帮不上忙得靠日志。建议在 MethodChannel 的两端都加上带 tag 的日志出问题时按 tag 过滤 logcat能快速定位是哪个方向没通。这块的调试体验确实不如纯 Flutter但把日志规范做好排查效率能提上来不少。7.3 构建速度的优化混合工程的全量构建比纯 Flutter 慢因为要过一遍原生 Gradle。日常开发尽量只走热重载减少全量构建次数。另外可以开启 Gradle 的构建缓存和并行构建在gradle.properties里配org.gradle.paralleltrue org.gradle.cachingtrue这两个开关对混合工程的构建速度提升比较明显尤其是反复构建的场景。7.4 一套我常用的日常流程把上面的东西串起来我日常的混合工程开发流程大概是这样早上开工Android Studio Run 一次原生 App进一次 Flutter 页面确认引擎正常。单独开终端跑flutter attach挂着不动。改 Dart 代码切到终端按r看效果涉及初始化逻辑按R。改原生代码回 Android Studio 重新 RunRun 完重新 attach因为进程重启了连接会断。遇到重载无效按第 4 节的排查链路走一遍。这套流程跑顺之后Dart 侧的迭代速度和纯 Flutter 工程差别不大混合工程那种改一行等三分钟的痛苦基本就消失了。最后说个我自己的体会混合工程的热重载配置难点不在配置本身而在理解它的边界。很多人卡住不是因为不会配而是因为对什么能热重载、什么不能没有清晰认知导致在能力之外的事情上反复尝试。把边界搞清楚配置反而是十分钟的事。上面这些方案和排查链路我在几个不同规模的混合工程里都用过flutter attach加源码依赖这个组合是最稳的底子剩下的都是在这个底子上做适配。