0%

Android 原生项目集成 Flutter

前言

了解一下如何在 Android 原生项目中集成 Flutter ,通过项目中打开 Flutter 页面。

添加 Flutter Module 并依赖

生成配置

在原生项目根目录执行命令

1
2
3
4
5
6
7
flutter create -t module --org {package_name} {module_name}

// 此处 module_name 的命令遵循 Android 子 module 的命名即可。不能有中划线。

// 比如,

flutter create -t module --org com.engineer.mini.flutter sub_flutter

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Creating project sub_flutter...
sub_flutter/test/widget_test.dart (created)
sub_flutter/sub_flutter.iml (created)
sub_flutter/.gitignore (created)
sub_flutter/.metadata (created)
sub_flutter/pubspec.yaml (created)
sub_flutter/README.md (created)
sub_flutter/lib/main.dart (created)
sub_flutter/sub_flutter_android.iml (created)
sub_flutter/.idea/libraries/Dart_SDK.xml (created)
sub_flutter/.idea/modules.xml (created)
sub_flutter/.idea/workspace.xml (created)
Running "flutter pub get" in sub_flutter... 1,054ms
Wrote 11 files.

最终生成了以上文件,注意这里最后自动执行了 flutter pub get 的命令。关于 flutter pub get 具体做了什么,可以参考后面的描述

这里在项目根目录创建子 module 只是为了把代码放在一个仓库,方便维护,理论上可以放在硬盘的任何位置

配置原生项目 settings.gradle

在配置 settings.gradle 之前先来简单回顾一下关于 Gradle 的一些基础知识。

如果你了解过 Gradle 相关的配置的话,一定会看到一个概念,就是约定优于配置,什么意思呢,按照面向对象的思路来理解,每一个工程是一个巨大的 Project 类,整个类里有很多的属性。而我们创建的每一个项目其实就是一个具体的 Project 对象(也就是实例).约定优于配置的意思,就是在 project 实例化的时候,其内部的属性已经有了默认值。那么我们怎么知道有哪些默认值呢?在项目根目录执行

1
./gradlew properties

就可以得到整个 Project 的一些默认配置,比如(此处节选部分结果)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
------------------------------------------------------------
Root project
------------------------------------------------------------

allprojects: [root project 'MiniApp', project ':app', project ':thirdlib']
android.agp.version.check.performed: true
android.enableJetifier: true
android.enableR8: true
android.enableR8.libraries: true
android.useAndroidX: true
buildDir: /Users/username/Documents/mygithub/MinApp/build
buildFile: /Users/username/Documents/mygithub/MinApp/build.gradle
projectDir: /Users/username/Documents/mygithub/MinApp
rootDir: /Users/username/Documents/mygithub/MinApp
rootProject: root project 'MiniApp'

这里当前有一些是我们配置的,比如 useAndroidX,但也有一些是约定的,比如 对于整个 project 来说 buildDir 就是项目根目录的 build 文件夹等。

执行

1
./gradlew :app:properties

节选部分结果

1
2
buildDir: /Users/username/Documents/mygithub/MinApp/app/build
buildFile: /Users/username/Documents/mygithub/MinApp/app/build.gradle

就会得到关于 app 整个 module 现阶段的一些配置信息,当然这些配置信息除了约定的,还有你自己配置的,比如 buildToolsVersion ,签名等相关信息。可以看到 buildDir 和整个 project 的是不一样的。

回到主题, 看看如何把我们刚才创建的 sub_flutter 模块集成到项目中。(严格来说并不是集成 sub_flutter 模块,因为他只是一个 flutter 的模块,而在 Android 主项目只能集成子 Android module,那么具体该怎么做呢,下面就来看看其中的奥秘)

按照官方的操作方法,会要求我们添加以下配置到 settings.gradle 中。

1
2
3
4
5
6
7
// Include the host app project.
include ':app' // assumed existing content
setBinding(new Binding([gradle: this])) // new
evaluate(new File( // new
settingsDir.parentFile, // new
'my_flutter/.android/include_flutter.groovy' // new
)) // new

首先看看 这里的 settingsDir 的值。在 settings.gradle 中直接添加

1
2
println "settings.dir=" + settingsDir
println "settings.dir.parent=" + settingsDir.parent

sync 之后就会看到输出

1
2
settings.dir=/Users/username/Documents/mygithub/MinApp
settings.dir.parent=/Users/username/Documents/mygithub

所以,上面的配置信息,就是说结合 settings 所在目录的父目录和我们配置的目录结合,找到一个名为 include_flutter.groovy 的文件,然后去执行他。

前面说了,创建子 module 的时候,可以是在项目根目录,也可以是在其他位置,如果是在其他位置,这里的 my_flutter 可以替换为你创建目录的绝对路劲。

这里是在根目录直接创建的,那么以上的配置就可以简化为

1
2
3
setBinding(new Binding([gradle: this]))
evaluate(new File(settingsDir, 'sub_flutter/.android/include_flutter.groovy'))
include ':sub_flutter'

关于 include_flutter.groovy

上面说了,settings.gradle 的配置,其实就是去执行 include_flutter.groovy 这个文件,可以简单看一下这个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def scriptFile = getClass().protectionDomain.codeSource.location.toURI()
def flutterProjectRoot = new File(scriptFile).parentFile.parentFile

gradle.include ":flutter"
gradle.project(":flutter").projectDir = new File(flutterProjectRoot, ".android/Flutter")

def localPropertiesFile = new File(flutterProjectRoot, ".android/local.properties")
def properties = new Properties()

assert localPropertiesFile.exists(), "❗️The Flutter module doesn't have a `$localPropertiesFile` file." +
"\nYou must run `flutter pub get` in `$flutterProjectRoot`."
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }

def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
gradle.apply from: "$flutterSdkPath/packages/flutter_tools/gradle/module_plugin_loader.gradle"

.android 其实就是一个 Android 项目,他包含一个 Flutter 文件夹,这 Flutter 是一个 library 类型的 Android module ,这个一点从他的 build.gradle 文件就可以看出。 include_flutter.groovy 所做的事情,就是将当前 library 命名为 flutter 的一个 moudle。然后检查项目中 local.properties 中 sdk 的相关配置,最后去执行 FlutterSDK 的中 gradle 脚本,这里具体的分析就不再展开了。

也就是说,现在有一个名为 flutter 的 Android Library Module 。这个 module 包含 flutter 的所有配置。我们如果依赖了这个 module ,那么就相当于是依赖了 Flutter .

依赖 flutter

最后在原生项目的 application-module 的 build.gradle 的 dependencies 闭包中添加

1
implementation project(':flutter')

至此,原生项目已经有了 Flutter 的依赖,可以使用 Flutter 的 UI 了。

至此,现在的原生项目就包含 Flutter SDK 的所有依赖了。 Flutter UI 相关的内容,该怎么写还是用 dart 在 main.dart 中写,然后我们就可以把这个 dart 渲染出来的内容按照 Activity 、Fragment 或 View 的形式添加到已有的项目中了。具体怎么添加,下面来看。

集成 Flutter UI 到原生项目

FlutterEngine 提前初始化并缓存

按照官方的建议以及实际体验,在这种原生项目后集成 Flutter 的场景下,需要提前预热(官方称为 pre-warm) Flutter 引擎,也就是说提前启动 Dart 的虚拟机,否则当你以其他方式添加 Flutter 的内容时再创建 Flutter 引擎,体验会非常糟糕,尤其是首次打开页面的时候。

因此,简单起见我们可以使用 IdleHandler 在应用启动后进行预热操作。(理论上这种后期集成 Flutter 的页面应该不是首页及需要 App 启动后立即显示的页面,大部分情况下至少是个二级甚至三级页面,因此这样做是合理的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    fun onCreate() {
Looper.myQueue().addIdleHandler {
initFlutterEngine()
false
}
}

private fun initFlutterEngine() {
val flutterEngine = FlutterEngine(context)
flutterEngine.navigationChannel.setInitialRoute("/custom/route/page") // 1
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault() // 2
// DartExecutor.DartEntrypoint(FlutterInjector.instance()
// .flutterLoader().findAppBundlePath(), "fakeMain")
)
FlutterEngineCache
.getInstance()
.put(MinApp.FLUTTER_ENGINE_KEY, flutterEngine)
}

注释 1 处的实现不是必须的,注释 2 处是默认实现。

按照上面的代码,在这个 FlutterEngine 被启动的时候,会默认执行 main.dart 中的 main 方法。DartExecutor.DartEntrypoint.createDefault() 这个默认的实现结点就是 main 方法,这点从代码了很容易窥探到。当然,我们也可以在 FlutterEngine 启动的时候指定自定义的执行结点,比如上面注释中的 fakeMain() 方法,是定义在 main.dart 中的一个 Dart 方法。(当然,这里有一个坑,后面再说)。

当然,我们也可以按注释 1 出写的那样, FlutterEngine 在启动的时候,执行指定路由对应页面的提前初始化工作。(当然,这里还是有坑的,后面统一说)

打开 FlutterActivity 或 FlutterFragment

有了缓存好的 FlutterEngine,我们就可以用它打开 FlutterActivity 和 FlutterFragment 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
viewBinding.flutterActivity.setOnClickListener {
startActivity(
FlutterActivity
.withCachedEngine(MinApp.FLUTTER_ENGINE_ID)
.build(this)
)
}
viewBinding.flutterFragment.setOnClickListener {
if (supportFragmentManager.fragments.size > 0) {
return@setOnClickListener
}
currentFragment = FlutterFragment.withCachedEngine(MinApp.FLUTTER_ENGINE_ID).build()
supportFragmentManager.beginTransaction()
.replace(R.id.flutter_container, currentFragment!!)
.commitAllowingStateLoss()
}

FlutterActivity 和 FlutterFragment 都是 Flutter SDK 内封装好的,便于在原生项目中集成 Flutter 的实现。当然,你也可以使用 FlutterView ,但是比较复杂,具体可以参考官方指导 。

这样上面打开的 Activity 或添加的 Fragment 就会默认展示 main.dart 中配置的 Widget 所对应的 UI。

FlutterFragment 已经被 add 到当前页面的 FrameLayout 中了。

一些坑

这里说一下这种做法的一些坑。

  • 官方是建议提前缓存 FlutterEngine ,但是按照实际体验与其说是建议,不如说是必须做的事情,否则打开页面的体验真的只能用非常卡来形容了。

  • 关于 pre-warmed FlutterEngine 的一些细节。

Note: To warm up a FlutterEngine, you must execute a Dart entrypoint. Keep in mind that the moment executeDartEntrypoint() is invoked, your Dart entrypoint method begins executing. If your Dart entrypoint invokes runApp() to run a Flutter app, then your Flutter app behaves as if it were running in a window of zero size until this FlutterEngine is attached to a FlutterActivity, FlutterFragment, or FlutterView. Make sure that your app behaves appropriately between the time you warm it up and the time you display Flutter content.

按照这段描述的意思,提前创建 FlutterEngine 并不只是创建了一个执行环境,而是在提前执行代码,类似于用一个 1 像素的 Activity 敲敲执行一些后台逻辑。因此,但是我们的 Flutter 代码的执行依赖于一个 Activity 或者是 Window,那么就会失败。

