2019.03.27 丨 今日头条技术团队
今日头条 | 让Flutter真正支持View级别的混合开发
2019.03.27 丨 今日头条技术团队
前言
Flutter发布以来,我们在今日头条主客户端多个有限场景下尝试了Flutter的落地,从实际表现上来看,整个技术栈还是不错的。上层Flutter Framework引入Widget/LayerTree等概念自己实现了界面描述框架,下层Flutter Engine把LayerTree用OpenGL渲染成用户界面,有点类似游戏引擎解决移动平台跨端开发的思路。性能方面超过目前已有的大部分跨平台动态化方案,包括RN和小程序。当然,bug还是有的,但任何处于起步阶段的新技术都存在这种情况,可以理解。
从长期来看,我们期待的是Flutter能在更多业务模块上替代Native开发,实现双端代码统一,从而减少研发成本,提升研发效率。但Native代码的迁移过程一定是渐进式的,而Flutter目前的实现机制对渐进迁移很不友好,这是目前我们在Flutter落地实践中所面临的最大问题。
问题
以Android为例,我们先来看看Flutter目前的整体架构设计图(得出这张图的过程需要另起一篇Flutter整体架构分析的文章,以后再写):
几个重点:
一个FlutterView对应一个FlutterEngine实例;
一个FlutterEngine实例对应一个Dart Isolate实例;
同一个进程只有且仅有一个Dart VM虚拟机;
一个Dart VM上会存在多个Dart Isolate实例,Isolate是dart代码的执行环境;
Flutter从一开始就是为纯Flutter应用设计的,纯Flutter应用整个运行生命周期都只有一个FlutterView和Root Isolate,依靠Flutter Framework自身Route支持在FlutterView内部完成界面跳转。但Flutter+Native混合开发并非如此,正常Native Activity和Flutter Activity混合堆叠,按照目前官方的设计与实现,一定会有多个FlutterView同时存在。根据官方FAQ:
Isolates are separate heaps in Flutter’s VM
这句话意味着对于不同的FlutterView,它们背后的Isolate——也就是Dart运行环境内存完全独立,互不共享。
这就引入了一个用户数据同步的问题:FlutterView A无法获知在FlutterView B执行的数据修改操作,除非走Platform Channel这种源生开发方式把数据操作放在Native端,但这样就失去了原有的双端统一代码的优势。
这还只是开发方面对代码的限制,更为重要的是内存占用方面的问题:同一张图片可能存在N份内存缓存;再加上单个FlutterView初始内存占用就很大(毕竟背后是一整套完整的Engine/Isolate/…等对象),在低端手机上一旦FlutterView堆叠层数过多,完全有可能内存直接爆掉。
所幸我们可以基于一个在大部分情况下都成立的假设:用户同一时间只能看到一个Flutter界面。基于这个假设,我们可以做一些Hack的事情把Native界面和Flutter界面混合堆叠的场景下的内存共享和内存过多的问题规避掉。
比如我们目前的混合栈方案:
创建一个全局共享的FlutterView
打开一个FlutterActivity时把全局共享FlutterView添加到这个FlutterActivity中
FlutterActivity跳转到新页面的时候先对FlutterView截图,把截图设置为FlutterActivity背景,然后把FlutterView移除
从其他界面返回到原FlutterActivity时,再次添加FlutterView,当FlutterView首帧展示后移除截图
本质上就是用一个全局共享FlutterView规避了多FlutterView内存共享和内存过多问题,配合FlutterView和截图的添加删除实现了界面跳转返回衔接。
闲鱼之前开源的混合栈方案也是类似的原理,不再赘述。
这类方案方才也说了,是基于一个大部分情况下都成立的假设。来看看一个假设不成立的场景:我们计划要用Flutter重构实现的小视频Tab界面。
这个首页上小视频Tab共有五个频道,这五个频道并不归属同一模块,所以只能逐个频道进行Flutter重构,未重构的频道继续沿用原有Native实现。这也是渐进迁移过程中一定会存在的场景:混合页面场景——即短期内无法完成页面级别的Flutter化,Native View和多个Flutter View必须混合存在的情况。类似的还有Feed流中的Flutter Cell,列表页中的某些ItemView是FlutterView……这些场景都不满足混合栈方案的前提假设,自然方案也无法满足需求。
我们所期待的,是Flutter能支持View级别的混合开发,目前Flutter显然满足不了这个需求,难道Flutter事业就要止步于此了么?只能小打小闹的做一些独立二级页面么?
破局
在去年12月初参加Flutter Beijing Live与Google官方工程师线下沟通会时,我们就曾提及过这个难题,并期望官方能支持多FlutterView共享FlutterEngine来解决多FlutterView内存共享问题,但官方后续的Milestone中并没有涉及这个议题。于是我们只能先行进行了探索,目前代码已经初步完成,也有了Demo,正打算在小视频Tab场景中验证。
然而3月7号闲鱼发表了一篇文章《已开源|码上用它开始Flutter混合开发——FlutterBoost》,这篇文章主体是如何优化之前开源的混合栈方案,但我们更关注到了文中透漏的一个关键信息:
在混合方案方面,我们跟Google讨论了可能的一些方案。Flutter官方给出的建议是从长期来看,我们应该支持在同一个引擎支持多窗口绘制的能力,至少在逻辑上做到FlutterViewController是共享同一个引擎的资源的。换句话说,我们希望所有绘制窗口共享同一个主Isolate。
但官方给出的长期建议目前来说没有很好的支持。
这个官方的建议方案居然跟我们目前的探索方案完全一致!本来计划是过两个月等代码在线上场景验证之后再公布方案提交到官方讨论,但现在看来,既然想法一致,提前公布方案让官方review一下,给点建议让我们少走点弯路似乎更好一点。
接下来是我们完整的重构方案。
加入WindowId概念,共享Root Isolate
回到本文最开头的那张架构图,只说结论:
Flutter Framework层和Native Engine层的界面绘制交互都是通过两者的Window对象——是的,在Framework层/Engine层都叫Window。
Framework层代码参见window.dart,实例为ui.window单例对象
Engine层代码参见window.cc
两者交互的API很少,且一一对应。
#window.cc
void Window::RegisterNatives(tonic::DartLibraryNatives* natives) {
natives->Register({
{"Window_defaultRouteName", _DefaultRouteName, 1, true},
{"Window_scheduleFrame", _ScheduleFrame, 1, true},
{"Window_sendPlatformMessage", _SendPlatformMessage, 4, true},
{"Window_sendPlatformMessageSync", _SendPlatformMessageSync, 3, true},
{"Window_respondToPlatformMessage", _RespondToPlatformMessage, 3, true},
{"Window_render", _Render, 2, true},
{"Window_updateSemantics", _UpdateSemantics, 2, true},
{"Window_setIsolateDebugName", _SetIsolateDebugName, 2, true},
{"Window_addNextFrameCallback", _AddNextFrameCallback, 2, true},
{"Window_reportUnhandledException", ReportUnhandledException, 2, true},
});
}
///window.dart
class Window {
String _defaultRouteName() native 'Window_defaultRouteName';
void scheduleFrame() native 'Window_scheduleFrame';
String _sendPlatformMessage(String name,
PlatformMessageResponseCallback callback,
ByteData data) native 'Window_sendPlatformMessage';
ByteData sendPlatformMessageSync(String name, ByteData data)
native 'Window_sendPlatformMessageSync';
/// ...
}
用图来说明现有设计下多FlutterView的对应关系:
一个Ioslate只有一个ui.window单例对象,只需要做一点修改:把FlutterEngine加入ID的概念传给Dart层,让dart层存在多个window,就可以实现多个FlutterEngine共享一个Ioslate了。
需要考虑的是让开发者无感知,不能让开发者显式的通过一个额外的方法去获知当前对应的ID进而获取对应的window,所以我们也修改了Engine调用dart入口main函数的代码。
/// called when isolate is already running.
@pragma("vm:entry-point")
void _runEntryPoint(Function entryPoint, String windowId, String args) {
runZoned(
() {
if (entryPoint is _UnaryFunction) {
(entryPoint as dynamic)(args != null ? json.decode(args) : null);
} else {
entryPoint();
}
},
zoneValues: {
#windowId: [windowId],
#extras: Map<dynamic, dynamic>(),
},
onError: (Object error, StackTrace stackTrace) {
_reportUnhandledException(error.toString(), stackTrace.toString());
},
);
}
利用Dart自带的神奇概念Zone,不同的Zone可以获取到不同的zoneValue这个隐藏Feature,把ID传给dart代码。
然后再改一下默认的ui.window实现。
/// The [Window] singleton. This object exposes the size of the display, the
/// core scheduler API, the input event callback, the graphics drawing API, and
/// other such core services.
/// final Window window = new Window._();
/// 以上是原实现
/// 以下是多window实现
Window get window {
String windowId = Zone.current[#windowId].first;
// must have windowId.
assert(windowId != null);
return windows[windowId];
}
这么一来,即便是运行同一份dart代码,所拿到的ui.window的交互对象也是跟调用其代码入口的FlutterView绑定的FlutterEngine。
修改过后的架构:多FlutterView对应多FlutterEngine然后共享Isolate。
这是一个退而求其次为了少修改代码的折中方案,追求极致的实现是做到真正的多FlutterView共享Engine,但需要引入更多修改,日后再议。
除了window之外,整个Flutter全局从Framework到Engine因为ID的引入需要重新适配的地方还有很多,比如paragraph/bindings/hotreload/hotrestart/IDE工具支持等等,有些不影响开发就暂时没有支持,在这里略过细节,大家有兴趣可以查看后续开源出来的Flutter Repo Fork源码。
内存回收
FlutterView在跳转至新界面调用onStop()时,原实现仅仅只是停掉了Flutter的绘制回调,该占用的内存一点没少,这里我们引入了两个概念,hibernate & recover,从命名上已经能猜出概念的大概含义,但还是先来看看代码,注意注释:
public void onStart() {
// recover:把未回收dart window跟新的mNativeView/Android Shell Holder 绑定起来
if (mNativeView == null) {
mNativeView = new FlutterNativeView(activity.getApplicationContext());
dartExecutor = mNativeView.getDartExecutor();
mNativeView.attachViewAndActivity(this, activity);
mNativeView.recover(oldWindowId, getSurface());
// ...
// ...
}
public void onStop() {
lifecycleChannel.appIsPaused();
if (!isAttached())
return;
// hibernate:逻辑跟 destroy 一模一样,只是不回收dart window
oldWindowId = mNativeView.hibernate();
mNativeView = null;
}
public void destroy() {
if (!isAttached()) {
if (oldWindowId != null) {
FlutterJNI.nativeDestroyHibernatingResources(oldWindowId);
}
return;
}
mNativeView.destroy();
mNativeView = null;
}
核心思路:
onStop时hibernate:执行原destroy以及onSurfaceTextureDestroyed的所有逻辑,销毁掉大部分内存,包括Engine,仅保留dart内存中的window
onStart时recover:重新创建之前销毁的所有对象,走一次完整的重建流程,与onCreate的区别只是在于不调用dart入口函数,而是把之前保存的dart window跟新创建的Engine绑定
onDestroy时销毁dart内存中的window:通过oldWindowId把之前保存的dart window从dart内存中移除
通过这些措施,在小米6上能堆叠近200个FlutterView,应该可以满足未来一段时间的使用需求。
实际编码中发现,Flutter Framework中大量存在一些static变量阻碍了Dart内存回收,在调试工具的帮助下花费了大量时间才消除了内存泄漏,一旦merge新的代码,内存泄漏可能会再次出现。所以目前的修改只能算是一个临时方案,需要官方引入exitApp的概念(与runApp对应)从Framework整体设计上更优雅的解决。
脑洞再开大一点,在内存中保留dart内存数据也是不必要的,可以考虑后续把hibernate中保留的dart相关的内存dump到文件里,这样不仅内存开销进一步降低,同时也能补全Flutter还未支持的Save/Restore功能(Android相关),这个脑洞需要官方从Dart VM层支持。
共享线程
官方设计是FlutterEngine对应四线程,或者说是Task Runner:
Platform Task Runner
UI Task Runner
GPU Task Runner
IO Task Runner
UI线程是Dart代码的运行线程,在共享Isolate之后,多FlutterEngine如果继续各自使用独立的UI线程,可能会出现多个UI线程操作同一份Dart内存导致线程不安全的情况(我猜的……毕竟本来Dart是单线程运行模式,多线程我也不知道会发生什么情况),出于这个考虑,我们把线程改成了全局公用的四个线程,当然线程收敛也会有额外的性能收益。
同时,对于线程模型我们也有自己的看法,IO Task Runner目前看起来是一个明显的性能瓶颈,因为单线程的IO肯定无法满足大量并发IO的需求,举个简单的例子:没有任何一个图片库使用单线程Decode。
目前我们正考虑引入IO Thread Pool的概念,也希望官方能直接解决这个问题。
SurfaceView改TextureView
这是个Android平台特有的额外问题。
今日头条线上的小视频详情页有一个跟随手势缩放关闭动画。在把小视频详情页Flutter化之后,我们发现FlutterView目前继承自SurfaceView,SurfaceView本身的问题导致这个缩放动画兼容性很差,根本达不到上线标准。
于是我们把SurfaceView改成了TextureView,除了动画兼容性要好不少以外,TextureView还可以通过一些手段复用Surface,这样back回Flutter界面时直接就是上次的界面,不会有因为SurfaceView的surface Destroy而导致的闪烁问题。
当然也存在一些问题,目前发现在debug模式下TextureView的帧率要比SurfaceView要低,但在Release模式下这个感受不太明显,后续需要进一步的研究。同时也希望官方能直接支持TextureView,SurfaceView对动画的支持实在太差了。
小结
以上就是我们为Flutter支持View级别开发所做的努力,目前本方案还在落地实践中,但因为跟官方思路吻合,于是仓促行文,供大家一起探讨交流,自己也少走弯路。不足之处请见谅。
Fork Repo还在进行公司内部开源审查程序,通过后会在字节跳动官方Github上开源
文章来源:今日头条技术团队微信公众号,欢迎大家前去关注!