Flutter 的渲染逻辑及和 Native 通信

在这篇文章中,我们主要了解两个部分的内容,一个是 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) 向上传递给父控件。如下图:

image

布局过程如下:

这里我们将父 widget 称为 parent;将子 widget 称为 child

  1. parent 会将某些布局约束传递给 child,这些约束是每个 child 在 layout 阶段必须要遵守的。如同 parent 这样告诉 child :“只要你遵守这些规则,你可以做任何你想做的事”。最常见的就是 parent 会限制 child 的大小,也就是 child 的 maxWidth 或者 maxHeight。

  2. 然后 child 会根据得到的约束生成一个新的约束,并将这个新的约束传递给自己的 child(也就是 child 的 child),这个过程会一直持续到出现没有 child 的 widget 为止。

  3. 之后,child 会根据 parent 传递过来的约束确定自己的布局详情(Layout Details)。如:假设 parent 传递给 child 的最大宽度约束为 500px,child 可能会说:“好吧,那我就用500px”,或者 “我只会用 100px”。这样,child 就确定了自己的布局详情,并将其传递给 parent。

  4. parent 反过来做同样的事情,它根据 child 传递回来的 Layout Details 来确定其自身的 Layout Details,然后将这些 Layout Details 向上层的 parent 传递,直到到达 root widget (根 widget)或者遇到了某些限制。

那我们上面所提到的 约束(Constraints)布局详情(Layout Details) 都是什么呢?这取决于布局协议(Layout protocol)。Flutter 中有两种主要的布局协议:Box ProtocolSliver Protocol,前者可以理解为类似于盒子模型协议,后者则是和滑动布局相关的协议。这里我们以前者为例。

Box Protocol 中,parent 传递给 child 的约束都叫做 BoxConstraints 这些约束决定了每个 child 的 maxWidth 和 maxHeight 以及 minWidth 和 minHeight。如:parent 可能会将如下的 BoxConstraints 传递给 child。

image

上图中,浅绿色的为 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 中 我们常见的 widgetStatefulWidgetStatelessWidgetInheritedWidget 等等。但是这里还有另外一种 widget 称为 RenderObjectWidget,这个 widget 中没有 build() 方法,而是有一个 createRenderObject() 方法,这个方法允许创建一个 RenderObject 并将其添加到 render tree 中。

RenderObject 是渲染过程中非常重要的组件,render tree 中的内容都是 RenderObject,每个 RenderObject 中都有许多用来执行渲染的属性和方法:

  • constraints : 从 parent 传递过来的约束。
  • parentData: 这里面携带的是 parent 渲染 child 的时候所用到的数据。
  • performLayout():此方法用于布局所有的 child。
  • paint():这个方法用于绘制自己或者 child。
  • 等等...

但是,RenderObject 是一个抽象类,他需要被子类继承来进行实际的渲染。RenderObject 的两个非常重要的子类是 RenderBoxRenderSliver 。这两个类是所有实现 Box ProtocolSliver 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(...) 方法创建并返回了一个 RenderObjectRenderStingy 类的实例

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,该类是继承自 RenderBoxRenderShiftedBox 实现了 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 选择一个尺寸,以至于 Stingyparent 知道如何放置它。类似于在 Android 中我们自定义 View 重写 onMeasure(...) 方法的时候需要调用 setMeasuredDimension(...) 一样。

运行效果如下:

image

绿色部分为我们定义的 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)

上述我们自定义 RenderBoxperformLayout() 中做的事情可大概分为如下三个步骤:

  • 使用 child.layout(...) 来布局 child,这里是为 child 根据 parent 传递过来的约束选择一个大小
  • child.parentData.offset , 这是在为 child 如何摆放设置一个偏移量
  • 设置当前 widgetsize

在我们的例子中,Stingychild 是一个 Container,并且 Container 没有 child,因此他会使用 child.layout(...) 中设置的最大约束。通常,每个 widget 都会以不同的方式来处理提供给他的约束。如果我们使用 RaiseButton 替换 Container

Stingy(  
  child: RaisedButton(  
    child: Text('Button'),
    onPressed: (){}
  )  
)

效果如下:

image

可以看到,RaisedButtonwidth 使用了 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 的时候会根据 childid 来布局。

下面我们来使用 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 表示的是当前 widgetparentsize,在我们这个例子中也就表示 Containersize。我们可以看看 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(...) 方法,这个方法接受一个 childIdchildId 是由我们自己规定的,这个方法的作用是判断当前的 childId 是否对应着一个非空的 child

满足 hasChild(...) 之后,接着就是 layoutChild(...) 来布局 child , 这个方法中我们会传递两个参数,一个是 childId,另外一个是 child约束(Constraints),这个方法返回的是当前这个 childSize

布局完成之后,就是如何摆放的问题了,也就是上述代码中的 positionChild(..) 了,此方法接受一个 childId 和 一个当前 child 对应的 Offsetparent 会根据这个 Offset 来放置当前的 child

最后我们重写了 shouldRelayout(...) 方法用于判断重新 Layout 的条件。

完整源码在文章末尾给出。

效果如下:

image

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 中还有其他类型的编解码器BinaryCodecJSONMessageCodec等,他们都有一个共同的父类 MessageCodec。 所以我们也可以根据规则创建自己编解码器。

接下来创建的例子是:FlutterAndroid 发送一条消息,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

image

如果错误,还请指出,谢谢!

源码地址:
flutter_rendering
flutter_android_communicate

参考:
Flutter’s Rendering Engine: A Tutorial — Part 1
Flutter's Rendering Pipeline

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容