比如,我要做一个显示用户相册的页面,因此需要申请权限,但在 pre-warmed 的时候,Flutter Widget 所要要依赖的 Activity 并没有被 attached. 这个时候,势必会导致代码运行的异常。

  • 前面说了,我们可以在 FlutterEngine 创建的时候,指定特定的执行结点,而不一定要执行 main() 方法,从而规避一些问题,是这样没错。但是,当打出 release 版本的 Apk时, main.dart 中的代码被混淆了,自定义的执行结点找不到了,比如上面代码中注释掉的 fakeMain() 方法,在 Debug 阶段时正常的,但是在 release 的版本就会出现找不到方法的异常日志。这里其实很好奇 main 方法是怎么找到的,难道没有被混淆?

  • 在来说说 flutterEngine.navigationChannel.setInitialRoute 这个似乎很牛逼的配置,意思是我可以在初始化 FlutterEngine 的时候,执行特定路由所对应的 Widget ,而不是默认的 home 配置。比如,main.dart 默认加载的是 A Widget,可以通过路由配置在初始化的时候执行 B Widget 的逻辑。似乎很好,如果 B Widget 恰好很耗时的话,似乎还可以做优化呢?但是我们再看一下打开页面时候的代码

1
2
3
4
5
startActivity(
FlutterActivity
.withCachedEngine(MinApp.FLUTTER_ENGINE_ID)
.build(this)
)

是的,你没法做任何其他和 FlutterEngine 有关的配置了,现在打开的 FlutterActivity 会默认显示 B Widget ,A Widget 直接被忽略了。是不是很坑?(o(╥﹏╥)o

  • 最后这个集成的 Flutter module 会导致整个项目编译变慢,是非常明显的变慢了。

坦白来说,在原生项目中接入 Flutter 现阶段还是会有投入产出比不高的体验,虽然写实际功能或业务的代码可以复用了,似乎是省了人力成本,但是实际上这中间的还是有许多意想不到的坑要去趟,有些问题都是前人没有遇到过的,到最后只能自己去读源码或是自己给官提 issue 找答案,总体来说整体流程还是比较费时的。

flutter pub get

flutter pub get 或者 pub get 是在做 flutter 的时候在使用第三方 lib 或版本更新的时候经常会使用一个命令,通过这个命令会拉取相关的依赖,其实这个命令还会自动生成 Android 和 iOS 的原生项目。比如在我们创建就的 sub_flutter 模块中,均自动生成了 .android 和 .ios 的原生项目目录。同时这两个目录都是点打头的,那么一般情况下就是隐藏文件,同时通过 .gitignore 文件也可以看到,对于 flutter module 形式来说,这两个文件夹都是被忽略的,毕竟 flutter module 的核心,还是为了方便以 module 的形式集成到原生的项目中,内部的两个原生目录,一方面是为了方便集成,另一方面是便于直接运行执行 hot-reload 的调试。

Flutter 一些思考

Flutter 的确很棒,尤其在低端机上的表现,比如我手边的小米 5 上,长列表滑动或者网格图显示图片,流畅度和原生页面几乎是一样的。但是在高端机上就没有什么优势了,总不能比原生实现还牛逼吧。 HotReload 很实用,尤其是客户端还是比较偏 UI 的情况下,对开发效率是一个很大的提升。同时现阶段各类需要和原生通信的插件还是挺丰富的,虽然适配情况及实现质量层次不齐,比如 Android 权限获取的适配,Android Q 沙盒机制下存储空间访问的适配,相册读写的适配等,都没有非常完美的实现。同时这类跨平台的插件实现,需要满足 Android、iOS 双端甚至是未来更多端的需求,所以还有很长的路要走。

至于 Flutter 自身,Dart 语法写 UI 的嵌套问题其实也不是什么完全无法接受的事情,写多了,自然而然就习惯了,或者说实在不行就不断的创建小方法,将局部的嵌套用方法体来包裹掉。

比如上面这样,按照一些都是 Widget 的思路,做一些封装之后,代码看起来也还行,写起来也能习惯。

槽点:

最后,再说一下 Flutter 页面的生命周期简直是过于捡漏了,关键结点太少了,来来去去就是 build 、setState .很多场景下,限制太大了。

好了,暂时这些,Flutter 未来可期。新项目或试水项目值得投入,在试错阶段可以有非常高效的产出。

参考文档

Add-To-An-Android-App

加个鸡腿呗.