掘金x得物公开课 - Flutter 3.0下的混合开发演进

11,638 阅读13分钟

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

TT

最初的社区支持

不支持 WebView 在最初可以说是 Flutter 最大的痛点之一,所以在这样窘迫的情况下,社区里涌现出一些临时的解决方法,比如 flutter_webview_plugin

类似 flutter_webview_plugin 的出现,解决了当时大部分时候 App 里打开一个网页的简单需求,如下图所示,它的思路就是:

在 Flutter 层面放一个占位控件提供大小,然后原生层在同样的位置把 WebView 添加进去,从而达到看起来把 WebView 集成进去的效果,这个思路在后续也一直被沿用

image-20220625170833702

这样的实现方式无疑成本最低速度最快,但是也带来了很多的局限性

相信大家也能想到,因为 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
11111111222222222333333image-20220616142126589

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 对应的内存中提取绘制的纹理, 简单看实现逻辑如下图所示:

image-20220626151538054

这里其实也是类似于最初社区支持的模式:通过在 Dart 层提供一个 AndroidView ,从而获取到控件所需的大小,位置等参数,当然这里多了一个 textureId ,这个 id 主要是提交给 Flutter Engine ,通过 id Flutter 就可以在渲染时将画面从内存里提出出来。

iOS

在 iOS 平台上就不使用类似 VirtualDisplay 的方法,而是通过将 Flutter UI 分为两个透明纹理来完成组合,这种方式无疑更符合 Flutter 社区的理念,这样的好处是:

需要在 PlatformView 下方呈现的 Flutter UI 可以被绘制到其下方的纹理;而需要在 PlatformView 上方呈现的 Flutter UI 可以被绘制到其上方的纹理, 它们只需要在最后组合起来就可以了。

是不是有点抽象?

简单看下面这张图,其实就是通过在 NativeView 的不同层级设置不同的透明图层,然后把不同位置的控件渲染到不同图层,最终达到组合起来的效果。

image-20220626151526444

那明明这种方法更好,为什么 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 的状态

InputConnectionsunfocused 的 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 代码变多了。

111111

但是其实 HybridComposition 的实现逻辑是变简单了: PlatformView 是通过 FlutterMutatorView 把原生控件 addViewFlutterView 上,然后再通过 FlutterImageView 的能力去实现图层的混合

又懵了?不怕,马上你就懂了

简单来说就是 HybridComposition 模式会直接把原生控件通过 addView 添加到 FlutterView 上 。这时候大家可能会说,咦~这不是和最初的实现一样吗?怎么逻辑又回去了

其实确实是社区的进阶版实现,Flutter 直接通过原生的 addView 方法将 PlatformView 添加到 FlutterView 里,而当你还需要在 PlatformView 上渲染 Flutter 自己的 Widget 时,Flutter 就会通过再叠加一个 FlutterImageView 来承载这个 Widget 的纹理。

举一个简单的例子,如下图所示,一个原生的 TextView 被通过 HybridComposition 模式接入到 Flutter 里(NativeView),而在 Android 的显示布局边界和 Layout Inspector 上可以清晰看到: 灰色 TextView 通过 FlutterMutatorView 被添加到 FlutterView 上被直接显示出来

image-20220618152055492

所以在 HybridCompositionTextView 是直接在原生代码上被 add 到 FlutterView 上,而不是提取纹理

那如果我们看一个复杂一点的案例,如下图所示,其中蓝色的文本是原生的 TextView ,红色的文本是 Flutter 的 Text 控件,在中间 Layout Inspector 的 3D 图层下可以清晰看到:

  • 两个蓝色的 TextView 是通过 FlutterMutatorView 被添加在 FlutterView 之上,并且把没有背景色的红色 RE 遮挡住了
  • 最顶部有背景色的红色 RE 也是 Flutter 控件,但是因为它需要渲染到 TextView 之上,所以这时候多一个 FlutterImageView ,它用于承载需要显示在 Native 控件之上的纹理,从而达 Flutter 控件“真正”和原生控件混合堆叠的效果。

image-20220616165047353

可以看到 Hybrid Composition 上这种实现,能更原汁原味地保流下原生控件的事件和特性,因为从原生角度看它就是原生层面的物理堆叠,需要都一个层级就多加一个 FlutterImageView ,同一个层级的 Flutter 控件共享一个 FlutterImageView

当然,在 HybridCompositionFlutterImageView 也是一个很有故事的对象,由于篇幅原因这里就不详细展开,这里大家可以简单看这张图感受下,也就是在有 PlatformView 和没有 PlatformView 是,Flutter 的渲染会有一个转化的过程,而在这个变化过程,在 Flutter 3.0 之前可以通过 PlatformViewsService.synchronizeToNativeViewHierarchy(false); 取消

