掘力计划|Flutter 混合开发的混乱之治【直播回顾】
Hello,大家好,我是 Flutter GDE 郭树煜,今天的主题是 Flutter 的混合开发,但是其实内容并不会很广,主要分享会集中在 Android 平台的 PlatformView
实现上,其实本次内容之前我已经在掘金发过一篇简要的文字概括,今天主要是根据这个内容做一个更详细的技术展开。
之所以会集中在 Android 平台的
PlatformView
实现上去分享,是因为正如标题所示那样,Android 平台的PlatformView
实现目前呈现的状态:混乱。
混乱之始
就像每个混乱都有它的起源,比如艾泽拉斯的混乱之治起源于燃烧军团的入侵,而混合开发在 Flutter 领域之所以混乱,主要源自它本身独特的实现。
我们常说光明总是伴随着黑暗,Flutter 最大的特点在于:渲染的控件是通过 Skia 直接和 GPU 交互,所以可做到在性能不错的同时,在不同平台得到一致性的渲染效果。
也就是说 Flutter 控件和平台无关,甚至连 UI 绘制线程都和原生平台 UI 线程是相互独立,这就决定了: Flutter 在和原生平台做混合开发时会有相对高昂的技术成本。
简单想象下,例如你需要把一个原生的按键渲染到 WebView 里面和前端标签混合到一起,这是不是很不可思议?
还一个更容易理解的角度,其实从渲染的角度看 Flutter 更像是一个「游戏」引擎,只是他可以用来开发 App ,当然它现在也可以用来开发游戏,近两年谷歌的 I/O 大会都用它做了热场游戏,例如今年就做了一个像图片里的卡牌动作游戏,所以 Flutter 其实更像是游戏引擎的逻辑,所以它独立于平台的特性,既是优势,也带来了劣势:
毕竟把原生控件渲染进一个类似 unity 的引擎进行混合并不容易。
那如果只是单纯的技术问题,也只是实现成本较高而已,为什么会说混乱呢?这就需要谈到目前 Android PlatformView
的实现。
不过再谈及 Android PlatformView
实现之前,先简单说说 iOS ,iOS 平台是通过将 Flutter UI 分为两个透明纹理来完成组合:
需要在
PlatformView
下方呈现的 Flutter UI 可以被绘制到其下方的纹理;而需要在PlatformView
上方呈现的 Flutter UI 可以被绘制到其上方的纹理, 它们只需要在最后组合起来就可以了。
简单来说,就是通过在 NativeView
的不同层级设置不同的透明图层,然后把不同位置的控件渲染到不同图层,最终达到组合起来的效果。
那 Android 是否采用这种实现?答案明显并不是,因为这种实现在 iOS 上框架渲染后系统会有回调通知,例如:当 iOS 视图向下移动
2px
时,我们也可以将其列表中的所有其他 Flutter 控件也向下渲染2px
。
但是在 Android 上就没有任何有关的系统 API,因此无法实现同步输出的渲染,所以基于此,在各个版本的更新迭代下, Android 的 PlatformView
实现衍生出多种实现逻辑。
目前活跃在 Android 平台的 PlatformView
支持主要有以下三种:
可以看到官方都已经为大家定义好了简称 VD、HC、TLHC ,有了简称也方便大家提 issue 时沟通,毕竟每次在讨论时都用全称很费劲:
因为你需要不停指出你用的是什么模式,然后在什么模式下正常or不正常,另外知道这些简称最大的作用就是看 issue 时不迷糊。
所以后续我们也会用简称来称呼它们,而之所以会有这么多模式,其实就是因为没有一种模式可以完全满足和覆盖需求 ,这也导致了明明后来出现的模式是为了替代旧的支持,但是最终形成了共存的情况,从而导致了后续混乱的开始。
这就好比兽族入侵艾泽拉斯,最后的结果却是兽族和人族共存下来,各个模式之间最终既相爱又相杀的一种情况。
VD
我们先说最早的 VD,VD 简单来说就是使用 VirtualDisplay 渲染原生控件到内存。
VirtualDisplay
类似于一个虚拟显示区域,需要结合 DisplayManager
一起调用,VirtualDisplay
一般在副屏显示或者录屏场景下会用到,而在 Flutter 里 VirtualDisplay
会将虚拟显示区域的内容渲染在一个内存 Surface
上。
在 Flutter 中需要用到 Android 原生 View 的地方会让你使用一个叫 AndroidView
的控件,如图所示,在 Flutter 中通过将 AndroidView
需要渲染的内容绘制到 VirtualDisplays
中 ,然后通过 textureId 在 VirtualDisplay
对应的内存中提取绘制的纹理:
通过在 Dart 层提供一个
AndroidView
,从而获取到控件所需的大小,位置等参数,然后通过textureId
,主要是这个 id 提交给 Flutter Engine ,通过 id Flutter 就可以在渲染时将画面从内存里提出出来。
那么这个实现在满足和最初混合开发接入原生控件的同时,也带来和许多的局限,最常见的就是触摸事件和文字输入的支持问题。
触摸事件
因为控件是被渲染在内存里,所以虽然你在 UI 上看到它就在那里,但是事实上它并不在那里,你点击到的是 Flutter 所在的原生 FlutterView
,用户产生的触摸事件是直接发送到 FlutterView
。
触摸事件需要在
FlutterView
到 Dart ,再从 Dart 转发到原生,然后如果原生不处理又要转发回 Flutter ,中间如果还存在其他派生视图,事件就很容易出现丢失和无法响应。
而 Android 的 MotionEvent
在转化到 Flutter 过程中可能会因为机制的不同,存在某些信息没办法完整转化的丢失。
文字输入
另外关于文字输入 的问题,一般情况下 AndroidView
是无法获取到文本输入,因为 VirtualDisplay
所在的内存位置会始终被认为是 unfocused
的状态。
而
InputConnections
在unfocused
的 View 中通常是会被丢弃。
所以 Flutter 重写了 View 的 checkInputConnectionProxy
方法,这样 Android 会认为 FlutterView
是作为 AndroidView
和输入法编辑器(IME)的代理,这样 Android 就可以从 FlutterView
中获取到 InputConnections
然后作用于 AndroidView
上面。
在 Android Q 开始又因为非全局的
InputMethodManager
需要新的兼容
所以键盘问题在第一代 VD 上最为突出,因为在不同版本的 Android 上可能会经常非常容易异常,为 WebView
作为混合开发里最常用到的插件,键盘是它最精彩会用到的能力之一,这个局限对于 VD 来说非常致命。
HC
Flutter 是在 1.2 版本开始支持 HC,简单说就是直接把原生控件覆盖在 Flutter 上进行堆叠,它使用了类似 iOS 的实现思路,简单来说就是 HybridComposition
模式会直接把原生控件通过 addView
添加到 FlutterView
上 。
举一个简单的例子,如图所示,一个原生的 TextView
被通过 HC 模式接入到 Flutter 里(NativeView
),而在 Android 的显示布局边界和 Layout Inspector 上可以清晰看到: 灰色 TextView
通过 FlutterMutatorView
被添加到 FlutterView
上被直接显示出来 。
所以在 HC 模式里 TextView
是直接在原生代码上被 add 到 FlutterView
上,而不是提取纹理。
那如果我们看一个复杂一点的案例,如图所示,其中蓝色的文本是原生的 TextView
,红色的文本是 Flutter 的 Text
控件,在中间 Layout Inspector 的 3D 图层下可以清晰看到:
- 两个蓝色的
TextView
是被添加在FlutterView
之上,并且把没有背景色的红色 RE 遮挡住了 - 最顶部有背景色的红色 RE 也是 Flutter 控件,但是因为它需要渲染到
TextView
之上,所以这时候多一个FlutterImageView
,它用于承载需要显示在 Native 控件之上的纹理,从而达 Flutter 控件“真正”和原生控件混合堆叠的效果。
可以看到 Hybrid Composition
上这种实现,能更原汁原味地保流下原生控件的事件和特性,因为从原生角度看它就是原生层面的物理堆叠,需要叠加一个层级就多加一个 FlutterImageView
,同一个层级的 Flutter 控件共享一个 FlutterImageView
。
当然,这里出现的 FlutterImageView
,其实还有一个作用,就是为了解决动画同步和渲染。
前面说过,HC 是直接被添加到原生 FlutterView
上面,所以走的还是原生的渲染流程和时机,而这时候通过 FlutterImageView
,也就是把 Flutter 控件渲染也同步到原生的 OnDraw
上,这样对于画面同步会更好。
当然,这样带来了一个问题,因为此时原生控件是直接渲染,所以需要在原生的平台线程上执行,纯在 Flutter 的 UI 线程就存在线程同步问题,所以在此之前一些场景下会有画面闪烁 bug ,例如:
A page
->webview page
->B page
, 当webview page
打开B page
时,有时候A page
的 UI 在B page
突然闪动当
B page
返回webview page
, 然后再返回A page
, 有时候B page
UI 突然闪现在A page
虽然这个问题最后也通过类似线程同步实现解决,但是也带来一定程度的性能开销,另外在 Android 10 之前还会存在 GPU->CPU->GPU的性能损耗,所以 HC 属于会性能开销较大,又需要原生控件特性的场景。
TLHC
3.0 版本之后开始支持 TLHC 模式,最初它的目的还是取代上面这两种模式,解决混乱之治,但是奈何它最后和阿尔萨斯一样,成了新一代的巫妖王。
目前 TLHC 和 VD 还有 HC 一起共存下来,该模式的最大特点是控件虽然在还是布局在该有的位置上,但是其实是通过一个 FrameLayout
代理 onDraw
然后替换掉 child 原生控件的 Canvas
来实现混合绘制。
TLHC 算是参考了 VD 和 HC 的模式,然后利用平台的特点来完成渲染,所以它带了 HC ,但又并不是 HC,最大的特点就是它不在让控件通过原生线程绘制,所以也就不需要做线程同步。
而说它参考 VD ,主要是它和 VD 很类似,不同之处在于原生控件纹理的提取方式上,如图可以看到 :
- 从 VD 到 TLHC 里, Plugin 的实现是可以无缝切换,因为主要修改的地方在于底层对于纹理的提取和渲染逻辑
- 以前 Flutter 中将
AndroidView
需要渲染的内容绘制到VirtualDisplays
,然后在VirtualDisplay
对应的内存中,绘制的画面就可以通过其Surface
获取得到;现在AndroidView
需要的内容,会通过 View 的draw
方法被绘制到SurfaceTexture
里,然后同样通过TextureId
获取绘制在内存的纹理
简单说就是不需要绘制到副屏里,现在直接通过 override View
的 onDraw
方法就可以了,然后因为它是绘制到内存,最终渲染还是在 Flutter 线程完成,所以也就不需要线程同步。
举个例子,还是之前的代码,如图所示,这时候通过 TLHC 模式运行之后,通过 Layout Inspector 的 3D 图层可以看到,两个原生的 TextView
通过 PlatformViewWrapper
被添加到 FlutterView
上。
但是不同的是,在 3D 图层里看不到 TextView
的内容,因为绘制 TextView
的 Canvas 被替换了,所以 TextView
的内容被绘制到内存的 Surface 上,最终会在渲染时同步 Flutter Engine 里。
不过
PlatfromViewWrapper
拦截了 Event ,但是其实还是通过 Dart 做二次分发响应,从而实现不同的事件响应 ,它和 VD 的不同是, VD 的事件响应都是在FlutterView
上,但是TLHC 模式,是有独立的原生PlatfromViewWrapper
控件来开始,所以区域效果和一致性会更好。
那么为什么说 TLHC 模式是巫妖王呢?
因为这种实现天然不支持 SurfaceView
,因为 SurfaceView
是双缓冲机制,所以通过 parent 替换 Canvas
的实现并不支持,也就是对于类似地图、视频等插件,如果是 SurfaceView
,会出现无法支持的问题。
那有人说,我用 TextureView
不就行了?对不起,目前在 #103686 下,对于 TextureView
有时候也会出现不正常更新的异常情况。
所以 TLHC 没能带来终结,它反而引入的新的致命缺陷,并且和 VD 还有 HC 融合到了一起。
混乱之治
那为什么这三种模式会导致混乱?首先我们简单总结下前面介绍的内容:
而随着三种模式的存在,在 API 层面,目前出现了兼容式运行的情况,在 API 上,在目前 3.0+ 的 Flutter 上同样对应有三个 API ,但是这三个 API 并不是直接对应上述三种模式:
看到没有,这里有一个问题就是:你其实没办法主动控制是 TLHC 还是 VD ,对于 HC 你倒是可以强行指定。
另外,不知道你注意到没有,不管是 initAndroidView
还是 initSurfaceAndroidView
,它们都可能会在升级到新版本时使用 TLHC 模式,也就是如果你的 Plugin 没有针对性做更新,那么可能会在不知觉的情况下换了模式,从而有可能出现 bug 。
对于 TLHC 还有一个问题,就是如果你原本没有 SurfaceView ,但是后面添加 SurfaceView ,也会触发异常显示的问题。
现在你看出 PlatformView 的混乱了吧?从底层实现的不统一,到 API 再不同版本下不同的行为变化,这就是目前 Android 在 PlatformView 支持下的混乱生态,同时如果你对于目前 PlatformView 存在的问题感兴趣,可以查阅以下相关 issue:
不过整体来说,官方还是建议大家使用 TLHC 模式,因为它的思路总的来说性能会更好,并且更符合预期,在不出现兼容运行的情况下。
好了,今天分享的内容就这些,谢谢大家。