本文首发于 RTC 开发者社区,作者刘斯龙, 5年的 Android 程序员,从事过 AR ,Unity3D,Weex,Cordova,Flutter 及小程序开发
作者 github: github.com/liusilong
作者 blog:liusilong.github.io/
作者 StackOverflow:stackoverflow.com/users/47233…
作者掘金博客:juejin.im/user/58eb94…
在这篇文章中,我们主要了解两个部分的内容,一个是 Flutter 的基本渲染逻辑 另一个是 Flutter 和 Native 互通的方法,这里的 Native 是以 Android 为例。然后使用案例分别进行演示。
Flutter 渲染
在 Android 中,我们所说的 View
的渲染逻辑指的是 onMeasure()
, onLayout()
, onDraw()
, 我们只要重写这三个方法就可以自定义出符合我们需求的 View
。其实,即使我们不懂 Android 中 View 的渲染逻辑,也能写出大部分的 App,但是当系统提供的 View 满足不了我们的需求的时候,这时就需要我们自定义 View 了,而自定义 View 的前提就是要知道 View 的渲染逻辑。
Flutter 中也一样,系统提供的 Widget 可以满足我们大部分的需求,但是在一些情况下我们还是得渲染自己的 Widget。
和 Android 类似,Flutter 中的渲染也会经历几个必要的阶段,如下:
- Layout : 布局阶段,Flutter 会确定每一个子 Widget 的大小和他们在屏幕中将要被放置的位置。
- Paint : 绘制阶段,Flutter 为每个子 Widget 提供一个 canvas,并让他们绘制自己。
- Composite : 组合阶段,Flutter 会将所有的 Widget 组合在一起,并交由 GPU 处理。
上面三个阶段中,比较重要的就是 Layout 阶段了,因为一切都始于布局。
在 Flutter 中,布局阶段会做两个事情:父控件将 约束(Constraints) 向下传递到子控件;子控件将自己的 布局详情(Layout Details) 向上传递给父控件。如下图:
布局过程如下:
这里我们将父 widget 称为 parent;将子 widget 称为 child
-
parent 会将某些布局约束传递给 child,这些约束是每个 child 在 layout 阶段必须要遵守的。如同 parent 这样告诉 child :“只要你遵守这些规则,你可以做任何你想做的事”。最常见的就是 parent 会限制 child 的大小,也就是 child 的 maxWidth 或者 maxHeight。
-
然后 child 会根据得到的约束生成一个新的约束,并将这个新的约束传递给自己的 child(也就是 child 的 child),这个过程会一直持续到出现没有 child 的 widget 为止。
-
之后,child 会根据 parent 传递过来的约束确定自己的布局详情(Layout Details)。如:假设 parent 传递给 child 的最大宽度约束为 500px,child 可能会说:“好吧,那我就用500px”,或者 “我只会用 100px”。这样,child 就确定了自己的布局详情,并将其传递给 parent。
-
parent 反过来做同样的事情,它根据 child 传递回来的 Layout Details 来确定其自身的 Layout Details,然后将这些 Layout Details 向上层的 parent 传递,直到到达 root widget (根 widget)或者遇到了某些限制。
那我们上面所提到的 约束(Constraints) 和 布局详情(Layout Details) 都是什么呢?这取决于布局协议(Layout protocol)。Flutter 中有两种主要的布局协议:Box Protocol 和 Sliver Protocol,前者可以理解为类似于盒子模型协议,后者则是和滑动布局相关的协议。这里我们以前者为例。
在 Box Protocol 中,parent 传递给 child 的约束都叫做 BoxConstraints 这些约束决定了每个 child 的 maxWidth 和 maxHeight 以及 minWidth 和 minHeight。如:parent 可能会将如下的 BoxConstraints 传递给 child。
上图中,浅绿色的为 parent,浅红色的小矩形为 child。 那么,parent 传递给 child 的约束就是 150 ≤ width ≤ 300, 100 ≤ height ≤ 无限大
而 child 回传给 parent 的布局详情就是 child 的尺寸(Size)。
有了 child 的 Layout Details ,parent 就可以绘制它们了。
在我们渲染自己的 widget 之前,先来了解下另外一个东西 Render Tree。
Render Tree
我们在 Android
中会有 View tree,Flutter 中与之对应的为 Widget tree,但是 Flutter 中还有另外一种 tree,称为 Render tree。
在 Flutter 中 我们常见的 widget 有 StatefulWidget
,StatelessWidget
,InheritedWidget
等等。但是这里还有另外一种 widget 称为 RenderObjectWidget
,这个 widget 中没有 build()
方法,而是有一个 createRenderObject()
方法,这个方法允许创建一个 RenderObject
并将其添加到 render tree 中。
RenderObject 是渲染过程中非常重要的组件,render tree 中的内容都是 RenderObject,每个 RenderObject 中都有许多用来执行渲染的属性和方法:
- constraints : 从 parent 传递过来的约束。
- parentData: 这里面携带的是 parent 渲染 child 的时候所用到的数据。
- performLayout():此方法用于布局所有的 child。
- paint():这个方法用于绘制自己或者 child。
- 等等...
但是,RenderObject 是一个抽象类,他需要被子类继承来进行实际的渲染。RenderObject 的两个非常重要的子类是 RenderBox 和 RenderSliver 。这两个类是所有实现 Box Protocol 和 Sliver Protocol 的渲染对象的父类。而且这两个类还扩展了数十个和其他几个处理特定场景的类,并且实现了渲染过程的细节。
现在我们开始渲染自己的 widget,也就是创建一个 RenderObject。这个 widget 需要满足下面两点要求:
- 它只会给 child 最小的宽和高
- 它会把它的 child 放在自己的右下角
如此 “小气” 的 widget ,我们就叫他 Stingy 吧!Stingy 所属的树形结构如下:
MaterialApp
|_Scaffold
|_Container // Stingy 的 parent
|_Stingy // 自定义的 RenderObject
|_Container // Stingy 的 child
复制代码
代码如下:
void main() {
runApp(MaterialApp(
home: Scaffold(
body: Container(
color: Colors.greenAccent,
constraints: BoxConstraints(
maxWidth: double.infinity,
minWidth: 100.0,
maxHeight: 300,
minHeight: 100.0),
child: Stingy(
child: Container(
color: Colors.red,
),
),
),
),
));
}
复制代码
Stingy
class Stingy extends SingleChildRenderObjectWidget {
Stingy({Widget child}) : super(child: child);
@override
RenderObject createRenderObject(BuildContext context) {
// TODO: implement createRenderObject
return RenderStingy();
}
}
复制代码
Stingy
继承了 SingleChildRenderObjectWidget
,顾名思义,他只能有一个 child 而 createRenderObject(...)
方法创建并返回了一个 RenderObject
为 RenderStingy
类的实例
RenderStingy
class RenderStingy extends RenderShiftedBox {
RenderStingy() : super(null);
// 绘制方法
@override
void paint(PaintingContext context, Offset offset) {
// TODO: implement paint
super.paint(context, offset);
}
// 布局方法
@override
void performLayout() {
// 布局 child 确定 child 的 size
child.layout(
BoxConstraints(
minHeight: 0.0,
maxHeight: constraints.minHeight,
minWidth: 0.0,
maxWidth: constraints.minWidth),
parentUsesSize: true);
print('constraints: $constraints');
// child 的 Offset
final BoxParentData childParentData = child.parentData;
childParentData.offset = Offset(constraints.maxWidth - child.size.width,
constraints.maxHeight - child.size.height);
print('childParentData: $childParentData');
// 确定自己(Stingy)的大小 类似于 Android View 的 setMeasuredDimension(...)
size = Size(constraints.maxWidth, constraints.maxHeight);
print('size: $size');
}
}
复制代码
RenderStingy
继承自 RenderShiftedBox
,该类是继承自 RenderBox
。RenderShiftedBox
实现了 Box Protocol 所有的细节,并且提供了 performLayout()
方法的实现。我们需要在 performLayout()
方法中布局我们的 child,还可以设置他们的偏移量。
我们在使用 child.layout(...)
方法布局 child 的时候传递了两个参数,第一个为 child 的布局约束,而另外一个参数是 parentUserSize
, 该参数如果设置为 false
,则意味着 parent 不关心 child 选择的大小,这对布局优化比较有用;因为如果 child 改变了自己的大小,parent 就不必重新 layout
了。但是在我们的例子中,我们的需要把 child 放置在 parent 的右下角,这意味着如果 child 的大小(Size)一旦改变,则其对应的偏移量(Offset) 也会改变,这就意味着 parent 需要重新布局,所以我们这里传递了一个 true
。
当 child.layout(...)
完成了以后,child 就确定了自己的 Layout Details。然后我们就还可以为其设置偏移量来将它放置到我们想放的位置。在我们的例子中为 右下角。
最后,和 child 根据 parent 传递过来的约束选择了一个尺寸一样,我们也需要为 Stingy 选择一个尺寸,以至于 Stingy 的 parent 知道如何放置它。类似于在 Android 中我们自定义 View
重写 onMeasure(...)
方法的时候需要调用 setMeasuredDimension(...)
一样。
运行效果如下:
绿色部分为我们定义的 Stingy,红色小方块为 Stingy 的 child ,这里是一个 Container
代码中的输入如下 (iphone 6 尺寸):
flutter: constraints: BoxConstraints(100.0<=w<=375.0, 100.0<=h<=300.0)
flutter: childParentData: offset=Offset(275.0, 200.0)
flutter: size: Size(375.0, 300.0)
复制代码
上述我们自定义 RenderBox
的 performLayout()
中做的事情可大概分为如下三个步骤:
- 使用
child.layout(...)
来布局 child,这里是为 child 根据 parent 传递过来的约束选择一个大小 child.parentData.offset
, 这是在为 child 如何摆放设置一个偏移量- 设置当前 widget 的
size
在我们的例子中,Stingy 的 child 是一个 Container
,并且 Container
没有 child,因此他会使用 child.layout(...)
中设置的最大约束。通常,每个 widget 都会以不同的方式来处理提供给他的约束。如果我们使用 RaiseButton
替换 Container
:
Stingy(
child: RaisedButton(
child: Text('Button'),
onPressed: (){}
)
)
复制代码
效果如下:
可以看到,RaisedButton
的 width 使用了 parent 给他传递的约束值 100,但是高度很明显没有 100,RaisedButton
的高度默认为 48 ,由此可见 RaisedButton
内部对 parent 传递过来的约束做了一些处理。
我们上面的 Stingy 继承的是 SingleChildRenderObjectWidget
,也就是只能有一个 child。那如果有多个 child 怎么办,不用担心,这里还有一个 MultiChildRenderObjectWidget
,而这个类有一个子类叫做 CustomMultiChildLayout
,我们直接用这个子类就好。
先来看看 CustomMultiChildLayout
的构造方法如下:
/// The [delegate] argument must not be null.
CustomMultiChildLayout({
Key key,
@required this.delegate,
List<Widget> children = const <Widget>[],
})
复制代码
- key:widget 的一个标记,可以起到标识符的作用
- delegate:这个特别重要,注释上明确指出这个参数一定不能为空,我们在下会说
- children:这个就很好理解了,他是一个 widget 数组,也就是我们们需要渲染的 widget
上面的 delegate
参数类型如下:
/// The delegate that controls the layout of the children.
final MultiChildLayoutDelegate delegate;
复制代码
可以看出 delegate
的类型为 MultiChildLayoutDelegate
,并且注释也说明了它的作用:控制 children 的布局。也就是说,我们的 CustomMultiChildLayout
里面要怎么布局,完全取决于我们自定义的 MultiChildLayoutDelegate
里面的实现。所以 MultiChildLayoutDelegate
中也会有类似的 performLayout(..)
方法。
另外,CustomMultiChildLayout
中的每个 child 必须使用 LayoutId
包裹,注释如下:
/// Each child must be wrapped in a [LayoutId] widget to identify the widget for
/// the delegate.
复制代码
LayoutId 的构造方法如下:
/// Marks a child with a layout identifier.
/// Both the child and the id arguments must not be null.
LayoutId({
Key key,
@required this.id,
@required Widget child
})
复制代码
注释的大概意思说的是:使用一个布局标识来标识一个 child;参数 child
和 参数 id
不定不能为空。 我们在布局 child 的时候会根据 child 的 id
来布局。
下面我们来使用 CustomMultiChildLayout
实现一个用于展示热门标签的效果:
Container(
child: CustomMultiChildLayout(
delegate: _LabelDelegate(itemCount: items.length, childId: childId),
children: items,
),
)
复制代码
我们的 _LabelDelegate
里面接受两个参数,一个为 itemCount
,还有是 childId
。
_LabelDelegate
代码如下:
class _LabelDelegate extends MultiChildLayoutDelegate {
final int itemCount;
final String childId;
// x 方向上的偏移量
double dx = 0.0;
// y 方向上的偏移量
double dy = 0.0;
_LabelDelegate({@required this.itemCount, @required this.childId});
@override
void performLayout(Size size) {
// 获取父控件的 width
double parentWidth = size.width;
for (int i = 0; i < itemCount; i++) {
// 获取子控件的 id
String id = '${this.childId}$i';
// 验证该 childId 是否对应一个 非空的 child
if (hasChild(id)) {
// layout child 并获取该 child 的 size
Size childSize = layoutChild(id, BoxConstraints.loose(size));
// 换行条件判断
if (parentWidth - dx < childSize.width) {
dx = 0;
dy += childSize.height;
}
// 根据 Offset 来放置 child
positionChild(id, Offset(dx, dy));
dx += childSize.width;
}
}
}
/// 该方法用来判断重新 layout 的条件
@override
bool shouldRelayout(_LabelDelegate oldDelegate) {
return oldDelegate.itemCount != this.itemCount;
}
}
复制代码
在 _LabelDelegate
中,重写了 performLayout(...)
方法。方法中有一个参数 size
,这个 size
表示的是当前 widget 的 parent 的 size
,在我们这个例子中也就表示 Container
的 size
。我们可以看看 performLayout(...)
方法的注释:
/// Override this method to lay out and position all children given this
/// widget's size.
///
/// This method must call [layoutChild] for each child. It should also specify
/// the final position of each child with [positionChild].
void performLayout(Size size);
复制代码
还有一个是 hasChild(...)
方法,这个方法接受一个 childId,childId 是由我们自己规定的,这个方法的作用是判断当前的 childId 是否对应着一个非空的 child。
满足 hasChild(...)
之后,接着就是 layoutChild(...)
来布局 child , 这个方法中我们会传递两个参数,一个是 childId,另外一个是 child 的约束(Constraints),这个方法返回的是当前这个 child 的 Size。
布局完成之后,就是如何摆放的问题了,也就是上述代码中的 positionChild(..)
了,此方法接受一个 childId
和 一个当前 child 对应的 Offset
,parent 会根据这个 Offset
来放置当前的 child。
最后我们重写了 shouldRelayout(...)
方法用于判断重新 Layout 的条件。
完整源码在文章末尾给出。
效果如下:
Flutter 和 Native 的交互
我们这里说的 Native 指的是 Android 平台。
那既然要相互通信,就需要将 Flutter 集成到 Android 工程中来,不清楚的如何集成可以看看这里
这里有一点需要注意,就是我们在 Android 代码中需要初始化 Dart VM,不然我们在使用 getFlutterView()
来获取一个 Flutter View 的时候会抛出如下异常:
Caused by: java.lang.IllegalStateException: ensureInitializationComplete must be called after startInitialization
at io.flutter.view.FlutterMain.ensureInitializationComplete(FlutterMain.java:178)
...
复制代码
我们有两种方式来执行初始化操作:一个是直接让我们的 Application
继承 FlutterApplication
,另外一个是需要我们在我们自己的 Application
中手动初始化:
方法一:
public class App extends FlutterApplication {
}
复制代码
方法二:
public class App extends Application {
@Override
public void onCreate() {
super.onCreate();
// 初始化 Flutter
Flutter.startInitialization(this);
}
}
复制代码
其实方法一中的 FlutterApplication
中在其 onCreate()
方法中干了同样的事情,部分代码如下:
public class FlutterApplication extends Application {
...
@CallSuper
public void onCreate() {
super.onCreate();
FlutterMain.startInitialization(this);
}
...
}
复制代码
如果我们的 App 只是需要使用 Flutter 在屏幕上绘制 UI,那么没问题, Flutter 框架能够独立完成这些事情。但是在实际的开发中,难免会需要调用 Native 的功能,如:定位,相机,电池等等。这个时候就需要 Flutter 和 Native 通信了。
官网上有一个案例 是使用 MethodChannel来调用给本地的方法获取手机电量。
其实我们还可以使用另外一个类进行通信,叫做 BasicMessageChannel,先来看看它如果创建:
// java
basicMessageChannel = new BasicMessageChannel<String>(getFlutterView(), "foo", StringCodec.INSTANCE);
复制代码
BasicMessageChannel
需要三个参数,第一个是 BinaryMessenger;第二个是通道名称,第三个是交互数据类型的编解码器,我们接下来的例子中的交互数据类型为 String
,所以这里传递的是 StringCodec.INSTANCE
,Flutter 中还有其他类型的编解码器BinaryCodec
,JSONMessageCodec
等,他们都有一个共同的父类 MessageCodec
。 所以我们也可以根据规则创建自己编解码器。
接下来创建的例子是:Flutter
给 Android
发送一条消息,Android
收到消息之后给 Flutter
回复一条消息,反之亦然。
先来看看 Android
端的部分代码:
// 接收 Flutter 发送的消息
basicMessageChannel.setMessageHandler(new BasicMessageChannel.MessageHandler<String>() {
@Override
public void onMessage(final String s, final BasicMessageChannel.Reply<String> reply) {
// 接收到的消息
linearMessageContainer.addView(buildMessage(s, true));
scrollToBottom();
// 延迟 500ms 回复
flutterContainer.postDelayed(new Runnable() {
@Override
public void run() {
// 回复 Flutter
String replyMsg = "Android : " + new Random().nextInt(100);
linearMessageContainer.addView(buildMessage(replyMsg, false));
scrollToBottom();
// 回复
reply.reply(replyMsg);
}
}, 500);
}
});
// ----------------------------------------------
// 向 Flutter 发送消息
basicMessageChannel.send(message, new BasicMessageChannel.Reply<String>() {
@Override
public void reply(final String s) {
linearMessageContainer.postDelayed(new Runnable() {
@Override
public void run() {
// Flutter 的回复
linearMessageContainer.addView(buildMessage(s, true));
scrollToBottom();
}
}, 500);
}
});
复制代码
类似的,Flutter
这边的部分代码如下:
// 消息通道
static const BasicMessageChannel<String> channel =
BasicMessageChannel<String>('foo', StringCodec());
// ----------------------------------------------
// 接收 Android 发送过来的消息,并且回复
channel.setMessageHandler((String message) async {
String replyMessage = 'Flutter: ${Random().nextInt(100)}';
setState(() {
// 收到的android 端的消息
_messageWidgets.add(_buildMessageWidget(message, true));
_scrollToBottom();
});
Future.delayed(const Duration(milliseconds: 500), () {
setState(() {
// 回复给 android 端的消息
_messageWidgets.add(_buildMessageWidget(replyMessage, false));
_scrollToBottom();
});
});
// 回复
return replyMessage;
});
// ----------------------------------------------
// 向 Android 发送消息
void _sendMessageToAndroid(String message) {
setState(() {
_messageWidgets.add(_buildMessageWidget(message, false));
_scrollToBottom();
});
// 向 Android 端发送发送消息并处理 Android 端给的回复
channel.send(message).then((value) {
setState(() {
_messageWidgets.add(_buildMessageWidget(value, true));
_scrollToBottom();
});
});
}
复制代码
最后的效果如下:
屏幕的上半部分为 Android,下半部分为 Flutter
源码地址: flutter_rendering flutter_android_communicate
参考:
Flutter’s Rendering Engine: A Tutorial — Part 1
相关阅读
推广:欢迎进入 Github 体验 Agora Flutter SDK,一个帮助 Flutter 应用实现实时音视频功能的 plugin。