image-20220618153757996

最后,Hybrid Composition 也不少问题,比如上面的转化就是为了解决动画同步问题,当然这个行为也会产生一些性能开销,例如:

在 Android 10 之前, Hybrid Composition 需要将内存中的每个 Flutter 绘制的帧数据复制到主内存,之后再从 GPU 渲染复制回来 ,所以也会导致 Hybrid Composition 在 Android 10 之前的性能表现更差,例如在滚动列表里每个 Item 嵌套一个 Hybrid CompositionPlatformView ,就可能会变卡顿甚至闪烁。

其他还有线程同步,闪烁等问题,由于篇幅就不详细展开,如果感兴趣的可以详细看我之前发布过的 《Flutter 深入探索混合开发的技术演进》

TextureLayer

随着 Flutter 3.0 的发布,第一代 PlatformView 的实现 VirtualDisplay 被新的 TextureLayer 所替代,如下图所示,简单对比 VirtualDisplayTextureLayer 的实现差异,可以看到主要还是在于原生控件纹理的提取方式上

image-20220618154327890

从上图我们可以得知:

  • VirtualDisplayTextureLayerPlugin 的实现是可以无缝切换,因为主要修改的地方在于底层对于纹理的提取和渲染逻辑
  • 以前 Flutter 中会将 AndroidView 需要渲染的内容绘制到 VirtualDisplays ,然后在 VirtualDisplay 对应的内存中,绘制的画面就可以通过其 Surface 获取得到;现在 AndroidView 需要的内容,会通过 View 的 draw 方法被绘制到 SurfaceTexture 里,然后同样通过 TextureId 获取绘制在内存的纹理

是不是又有点蒙?简单说就是不需要绘制到副屏里,现在直接通过 override Viewdraw 方法就可以了。

TextureLayer 的实现里,同样是需要把控件添加到一个 PlatformViewWrapper 的原生布局控件里,但是这个控件通过 override 了 Viewdraw 方法,把原本的 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
image.pngimg

思考一下,因为最直观的感受:点击不都是被 PlatformViewWrapper 拦截了吗?明明 PlatformViewWrapper 是在 FlutterSurfaceView 之上,为什么 FlutterSurfaceView 里的 FlutterButton 还能被点击到

这里简单解释一下:

  • 1、首先那个 Button 并不是真的被摆放在那里,而是通过 PlatformViewWrappersuper.draw绘制到 surface 上的,所以在那里的是 PlatformViewWrapper ,而不是 Button ,Button 的内容已经变成纹理去到了 FlutterSurfaceView 里面
  • 2、 PlatformViewWrapper 里重写了 onInterceptTouchEvent 做了拦截onInterceptTouchEvent 这个事件是从父控件开始往子控件传,因为拦截了所以不会让 Button 直接响应,然后在 PlatformViewWrapperonTouchEvent 响应里是做了点击区域的分发,响应会分发到了 AndroidTouchProcessor 之后,会打包发到 _unpackPointerDataPacket 进入 Dart
  • 3、 在 Dart 层的点击区域,如果没有 Flutter 控件响应,会是 _PlatformViewGestureRecognizer-> updateGestureRecognizers -> dispatchPointerEvent -> sendMotionEvent 又发送回原生层
  • 4、回到原生 PlatformViewsControllercreateForTextureLayer 里的 onTouch ,执行 view.dispatchTouchEvent(event);

image-20220625171101069

总结起来就是:**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 ,所以比如一些需要 SurfaceViewTextureView 或者有自己内部特殊 Canvas 的场景,你还是需要 HybirdComposition ,只不过可能会和官方新的 API 名字一样,它 Expensive 。

Expensive 是因为在 Flutter 3.0 正式版开始,FlutterView 在使用 HybirdComposition 时一定会 converted to FlutterImageView ,这也是 Flutter 3.0 下一个需要注意的点。

image-20220616170253242

更多内容可见 《Flutter 3.0 之 PlatformView :告别 VirtualDisplay ,拥抱 TextureLayer》

image-20220625164049356

最后

最后做个总结,可以看到 Flutter 为了混合开发做了很多的努力,特别是在 Android 上,也是因为历史埋坑的原因,由于时间关系这里没办法都详细介绍,但是相信本次之后大家对 Flutter 的 PlatformView 实现都有了全面的了解,这对大家在未来使用 Flutter 也会有很好的帮助,如果你还有什么问题,欢迎交流。

image-20220626151444011