乐趣区

在Flutter中嵌入Native组件的正确姿势是…

引言
在漫长的从 Native 向 Flutter 过渡的混合工程时期,要想平滑地过渡,在 Flutter 中使用 Native 中较为完善的控件会是一个很好的选择。本文希望向大家介绍 AndroidView 的使用方式以及在此基础之上拓展的双端嵌入 Native 组件的解决方案。
1. 使用教程
1.1. DemoRun
嵌入地图这一场景可能在很多 App 中都会存在,但是现在的地图 SDK 都没有提供 Flutter 的库,而自己开发一套地图显然不太现实。这种场景下,使用混合栈的形式是一个比较好的选择。我们可以直接在 Native 的绘图树中嵌入一个 Map,但是这个方案嵌入的 View 并不在 Flutter 的绘图树中,是一种比较暴力且不优雅的方式,使用起来也很费劲。
这时候,使用 Flutter 官方提供的控件 AndroidView 就是一种比较优雅的解决方案了。这里做了一个简单的嵌入高德地图的 demo,就让我们跟着这个应用场景,看一下 AndroidView 的使用方式和实现原理。

1.2. AndroidView 使用方式
AndroidView 的使用方式和 MethodChannel 类似,比较简单,主要分为三个步骤:
第一步:在 dart 代码的相应位置使用 AndroidView,使用时需要传入一个 viewType,这个 String 将用于唯一标识该 Widget,用于和 Native 的 View 建立关联。

第二步:在 native 侧添加代码,写一个 PlatformViewFactory,PlatformViewFactory 的主要任务是,在 create() 方法中创建一个 View 并把它传给 Flutter(这个说法并不准确,但是我们姑且可以这么理解,后续会进行解释)

第三步:使用 registerViewFactory() 方法注册刚刚写好的 PlatformViewFactory,该方法需要传入两个参数,第一个参数需要和之前在 Flutter 端写的 viewType 对应,第二个参数是刚刚写好的的 PlatformViewFactory。

配置高德地图的部分这里就省略不说了,官方有比较详细的文档,可以去高德开发者平台进行查阅。
以上便是使用 AndroidView 的所有操作,总体看起来还是比较简单的,但是真正要用起来,还是有两个无法忽视的问题:

View 最终的显示尺寸由谁决定?
触摸事件是如何处理的?

下面就让小闲鱼来给各位一一解答。
2. 原理讲解
想要解决上面的两个问题,首先必须得理解所谓 ” 传 View” 的本质是什么?
2.1. 所谓 ” 传 View” 的本质是什么?
要解决这个问题,自然避免不了的需要去阅读源码,从更深的层面去看这个传递的整个过程,可以整理出一张这样的流程图:

我们可以看到,Flutter 最终拿到的是 native 层返回的一个 textureId。根据 native 的知识 ky h 这个 textureId 是已经在 native 侧渲染好了的 view 的绘图数据对应的 ID,通过这个 ID 可以直接在 GPU 中找到相应的绘图数据并使用,那么 Flutter 是如何去利用这个 ID 的呢?
在之前的深入了解 Flutter 界面开发中,也给大家介绍了 Flutter 的绘图流程。我这里也给大家再简单整理一下

Flutter 的 Framework 层最后会递交给 Engine 层一个 layerTree,在管线中会遍历 layertree 的每一个叶子节点,每一个叶子节点最终会调用 Skia 引擎完成界面元素的绘制,在遍历完成后,在调用 glPresentRenderBuffer(IOS)或者 glSwapBuffer(Android) 按完成上屏操作。
Layer 的种类有很多,而 AndroidView 则使用的是其中的 TextureLayer。TextureLayer 在之前的《Flutter 外接纹理》中有更为详细的介绍,这里就不再赘述。TextureLayer 在被遍历到时,会调用一个 engine 层的方法 SceneBuilder::addTexture() 将 textureId 作为参数传入。最终在绘制的时候,skia 会直接在 GPU 中根据 textureId 找到相应的绘制数据,并将其绘制到屏幕上。
那么是不是谁拿到这个 ID 都可以进行这样的操作呢?答案当然是否定的,Texture 数据存储在创建它的 EGLContext 对应的线程中,所以如果在别的线程进行操作是无法获取到对应的数据的。这里需要引入几个概念:

