Flutter事件響應原始碼分析

得物技術發表於2022-03-23

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,感興趣的同學可以自己取看下對應的原始碼。

文/阿寶
關注得物技術,做最潮技術人!

相關文章