Flutter作為一個UI框架,本身也有自己的事件處理方式,本文主要闡述觸控事件從native傳遞到Flutter後是如何被widget識別以及分發的。至於native系統是如何監聽觸控事件以及傳遞事件到Flutter,感興趣的可以自己去了解下不同的宿主系統處理的方式也是不同的。
事件處理流程
Flutter中對觸控事件的處理大致可以分為以下幾個階段:
- 監聽事件的到來
- 對widget是否能響應事件進行命中測試
- 將事件分發給通過命中測試的widget
後續將觸控事件直接稱為event
監聽事件
event是由native系統通過訊息通道傳遞到Flutter中的,因此Flutter必然會有對應的監聽方法或者回撥,從Flutter啟動流程的原始碼中可以在mixin GestureBinding檢視到下面程式碼:
@override
void initInstances() {
super.initInstances();
_instance = this;
window.onPointerDataPacket = _handlePointerDataPacket;
}
其中window.onPointerDataPacket正是監聽event的回撥,window是 Flutter 連線宿主作業系統的介面,其中包含了當前裝置和系統的一些資訊以及Flutter Engine的一些回撥,下面展示了其部分屬性。其他屬性可以自行檢視官方文件,注意這裡的window不是dart:html標準庫裡window 類。
class Window {
// 當前裝置的DPI,即一個邏輯畫素顯示多少物理畫素,數字越大,顯示效果就越精細保真。
// DPI是裝置螢幕的韌體屬性,如Nexus 6的螢幕DPI為3.5
double get devicePixelRatio => _devicePixelRatio;
// Flutter UI繪製區域的大小
Size get physicalSize => _physicalSize;
// 當前系統預設的語言Locale
Locale get locale;
// 當前系統字型縮放比例。
double get textScaleFactor => _textScaleFactor;
// 當繪製區域大小改變回撥
VoidCallback get onMetricsChanged => _onMetricsChanged;
// Locale發生變化回撥
VoidCallback get onLocaleChanged => _onLocaleChanged;
// 系統字型縮放變化回撥
VoidCallback get onTextScaleFactorChanged => _onTextScaleFactorChanged;
// 繪製前回撥,一般會受顯示器的垂直同步訊號VSync驅動,當螢幕重新整理時就會被呼叫
FrameCallback get onBeginFrame => _onBeginFrame;
// 繪製回撥
VoidCallback get onDrawFrame => _onDrawFrame;
// 點選或指標事件回撥
PointerDataPacketCallback get onPointerDataPacket => _onPointerDataPacket;
// 排程Frame,該方法執行後,onBeginFrame和onDrawFrame將緊接著會在合適時機被呼叫,
// 此方法會直接呼叫Flutter engine的Window_scheduleFrame方法
void scheduleFrame() native 'Window_scheduleFrame';
// 更新應用在GPU上的渲染,此方法會直接呼叫Flutter engine的Window_render方法
void render(Scene scene) native 'Window_render';
// 傳送平臺訊息
void sendPlatformMessage(String name,
ByteData data,
PlatformMessageResponseCallback callback) ;
// 平臺通道訊息處理回撥
PlatformMessageCallback get onPlatformMessage => _onPlatformMessage;
... //其它屬性及回撥
}
現在我們有了event在Flutter端的入口函式 _handlePointerDataPacket,通過這個函式我們可以檢視Flutter接收到event後是如何操作的,比較簡單我們直接看下程式碼。
_handlePointerDataPacket
將event做一次轉換,然後新增到一個佇列中
///_pendingPointerEvents: Queue<PointerEvent>型別的佇列
///locked: 通過標記位來實現的一個鎖
void _handlePointerDataPacket(ui.PointerDataPacket packet) {
// We convert pointer data to logical pixels so that e.g. the touch slop can be
// defined in a device-independent manner.
_pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
if (!locked)
_flushPointerEventQueue();
}
_flushPointerEventQueue
遍歷上面的佇列,locked可以理解為一個簡單的訊號量(鎖),呼叫對應的handlePointerEvent,handlePointerEvent內直接呼叫_handlePointerEventImmediately方法。
void _flushPointerEventQueue() {
assert(!locked);
while (_pendingPointerEvents.isNotEmpty)
handlePointerEvent(_pendingPointerEvents.removeFirst());
}
///handlePointerEvent :預設啥也沒幹就是呼叫了_handlePointerEventImmediately方法
///簡化後的程式碼
void handlePointerEvent(PointerEvent event) {
_handlePointerEventImmediately(event);
}
_handlePointerEventImmediately
核心方法:根據不同事件型別開啟不同的流程,這裡我們只關心PointerDownEvent事件。
可以看到當flutter監聽到PointerDownEvent時,會對指定位置開啟命中測試流程。
Flutter中包含多種事件型別:可以在lib->src->gesture->event.dart中檢視具體資訊
// PointerDownEvent: 手指在螢幕按下是產生的事件
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {//down
assert(!_hitTests.containsKey(event.pointer));
///儲存通過命中測試的widget
hitTestResult = HitTestResult();
///開始命中測試
hitTest(hitTestResult, event.position);
///測試完成後會將通過命中測試的結果存放到一個全域性map物件裡
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
} else if (event is PointerUpEvent || event is PointerCancelEvent) {//cancel
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {//move
hitTestResult = _hitTests[event.pointer];
}
if (hitTestResult != null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
///分發事件
dispatchEvent(event, hitTestResult);
}
}
本階段主要內容:
- 註冊了監聽事件的回撥:_handlePointerDataPacket
- 接收事件後,將轉換後的事件放到一個queue中:_flushPointerEventQueue
- 遍歷queue開始命中測試流程:_handlePointerEventImmediately-> hitTest(hitTestResult, event.position)
命中測試
目的是確定在給定的event的位置上有哪些渲染物件(renderObject),並且在這個過程中會將通過命中測試的物件存放在上文中的HitTestResult物件中。 通過原始碼呼叫流程看下flutter內部是如何進行命中測試的,在這些流程中那些我們是可以控制的。
準備
開始命中測試原始碼分析之前先看下下面的程式碼,這是Flutter入口函式main方法中呼叫runApp初始化的核心方法,這裡WidgetsFlutterBinding 實現了多個mixin,而這些mixin中有多個都實現了hitTest方法,這種情況下離with關鍵字遠的優先執行,所以在 _handlePointerEventImmediately中呼叫的hitTest方法是在RendererBinding中而不是GestureBinding。具體細節可以去了解下dart中with多個mixin且每個mixin中都包含同一個方法時的呼叫關係,簡單說就是會先呼叫最後with的mixin。
class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
static WidgetsBinding ensureInitialized() {
if (WidgetsBinding.instance == null)
WidgetsFlutterBinding();
return WidgetsBinding.instance!;
}
}
RendererBinding. hitTest: 命中測試的開始方法
主要作用是呼叫渲染樹根節點的hitTest方法
@override
void hitTest(HitTestResult result, Offset position) {
assert(renderView != null);
assert(result != null);
assert(position != null);
/// renderView:渲染樹根節點,繼承自RenderObject
renderView.hitTest(result, position: position);
super.hitTest(result, position);
}
RendererBinding.renderView:
渲染樹的根節點
/// The render tree that's attached to the output surface.
RenderView get renderView => _pipelineOwner.rootNode! as RenderView;
/// Sets the given [RenderView] object (which must not be null), and its tree, to
/// be the new render tree to display. The previous tree, if any, is detached.
set renderView(RenderView value) {
assert(value != null);
_pipelineOwner.rootNode = value;
}
RenderView.hitTest
根節點的hitTest方法實現中有兩個注意點:
- 根節點必然會被新增到HitTestResult中,預設通過命中測試
- 從這裡開始下面的呼叫流程就是和child型別相關了
<!---->
- child重寫了hitTest呼叫重寫後的方法
- child沒有重寫則呼叫父類RenderBox的預設實現
bool hitTest(HitTestResult result, { required Offset position }) {
///child是一個 RenderObject 物件
if (child != null)
child!.hitTest(BoxHitTestResult.wrap(result), position: position);
result.add(HitTestEntry(this));
return true;
}
RenderBox.hitTest
預設實現的方法,如果child沒有重寫則會呼叫到此方法,內部主要包含下面兩個方法的呼叫:
- hitTestChildren功能是判斷是否有子節點通過了命中測試,如果有,則會將子元件新增到 HitTestResult 中同時返回 true;如果沒有則直接返回false。該方法中會遞迴呼叫子元件的 hitTest 方法。
- hitTestSelf() 決定自身是否通過命中測試,如果節點需要確保自身一定能響應事件可以重寫此函式並返回true ,相當於“強行宣告”自己通過了命中測試。
/// 移除了斷言後的程式碼
bool hitTest(BoxHitTestResult result, { required Offset position }) {
if (_size!.contains(position)) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
/// RenderBox中預設實現都是返回的false
@protected
bool hitTestSelf(Offset position) => false;
@protected
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) => false;
重寫hitTest:
在這個例子裡,我們自定義一個widget,重寫其hitTest方法,看下呼叫流程。
void main() {
runApp( MyAPP());
}
class MyAPP extends StatelessWidget {
const MyAPP({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
child: DuTestListener(),
);
}
}
class DuTestListener extends SingleChildRenderObjectWidget {
DuTestListener({Key? key, this.onPointerDown, Widget? child})
: super(key: key, child: child);
final PointerDownEventListener? onPointerDown;
@override
RenderObject createRenderObject(BuildContext context) =>
DuTestRenderObject()..onPointerDown = onPointerDown;
@override
void updateRenderObject(
BuildContext context, DuTestRenderObject renderObject) {
renderObject.onPointerDown = onPointerDown;
}
}
class DuTestRenderObject extends RenderProxyBox {
PointerDownEventListener? onPointerDown;
@override
bool hitTestSelf(Offset position) => true; //始終通過命中測試
@override
void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
//事件分發時處理事件
if (event is PointerDownEvent) onPointerDown?.call(event);
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
// TODO: implement hitTest
print('ss');
result.add(BoxHitTestEntry(this, position));
return true;
}
}
點選螢幕(黑色的)展示下面的呼叫棧:
子類重寫HitTest後,在RenderView後,直接呼叫了我們過載的hitTest方法,完全印證了我們上面分析的邏輯
常用widget分析
本節來分析下Flutter中的Center、Column,看下Flutter是如何處理child和children兩種型別的hitTest.
Center
繼承:Center->Align->SingleChildRenderObjectWidget
在Align中重寫createRenderObject 返回RenderPositionedBox類。RenderPositionedBox本身沒有重寫hitTest方法,但在其父類的父類RenderShiftedBox中重寫了hitTestChildren方法
hitTestChildren
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
if (child != null) {
///父元件在傳遞約束到子widget時,會計算一些子widget在父widget中的偏移,這些資料通常存在BoxParentData中
///這裡就使用子widget在父widget中的偏移
final BoxParentData childParentData = child!.parentData! as BoxParentData;
return result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset? transformed) {
assert(transformed == position - childParentData.offset);
///遞迴呼叫child的hitTest方法
///transformed轉換後的位置
return child!.hitTest(result, position: transformed!);
},
);
}
return false;
}
addWithPaintOffset
bool addWithPaintOffset({
required Offset? offset,
required Offset position,
required BoxHitTest hitTest,
}) {
///做一些座標轉換
final Offset transformedPosition = offset == null ? position : position - offset;
if (offset != null) {
pushOffset(-offset);
}
///回撥callBack
final bool isHit = hitTest(this, transformedPosition);
if (offset != null) {
popTransform();
}
return isHit;
}
將上面示例中MyApp中的build換成下面程式碼,在來看下呼叫棧
@override
Widget build(BuildContext context) {
return Container(
child: Center(child: DuTestListener()),
);
}
呼叫棧:
很清晰,因為Center相關父類沒有重寫hitTest方法,所以renderView中直接呼叫基類RenderBox中的hitTest,這個hitTest中又呼叫了被重寫的hitTestChildren,在hitTestChildren中通過遞迴的方式對widget進行命中測試。
Column
繼承:Column->Flex->MultiChildRenderObjectWidget
RenderFlex在Flex中重寫createRenderObject返回RenderFlex,RenderFlex本身沒有重寫hitTest方法,而是重寫了hitTestChildren方法
hitTestChildren
內部直接呼叫了RenderBoxContainerDefaultsMixin.defaultHitTestChildren方法
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
return defaultHitTestChildren(result, position: position);
}
RenderBoxContainerDefaultsMixin.defaultHitTestChildren
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;
}
Center和Colunm一個是包含單個widget,一個包含多個widget,而且都是重寫了hitTestChildren方法來控制命中測試,兩者主要區別就在於Colunm的hitTestChildren使用了while迴圈來遍歷自己的子widget進行命中測試。而且Colunm遍歷順序是先遍歷lastchild,如果lastchild沒有通過命中測試,則會繼續遍歷它的兄弟節點,如果lastchild通過命中測試,這直接return true,其兄弟節點沒有機會進行命中測試,這種遍歷方式也可以叫做深度優先遍歷。
如果需要兄弟節點也可以通過命中測試,可以參考<Flutter實戰> 8.3節的描述,這裡不在展開
將上面事例中MyApp中的build換成下面程式碼,在來看下呼叫棧
@override
Widget build(BuildContext context) {
return Container(
child: Column(
children: [
DuTestListener(),
DuTestListener()
],
)
);
}
呼叫棧
雖然我們包含了兩個DuTestListener,但是最終只會呼叫一次DuTestListener的hitTest方法,就是因為lastChid已經通過命中測試,它的兄弟節點沒有機會進行命中測試了。
流程圖:
命中測試小結:
- 從Render Tree的節點開始向下遍歷子樹
- 遍歷的方式:深度優先遍歷
- 可以通過重寫hitTest、hitTestChildren、hitTestSelf來自定義命中測試相關的操作
- 存在兄弟節點時,從最後一個開始遍歷,任何一個通過命中測試,則終止遍歷,未遍歷的兄弟節點沒有機會在參與。
- 深度優先遍歷的過程會先對子widget進行命中測試,因此子widget會先於父widget新增到BoxHitTestResult中。
- 所有通過命中測試的widget會被新增到BoxHitTestResult內一個陣列中,用於事件分發。
注意:hitTest方法的返回值不會影響是否通過命中測試,只有被新增到BoxHitTestResult中的widget才是通過命中測試的。
事件分發
完成所有節點的命中測試後,程式碼返回到GestureBinding._handlePointerEventImmediately,將通過命中測試的hitTestResult儲存在一個全域性的Map物件 _hitTests裡,key為event.pointer, 而後呼叫 dispatchEvent方法進行事件分發。
GestrueBinding.dispatchEvent
///精簡後的程式碼
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
assert(!locked);
if (hitTestResult == null) {
assert(event is PointerAddedEvent || event is PointerRemovedEvent);
pointerRouter.route(event);
return;
}
for (final HitTestEntry entry in hitTestResult.path) {
entry.target.handleEvent(event.transformed(entry.transform), entry);
}
}
通過原始碼可以看到dispatchEvent函式的的作用就是遍歷通過命中測試的節點,然後呼叫對應的handleEvent方法,子類可以重寫handleEvent方法來監聽事件的分發。
仍然以上面的程式碼為例看下呼叫棧:
和我們想的一致從dispatchEvent方法開始,呼叫我們自定義的widget中的handleEvent。
小結:
- 事件分發沒有終止條件,只要在通過命中測試的點,都會被按照加入順序分發事件
- 子widget的分發先於父widget
總結
本文主要通過原始碼的呼叫流程結合一些簡單的事例來分析flutter中事件的響應原理,這裡討論的只是最基礎的事件處理流程,Flutter在這些基礎流程上封裝了事件監聽、手勢處理以及層疊元件這些更加語義化的widget,感興趣的同學可以自己取看下對應的原始碼。
文/阿寶
關注得物技術,做最潮技術人!