得益于 Flutter 优秀的跨平台表现,混合开发在如今的 App 中随处可见,如最近微信公布的小程序新渲染引擎 Skyline 发布正式版也在底层渲染上使用了 Flutter,号称渲染速度提升50%。
在现有的原生 App 中引入 Flutter 来开发不是一件简单的事,需要解决混合模式下带来的种种问题,如路由栈管理、包体积和内存突增等;另外还有一种特殊的情况,一个最初就由 Flutter 来开发的 App 也有可能在后期混入原生 View 去开发。
我所在的团队目前就是处于这种情况,Flutter 目前在性能表现上面还不够完美,整体页面还不够流畅,并且在一些复杂的页面场景下会出现比较严重的发热行为,尽管目前 Flutter 团队发布了新的渲染引擎 impeller,它在 iOS 上表现优异,流畅度有了质的提升,但还是无法完全解决一些性能问题且 Android 下 impeller 也还没开发完成。
为了应对当下出现的困局和以后可能出现的未知问题,我们期望通过混合模式来扩宽更多的可能性。
混合开发下最难处理的就是路由问题了,我们知道原生和 Flutter 都有各自的路由管理系统,在原生页面和 Flutter 页面穿插的情况下如何统一管理和互相交互是一大难点。目前比较流行的单引擎方案
,代表框架是闲鱼团队出品flutter_boost;flutter 官方代表的多引擎解决方案 FlutterEngineGroup。
flutter_boost 通过复用 Engine 达到最小内存的目的,下面这张图是它的设计架构图(图片来自官方)
在引擎处理上,flutter_boost 定义了一个通用的 CacheId:"flutter_boost_default_engine",当原生需要跳转到 Flutter 页面时,通过FlutterEngineCache.getInstance().get(ENGINE_ID);
获取同一个 Engine,这样无论打开了多少如图中的 A、B、C 的 Flutter 页面时,都不会产生额外的Engine
内存损耗。
public class FlutterBoost {
public static final String ENGINE_ID = "flutter_boost_default_engine";
...
}
另外,双端都注册了导航的接口,通过Channel
来通知,用于请求路由变化、页面返回以及页面的生命周期处理等。在这种模式下,这一层Channel
的接口处理是重点。
为了应对内存爆炸问题,官方对多引擎场景做了优化,FlutterEngineGroup
应运而生,FlutterEngineGroup
下的 Engine 共用一些通用的资源,例如GPU 上下文、线程快照等,生成额外的 Engine 时,号称内存占用缩小到 180k。这个程度,基本可以视为正常的损耗了。
以上图中的 B、C 页面为例,两者都是 Flutter 页面,在 FlutterEngineGroup 这种处理下,因为它们所在的 Engine 不是同一个,这会产生完全的隔离行为,也就是 B、C 页面使用不同的堆栈,处在不同的 Isolate 中,两者是无法直接进行交互的。
多引擎的优点是:它可以抹掉上图所示的 F、E、C 和 D、A 等内部路由,每次新增 Flutter 页面时,全部回调到原生,让原生生成新的 Engine 去承载页面,这样路由的管理全部由原生去处理,一个 Engine 只对应一个 Flutter 页面。
但它也会带来一些额外的处理,像上面提到的,处在不同 Engine 下的Flutter 页面之间是无法直接交互的,如果涉及到需要通知和交互的场景,还得通过原生去转发。
关于FlutterEngineGroup
的更多信息,可以参考官方说明。
官方号称 FlutterEngineGroup 创建新的 Engine 只会占用 180k 的内存,那么是不是真就如它所说呢?下面我们来针对上面这两种方案做一个内存占用测试
测试机型:OPPO CPH2269
测试代码:github.com/alibaba/flu…
内存 dump 命令: adb shell dumpsys meminfo com.idlefish.flutterboost.example
条件 | PSS | RSS | 最大变化 |
---|---|---|---|
1 Native | 88667 | 165971 | |
+26105 | +28313 | +27M | |
1 Native + 1 Flutter | 114772 | 194284 | |
-282 | +1721 | +1M | |
2 Native + 2 Flutter | 114490 | 196005 | |
+5774 | +5992 | +6M | |
5 Native + 5 Flutter | 120264 | 201997 | |
+13414 | +14119 | +13M | |
10 Native + 10 Flutter | 133678 | 216116 |
第一次加载 Flutter 页面时,增加 27M 左右内存,此后多开一个页面内存增加呈现从 1M -> 2M -> 2.6 M 这种越来越陡的趋势(数值只是参考,因为其中有 Native 页面,只看趋势变化上看)
测试机型:OPPO CPH2269
测试代码:github.com/flutter/sam…
内存 dump 命令: adb shell dumpsys meminfo dev.flutter.multipleflutters
条件 | PSS | RSS | 最大变化 |
---|---|---|---|
1 Native | 45962 | 140817 | |
+29822 | +31675 | +31M | |
1 Native + 1 Flutter | 75784 | 172492 | |
-610 | +2063 | +2M | |
2 Native + 2 Flutter | 75174 | 174555 | |
+7451 | +7027 | +3.7M | |
5 Native + 5 Flutter | 82625 | 181582 | |
+8558 | +7442 | +8M | |
10 Native + 10 Flutter | 91183 | 189024 |
第一次加载 Flutter 页面时,增加 31M 左右内存,此后多开一个页面内存增加呈现从 1M -> 1.2M -> 1.6 M 这种越来越陡的趋势(数值只是参考,因为其中有 Native 页面,只看趋势变化上看)
两个测试使用的是不同的 demo 代码,不能通过数值去得出孰优孰劣。但通过数值的表现,我们基本可以确认,两个方案都不会带来异常的内存暴涨,完全在可以接受的范围。
PlatformView 也可实现混合 UI,Flutter 中的 WebView 就是通过 PlatformView 这种方式引入的。
PlatformView 允许我们向 Flutter 界面中插入原生 View,在一个页面的最外层包裹一层 PlatformView,路由的管理都由 Flutter 来处理。这种方式下没有额外的 Engine 产生,是最简单的混合方式。
但它也有缺点,不适合主 Native 混 Flutter 的场景,而现在大多都是以主 Native 混 Flutter的场景为主。另外,PlatformView 因其底层实现,会出现兼容性问题,在一些机型下可能会出现键盘问题、闪烁或其它的性能开销,具体可看这篇介绍
原生和 Flutter 使用不同的开发语言去开发,所以在一侧定义的数据结构对象和内存对象对方都无法感知,在数据同步和处理上必须使用其它手段。
Flutter 开发者对 MethodChannel 一定不陌生,开发当中免不了跟原生交互,MethodChannel 是双向设计,即允许我们在 Flutter 中调用原生的方法,也允许我们在原生中调用 Flutter 的方法。对 Channel 不太了解的可以看一下官方文档,如文档中提到的,这个通道传输的过程中需要将数据编解码,对应的关系以kotlin
为例(完整的映射可以查看文档):
Dart | Kotlin |
| -------------------------- | ----------- |
| null | null |
| bool | Boolean |
| int | Int |
| int, if 32 bits not enough | Long |
| double | Double |
| String | String |
| Uint8List | ByteArray |
| Int32List | IntArray |
| Int64List | LongArray |
| Float32List | FloatArray |
| Float64List | DoubleArray |
| List | List |
| Map | HashMap |
这种方式比较容易理解,将本地存储视为中转站,Flutter中将数据操作存储到本地上,回到原生页面时在某个时机(如onResume)去查询本地数据库即可,反之亦然。
不管是MethodChannel
或是本地存储
,都会面临一个问题:对象的数据结构是独立的,两边需要重复定义。比如我在 Flutter 中有一个 Student 对象,Android 端也要定义一个同样结构的 Student,这样才能方便操作,现在我将Student student
转成Unit8List
传到Android
,Channel中解码成Kotlin
能操作的ByteArray
,再将ByteArray
转译成Android
中Student
对象。
class Student {
String name;
int age;
Student(this.name, this.age);
}
对于这个问题最好的解决办法是使用DSL
一类的框架,如Google的ProtoBuf,将同一份对象配置文件编译到不同的语言环境中,便能省去这部分双端重复定义的行为。
在内存方面,如果同样的图片在两边都加载时,会使得原生和 Flutter 都会产生一次缓存。在 Flutter 下默认就会缓存在ImageCache
中,原生下不同的框架由不同的对象负责,为了去掉重复的图片缓存,势必要统一图片的加载管理。
阿里的方案也是如此,通过外接原生图片库,共享图片的本地文作缓存和内存缓存。它的实现思路是通过自定义ImageProvider
和Codec
,对接外部图库,获取到图片数据做解析,对接的处理是通过扩展 Flutter Engine。
如果期望不修改Flutter Engine,也可通过外接纹理的方式去处理。通过PlatformChannel
去请求原生,使到图片的外接纹理数据,通过TextTure
组件展示图片。
// 自定义 ImageProvider 中,通过 Channel 去请求 textureId
var id = await _channel.invokeMethod('newTexture', {
"imageUrl": imageUrl,
"width": width ?? 0,
"height": height ?? 0,
"minWidth": constraints.minWidth,
"minHeight": constraints.minHeight,
"maxWidth": constraints.maxWidth,
"maxHeight": constraints.maxHeight,
"cacheKey": cacheKey,
"fit": fit.index,
"cacheOriginFile": cacheOriginFile,
});
// ImageWidget 中展示时通过 textureId 去显示图片
SizedBox(
width: width,
heigt: height,
child: Texture(
filterQuality: FilterQuality.high,
textureId: _imageProvider.textureId.value,
),
)
不同业务对于混合的程度和要求有所要求,并没有万能的方案。比如我团队的情况就是主Flutter混原生
,在路由管理
上我选择了PlatformView
这种处理模式,这种方式更容易开发和维护,后期如果发现有兼容性问题,也可过渡到flutter_boost
和FlutterEngineGroup
上。