今天又到了我們Flutter原理篇的內容了,今天給大家講的是Flutter的事件傳播與機制,順便再給大家介紹下傳播里面HitTestBehavior的作用(感覺很多文章對于這個的介紹不是很詳細),好了讓我們開始吧:
老實說網絡上已經有不少文章介紹了Flutter的事件機制了,為什么我還要出一篇來寫呢,主要是一方面我覺得網絡上有些高手寫的并沒有通俗易懂,他們的內容沒有問題,但是語言組織上面可能沒有連貫,導致我在看他們的文章的時候有時候總覺得”跟不上節拍“,覺得他們沒有按照順序娓娓道來,給讀者的閱讀體驗性可能沒有那么好,最重要的是就怕讀者看完了沒看懂里面的重要思想這就不太好了,所以今天由我給大家來一偏通俗易懂的介紹Flutter事件傳播機制的文章,讓你在閱讀的時候幾乎無障礙理解
其實我們從事移動端開發,對于事件傳遞并不陌生,無論是Android還是iOS,還是Web等等都有這一套機制在里面,而且機制都大同小異,對于Flutter事件機制我覺得比較像iOS,因為他連函數名字都叫一樣的,好了回到正題來:
首先我們先拋出個結論就是Flutter的事件傳播機制流程大致分為兩個步驟:
1 命中測試階段,我習慣叫他為hitTest階段
這個階段里面主要是調用hitTest方法去測試一些列可以被響應的RenderObject對象(注意只有RenderObject才會有hitTest方法),然后把這些對象添加進一個隊列里面保存起來,剛剛說到iOS,這里面hitTest方法的名字與iOS是一樣的但是作用卻不太一樣,iOS里面的hitTest方法是去尋找一個最合適響應的對象返回,而Flutter里面卻是所有可以命中測試的對象都保存起來,這個階段的細節我們在下面的代碼再來講解
2 事件傳播階段
當第一個階段完成以后你的隊列里面就有了N個可以命中的RenderObject對象,這個時候進行事件分發dispatch,其實非常簡單就是循環調用命中的RenderObject對象的handleEvent
方法,調用順序是先進先出(大家要記住這里)
好了,原理非常的簡單,讓我去結合代碼看看細節是怎么樣的,首先事件命中測試是在 PointerDownEvent 事件觸發時進行的,一個完成的事件流是 down > move > up (cancle) (這里無論是Android,iOS都是一樣的),首先觸發新事件時,flutter 會調用此方法_handlePointerEventImmediately,如下:
GestureBinding._handlePointerEventImmediately
// 觸發新事件時,flutter 會調用此方法
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent ) {
hitTestResult = HitTestResult();
// 發起命中測試
hitTest(hitTestResult, event.position);
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
//獲取命中測試的結果,然后移除它
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) { // PointerMoveEvent
//直接獲取命中測試的結果
hitTestResult = _hitTests[event.pointer];
}
// 事件分發
if (hitTestResult != null) {
dispatchEvent(event, hitTestResult);
}
}
我們主要關注:hitTest,dispatchEvent兩個函數即可,其中HitTestResult的作用是存儲可以被命中的對象的,在這個方法里面首先發起了命中測試,這個函數的是mixin GestureBinding實現的,由于RendererBinding混合了GestureBinding
/// The glue between the render tree and the Flutter engine.
mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, SemanticsBinding, HitTestable {
}
所以接下來我們看看他的hitTest方法是怎么樣的,如下:
RendererBinding.hitTest
@override
void hitTest(HitTestResult result, Offset position) {
assert(renderView != null);
assert(result != null);
assert(position != null);
renderView.hitTest(result, position: position);
super.hitTest(result, position);
}
這里會調用renderView.hitTest的方法 ,我們知道renderView是整個RenderObject的根,我們看看他的這個方法實現:
RenderView.hitTest
bool hitTest(HitTestResult result, { required Offset position }) {
if (child != null)
child!.hitTest(BoxHitTestResult.wrap(result), position: position);
result.add(HitTestEntry(this));
return true;
}
這里面很清楚了,就是要調用child的hitTest方法,并且把result傳遞了過去,這里我們以RenderProxyBoxWithHitTestBehavior舉例子(因為他很常見,我們平時看到的Container的renderObject:_RenderColoredBox的hitTest會對應到他)
RenderProxyBoxWithHitTestBehavior.hitTest
@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
分兩步驟:
首先調用size.contains 來簡單點擊區域是否在該組件范圍內,為true則進行下一步
再會調用hitTestChildren方法與hitTestSelf方法來作為hitTarget的值,根據這個值來決定是否把當前的RenderObject添加進入result里面(behavior我們晚點再說)
我們以Container為hitTest命中對象舉例的話,最后的hitTestChildren就會是如下的:
RenderBoxContainerDefaultsMixin.hitTestChildren
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
// The x, y parameters have the top left of the node's box as the origin.
ChildType? child = lastChild;
while (child != null) {
final ParentDataType childParentData = child.parentData! as ParentDataType;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset? transformed) {
assert(transformed == position - childParentData.offset);
return child!.hitTest(result, position: transformed!);
},
);
if (isHit)
return true;
child = childParentData.previousSibling;
}
return false;
}
我們可以看到上面代碼的主要邏輯是遍歷調用子組件的 hitTest() 方法,同時會做一個判讀:即遍歷過程中只要有子節點的 hitTest() 返回了 true 時:會終止子節點遍歷,這意味著該子節點前面的兄弟節點將沒有機會通過命中測試,也就沒有機會加入到命中隊列result里面
里面需要注意一個就是這個深度遍歷的過程,首先會找到組件的子組件進行hitTest判斷(子節點沒有Child了,子節點的hitTestChildren一般默認返回為false大家可以這么簡單的理解一下),如果hitTest為false那個繼續尋找他的上一個兄弟結點(因為深度遍歷所以是倒序)
如果子節點 hitTest() 返回了 true 導父節點 hitTestChildren 也會返回 true,最終會導致 父節點的 hitTest 返回 true,父節點被添加到 HitTestResult 中。
當子節點的 hitTest() 返回了 false 時,繼續遍歷該子節點前面的兄弟節點,對它們進行命中測試,如果所有子節點都返回 false 時,則父節點會調用自身的 hitTestSelf 方法,如果該方法也返回 false,則父節點就會被認為沒有通過命中測試。
好了,其實還是很簡單的,說完了命中測試,我們再來說說事件傳播階段
也就是事件分發,說完了分發我們就會舉兩個例子來論證我們的結論
事件的分發非常的簡單,代碼如下:
GestureBinding.dispatchEvent
@pragma('vm:notify-debugger-on-exception')
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
assert(!locked);
// No hit test information implies that this is a [PointerHoverEvent],
// [PointerAddedEvent], or [PointerRemovedEvent]. These events are specially
// routed here; other events will be routed through the `handleEvent` below.
if (hitTestResult == null) {
assert(event is PointerAddedEvent || event is PointerRemovedEvent);
try {
pointerRouter.route(event);
} catch (exception, stack) {
//省略部分代碼
}
return;
}
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} catch (exception, stack) {
//省略部分代碼
}
}
}
最主要的就是for循環hitTestResult里面的存儲的HitTestEntry,而這個hitTestResult就是我們在_handlePointerEventImmediately命中初始階段初始化的那個變量,也就是存儲我們命中對象的對象,再調用這些HitTestEntry的handleEvent分發即可,就是這么簡單
好了,我們以下面的一個例子給大家舉例說明一下整個流程的運行,并且說明我們上面的論證,為什么子組件返回true以后前兄弟結點沒有辦法命中測試
class StackEventTest extends StatelessWidget {
const StackEventTest({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
wChild(1),
wChild(2),
],
);
}
Widget wChild(int index) {
return Listener(
onPointerDown: (e) => print(index),
child: Container(
width: 100,
height: 100,
color: Colors.grey,
),
);
}
}
代碼很簡單,主要是Container的使用而已,我們就來看看Container的命中流程是怎么樣的呢
首先這里的Container對應的renderObject是一個_RenderColoredBox(因為他只有一個Colors屬性最后轉換是他)最后面回到RenderProxyBoxWithHitTestBehavior這個里面的hitTest里面如下:
@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
首先調用size.contains來簡單點擊區域是否在該組件范圍內,然后調用hitTestChildren,因為我們的例子Container沒有child所以下面直接返回false
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
return child?.hitTest(result, position: position) ?? false;
}
所以我們直接看他的hitTestSelf方法,這個方法如下:
@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
其實就是判斷他的behavior屬性到底是不是HitTestBehavior.opaque而已了,我們再看看初始化的時候傳的值就明白了
class _RenderColoredBox extends RenderProxyBoxWithHitTestBehavior {
_RenderColoredBox({ required Color color })
: _color = color,
super(behavior: HitTestBehavior.opaque); //注意這一行
//省略部分代碼
}
這里指定了behavior為HitTestBehavior.opaque,所以hitTestSelf方法返回為true,所以他會被添加進入result命中隊列,而由于上面我們分析的子組件返回為true以后,他的前兄弟組件則不會添加進入命中隊列,所以不會影響點擊的事件,所以打印就只會有最后一個打印也就是2:
2021-12-24 01:04:53.052 6580-6629/com.example.my_app I/flutter: 2
好了,命中的流程現象與我們上面分析的一模一樣,我們結合這個例子再來就看看分發事件執行的流程是什么樣子的,我們先看看Listener底層是怎么樣的,首先他對應的renderObject是RenderPointerListener
class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
/// Creates a render object that forwards pointer events to callbacks.
///
/// The [behavior] argument defaults to [HitTestBehavior.deferToChild].
RenderPointerListener({
this.onPointerDown,
this.onPointerMove,
this.onPointerUp,
this.onPointerHover,
this.onPointerCancel,
this.onPointerSignal,
HitTestBehavior behavior = HitTestBehavior.deferToChild,
RenderBox? child,
}) : super(behavior: behavior, child: child);
而我們的onPointerDown函數直接賦值給他里面的一個屬性,如果Listener可以被命中的話,那么對應的RenderPointerListener對象會被加入到result命中隊列,由于事件分發的流程可知,會調用到命中對象的handleEvent分發,我們再來看看他的handleEvent方法:
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent)
return onPointerDown?.call(event);
if (event is PointerMoveEvent)
return onPointerMove?.call(event);
if (event is PointerUpEvent)
return onPointerUp?.call(event);
if (event is PointerHoverEvent)
return onPointerHover?.call(event);
if (event is PointerCancelEvent)
return onPointerCancel?.call(event);
if (event is PointerSignalEvent)
return onPointerSignal?.call(event);
}
看看,就是這么簡單如果是點擊Down事件的話直接執行onPointerDown方法,也就是我們再Listener中定義的打印函數
好了,到這樣我們已經結合源碼把事件命中,傳遞流程解說了一遍了,下面還剩下一個話題就是HitTestBehavior的介紹,由于很多解釋得不是很清楚,這里我順帶講一下
其實上面的例子我們就見過了HitTestBehavior的使用了,
@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
看一下這個流程,behavior的判斷要在hitTestChildren為false,并且hitTestSelf為false的時候才起真正的作用,其實說白了就是起一個輔助作用在hitTestChildren與hitTestSelf均未命中的情況下,如果你還想這個對象可以被加入命中隊列的話,那么可以初始化的時候給behavior賦上合適的值即可,而hitTestSelf又如下:
@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
我們再看看這個枚舉的幾個值:
/// How to behave during hit tests.
enum HitTestBehavior {
/// Targets that defer to their children receive events within their bounds
/// only if one of their children is hit by the hit test.
deferToChild,
/// Opaque targets can be hit by hit tests, causing them to both receive
/// events within their bounds and prevent targets visually behind them from
/// also receiving events.
opaque,
/// Translucent targets both receive events within their bounds and permit
/// targets visually behind them to also receive events.
translucent,
}
大意就是:
- deferToChild: 命中測試決定于子組件是否通過命中測試
- opaque:顧名思義不透明的,也就是說自己接收命中測試,但是會阻礙前兄弟節點進行命中測試
- translucent:顧名思義半透明,也就是說自己可以命中測試,但是不會阻礙前兄弟節點進行命中測試
其實大家可以不用糾結這個語意記不住也沒有關系,根據代碼分析一下情況便可以知道了,這也是我搞不懂為什么網上很多人在糾結這個翻譯的意思,其實這個枚舉就是用來做判斷使用的,僅此而已
好了,讓我結合這個HitTestBehavior枚舉的解釋最好再來一個例子說明下:
class HitTestBehaviorTest extends StatelessWidget {
const HitTestBehaviorTest({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
wChild(1),
wChild(2),
],
);
}
Widget wChild(int index) {
return Listener(
//behavior: HitTestBehavior.deferToChild, // 運行這一行不會輸出
//behavior: HitTestBehavior.opaque, // 運行這一行點擊只會輸出 2
behavior: HitTestBehavior.translucent, // 運行這一行點擊會同時輸出 2 和 1
onPointerDown: (e) => print(index),
child: SizedBox.expand(),
);
}
}
和上面的例子差不多,但是Listener的child是SizedBox,我們先看看他對應的renderObject是RenderConstrainedBox,而由于RenderConstrainedBox繼承于RenderProxyBox,我們直接看他:
class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> {
/// Creates a proxy render box.
///
/// Proxy render boxes are rarely created directly because they simply proxy
/// the render box protocol to [child]. Instead, consider using one of the
/// subclasses.
RenderProxyBox([RenderBox? child]) {
this.child = child;
}
}
@optionalTypeArgs
mixin RenderProxyBoxMixin<T extends RenderBox> on RenderBox, RenderObjectWithChildMixin<T> {
}
由于他的繼承與混合的特性所以他的hitTestChildren如下:
RenderProxyBoxMixin.hitTestChildren
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
return child?.hitTest(result, position: position) ?? false;
}
由于SizedBox是沒有子類的,所以hitTestChildren返回為false,他的hitTestSelf如下直接返回false:
RenderBox.hitTestSelf
@protected
bool hitTestSelf(Offset position) => false;
我們再來看看Listener的hitTest函數:
RenderProxyBoxWithHitTestBehavior.hitTest
@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent)
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
他的hitTestChildren對應的是SizedBox我們上面分析的兩步結果為false,他的hitTestSelf如下:
@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
結合我們上面分析的,如果傳遞的behavior的值為opaque的時候他自己可以被命中,但是前兄弟節點不會被命中;如果傳遞的是translucent的話那么自己可以命中,而且錢兄弟節點也可以命中,如果傳遞的是deferToChild的話,那么不會有任何的命中,
- 所以我們在Demo中Listener中的behavior傳遞HitTestBehavior.opaque輸出2;
- 傳遞HitTestBehavior.translucent輸出2,1;
- 傳遞HitTestBehavior.deferToChild就不會有任何輸出;
好了,我們已經結合例子又說明了HitTestBehavior的使用及原理,今天的文章要結束啦,如果你喜歡的話記得給我點贊加關注,下一篇我們會接著說一下《GestureDetector手勢的運行以及沖突的原理》,好了就到這里了,你的點贊加關注是我寫作持續的動力,謝謝···