Flutter 的渲染邏輯及和 Native 通訊

聲網Agora發表於2019-02-15

本文首發於 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) 向上傳遞給父控制元件。如下圖:

Flutter 的渲染邏輯及和 Native 通訊

佈局過程如下:

這裡我們將父 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。

Flutter 的渲染邏輯及和 Native 通訊

上圖中,淺綠色的為 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,顧名思義,他只能有一個 childcreateRenderObject(...) 方法建立並返回了一個 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(...) 一樣。

執行效果如下:

Flutter 的渲染邏輯及和 Native 通訊

綠色部分為我們定義的 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: (){}
  )  
)
複製程式碼

效果如下:

Flutter 的渲染邏輯及和 Native 通訊

可以看到,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 的條件。

完整原始碼在文章末尾給出。

效果如下:

Flutter 的渲染邏輯及和 Native 通訊

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

Flutter 的渲染邏輯及和 Native 通訊

原始碼地址: flutter_rendering flutter_android_communicate

參考:

Flutter’s Rendering Engine: A Tutorial — Part 1

Flutter's Rendering Pipeline

相關閱讀

構建你的第一個 Flutter 視訊通話應用


推廣:歡迎進入 Github 體驗 Agora Flutter SDK,一個幫助 Flutter 應用實現實時音視訊功能的 plugin。

相關文章