作为系列文章的第二十篇,本篇将结合官方的技术文档科普 Android 上 PlatformView
的实现逻辑,并且解释为什么在 Android 上 PlatformView
的键盘总是有问题。
为什么 iOS 上相对稳定,文中也做了对应介绍。
Flutter 完整实战实战系列文章专栏
Flutter 番外的世界系列文章专栏
因为 Flutter 的实现在概念上类似于 Android 上的 WebView
,Flutter 是通过将 Widget Tree
转化为纹理后通过 Skia 实现控件绘制,这造就了优秀的跨平台效果的同时,也带来了不可逆的兼容问题。
这就像 WebView 一样,Flutter UI 不会转换为 Android 控件,而是由 Flutter Engine 使用 Skia 直接在 SurfaceView
上渲染出来。
这意味着默认情况下 Flutter UI 永远不会包含 Android Native 的控件,也就是说无法在 Flutter 中集成如 WebView
或 MapView
这些常用的控件。
所以为解决这个问题,Flutter 创建了一个叫 AndroidView
的控件逻辑, 开发者使用该 Widget 可以将 Android Native 组件嵌入到 Flutter UI 中。
AndroidView
这个 Widget 需要和 Flutter 相结合才能完整显示:在 Flutter 中通过将 AndroidView
需要渲染的内容绘制到 VirtualDisplays
中
,然后在 VirtualDisplay
对应的内存中,绘制的画面就可以通过其 Surface
获取得到。
VirtualDisplay
类似于一个虚拟显示区域,需要结合DisplayManager
一起调用,一般在副屏显示或者录屏场景下会用到。VirtualDisplay
会将虚拟显示区域的内容渲染在一个Surface
上。
如上图所示,简单来说就是原生控件的内容被绘制到内存里,然后 Flutter Engine 通过相对应的 textureId
就可以获取到控件的渲染数据并显示出来。
通过从 VirtualDisplay
输出中获取纹理,并将其和 Flutter 原有的 UI 渲染树混合,使得 Flutter 可以在自己的 Flutter Widget tree 中以图形方式插入 Android 原生控件。
在 iOS 平台上就不使用类似 VirtualDisplay
的方法,而是通过将 Flutter UI 分为两个透明纹理来完成组合:一个在 iOS 平台视图之下,一个在其上面。
所以这样的好处就是:需要在“iOS平台”视图下方呈现的Flutter UI,最终会被绘制到其下方的纹理上;而需要在“平台”上方呈现的Flutter UI,最终会被绘制在其上方的纹理。它们只需要在最后组合起来就可以了。
通常这种方法更好,因为这意味着 Android Native View 可以直接添加到 Flutter 的 UI 层次结构中。
但是,Android 平台并不支持这种模式,因为在 iOS 上框架渲染后系统会有回调通知,例如:当 iOS 视图向下移动 2px
时,我们也可以将其列表中的所有其他 Flutter 控件也向下渲染 2px
。
但是在 Android 上就没有任何有关的系统 API,因此无法实现同步输出的渲染。如果强行以这种方式在 Android 上使用,最终将产生很多如 AndroidView
与 Flutter UI 不同步的问题。
有关此替代方法的详细讨论,详见 https://flutter.dev/go/nshc
尽管前面可以使用 VirtualDisplay
将 Android 控件嵌入到 Flutter UI 中 ,但这种 VirtualDisplay
的介入还有其他麻烦的问题需要处理。
默认情况下, PlatformViews
是没办法接收触摸事件。
因为 AndroidView
其实是被渲染在 VirtualDisplay
中 ,而每当用户点击看到的 "AndroidView"
时,其实他们就真正”点击的是正在渲染的 Flutter
纹理 。用户产生的触摸事件是直接发送到 Flutter View 中,而不是他们实际点击的 AndroidView
。
AndroidView
使用 Flutter Framework 中的点击测试逻辑来检测用户的触摸是否在需要特殊处理的区域内。类似可见:《Flutter完整开发实战详解(十三、全面深入触摸和滑动原理)》
当触摸成功时会向 Android embedding 发送一条消息,其中包含 touch 事件的详细信息。
在 Android embedding 中,该事件的坐标最后会匹配到 AndroidView
在 VirtualDisplay
中的坐标,然后会创建一个 MotionEvent
用于 描述触摸的新控件,并将其转发到内部 VirtualDisplay
中真实的 AndroidView
中进行响应。
该实现逻辑会将新的 MotionEvent
直接分发给 AndroidView
,如果这个 View 又派生了其他视图,那么就可能会出现触摸信息被发送到错误的位置。
MotionEvent
的转化过程中可能会因为机制的不同,存在某些信息没办法完整转化的丢失。
通常,AndroidView
是无法获取到文本输入,因为 VirtualDisplay
所在的位置会始终被认为是 unfocused
的状态。
Android 目前不提供任何 API 来动态设置或更改的焦点 Window
,Flutter
中focused
的 Window
通常是实际持有“真实的” Flutter 纹理和 UI ,并且对于用户直接可见。
而 InputConnections
(如何在 Android 中 输入文本)在 unfocused
的 View 中通常是会被丢弃。
Flutter 重写了 checkInputConnectionProxy
方法,这样 Android 会认为 Flutter View 是作为 AndroidView
和输入法编辑器(IME)的代理,这样 Android 就可以从 Flutter View 中获取到 InputConnections
然后作用于 AndroidView
上面。
在 Android Q 开始 InputMethodManager
(IMM)改为每个 Window
自己实例化而不是全局单例。因此之前幼稚的“设置代理”的模式在 Q 开始不起作用。为了进一步解决这个问题,Flutter 创建了一个 Context
的子类, 该子类返回的内容与 Flutter View 中的 IMM
相同,这样就不会需要在查询 IMM
时需要返回的真实的 Window
。这意味着当 Android 需要 IMM
时,VirtualDisplay
仍然会使用 Flutter View 的 IMM
作为代理。
当要求 AndroidView
提供 InputConnection
时,它会检查 AndroidView
是否确实是输入的目标。如果是,那 AndroidView
中的 InputConnection
将被获取并返回给 Android 。
Android 认为 Flutter View 是 focused
且可用的,因此 AndroidView
的 InputConnection
可以成功被获取并使用。
在 Android N 之前的版本上 WebView
输入比较复杂,因为它们具有自己内部的逻辑来创建和设置输入连接,而这些输入连接并没有完全遵循 Android 的协议。在 flutter_webview
插件中,还需要添加其他解决方法以便在可以在 WebView
启用文本输入。
WebView
在相同的线程上侦听输入连接。如果没有此功能,WebView
将在内部消耗所有 InputConnection
的呼叫,而不会通知 Flutter View 代理。WebView
失去焦点时,将输入连接重置回 Flutter 线程。这样可以防止文本输入“卡”在 WebView 内。通常这个逻辑取决于 Android 的内部行为,并且可能会十分脆弱,比如: 1.12 版本下针对华为等设备出现的键盘输入异常等问题。
某些文本功能仍然不可用,例如:“复制”和“共享”对话框当前不可用。
PlatformView
的实现模式增加了 Flutter 的生命力和活力,但是相对的也引出了很多问题,比如 #webview-keyboard、#webview、#platform-views 相关的 issue 专题高居不下,并且如 webview_flutter 插件的文档所述:
该插件依赖 Flutter 的新机制来嵌入 Android 和 iOS 视图。由于该机制当前处于开发人员预览中,因此该插件也应被视为开发人员预览。
webview_flutter
的键盘支持也尚未准备好用于生产,因为 Webview 中的键盘支持目前还处于实验性的阶段。
所以到这里相信你应该知道,为什么 Flutter 中的 PlatforView
在 Android 上如此之难兼容,并且键盘输入问题会那么多坑了。
自此,第二十篇终于结束了!(///▽///)