【中软国际】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
机制本文都没考虑,如果本质上能够理解透彻事件的分发机制,在此基础上进行扩展也不是什么难事。但是回归当下,从个人角度去评判,这类理应该由系统提供的机制,毕竟应用层更多的精力应该放在业务的实现,用户界面交互,应用性能方面,而不是把一些框架层机制自己去实现一遍。
- 作者:卢日见
更多原创内容请关注:中软国际 HarmonyOS 技术学院
入门到精通、技巧到案例,系统化分享HarmonyOS开发技术,共建鸿蒙生态,欢迎投稿和订阅,让我们一起携手前行共建鸿蒙生态。
厉害,要是代码有注释就更好了!
真不错
真不错
很受用,收藏了!