1. 程式人生 > >Flutter的RenderBox使用說明書&原理淺析

Flutter的RenderBox使用說明書&原理淺析

本文基於1.12.13+hotfix.8版本原始碼分析。 [TOC] # 一、RenderBox的用法 ## 1、RenderBox的使用基本流程 在flutter中,我們最常接觸的,莫過於各種各樣的widget了,但是,實際負責渲染的RenderObject是很少接觸的(它們之間的關聯可以看看閒魚的這篇文章:https://www.yuque.com/xytech/flutter/tge705)。而作為一名天天向上的程式設計師,我們自然要去學習一下它的原理,做到知其然且知其所以然。本文會先來看看RenderBox的用法,以此拋磚引玉,便於後面繼續深入flutter的繪製原理。 使用RenderBox進行繪製,我們需要做三件事: ### (1)測量 第一步,我們需要確定檢視大小,並賦值給父類的size屬性。測量有兩種情況,第一種是size由自身決定,第二種是由parent決定。 首先,由自身決定size的情況,需要在performLayout方法中完成測量,通過父類的constraints可得到滿足約束的值: ``` @override void performLayout() { size = Size( constraints.constrainWidth(200), constraints.constrainHeight(200), ); } ``` 第二種情況,size由parent決定,這種情況下檢視大小應該完全通過parent提供的constraints測量,不存在其它因素。這種情況下,只要parent的約束不發生變化,就不會重新測量。 這種情況需要重寫sizedByParent並返回true,然後在performResize中完成測量。 ``` @override void performResize() { size = Size( constraints.constrainWidth(200), constraints.constrainHeight(200), ); } @override bool get sizedByParent => true; ``` 看到這裡,你可能會疑惑了,這兩個方法什麼時候會被呼叫?順序是怎樣的?答案在RenderObject的layout方法中: ``` void layout(Constraints constraints, { bool parentUsesSize = false }) { //計算relayoutBoundary ...... //layout _constraints = constraints; if (sizedByParent) { performResize(); } performLayout(); ...... } } ``` ### (2)繪製 RenderBox的繪製與android原生的view繪製非常相似,同樣是Paint+Canvas的組合,而且api也非常接近,會非常容易上手。 ``` @override void paint(PaintingContext context, Offset offset) { Paint paint = Paint() ..color = _color ..style = PaintingStyle.fill; context.canvas.drawRect( Rect.fromLTRB( 0, 0, size.width, size.height, ), paint); } ``` 這樣是不是就萬事大吉了呢?如果通過上面的程式碼進行繪製,你會發現,不管在外層怎麼設定位置,繪製出來的矩形都是固定在螢幕左上角的!怎麼回事? 這裡就是flutter中繪製與android的最大不同:**在這裡繪製的座標系是全域性座標系,即原點在螢幕左上角,而非檢視左上角。** 細心的同學可能已經發現,paint方法中還有一個offset引數,這就是經過parent的約束後,當前檢視的偏移量,繪製時應該將它考慮進去: ``` @override void paint(PaintingContext context, Offset offset) { Paint paint = Paint() ..color = _color ..style = PaintingStyle.fill; context.canvas.drawRect( Rect.fromLTRB( offset.dx, offset.dy, offset.dx + size.width, offset.dy + size.height, ), paint); } ``` ### (3)更新 在flutter中,是由Widget的配置發生變更而引起的rebuild,而這就是我們要實現的第三步:當檢視屬性發生變更時,標記重新佈局或重新繪製,當螢幕重新整理時就會做相應的重新整理。 這裡涉及到兩個方法:markNeedsLayout、markNeedsPaint。顧名思義,前者標記重佈局,後者標記重繪。 我們需要做的,就是根據屬性的影響範圍,在更新屬性時,呼叫合適的標記方法,例如color變化時呼叫markNeedsPaint,width變化時呼叫markNeedsLayout。另外,**兩者都需要更新的情況下,只調用markNeedsLayout即可,不需要兩個方法都調。** ``` set width(double width) { if (width != _width) { _width = width; markNeedsLayout(); } } set color(Color color) { if (color != _color) { _color = color; markNeedsPaint(); } } ``` ## 2、RenderObjectWidget ### (1)簡介 上面講了一大堆RenderBox的用法,但是,這玩意兒怎麼用到我們熟知的Widget裡面去? 按照正常流程,我們得實現一個Element和一個Widget,然後在Widget中建立Element,在Element中建立和更新RenderObject,另外還得管理一大堆狀態,處理非常繁瑣。所幸flutter為我們封裝了這一套邏輯,即RenderObjectWidget。 相信看到這裡的同學都對StatelessWidget和StatefulWidget不會陌生,但其實,StatelessWidget和StatefulWidget僅負責屬性、生命週期等的管理,在它們的build方法實現中都會建立RenderObjectWidget,通過它來實現與RenderObject的關聯。 舉個栗子,我們經常使用的Image是個StatefulWidget,對應的state的build方法中實際返回了一個RawImage物件,而這個RawImage是繼承自LeafRenderObjectWidget的,這正是RenderObjectWidget的一個子類;再比如Text,它build方法中建立的RichText是繼承自MultiChildRenderObjectWidget,這同樣是RenderObjectWidget的一個子類。 我們再看看RenderObjectWidget頂部的註釋即可明白: ``` RenderObjectWidgets provide the configuration for [RenderObjectElement]s, which wrap [RenderObject]s, which provide the actual rendering of the application. ``` 大概意思就是RenderObject才是實際負責渲染應用的,而RenderObjectWidget提供包裝了RenderObject的配置,方便我們使用。 另外,flutter還分別實現了幾個子類,進一步封裝了RenderObjectWidget,它們分別是LeafRenderObjectWidget、SingleChildRenderObjectWidget、MultiChildRenderObjectWidget。其中,LeafRenderObjectWidget是葉節點,不含子Widget;SingleChildRenderObjectWidget僅有一個child;而MultiChildRenderObjectWidget則是含有children列表。這幾個子類根據child的情況分別建立了對應的Element,所以通過這幾個子類,我們只需要關注RenderObject的建立和更新。 ### (2)用法 以最簡單的LeafRenderObjectWidget為例,我們需要實現createRenderObject、updateRenderObject兩個方法: ``` class CustomRenderWidget extends LeafRenderObjectWidget { CustomRenderWidget({ this.width = 0, this.height = 0, this.color, }); final double width; final double height; final Color color; @override RenderObject createRenderObject(BuildContext context) { return CustomRenderBox(width, height, color); } @override void updateRenderObject(BuildContext context, RenderObject renderObject) { CustomRenderBox renderBox = renderObject as CustomRenderBox; renderBox ..width = width ..height = height ..color = color; } } ``` ## 3、非容器控制元件的hitTest 通過上面的內容,我們已經可以實現自定義控制元件並用到介面開發中,但是距離一個完整的控制元件還差最後一步:命中測試。當用戶使用手勢,flutter會將手勢資訊交由控制元件進行檢查是否命中。 RenderBox中命中測試的方法有仨:hitTest、hitTestSelf、hitTestChildren,其中hitTest預設實現是呼叫另外兩個方法的: ``` bool hitTest(BoxHitTestResult result, { @required Offset position }) { if (_size.contains(position)) { // 從這裡也能看到,當命中children時,不會再進行自身的命中測試 if (hitTestChildren(result, position: position) || hitTestSelf(position)) { result.add(BoxHitTestEntry(this, position)); return true; } } return false; } ``` 所以重寫命中測試方法有兩個方案,一是重寫hitTest,這種方法需要將命中測試的資訊加到BoxHitTestResult中;二是重寫hitTestSelf和hitTestChildren,這種方法就簡單地返回是否命中即可。 非容器型別的控制元件,只需要重寫hitTestSelf,返回true即命中,例如RawImage中: ``` @override bool hitTestSelf(Offset position) => true; ``` # 二、容器型別的RenderBox ## 1、介紹 在繪製篇中,我們已經瞭解到如何使用RenderObjectWidget和RenderBox進行基礎的繪製,在本篇中,我們將繼續學習RenderBox如何管理子物件。首先,我們來看看RenderBox頂部的一段註釋: ``` For render objects with children, there are four possible scenarios: * A single [RenderBox] child. In this scenario, consider inheriting from [RenderProxyBox] (if the render object sizes itself to match the child) or [RenderShiftedBox] (if the child will be smaller than the box and the box will align the child inside itself). * A single child, but it isn't a [RenderBox]. Use the [RenderObjectWithChildMixin] mixin. * A single list of children. Use the [ContainerRenderObjectMixin] mixin. * A more complicated child model. ``` 從上面我們可以瞭解到,帶有子物件的情況有四種: (1)子物件只有一個,並且是RenderBox的子類。如果當前檢視需要根據子物件調整大小,則繼承RenderProxyBox;如果子物件小於當前檢視,且在當前檢視內部對齊,則繼承RenderShiftedBox(想一下Align會好理解一點); (2)子物件只有一個,且非RenderBox子類,這種情況使用RenderObjectWithChildMixin; (3)有多個子物件則使用ContainerRenderObjectMixin; (4)更復雜的情況。 第四種情況是要用非連結串列的children結構時需要考慮的,比如children要用map或list等結構,這種情況需要繼承RenderObject去實現一套繪製協議,我們這裡暫且先不討論。 而前三種情況其實註釋裡的描述不夠明確,其實情況只有兩種,第一是帶有單一的child,第二是帶有一個children列表,上面的第一第二兩種情況其實可以合併為一種,為什麼這麼說呢?看下去吧~ ## 2、單個子物件 ### (1)RenderProxyBox 這種情況其實就是當前容器沒有跟大小相關的屬性,size由子類決定,具體邏輯flutter已經在RenderProxyBoxMixin實現了,我們來看看: ``` void performLayout() { if (child != null) { child.layout(constraints, parentUsesSize: true); size = child.size; } else { performResize(); } } ``` 邏輯非常簡單,如果有child,則直接使用child的size;如果沒有,就走performResize,而這裡並沒有實現performResize,即走RenderBox的預設實現,取約束的最小值: ``` void performResize() { size = constraints.smallest; assert(size.isFinite); } ``` 而繪製方法中,通過PaintingContext的paintChild方法,即可繪製child: ``` @override void paint(PaintingContext context, Offset offset) { if (child != null) context.paintChild(child, offset); } ``` ### (2)RenderShiftedBox 這種情況則與RenderProxyBox相反,即當前容器有跟大小相關的屬性,比如padding。接下來就以非常常見的Padding為例,看看RenderPadding的佈局方法: ``` @override void performLayout() { // 將padding的值按照語言方向解析 _resolve(); assert(_resolvedPadding != null); if (child == null) { // 如果沒有child,就按照垂直、水平方向的padding值計算得出size size = constraints.constrain(Size( _resolvedPadding.left + _resolvedPadding.right, _resolvedPadding.top + _resolvedPadding.bottom, )); return; } // 如果有child,則將當前約束減去padding值以後,再傳給child進行測量 final BoxConstraints innerConstraints = constraints.deflate(_resolvedPadding); child.layout(innerConstraints, parentUsesSize: true); // 測量完畢以後,計算出座標偏移量,提供給child繪製時使用 // parentData是RenderObject的屬性,提供給父佈局使用,用來存取child在父佈局中的一些資訊,包括位置等 final BoxParentData childParentData = child.parentData; childParentData.offset = Offset(_resolvedPadding.left, _resolvedPadding.top); // 最後得出大小是padding加上child的大小 size = constraints.constrain(Size( _resolvedPadding.left + child.size.width + _resolvedPadding.right, _resolvedPadding.top + child.size.height + _resolvedPadding.bottom, )); } ``` 可以看到,這裡有三個關鍵步驟:第一,根據屬性將約束減去需要額外佔用的寬高,然後傳給child進行測量;第二,測量完畢後計算出child需要用到的繪製偏移量;第三,根據屬性和child的size得出總寬高。 另外,RenderShiftedBox的paint方法邏輯與RenderProxyBox稍微有點不同,會對offset進行處理: ``` @override void paint(PaintingContext context, Offset offset) { if (child != null) { final BoxParentData childParentData = child.parentData; context.paintChild(child, childParentData.offset + offset); } } ``` ### (3)RenderObjectWithChildMixin 回到上面的問題,為什麼說RenderBox和非RenderBox的單一子物件是一樣的呢?其實,RenderProxyBox和RenderShiftedBox是專門為RenderBox的子類再封裝了一層便於使用,它們本身還是with了RenderObjectWithChildMixin: ``` class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin