HarmonyOS非侵入式事件分發設計
在鴻蒙的Java UI框架中的交互中,是只存在消費機制,并沒有分發機制。消費事件是從子控件向父控件傳遞,而分發事件是從父控件向子控件傳遞。消費機制雖然可以滿足大部分單一化的場景,但是隨著業務和UI設計的復雜化,僅靠消費機制是無法滿足實際需求的。下面簡單介紹下鴻蒙目前的消費機制流程:
首先自定義一個CustomContainer和CustomChild,然后都增加TouchEventListener的監聽,下面打印出父控件和子控件的onTouchEvent設置不同返回值時候的事件消費日志:
- CustomContainer:true CustomChild:false
- 07-12 10:15:29.785 28923-28923/? W 0006E/seagazer: com.testbug.widget.CustomContainer # init[Line:33]: onTouchEvent: DOWN 1, MOVE 3, UP 2
- 07-12 10:15:33.103 28923-28923/? D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->1
- 07-12 10:15:33.103 28923-28923/? D 0006E/seagazer: com.testbug.widget.CustomContainer$1 # onTouchEvent[Line:37]: ------->1
- 07-12 10:15:33.652 28923-28923/? D 0006E/seagazer: com.testbug.widget.CustomContainer$1 # onTouchEvent[Line:37]: ------->3
- 07-12 10:15:34.344 28923-28923/? D 0006E/seagazer: com.testbug.widget.CustomContainer$1 # onTouchEvent[Line:37]: ------->2
- CustomContainer:true CustomChild:true
- 07-12 10:16:02.501 5438-5438/? D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->1
- 07-12 10:16:03.050 5438-5438/? D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->3
- 07-12 10:16:03.532 5438-5438/? D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->3
- 07-12 10:16:03.970 5438-5438/? D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->2
- CustomContainer:false CustomChild:true
- 07-12 10:16:54.300 5441-5441/ D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->1
- 07-12 10:16:54.555 5441-5441/ D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->3
- 07-12 10:16:54.881 5441-5441/ D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->3
- 07-12 10:16:55.269 5441-5441/ D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->2
- CustomContainer:false CustomChild:false
- 07-12 10:17:29.362 10847-10847/? D 0006E/seagazer: com.testbug.widget.CustomChild$1 # onTouchEvent[Line:33]: ------->1
- 07-12 10:17:29.362 10847-10847/? D 0006E/seagazer: com.testbug.widget.CustomContainer$1 # onTouchEvent[Line:37]: ------->1
因為不存在分發和攔截機制,不論什么情況,down事件永遠是子控件優先觸發,根據子控件是否消費down事件來判斷后續的move,up事件是否傳遞給它。
何為事件分發
這里簡單介紹下事件分發:客戶端的視圖框架一般都是設計成樹結構,視圖樹會有根節點。事件的源頭就是從根節點開始,一般通過深度遍歷傳遞給各個子節點,然后根據各個子節點是否攔截,繼續下發給各個子子節點,以此類推。這就是事件分發模型。事件消費模型則是從子節點開始,根據該子節點是否消費,繼續把事件回溯給父節點或者同級子節點,看其是否消費。分發消費機制可以理解為一種典型的責任鏈的設計模式。
主流的事件分發設計
鴻蒙目前其實也已經存在一些分發的框架,但是多數都是屬于侵入式的設計,需要自定義控件繼承或者實現其接口,再在onTouchEvent代理其事件。這種方式在絕大部分場景的確可以滿足需求,并且如果是從framework層設計,這種方式也是最優的。畢竟都是通過頂層接口或者抽象類對外暴露的方式,說白點就是把所有原生控件完全自主可控化,需要外界繼承或實現其進行統一化的邏輯處理。
何為侵入式以及其缺陷
但是有些開發場景,里面涉及到第三方提供的控件,第三方提供的控件肯定不會去實現我們的頂層接口或抽象類,這種場景就不是太合適了,畢竟我們是從應用層的角度去增加一個分發機制。如果我們按照這種方式去設計,就需要把第三方源碼全部拷貝到我們項目,自行對其進行修改適配我們的規則,暫且稱之為侵入式設計。能夠保證在不需要修改第三方源碼的前提下去實現,稱之為非侵入式設計。
舉個例子,你的項目中用到一個第三方提供的自定義CustomView組件,它并沒有繼承你的頂層接口,但是它把所有事件都消費掉了,因為它自身并不會考慮太多復雜的場景,那假設你需要CustomView插入到一個自定義的滑動列表使用,它都完全消費掉了事件,你的自定義滑動列表還能處理消費事件么?答案是肯定不能的。那有什么辦法可以讓第三方組件不消費事件呢,并且讓其加入我們自定義的攔截機制中呢?可以通過邏輯托管方式。
事件溯源托管
在當前鴻蒙提供的消費機制中,我們要想自定義父控件能夠接受到事件,子控件必須保證不能消費事件。因此,我們必須將子控件的消費邏輯暫時屏蔽(或者onTouchEvent中返回false),這樣,我們就能將所有事件一級級的回溯到頂層父控件:
- private final WeakHashMap<Component, Component.TouchEventListener> observers = new WeakHashMap<>();
- ...
- // 遍歷所有子控件,如果子控件有自己的touch事件處理邏輯,加入緩存列表,并重置子控件的touch監聽
- // 這樣,所有子控件的touch事件處理邏輯都被托管至緩存列表,實際上所有子控件并不消費事件,事件消費回到了頂層控件,也就是我們所說的事件源
- for (int i = 0; i < childCount; i++) {
- Component child = rootComponent.getComponentAt(i);
- Component.TouchEventListener childListener = child.getTouchEventListener();
- if (childListener != null) {
- observers.put(child, childListener);
- child.setTouchEventListener(null);
- }
通過上面的邏輯,我們把所有子控件的事件處理都托管到一個緩存列表,并且重置子控件的事件監聽,這樣一來,事件就會溯源到了我們頂層控件,而一般情況下頂層控件都是屬于布局容器,因此我們就只需要處理好該容器的事件流程:
- private Component touchTarget = null;
- @Override
- public boolean onTouchEvent(Component component, TouchEvent touchEvent) {
- int action = touchEvent.getAction();
- boolean isIntercepted = false;
- // down事件,判斷當前是否需要攔截
- if (action == TouchEvent.PRIMARY_POINT_DOWN) {
- touchTarget = null;
- isIntercepted = interceptTouchEvent(component, touchEvent);
- }
- if (isIntercepted) {
- // 攔截的話,自己處理掉
- return processTouchEvent(component, touchEvent);
- } else {
- if (action == TouchEvent.PRIMARY_POINT_DOWN) {
- // down事件,查找touch目標子控件
- // 當前控件為布局容器時,遍歷子控件查找,符合目標如果需要消費down事件,則后續事件都交給其處理
- if (component instanceof ComponentContainer) {
- ComponentContainer root = (ComponentContainer) component;
- int childCount = root.getChildCount();
- for (int i = childCount - 1; i >= 0; i--) {
- Component child = root.getComponentAt(i);
- if (isTouchInTarget(child, touchEvent)) {
- Component.TouchEventListener listener = observers.get(child);
- if (listener != null) {
- boolean handled = listener.onTouchEvent(child, touchEvent);
- if (handled) {
- touchTarget = child;
- return true;
- }
- }
- }
- }
- } else {
- if (isTouchInTarget(component, touchEvent)) {
- Component.TouchEventListener listener = observers.get(component);
- if (listener != null) {
- boolean handled = listener.onTouchEvent(component, touchEvent);
- if (handled) {
- touchTarget = component;
- return true;
- }
- }
- }
- }
- }
- }
- // 沒有找到touch目標子控件,自己處理
- if (touchTarget == null) {
- return processTouchEvent(component, touchEvent);
- }
- // 如果touchTarget不為null,說明down事件時候已經找到了需要消費的目標控件,直接將其余事件交給它處理
- Component.TouchEventListener listener = observers.get(touchTarget);
- if (listener != null) {
- return listener.onTouchEvent(touchTarget, touchEvent);
- }
- // 上述條件都不符合,自己處理
- return processTouchEvent(component, touchEvent);
- }
這樣一來,自定義控件對事件的監聽回調的onTouchEvent邏輯就被托管了,具體是否會執行該消費邏輯,不再由系統進行處理,而是由我們的ExTouchListener根據布局容器是否攔截,以及子控件是否消費共同進行決策。下面列列舉一個demo,里面有2個自定義控件,一個自定義父布局包裹一個自定義子控件:
<ExTouchListener.java>
- public abstract class ExTouchListener implements Component.TouchEventListener, Component.LayoutRefreshedListener {
- private final WeakHashMap<Component, Component.TouchEventListener> observers = new WeakHashMap<>();
- private final ComponentContainer rootComponent;
- private Component touchTarget = null;
- public ExTouchListener(ComponentContainer root) {
- this.rootComponent = root;
- this.rootComponent.setLayoutRefreshedListener(this);
- }
- @Override
- public void onRefreshed(Component component) {
- int childCount = rootComponent.getChildCount();
- if (childCount != observers.size()) {
- for (int i = 0; i < childCount; i++) {
- Component child = rootComponent.getComponentAt(i);
- Component.TouchEventListener childListener = child.getTouchEventListener();
- if (childListener != null) {
- observers.put(child, childListener);
- child.setTouchEventListener(null);
- }
- }
- }
- }
- @Override
- public boolean onTouchEvent(Component component, TouchEvent touchEvent) {
- int action = touchEvent.getAction();
- boolean isIntercepted = false;
- if (action == TouchEvent.PRIMARY_POINT_DOWN) {
- touchTarget = null;
- isIntercepted = interceptTouchEvent(component, touchEvent);
- }
- if (isIntercepted) {
- // intercepted
- return processTouchEvent(component, touchEvent);
- } else {
- if (action == TouchEvent.PRIMARY_POINT_DOWN) {
- // down, find touch target
- if (component instanceof ComponentContainer) {
- ComponentContainer root = (ComponentContainer) component;
- int childCount = root.getChildCount();
- for (int i = childCount - 1; i >= 0; i--) {
- Component child = root.getComponentAt(i);
- if (isTouchInTarget(child, touchEvent)) {
- Component.TouchEventListener listener = observers.get(child);
- if (listener != null) {
- boolean handled = listener.onTouchEvent(child, touchEvent);
- if (handled) {
- touchTarget = child;
- return true;
- }
- }
- }
- }
- } else {
- if (isTouchInTarget(component, touchEvent)) {
- Component.TouchEventListener listener = observers.get(component);
- if (listener != null) {
- boolean handled = listener.onTouchEvent(component, touchEvent);
- if (handled) {
- touchTarget = component;
- return true;
- }
- }
- }
- }
- }
- }
- // not find touch target, handle self
- if (touchTarget == null) {
- return processTouchEvent(component, touchEvent);
- }
- // move, up ...
- Component.TouchEventListener listener = observers.get(touchTarget);
- if (listener != null) {
- return listener.onTouchEvent(touchTarget, touchEvent);
- }
- return processTouchEvent(component, touchEvent);
- }
- public abstract boolean interceptTouchEvent(Component component, TouchEvent touchEvent);
- public abstract boolean processTouchEvent(Component component, TouchEvent touchEvent);
- private boolean isTouchInTarget(Component target, TouchEvent touchEvent) {
- MmiPoint pointer = touchEvent.getPointerScreenPosition(touchEvent.getIndex());
- float touchX = pointer.getX();
- float touchY = pointer.getY();
- int[] location = target.getLocationOnScreen();
- int targetX = location[0];
- int targetY = location[1];
- int targetWidth = target.getWidth();
- int targetHeight = target.getHeight();
- boolean result = touchX >= targetX && touchX <= targetX + targetWidth && touchY >= targetY && touchY <= targetY + targetHeight;
- return result;
- }
- }
<CustomContainer.java>
- public class CustomContainer extends DirectionalLayout {
- public CustomContainer(Context context, AttrSet attrSet) {
- super(context, attrSet);
- setTouchEventListener(new ExTouchListener(this) {
- @Override
- public boolean interceptTouchEvent(Component component, TouchEvent touchEvent) {
- return false;
- }
- @Override
- public boolean processTouchEvent(Component component, TouchEvent touchEvent) {
- switch (touchEvent.getAction()) {
- case TouchEvent.PRIMARY_POINT_DOWN:
- Logger2.w("--->down");
- return true;
- case TouchEvent.POINT_MOVE:
- Logger2.w("--->move");
- return true;
- case TouchEvent.PRIMARY_POINT_UP:
- Logger2.w("--->up");
- return true;
- }
- return true;
- }
- });
- }
- }
<CustomComponent.java>
- public class CustomComponent extends Text {
- public CustomComponent(Context context, AttrSet attrSet) {
- super(context, attrSet);
- setTouchEventListener(new TouchEventListener() {
- @Override
- public boolean onTouchEvent(Component component, TouchEvent touchEvent) {
- switch (touchEvent.getAction()) {
- case TouchEvent.PRIMARY_POINT_DOWN:
- Logger2.e( "--->down");
- return true;
- case TouchEvent.POINT_MOVE:
- Logger2.e( "--->move");
- return true;
- case TouchEvent.PRIMARY_POINT_UP:
- Logger2.e( "--->up");
- return true;
- }
- return false;
- }
- });
- }
- }
下面看下3種常見場景的處理打印日志(上面已經貼出全部源碼,可以復制進自己的項目運行):
- // 父控件不攔截,子控件down事件不消費,父控件的processTouchEvent進行處理
- CustomContainer interceptTouchEvent:false CustomComponent onTouchEvent down:false
- 08-02 14:42:53.754 17396-17396/? E 0006E/seagazer: com.example.touch.CustomComponent$1 # onTouchEvent[Line:35]: --->down
- 08-02 14:42:53.754 17396-17396/? D 0006E/seagazer: com.example.touch.CustomContainer$1 # processTouchEvent[Line:42]: --->down
- 08-02 14:42:53.824 17396-17396/? D 0006E/seagazer: com.example.touch.CustomContainer$1 # processTouchEvent[Line:48]: --->up
- // 父控件不攔截,子控件down事件消費,子控件onTouchEvent處理
- CustomContainer interceptTouchEvent:false CustomComponent onTouchEvent down:true
- 08-02 14:43:29.132 17661-17661/com.example.touch E 0006E/seagazer: com.example.touch.CustomComponent$1 # onTouchEvent[Line:35]: --->down
- 08-02 14:43:29.218 17661-17661/com.example.touch E 0006E/seagazer: com.example.touch.CustomComponent$1 # onTouchEvent[Line:41]: --->up
- // 父控件攔截,父控件的processTouchEvent進行處理
- 08-02 14:42:13.409 13918-13918/? W 0006E/seagazer: com.example.touch.CustomContainer$1 # processTouchEvent[Line:41]: --->down
- 08-02 14:42:13.533 13918-13918/? W 0006E/seagazer: com.example.touch.CustomContainer$1 # processTouchEvent[Line:47]: --->up
結語
通過上面的事件托管、事件溯源再傳遞,就已經能夠實現簡單的分發攔截機制,并且兼容第三方庫的控件。當然,這里主要是提供一種設計的簡化模型,包括disptach機制,touchTarget的復用,nestScroll機制本文都沒考慮,如果本質上能夠理解透徹事件的分發機制,在此基礎上進行擴展也不是什么難事。但是回歸當下,從個人角度去評判,這類理應該由系統提供的機制,畢竟應用層更多的精力應該放在業務的實現,用戶界面交互,應用性能方面,而不是把一些框架層機制自己去實現一遍。