hello 大家好,我是《Flutter 开发实战详解》的作者,Github GSY 项目的负责人郭树煜,同时也是今年新晋的 Flutter GDE,借着本次 Google I/O 之后发布的 Flutter 3.0,来和大家聊一聊 Flutter 里混合开发的技术演进。
为什么混合开发在 Flutter 里是特殊的存在?因为它渲染的控件是通过 Skia 直接和 GPU 交互,也就是说 Flutter 控件和平台无关,甚至连 UI 绘制线程都和原生平台 UI 线程是相互独立,所以甚至于 Flutter 在诞生之初都不支持和原生平台的控件进行混合开发,也就是不支持 WebView
,这就成了当时最大的缺陷之一 。
其实从渲染的角度看 Flutter 更像是一个 2D 游戏引擎,事实上 Flutter 在这次 Google I/O 也分享了基于 Flutter 的游戏开发 ToolKit 和第三方工具包 Flame ,如图所示就是本次 Google I/O 发布的 Pinball 小游戏,所以从这些角度上看都可以看出 Flutter 在混合开发的特殊性。
如果说的更形象简单一点,那就是如何把原生控件渲染到 WebView
里。
最初的社区支持
不支持 WebView
在最初可以说是 Flutter 最大的痛点之一,所以在这样窘迫的情况下,社区里涌现出一些临时的解决方法,比如 flutter_webview_plugin
。
类似 flutter_webview_plugin
的出现,解决了当时大部分时候 App 里打开一个网页的简单需求,如下图所示,它的思路就是:
在 Flutter 层面放一个占位控件提供大小,然后原生层在同样的位置把 WebView
添加进去,从而达到看起来把 WebView
集成进去的效果,这个思路在后续也一直被沿用。
这样的实现方式无疑成本最低速度最快,但是也带来了很多的局限性。
相信大家也能想到,因为 Flutter 的所有控件都是渲染一个 FlutterView
上,也就是从原生的角度其实是一个单页面的效果,所以这种脱离 Flutter 渲染树的添加控件的方法,无疑是没办法和 Flutter 融合到一起,举个例子:
- 如图一所示,从 Flutter 页面跳到 Native 页面的时候,打开动画无法同步,因为
AppBar
是 Flutter 的,而 Native 是原生层,它们不在同一个渲染树内,所以无法实现同步的动画效果 - 如图二所示,比如在打开 Native 页面之后,通过
Appbar
再打开一个黄色的 Bottm Sheet ,可以看到此时黄色的 Bottm Sheet 打开了,但是却被 Native 遮挡住(Demo 里给 Native 设置了透明色),因为 Flutter 的 Bottm Sheet 是被渲染在FlutterView
里面,而 Native UI 把FlutterView
挡住了,所以新的Flutter UI
自然也被遮挡 - 如图三所示,当我们通过 reload 重刷 Flutter UI 之后,可以看到 Flutter 得 UI 都被重置了,但是此时 Native UI 还在,因为此时已经没有返回按键之类的无法关闭,这也是这种集成方式一不小心就影响开发的问题
- 如图四通过 iOS 上的 debug 图层,我们可以更形象地看到这种方式的实现逻辑和堆叠效果
动画不同步 | 页面被挡 | reload 之后 | iOS |
---|---|---|---|
![]() |
![]() |
![]() |
![]() |
PlatformView
随着 Flutter 的发展,官方支持混合开发势在必行,所以第一代 PlatformView
的支持还是诞生了,但是由于 Android 和 iOS 平台特性的不同,最初Android 的 AndroidView
和 iOS 的 UIKitView
实现逻辑相差甚远,以至于后面 Flutter 的 PlatformView
的每次大调整都是围绕于 Android 在做优化 。
Android
最初 Flutter 在 Android 上对 PlatformView
的支持是通过 VirtualDisplay
实现,VirtualDisplay
类似于一个虚拟显示区域,需要结合 DisplayManager
一起调用,VirtualDisplay
一般在副屏显示或者录屏场景下会用到,而在 Flutter 里 VirtualDisplay
会将虚拟显示区域的内容渲染在一个内存 Surface
上。
在 Flutter 中通过将 AndroidView
需要渲染的内容绘制到 VirtualDisplays
中 ,然后通过 textureId 在 VirtualDisplay
对应的内存中提取绘制的纹理, 简单看实现逻辑如下图所示:
这里其实也是类似于最初社区支持的模式:通过在 Dart 层提供一个 AndroidView
,从而获取到控件所需的大小,位置等参数,当然这里多了一个 textureId
,这个 id 主要是提交给 Flutter Engine ,通过 id Flutter 就可以在渲染时将画面从内存里提出出来。
iOS
在 iOS 平台上就不使用类似 VirtualDisplay
的方法,而是通过将 Flutter UI 分为两个透明纹理来完成组合,这种方式无疑更符合 Flutter 社区的理念,这样的好处是:
需要在 PlatformView
下方呈现的 Flutter UI 可以被绘制到其下方的纹理;而需要在 PlatformView
上方呈现的 Flutter UI 可以被绘制到其上方的纹理, 它们只需要在最后组合起来就可以了。
是不是有点抽象?
简单看下面这张图,其实就是通过在 NativeView
的不同层级设置不同的透明图层,然后把不同位置的控件渲染到不同图层,最终达到组合起来的效果。
那明明这种方法更好,为什么 Android 不一开始也这样实现呢?
因为当时在实现思路上, VirtualDisplay
的实现模式并不支持这种模式,因为在 iOS 上框架渲染后系统会有回调通知,例如:当 iOS 视图向下移动 2px
时,我们也可以将其列表中的所有其他 Flutter 控件也向下渲染 2px
。
但是在 Android 上就没有任何有关的系统 API,因此无法实现同步输出的渲染。如果强行以这种方式在 Android 上使用,最终将产生很多如 AndroidView
与 Flutter UI 不同步的问题。
问题
事实上 VirtualDisplay
的实现方式也带来和很多问题,简单说两个大家最直观的体会:
触摸事件
因为控件是被渲染在内存里,虽然你在 UI 上看到它就在那里,但是事实上它并不在那里,你点击到的是 FlutterView
,所以用户产生的触摸事件是直接发送到 FlutterView
。
所以触摸事件需要在 FlutterView
到 Dart ,再从 Dart 转发到原生,然后如果原生不处理又要转发回 Flutter ,如果中间还存在其他派生视图,事件就很容易出现丢失和无法响应,而这个过程对于 FlutterView
来说,在原生层它只有一个 View 。
所以 Android 的 MotionEvent
在转化到 Flutter 过程中可能会因为机制的不同,存在某些信息没办法完整转化的丢失。
文字输入
一般情况下 AndroidView
是无法获取到文本输入,因为 VirtualDisplay
所在的内存位置会始终被认为是 unfocused
的状态。
InputConnections
在 unfocused
的 View 中通常是会被丢弃。
所以 Flutter 重写了 checkInputConnectionProxy
方法,这样 Android 会认为 FlutterView
是作为 AndroidView
和输入法编辑器(IME)的代理,这样 Android 就可以从 FlutterView
中获取到 InputConnections
然后作用于 AndroidView
上面。
在 Android Q 开始又因为非全局的 InputMethodManager
需要新的兼容
当然还有诸如性能等其他问题,但是至少先有了支持,有了开始才会有后续的进阶,在 Flutter 3.0 之前, VirtualDisplay
一直默默在 PlatformView
的背后耕耘。
HybridComposition
时间来到 Flutter 1.2,Hybrid Composition 是在 Flutter 1.2 时发布的 Android 混合开发实现,它使用了类似 iOS 的实现思路,提供了 Flutter 在 Android 上的另外一种 PlatformView
的实现。
如下图是在 Dart 层使用 VirtualDisplay
切换到 HybridComposition
模式的区别,最直观的感受应该是需要写的 Dart 代码变多了。
但是其实 HybridComposition
的实现逻辑是变简单了: PlatformView
是通过 FlutterMutatorView
把原生控件 addView
到 FlutterView
上,然后再通过 FlutterImageView
的能力去实现图层的混合。
又懵了?不怕,马上你就懂了
简单来说就是 HybridComposition
模式会直接把原生控件通过 addView
添加到 FlutterView
上 。这时候大家可能会说,咦~这不是和最初的实现一样吗?怎么逻辑又回去了 ?
其实确实是社区的进阶版实现,Flutter 直接通过原生的 addView
方法将 PlatformView
添加到 FlutterView
里,而当你还需要在 PlatformView
上渲染 Flutter 自己的 Widget 时,Flutter 就会通过再叠加一个 FlutterImageView
来承载这个 Widget 的纹理。
举一个简单的例子,如下图所示,一个原生的 TextView
被通过 HybridComposition
模式接入到 Flutter 里(NativeView
),而在 Android 的显示布局边界和 Layout Inspector 上可以清晰看到: 灰色 TextView
通过 FlutterMutatorView
被添加到 FlutterView
上被直接显示出来 。
所以在 HybridComposition
里 TextView
是直接在原生代码上被 add 到 FlutterView
上,而不是提取纹理。
那如果我们看一个复杂一点的案例,如下图所示,其中蓝色的文本是原生的 TextView
,红色的文本是 Flutter 的 Text
控件,在中间 Layout Inspector 的 3D 图层下可以清晰看到:
- 两个蓝色的
TextView
是通过FlutterMutatorView
被添加在FlutterView
之上,并且把没有背景色的红色 RE 遮挡住了 - 最顶部有背景色的红色 RE 也是 Flutter 控件,但是因为它需要渲染到
TextView
之上,所以这时候多一个FlutterImageView
,它用于承载需要显示在 Native 控件之上的纹理,从而达 Flutter 控件“真正”和原生控件混合堆叠的效果。
可以看到 Hybrid Composition
上这种实现,能更原汁原味地保流下原生控件的事件和特性,因为从原生角度看它就是原生层面的物理堆叠,需要都一个层级就多加一个 FlutterImageView
,同一个层级的 Flutter 控件共享一个 FlutterImageView
。
当然,在 HybridComposition
里 FlutterImageView
也是一个很有故事的对象,由于篇幅原因这里就不详细展开,这里大家可以简单看这张图感受下,也就是在有 PlatformView
和没有 PlatformView
是,Flutter 的渲染会有一个转化的过程,而在这个变化过程,在 Flutter 3.0 之前可以通过 PlatformViewsService.synchronizeToNativeViewHierarchy(false);
取消。
最后,Hybrid Composition 也不少问题,比如上面的转化就是为了解决动画同步问题,当然这个行为也会产生一些性能开销,例如:
在 Android 10 之前, Hybrid Composition 需要将内存中的每个 Flutter 绘制的帧数据复制到主内存,之后再从 GPU 渲染复制回来 ,所以也会导致 Hybrid Composition 在 Android 10 之前的性能表现更差,例如在滚动列表里每个 Item 嵌套一个 Hybrid Composition 的 PlatformView
,就可能会变卡顿甚至闪烁。
其他还有线程同步,闪烁等问题,由于篇幅就不详细展开,如果感兴趣的可以详细看我之前发布过的 《Flutter 深入探索混合开发的技术演进》 。
TextureLayer
随着 Flutter 3.0 的发布,第一代 PlatformView
的实现 VirtualDisplay
被新的 TextureLayer
所替代,如下图所示,简单对比 VirtualDisplay
和 TextureLayer
的实现差异,可以看到主要还是在于原生控件纹理的提取方式上。
从上图我们可以得知:
- 从
VirtualDisplay
到TextureLayer
, Plugin 的实现是可以无缝切换,因为主要修改的地方在于底层对于纹理的提取和渲染逻辑; - 以前 Flutter 中会将
AndroidView
需要渲染的内容绘制到VirtualDisplays
,然后在VirtualDisplay
对应的内存中,绘制的画面就可以通过其Surface
获取得到;现在AndroidView
需要的内容,会通过 View 的draw
方法被绘制到SurfaceTexture
里,然后同样通过TextureId
获取绘制在内存的纹理 ;
是不是又有点蒙?简单说就是不需要绘制到副屏里,现在直接通过 override View
的 draw
方法就可以了。
在 TextureLayer 的实现里,同样是需要把控件添加到一个 PlatformViewWrapper
的原生布局控件里,但是这个控件通过 override 了 View
的 draw
方法,把原本的 Canvas 替换成 SurfaceTexture
在内存的 Canvas ,所以 PlatformViewWrapper
的 child 会把控件绘制到内存的 SurfaceTexture
上。
举个例子,还是之前的代码,如下图所示,这时候通过 TextureLayer 模式运行之后,通过 Layout Inspector 的 3D 图层可以看到,两个原生的 TextView
通过 PlatformViewWrapper
被添加到 FlutterView
上。
但是不同的是,在 3D 图层里看不到 TextView
的内容,因为绘制 TextView
的 Canvas 被替换了,所以 TextView
的内容被绘制到内存的 Surface 上,最终会在渲染时同步 Flutter Engine 里。
看到这里,你可能也发现了,这时候因为有 PlatformViewWrapper
的存在,点击会被 PlatformViewWrapper
内部拦截,从而也解决了触摸的问题, 而这里刚好有人提了一个问题,如下图所示:
"从图 1 Layout Inspector 看, PlatformWrapperView
是在 FlutterSurfaceView
上方,为什么如图 2 所示,点击 Flutter button 却可以不触发 native button的点击效果?"。
图1 | 图2 |
---|---|
![]() |
![]() |
思考一下,因为最直观的感受:点击不都是被 PlatformViewWrapper
拦截了吗?明明 PlatformViewWrapper
是在 FlutterSurfaceView
之上,为什么 FlutterSurfaceView
里的 FlutterButton 还能被点击到?
这里简单解释一下:
- 1、首先那个 Button 并不是真的被摆放在那里,而是通过
PlatformViewWrapper
的super.draw
绘制到 surface 上的,所以在那里的是PlatformViewWrapper
,而不是 Button ,Button 的内容已经变成纹理去到了FlutterSurfaceView
里面。 - 2、
PlatformViewWrapper
里重写了onInterceptTouchEvent
做了拦截,onInterceptTouchEvent
这个事件是从父控件开始往子控件传,因为拦截了所以不会让 Button 直接响应,然后在PlatformViewWrapper
的onTouchEvent
响应里是做了点击区域的分发,响应会分发到了AndroidTouchProcessor
之后,会打包发到_unpackPointerDataPacket
进入 Dart - 3、 在 Dart 层的点击区域,如果没有 Flutter 控件响应,会是
_PlatformViewGestureRecognizer
->updateGestureRecognizers
->dispatchPointerEvent
->sendMotionEvent
又发送回原生层 - 4、回到原生
PlatformViewsController
的createForTextureLayer
里的onTouch
,执行view.dispatchTouchEvent(event);
总结起来就是:PlatfromViewWrapper
拦截了 Event ,通过 Dart 做二次分发响应,从而实现不同的事件响应 ,它和 VirtualDisplay 的不同是, VirtualDisplay 的事件响应都是在 FlutterView
上,但是TextureLayout 模式,是有独立的原生 PlatfromViewWrapper
控件来开始,所以区域效果和一致性会更好。
问题
最后这里还需要提个醒,如果你之前使用的插件使用的是 HybirdComposition
,但是没做兼容,也就是使用的还是 PlatformViewsService.initSurfaceAndroidView
的话,它也会切换成 TextureLayer
的逻辑,所以你需要切换为 PlatformViewsService.initExpensiveAndroidView
,才能继续使用原本 HybirdComposition
的效果。
⚠️我也比较奇怪为什么 Flutter 3.0 没有提及 Android 这个 breaking change ,因为对于开发来说其实是无感的,不小心就掉坑里。
那你说为什么还要 HybirdComposition
?
前面我们说过, TextureLayer
是通过在 super.draw
替换 Canvas 的方法去实现绘制,但是它替换不了 Surface
里的一些 Canvas ,所以比如一些需要 SurfaceView
、TextureView
或者有自己内部特殊 Canvas
的场景,你还是需要 HybirdComposition
,只不过可能会和官方新的 API 名字一样,它 Expensive 。
Expensive 是因为在 Flutter 3.0 正式版开始,FlutterView
在使用 HybirdComposition
时一定会 converted to FlutterImageView
,这也是 Flutter 3.0 下一个需要注意的点。
更多内容可见 《Flutter 3.0 之 PlatformView :告别 VirtualDisplay ,拥抱 TextureLayer》
最后
最后做个总结,可以看到 Flutter 为了混合开发做了很多的努力,特别是在 Android 上,也是因为历史埋坑的原因,由于时间关系这里没办法都详细介绍,但是相信本次之后大家对 Flutter 的 PlatformView
实现都有了全面的了解,这对大家在未来使用 Flutter 也会有很好的帮助,如果你还有什么问题,欢迎交流。