显示屏对象(Display):提供合理的显示器的像素密度和大小的信息
Presentation:它给 Android 提供了在对应的上下文(Context)和显示屏对象(Display)上绘制的能力,通常用于双屏异显。

这里不展开讲解 Presentation,我们只需要明白 Flutter 是通过 Presentation 实现了外接纹理,在创建 Presentation 时,传入 FlutterView 对应的 Context 和创建出来的一个虚拟显示屏对象,使得 Flutter 可以直接通过 ID 找到并使用 Native 创建出来的纹理数据。
2.2. View 最终的显示尺寸由谁决定?
通过上面的流程大家应该都能想到,显示尺寸看起来像是由两部分决定的:AndroidView 的大小,Android 端 View 的大小。那么实际上到底是有谁来决定的呢,让我们来做一个实验?
直接新建一个 Flutter 工程,并把中间改成一个 AndroidView。
//Flutter
class _MyHomePageState extends State<MyHomePage> {
double size = 200.0;

void _changeSize() {
setState(() {
size = 100.0;
});
}

@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(widget.title),
),
body: Container(
color: Color(0xff0000ff),
child: SizedBox(
width: size,
height: size,
child: AndroidView(
viewType: ‘testView’,
),
),
),
floatingActionButton: new FloatingActionButton(
onPressed: _changeSize,
child: new Icon(Icons.add),
),
);
}
}
在 Android 端也要加上对应的代码,为了更好地看出裁切效果,这里使用 ImageView。
//Android
@Override
public PlatformView create(final Context context, int i, Object o) {
final ImageView imageView = new ImageView(context);
imageView.setLayoutParams(new ViewGroup.LayoutParams(500,500));
imageView.setBackground(context.getResources().getDrawable(R.drawable.idle_fish));
return new PlatformView() {
@Override
public View getView() {
return imageView;
}

@Override
public void dispose() {

}
};
}

首先先看 AndroidView,AndroidView 对应的 RenderObject 是 RenderAndroidView,而一个 RenderObject 的最终大小的确定是存在两种可能,一种是由父节点所指定,还有一种是在父节点指定的范围中根据自身情况确定大小。打开对应的源码,可以看到其中有个很重要的属性 sizedByParent = true,也就是说 AndroidView 的大小是由其父节点所决定的,我们可以使用 Container、SizedBox 等控件控制 AndroidView 的大小。
AndroidView 的绘图数据是 Native 层所提供的,那么当 Native 中渲染的 View 的实际像素大小大于 AndroidView 的大小时,会发生什么呢?通常情况下,这种情况的处理思路无非就两种选择,一种是裁切,另一种是缩放。Flutter 保持了其一贯的做法,所有 out of the bounds 的 Widget 统一使用裁切的方式进行展示,上面所描述的情况就被当作是一种 out of the bounds。
当这个 View 的实际像素大小小于 AndroidView 的时候,会发现 View 并不会相应地变小(Container 的背景色并没有显露出来),没有内容的地方会被白色填充。这其中的原因是 SingleViewPresentation::onCreate 中,会使用一个 FrameLayout 作为 rootView。
2.3. 触摸事件如何传递
Android 的事件流大家应该都很熟悉了,自顶向下传递,自底向上处理或回流。Flutter 同样是使用这一规则,但是其中 AndroidView 通过两个类来去处理手势:
MotionEventsDispatcher:负责将事件封装成 Native 的事件并向 Native 传递;
AndroidViewGestureRecognizer:负责识别出相应的手势,其中有两个属性:

cachedEvents 和 forwardedPointers,只有当 PointerEvent 的 pointer 属性在 forwardedPointers 中时才会去进行分发,否则会存在 cacheEvents 中。这里的实现主要是为了解决一些事件的冲突,比如滑动事件,可以通过 gestureRecognizers 来进行处理,这里可以参考官方注释。
/// For example, with the following setup vertical drags will not be dispatched to the Android view as the vertical drag gesture is claimed by the parent [GestureDetector].
///
/// GestureDetector(
/// onVerticalDragStart: (DragStartDetails d) {},
/// child: AndroidView(
/// viewType: ‘webview’,
/// gestureRecognizers: <OneSequenceGestureRecognizer>[],
/// ),
/// )
///
/// To get the [AndroidView] to claim the vertical drag gestures we can pass a vertical drag gesture recognizer in [gestureRecognizers] e.g:
///
/// GestureDetector(
/// onVerticalDragStart: (DragStartDetails d) {},
/// child: SizedBox(
/// width: 200.0,
/// height: 100.0,
/// child: AndroidView(
/// viewType: ‘webview’,
/// gestureRecognizers: <OneSequenceGestureRecognizer>[new VerticalDragGestureRecognizer() ],
/// ),
/// ),
/// )
所以总结起来,这部分流程总结起来其实也很简单:事件最初从 Native 到 Flutter 这一阶段不在本文的讨论范围之内,Flutter 按照自己的规则去处理事件,如果 AndroidView 赢得了事件,事件就会被封装成相应的 Native 端的事件并且通过方法通道传回 Native,Native 再根据自己的处理事件的规则去处理。
3. 总结
3.1. 方案局限性
往大里说:这套方案是 Google 为了解决开发者日益增长的业务需求与落后的生态环境之间的矛盾而产生的,这一矛盾是一个新生态必然需要去面对的主要矛盾。为了解决这一个问题,最简单的方式当然就是允许开发者使用老生态中已经非常成熟的控件。当然,这样是可以临时解决 Flutter 生态发展不全面的问题,但是使用这套方案不可避免的需要去编写双端代码(甚至现在 iOS 还没有对应的控件,当然之后肯定会更新),不能做到真正的跨端。
往小里说:这套方案存在着性能上的缺陷,在 AndroidView 这个类的第三句注释中,官方就已经提到了这是一套比较昂贵的方案,避免在使用 Flutter 控件也能实现的情况下去使用它。如果之前有看过《Flutter 外接纹理》这一文章的同学应该知道,Flutter 实现外接纹理的方案中,数据从 GPU->CPU->GPU 的过程代价是比较大的,在大量使用的场景会造成明显的性能缺陷。我们通过一些手段绕过了中间 CPU 这一步,并且将这项技术在 APP 中落地,用于处理图片资源。
3.2. 实际应用
目前闲鱼从 Native 向 Flutter 的迁移工作遇到了 Native 的本地图片资源在 Flutter 侧无法访问的问题,在现在 Flutter 和 Native 必将长期共存的情况下,重新拷贝一份资源以 Flutter 的规则来存储当然可以,但是不可避免地增大了包体积,而且不好管理。
面对这个问题,我们的解法便是借鉴了 AndroidView 使用 Texture 的思路并在将其优化。实现了 Native 和 Flutter 的图片资源归一化。除了用于加载位于 Native 资源目录下的本地图片之外,还可以利用 Native 的图片库来加载网络图片。
我们这么去做的原因是我们在 Native 侧的图片库较为完善并且经受过大量的线上考验,现在这一阶段,我们不希望将过多的精力投入到重复造轮子这一件事上,而处理网络图片资源和处理本地图片资源的思路其实是一样的,所以我们选择将图片资源进行了统一地整合,在与官方的团队进行沟通并完善后会和大家同步,敬请关注我们的公众号。
3.4. 引用
高德地图 SDK 文档
万万没想到——Flutter 外接纹理
Android7.1 Presentation 双屏异显原理分析

本文作者:闲鱼技术 - 尘萧阅读原文
本文为云栖社区原创内容,未经允许不得转载。

退出移动版