本文首發於 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。