1. 先搞清楚:Module 集成到底解决什么问题
1.1 为什么是"嵌"而不是"重写"
做 Android 的同学,尤其是负责存量业务的同学,肯定遇到过这种局面:公司新的一把手拍板要上 Flutter,但整个 App 里登录、支付、分享、消息推送这些核心链路全是原生代码,重构一个亿级用户的 App 没有半年下不来,业务又等不起。这时候脑子里冒出来的方案无非三种:把 App 整体重写成 Flutter、用 Flutter 从零做一套新 App 然后导流、还有一种就是在现有 Android 工程里嵌入 Flutter 页面——也就是把 Flutter 当作一个可增删的模块,跟着宿主 App 一起打包发布。第三种方案就是标题里说的 Flutter Module 集成。
Flutter Module 集成的核心价值在于:它不是一个独立的完整 App,而是一个可以被原生 Android 工程引用的依赖模块,Flutter 页面以 Activity 或 Fragment 的形式嵌进宿主工程,走的是原生跳转的路径。用户感知不到页面是 Flutter 渲染的还是原生渲染的,但从研发角度来说,新业务团队可以完全用 Dart 开发,原生团队只需要维护一个稳定的容器和一个通信层。一句话概括:这套方案不改变你现有的架构,只是给 App 开了一扇可以让 Flutter 页面跑进来的门。
1.2 三种集成路线的核心差异
我在实际操作中把市面上的集成方式归成三类,这里直接拿表格对比一下,方便你根据自己团队的情况做选型。
| 方案 | 实现方式 | 构建产物 | 适合场景 | 维护成本 |
|---|---|---|---|---|
| 源码级集成 | 在 Android 工程里直接引入 Flutter Module 的源码 | 宿主 App 打包时把 Flutter 源码一并编译 | 团队既有 Flutter 研发能力,需要频繁改 Flutter 代码并热重载联调 | 中等,需要联调环境 |
| AAR 预构建 | 用 flutter build aar 把 Flutter 模块构建成 Android 依赖 |
debug/profile/release 三种 AAR 包 | 原生团队和 Flutter 团队分离,通过 Maven 仓库交付 | 较低,但构建链路要提前搭好 |
| 私有化源码分发 | 把 Flutter Module 作为私有 Git 仓库,宿主用 submodule 引入 | 同上,源码直接在宿主工程编译 | 小团队、起步阶段,想省掉 AAR 仓库维护 | 最简单,但耦合较重 |
这里要强调的是,这三种方案的底层运行机制完全一致:都是通过 FlutterEngine 来执行 Dart 代码,然后渲染到 FlutterView 上。区别只在于 Flutter 侧的代码以什么形式进入你的 Android 构建体系。能用源码集成就不急着上 AAR,因为开发期的热重载体验是 AAR 给不了的;但如果你面临的是跨团队交付,那 AAR 方案可以免去对方安装 Flutter SDK 的麻烦,这个后面我单独讲。
1.3 我的选型结论
如果你现在是一个 Android 工程,想尝试引入 Flutter,我建议从源码级集成开始。原因很简单:搭建成本最低,出现问题也最好排查。你只需要一个 Flutter SDK、一个能跑的 Android 工程,然后按下面的步骤把两个工程"粘"在一起,Flutter 页面就能在 App 里跑起来。等整个链路稳定了,再考虑把 Flutter Module 抽成 AAR 进 CI 也不迟。接下来我完整走一遍源码集成的实操过程,所有细节都基于 Flutter 3.x 版本和 AGP 8.x 的环境,版本不同会有差异,我会在对应位置指出。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与版本对齐
2.1 一张表看清版本对应关系
Flutter 集成 Android 最怕的事情之一就是版本不匹配。网上大量报错案例,比如 Flutter error resolving plugin [id: 'dev.flutter.flutter-plugin-loader'],十有八九是 Flutter 版本、AGP 版本和 Gradle 版本三者之间不对齐。我自己踩过坑之后整理了一张对应关系表,虽然不是官方权威版本表,但实测下来可以作为参考基线。
| Flutter 版本 | 建议 AGP 版本 | 建议 Gradle 版本 | 建议 compileSdk | 备注 |
|---|---|---|---|---|
| Flutter 3.13 | AGP 7.4.2 | Gradle 7.6.x | 33 | 较老的稳定组合 |
| Flutter 3.16 | AGP 8.1.x | Gradle 8.3+ | 34 | Hedgehog 版本可用 |
| Flutter 3.19+ | AGP 8.2+ | Gradle 8.4+ | 34+ | 新工程默认模板 |
为什么版本这么敏感?因为 Flutter 插件机制会通过 Gradle Plugin 的 DSL 往你的构建系统里注入各种 task,它依赖特定版本的 AGP API。AGP 8.x 开始默认移除了不少老 API,如果你把 Flutter 3.13 硬配到 AGP 8.2 上,构建期大概率会出现插件加载失败或者 task 找不到的诡异问题。所以我的建议是:不要追求最新,先用 Flutter 自带的模板工程跑通,再逐步升版本。
2.2 项目目录怎么摆
目录结构是集成前就要想清楚的事情。Flutter Module 既可以放在 Android 工程的子目录里,也可以放在同级目录下,但推荐的做法是放在同级的兄弟目录。看下面这个例子:
code复制project_root/
├── MyAndroidApp/ # 宿主 Android 工程
│ ├── app/
│ ├── build.gradle
│ └── settings.gradle
└── flutter_module/ # Flutter Module
├── lib/
├── pubspec.yaml
└── .android/ # 自动生成的 Android 壳工程
注意,flutter_module/.android 目录是 flutter create -t module 的时候自动生成的,它本质上是 Flutter 为 Module 准备的一个最小 Android 工程壳,里面有个 include_flutter.groovy 脚本,宿主工程就是靠这个脚本把 Flutter 的 Gradle 任务引入进来的。这个目录属于生成物,不要在它里面直接改业务代码,因为它随时可能被 Flutter 工具链重新生成。
把 Flutter Module 放在宿主工程的兄弟目录,最大的好处是两端各自有自己的版本管理和 CI 配置,互不干扰。有的团队喜欢直接把模块放到宿主工程根目录下,比如 MyAndroidApp/flutter_module,也能跑,但会让宿主工程变得很臃肿,而且 .gitignore 规则要处理的东西变多,我不推荐。
2.3 全局 Gradle 配置里要改的三处重点
源码级集成的时候,真正需要动的地方集中在三个文件:settings.gradle、根目录 build.gradle 和 app/build.gradle。
先说 settings.gradle。这里有一个很容易忽略的细节:Flutter 的 include 脚本需要访问宿主的 gradle 对象,所以接入顺序必须是在 include ':app' 之前执行 setBinding。一旦你在 settings.gradle 里使用 dependencyResolutionManagement 的 RepositoriesMode.FAIL_ON_PROJECT_REPOS,Flutter 插件就会尝试往 project 仓库里注册东西,这种模式下会直接报错,选 PREFER_PROJECT 或者 PREFER_SETTINGS 要谨慎。我实际推荐在 settings.gradle 里保留一个宽松的仓库配置,因为 Flutter 插件和本地 AAR 仓库都需要走 project 级别的仓库声明。
再说根目录 build.gradle。Flutter 的 Gradle 插件会要求你声明 dev.flutter.flutter-plugin-loader 这个插件,它负责把 Flutter 项目的构建逻辑加载进来。老的写法是用 apply script: ...include_flutter.groovy,新的写法是在 pluginManagement 里加一个 plugin 声明,注意这两者不能混用,混用会出现标题里热搜词提到的 You are applying Flutter's main Gradle plugin imperatively using the apply script 警告,虽然部分版本能忍,但到了 Flutter 3.16 以后,建议直接切换到新写法。
最后是 app/build.gradle。这里面最关键的几件事我放到下一节一起讲,因为光配置改对了还不够,宿主侧的编译参数和依赖方式决定了它能不能顺利编译出包。
3. 源码级集成实操记录
3.1 创建 Flutter Module
创建 Flutter Module 的命令和创建完整 App 不一样,要用 -t module 模板:
bash复制cd project_root
flutter create -t module --org com.example flutter_module
这条命令会生成一个不含 iOS 运行入口、但包含 pubspec.yaml 和 .android 壳工程的 Flutter 模块。--org 参数很重要,它决定 Android 侧生成的 applicationId 前缀,如果宿主工程和这个 id 不一致,后面 Manifest 合并时可能出现冲突。我的经验是,--org 用宿主工程的包名反过来的域名前缀,可以省掉很多不必要的 namespace 冲突。
生成完之后先别急着写业务代码,先在 pubspec.yaml 里加一个测试页面,我一般会写一个简单的页面,能加载、能展示文本就行。这一步是为了确认环境没问题,再往下走。
3.2 settings.gradle 的接入写法
假设你的宿主工程在 MyAndroidApp/,Flutter Module 在 flutter_module/,那么宿主 settings.gradle 写成这样:
gradle复制// MyAndroidApp/settings.gradle
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MyAndroidApp"
include ':app'
// 关键:把 Flutter Module 引入构建
setBinding(new Binding([gradle: this]))
evaluate(new File(
settingsDir.parentFile,
'flutter_module/.android/include_flutter.groovy'
))
这里的 include_flutter.groovy 脚本做了三件事:注册 :flutter 子项目、把 Flutter 插件的 classpath 加进构建、把 Flutter 引擎依赖导入。所以宿主里不需要再手动 include ':flutter',脚本会处理好。如果这一步报了找不到文件的错,先检查相对路径写对没有,settingsDir.parentFile 指向的是 MyAndroidApp 的上一级目录,如果你把 Flutter Module 放在别的位置,路径要相应调整。
我遇到过有人在把这段逻辑写成多行时,把 evaluate 的 new File 参数顺序写反,结果脚本尝试去 flutter_module/.android 的父目录找 include 文件,报错信息很迷。遇到这种情况,直接打印一下解析后的绝对路径,一眼就能发现问题。
3.3 app 模块的依赖和编译参数
接下来是 app/build.gradle,需要加两个部分:依赖声明和编译参数。
gradle复制// MyAndroidApp/app/build.gradle
android {
compileSdk 34
defaultConfig {
minSdk 21
targetSdk 34
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation project(':flutter')
}
上面这段是核心。implementation project(':flutter') 会把 Flutter 引擎和 Flutter Module 的 Dart 产物全部打进来,前提是你在 flutter_module 里已经跑过 flutter pub get,否则 :flutter 项目没有依赖缓存,构建会失败。
关于 compileSdk,我强烈建议对齐 Flutter 当前建议的版本。Flutter 3.16 对应 compileSdk 34,如果你宿主的 compileSdk 还停留在 33,编译时可能出现 AAPT: error 或者引擎类找不到的报错。另外,如果你的宿主工程之前启用了 desugar 或者自定义了 sourceSets,要注意 Flutter 引擎对 Java 8 字节码有硬性要求,compileOptions 里必须打开 Java 8 支持。
minSdk 方面,Flutter 3.x 不再支持 API 19 以下,建议 minSdk 21 及以上。宿主如果还在用 19,请先升级 minSdk,否则打包时你会看到类似 minSdkVersion 19 cannot be smaller than version 21 declared in library 的报错。
3.4 宿主页面的加载方式
集成完依赖,下一步就是在原生侧写代码把 Flutter 页面加载出来。这里有两个层次:临时验证直接跳 FlutterActivity,正式接业务用 Fragment + 自定义容器。
最简单的验证方式,在 Activity 里直接跳转:
kotlin复制// MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<Button>(R.id.btn_flutter).setOnClickListener {
startActivity(
FlutterActivity
.withNewEngine()
.initialRoute("/home")
.build(this)
)
}
}
}
withNewEngine() 意味着每次跳转都会新创建一个 FlutterEngine。这个写法适合开发期验证,但生产环境每一秒都要初始化一个新引擎,冷启动时间至少多出几百毫秒,内存峰值也会明显抬升。所以实际项目中,我用的是缓存引擎的方式,见下一节。
如果你想把 Flutter 页面嵌到一个已有的原生页面里,而不是整个全屏跳转,要用 FlutterFragment:
kotlin复制val flutterFragment = FlutterFragment
.withCachedEngine("my_engine")
.build()
supportFragmentManager
.beginTransaction()
.replace(R.id.fl_container, flutterFragment)
.commit()
这种方式可以把 Flutter 页面塞进任何布局位置,适合那种上半部分原生、下半部分 Flutter 的混合页面。注意点是 FlutterFragment 继承自 Fragment,必须使用 supportFragmentManager 而不能用老的 fragmentManager,否则在 AndroidX 环境下会直接崩溃。
3.5 引擎缓存与启动优化
生产环境里最常规的做法是提前创建 FlutterEngine 并缓存,常见方案是在 Application 里做 warmup。下面这段是我的实测模板:
kotlin复制// MyApplication.kt
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
val flutterEngine = FlutterEngine(this)
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)
FlutterEngineCache.getInstance().put("default_engine", flutterEngine)
}
}
这个引擎在 App 冷启动阶段就开始初始化,用户点进 Flutter 页面时直接取缓存,页面首帧速度能压缩到接近原生页面。但代价是 App 启动阶段多了几十到一百多毫秒的开销和约几十兆的内存。是否值得,要看 Flutter 页面在你的 App 里的触达频率,如果用户十次启动才点一次 Flutter 页面,那就不合算,改成进入前再预创建更好。
引擎缓存还有两个容易踩的坑:一是不能把同一个引擎给多个 FlutterActivity 同时使用,否则会崩溃;二是 Application 里创建完引擎后,Dart 侧代码已经在执行了,如果你的 Flutter 入口函数里有访问原生数据的逻辑,此时可能拿不到最新状态。所以我把 Dart 入口设计成先干等信号、再初始化业务数据,原生侧通过 MethodChannel 把数据塞进来。
4. 双端通信:MethodChannel 的完整设计
4.1 通道的建立与命名规范
Flutter 页面跑起来之后,原生和 Dart 之间必然要通信,最常见的方案就是 MethodChannel。它的工作方式可以类比成一个双向电话:Dart 侧拨号调用原生方法,原生侧接听后返回结果;反过来原生也可以主动给 Dart 发消息。关键在于电话的"频道"名字必须两端一致,命名规范通常用反域名格式,比如 com.example.app/user_info,这样能避免不同业务模块之间互相串线。
dart复制// Dart 侧
import 'package:flutter/services.dart';
class UserChannel {
static const MethodChannel _channel = MethodChannel('com.example.app/user_info');
static Future<String> getToken() async {
final String token = await _channel.invokeMethod('getToken');
return token;
}
}
kotlin复制// 原生侧
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.example.app/user_info"
).setMethodCallHandler { call, result ->
when (call.method) {
"getToken" -> result.success(UserManager.getToken())
else -> result.notImplemented()
}
}
我见过不少团队把通道名写得很随意,比如直接叫 "channel" 或者 "test",短期能用,一旦模块多了,排查问题时在日志里根本分不清是谁在调谁。建议一开始就按模块划分通道,一个业务域一个 channel,方法和参数全部定义为常量,两端各自维护一份同名常量文件,靠 code review 保证同步。
4.2 参数传递的序列化细节
MethodChannel 的参数传递不是 Java 对象直传,而是要经过标准消息编解码器序列化。原生侧传 Map 给 Dart,Dart 收到的是一个 Map<Object?, Object?>,里面嵌套的类只能是基本类型、String、List、Map 这类标准类型,自定义 Java Bean 直接传过去会变成一坨无法解析的类型,然后在运行时抛 MissingPluginException 或者更诡异的类转换错误。
序列化这块最容易忽略的是类型宽度。原生传 Long 给 Dart,Dart 侧接收时可能变成一个非常大的 int,如果你的业务对它做取模或者位运算,可能和原生端结果不一致。反过来,Dart 传 double 给原生,原生侧如果强制转 Float,也会出问题,因为 Flutter 侧的 double 传给 Android 是 Double。我在项目中定了一条铁律:跨端传参只允许用 String、int、double、bool、List、Map,所有业务模型在两端分别定义映射,不要尝试跨端传对象。
4.3 回调与线程切换
MethodChannel 的 invokeMethod 返回的 Future 默认在 Dart 的 root isolate 上执行,而原生侧 setMethodCallHandler 的回调跑在 Android 主线程。这意味着你如果在回调里做网络请求、数据库查询这类耗时操作,会直接卡掉主线程,进而引发 ANR。解决办法是在回调里把任务切到子线程,完成后回到主线程调用 result.success()。
kotlin复制MethodChannel(engine.dartExecutor.binaryMessenger, "com.example.app/data")
.setMethodCallHandler { call, result ->
if (call.method == "fetchBigData") {
Thread {
val data = DataRepository.fetch() // 耗时操作
runOnUiThread {
result.success(data)
}
}.start()
} else {
result.notImplemented()
}
}
注意 result 只能被调用一次,重复调用会抛异常。我一个同事就因为在高频事件里误调了两次 result.success,排查了一整天,最后是看 Flutter 侧日志里那句 A result was already set 才定位到的。所以对 result 的调用建议收敛到一个封装函数里,统一做空安全判断和错误分支。
4.4 事件流与生命周期联动
除了"请求-响应"的 MethodChannel,有些场景需要原生主动持续向 Dart 推送数据,比如定位变化、播放进度、网络状态。这种场景用 EventChannel 更合适,它类似一个广播管道,原生作为发送方可以持续向 Dart 侧发送事件。
dart复制// Dart 侧接收事件流
EventChannel('com.example.app/location')
.receiveBroadcastStream()
.listen((event) {
// 处理位置更新
});
使用 EventChannel 一定要在页面销毁时取消订阅,否则原生侧还在持续发送事件,Dart 侧页面已经没了,轻则白收一堆消息,重则造成内存泄漏。我的做法是在 StatefulWidget 的 dispose 里把 StreamSubscription cancel 掉,同时原生侧也要在引擎销毁时把事件通道关闭,形成双端闭环。
这里还有一个和生命周期强相关的细节:当 Flutter 页面退到后台时,引擎并不会自动暂停 Dart 执行,如果你的业务有持续性消息推送,内存和电量的消耗会肉眼可见地上升。我建议在 Flutter 侧监听 AppLifecycleState,切到 paused 或 hidden 状态时停止高频任务,回到 resumed 再恢复,这也是原生开发里常见的生命周期管理思路,只是换到了 Flutter 环境。
5. AAR 方案:适合多团队协作的构建方式
5.1 flutter build aar 到底生成了什么
如果你所在的团队原生开发和 Flutter 开发是两条线,原生工程师不想装 Flutter SDK,或者你们需要把 Flutter 模块发布到公司内部的 Maven 仓库,那么源码集成就不是最优解了。flutter build aar 就是为了这种场景准备的。
在 Flutter Module 目录下执行:
bash复制cd flutter_module
flutter build aar
构建结束后,控制台会打印一段非常明确的接入指引,告诉你每个 variant 对应的 Maven 仓库地址和依赖坐标。整个产物输出在 build/host/outputs/repo 下,里面按 com/example/flutter_module 的组织结构放了三个 AAR 包,分别对应 flutter_debug、flutter_profile、flutter_release 三种模式。
这个产物仓库既可以直接被宿主工程的 settings.gradle 通过 maven 指向本地路径方式引用,也可以推送到公司内部 Maven 服务器。推送后的接入方式和一个普通的三方库没有区别,Flutter 团队改完代码执行一次构建发布,原生团队只要升级依赖版本号即可。
5.2 仓库发布与消费配置
把 AAR 发布到内部 Maven 后,宿主工程这样接:
gradle复制// settings.gradle
dependencyResolutionManagement {
repositories {
maven { url 'https://maven.internal.example.com/flutter' }
}
}
// app/build.gradle
dependencies {
debugImplementation 'com.example.flutter_module:flutter_debug:1.0.0'
profileImplementation 'com.example.flutter_module:flutter_profile:1.0.0'
releaseImplementation 'com.example.flutter_module:flutter_release:1.0.0'
}
注意这里必须按 build type 分别引用,因为 debug 和 release 的 Flutter 引擎完全是两套产物,混用会出现执行 Dart 代码时找不到 engine library 的运行时错误。另外,发布 AAR 有天然的短板:如果 Flutter 团队启用了新插件,必须在 flutter build aar 之前把插件在你的 Flutter Module 里声明好,否则插件不会出现在产物里,宿主接入后调用相关功能会直接报 MissingPluginException。
5.3 源码方案和 AAR 方案怎么选
我在不同项目里两种方案都试过,总结一下比较实在的体验差异。源码方案最大的优点是开发效率高,改 Flutter 代码,热重载立刻生效,不需要经过构建和仓库上传;调试时可以直接在 Android Studio 里同时看 Android 和 Flutter 两侧的日志。缺点是整个工程对 Flutter SDK 的依赖较重,任何一个原生工程师打开工程都要求本机装好 Flutter 环境,版本不一致还会造成各种诡异的构建问题。
AAR 方案则反过来,原生团队不必关心 Flutter 内部,只要在依赖里声明坐标就能开发,适合公司内部 App 数量多、Flutter 团队需要一套代码同时提供给多个宿主应用接入的场景。缺点就是 Flutter 侧开发调试成本变高,每次改动都要 build + 发布 + 宿主侧拉依赖,本地联调效率打折。
我的建议是:一个团队拥有前后端能力、独享一个 App 时,直接用源码方案,开发体验好太多;一旦涉及两个以上 App 接入,或者原生 Flutter 团队组织隔离明显,AAR 方案更符合组织协作的边界。
6. 常见问题排查实录
6.1 Gradle 插件声明方式的变更坑
最近我同事升级 Flutter 3.16 之后,构建时反复出现一段警告:You are applying Flutter's main Gradle plugin imperatively using the apply script。这句话的意思是 Flutter 官方已经不推荐在根 build.gradle 里用老的 apply from 方式引入 Flutter 插件脚本,而是要求在 pluginManagement 里声明插件 id。处理方式是把根 build.gradle 里那些旧的 apply 脚本删掉,改成在 settings.gradle 的 pluginManagement 中添加对应插件版本:
gradle复制pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
}
}
这里有个细节:插件版本号和 Flutter 的版本强相关,并不是随便写。如果你用的是 Flutter 3.16 稳定版,那么 flutter-plugin-loader 版本一般写 1.0.0 即可,但如果你的 Flutter 版本更老,写成 1.0.0 反而可能解析失败。稳妥的办法是参考 Flutter 官方模板工程里的默认配置,不要在别人的配置上猜版本。
6.2 Plugin Loader 解析失败
另一个高频报错是 Flutter error resolving plugin [id: 'dev.flutter.flutter-plugin-loader', version: '1.0.0']。看到这个报错,先别急着怀疑网络,优先检查仓库配置。flutter-plugin-loader 是托管在 Gradle Plugin Portal 上的,你的 pluginManagement.repositories 里必须包含 gradlePluginPortal()。有些公司的工程为了统一依赖管理,把仓库收敛成了只有 google 和 mavenCentral,插件解析就会失败。
如果你的网络环境拉取官方仓库比较慢,可以在 pluginManagement.repositories 里配置国内镜像源。国内开源镜像站提供了 Gradle 插件仓库和 Flutter 存储的镜像,配置好之后,依赖下载速度会有质的提升。同时 Flutter 侧也可以配置 PUB_HOSTED_URL 和 FLUTTER_STORAGE_BASE_URL 两个环境变量,把 Dart 包和 Flutter 引擎产物都切到镜像源。
6.3 版本冲突与依赖隔离
集成 Flutter 后最常见的 Gradle 冲突是 Kotlin 版本和 AGP 版本不一致。Flutter 的 Android 壳工程默认带一套 Kotlin 版本,如果你的宿主工程 Kotlin 版本比它低,构建时会出现 Incompatible Kotlin versions 的报错。我处理过几次,最干净的做法是在根 build.gradle 里统一指定 ext 变量:
gradle复制ext {
kotlin_version = "1.9.22"
}
然后所有模块的 kotlin 插件都引用这个变量,避免子模块各自写死版本号。另外,Flutter 引擎依赖的 AndroidX 库版本可能和你宿主已有的版本冲突,如果开启 jetifier,要确认你的依赖树里没有重复的旧版 support 库,否则会出现运行时找不到类的情况。
依赖隔离方面有个思路值得参考:如果宿主工程里已经有大量第三方库,先在 app/build.gradle 里运行 ./gradlew :app:dependencies --configuration debugRuntimeClasspath 导出依赖树,把和 Flutter 相关的库全部列出来,逐一检查版本。我通常的做法是把 Flutter 相关依赖统一用 resolutionStrategy 强制到和 Flutter 引擎匹配的版本,省得来回试探。
6.4 热重载失效、资源找不到等运行时问题
源码集成时,如果你 Android Studio 直接点 Run 启动宿主 App,Flutter 侧的热重载是失灵的,只能通过 flutter attach 命令手动连接。这个问题不是 bug,而是机制如此:宿主 App 的 Flutter 引擎是打包进 APK 的,热重载需要 DevTools 的 VM Service 通道,你必须先启动 App,再执行:
bash复制cd flutter_module
flutter attach
连接成功后,每次保存 Dart 代码,热重载就能生效。但要注意,flutter attach 只能连接一个在跑的引擎,如果你在宿主里创建了多个 FlutterEngine 实例,连接时要用 --debug-url 指定具体端口。
资源找不到的典型场景是 Flutter 侧用了本地图片、字体等 assets,但宿主直接跑起来后发现白屏或者报找不到资源。检查思路有两个:一是确认 flutter_module/pubspec.yaml 里 assets 目录声明了,二是确认打包后 APK 里 assets/flutter_assets 目录确实存在。如果用了 Android Studio 的 Instant Run 或者增量构建,偶尔会出现资源没同步进去的情况,Clean 一下再重编就好。
6.5 多引擎内存治理
最后聊一个隐蔽但很烦的问题:多引擎内存暴涨。如果 App 里有多个 Flutter 入口,每个入口都 withNewEngine() 创建一个新引擎,内存会直线上升。我实测过,一个闲置的 FlutterEngine 大约占用 30MB 到 50MB 内存,创建三五个就可能把低端机直接压垮。所以生产环境尽量复用引擎,一个进程里默认只留一个 cached engine 足够。
引擎销毁时机也要注意。在 Activity.onDestroy 里如果直接调 engine.destroy(),你会看到主线程卡顿甚至崩溃,因为引擎销毁是异步的,最好在后台线程执行。另外,Flutter 1.20 之后引擎被设计成不可在同一页面重建,你的引擎一旦销毁,Dart 侧状态就全丢了,想要恢复只能重新执行 Dart 入口,这涉及到业务数据恢复的问题,最好在设计通信协议时就把状态恢复纳入考虑。
我最后的建议是:集成 Flutter Module 不是把两个工程拼起来这么简单,它更考验的是两端团队的协作方式和工程规范。环境变量、镜像配置、插件版本、依赖策略这些基础工程问题不解决清楚,后面业务开发会反复被构建问题打断。先把工程底座打通,再谈业务迭代,这条路才是稳的。
