本文为稀土技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前文摘要

从之前的三篇文章中,我们打通了Componet到Element以及RenderNode之间的关系,通过学习这些知识,相信读者能够学习任意一个组件的内部知识。

今天的内容是命中测试,通过学习本篇,我们将明白ArkUI中是如何进行事件的分发,同时我们将学习一个基础类ClickRecognizer,它是engine中点击事件非常重要的基础类。

TouchTest

TouchTest是RenderNode中非常重要的方法,我们前面提到过,RenderNode是最终负责提交给pipeline形成Layer的最后一块单元,它不仅包含着渲染逻辑的驱动,也包含着点击事件的识别。

bool RenderNode::TouchTest(const Point& globalPoint, const Point& parentLocalPoint, const TouchRestrict& touchRestrict,
    TouchTestResult& result)
{
    if (disableTouchEvent_ || disabled_) {
        return false;
    }
    Point transformPoint = GetTransformPoint(parentLocalPoint);
    if (!InTouchRectList(transformPoint, GetTouchRectList())) {
        return false;
    }
    构造本地坐标
    const auto localPoint = transformPoint - GetPaintRect().GetOffset();
    bool dispatchSuccess = DispatchTouchTestToChildren(localPoint, globalPoint, touchRestrict, result);
    auto beforeSize = result.size();
    std::vector<Rect> vrect;
    if (IsResponseRegion()) {
        vrect = responseRegionList_;
    }
    vrect.emplace_back(paintRect_);
    for (const auto& rect : vrect) {
        if (touchable_ && rect.IsInRegion(transformPoint)) {
            // Calculates the coordinate offset in this node.
            globalPoint_ = globalPoint;
            const auto coordinateOffset = globalPoint - localPoint;
            coordinatePoint_ = Point(coordinateOffset.GetX(), coordinateOffset.GetY());
            // OnTouchTestHit 这个方法很重要
            OnTouchTestHit(coordinateOffset, touchRestrict, result);
            break;
        }
    }
    auto endSize = result.size();
    return dispatchSuccess || (beforeSize != endSize && IsNotSiblingAddRecognizerToResult());
}

当点击触发时,window会负责把点击事件进行分发,这个时候每一个RenderNode需要做的事情如下:

  1. 判断自己本身是否能够响应点击事件

  2. 分发点击事件给子RenderNode

  3. 判断自身是否需要响应(即加入TouchTestResult)

本身是否能够响应点击事件

RenderNode判断自身能不能响应点击,其实就是上面这两步

if (disableTouchEvent_ || disabled_) {
        return false;
    }
    Point transformPoint = GetTransformPoint(parentLocalPoint);
    if (!InTouchRectList(transformPoint, GetTouchRectList())) {
        return false;
    }

如果自身被设置为不可点击状态,那么自然就不能进行事件的响应,同时还要根据父RenderNode的坐标计算相对于父RenderNode的坐标,来判断自身的可点击区域是否在本次点击事件之内

如果满足了自身可点击且在点击事件范围内,那么就可以进行下一步。

分发事件

分发事件的过程跟一般View系统的分发过程一样,首先按照Z轴方向进行排列,这是因为最先被看到的子控件当然最先有响应的权限。

bool RenderNode::DispatchTouchTestToChildren(
    const Point& localPoint, const Point& globalPoint, const TouchRestrict& touchRestrict, TouchTestResult& result)
{
    bool dispatchSuccess = false;
    if (!IsChildrenTouchEnable() || GetHitTestMode() == HitTestMode::HTMBLOCK) {
        return dispatchSuccess;
    }
    根据Z轴进行排序
    const auto& sortedChildren = SortChildrenByZIndex(GetChildren());
    for (auto iter = sortedChildren.rbegin(); iter != sortedChildren.rend(); ++iter) {
        const auto& child = *iter;
        if (!child->GetVisible() || child->disabled_ || child->disableTouchEvent_) {
            continue;
        }
        调用TouchTest
        if (child->TouchTest(globalPoint, localPoint, touchRestrict, result)) {
            dispatchSuccess = true;
            if (child->GetHitTestMode() != HitTestMode::HTMTRANSPARENT) {
                break;
            }
        }
        auto interceptTouchEvent =
            (child->IsTouchable() && (child->InterceptTouchEvent() || IsExclusiveEventForChild()) &&
                child->GetHitTestMode() != HitTestMode::HTMTRANSPARENT);
        if (child->GetHitTestMode() == HitTestMode::HTMBLOCK || interceptTouchEvent) {
            auto localTransformPoint = child->GetTransformPoint(localPoint);
            bool isInRegion = false;
            for (const auto& rect : child->GetTouchRectList()) {
                if (rect.IsInRegion(localTransformPoint)) {
                    dispatchSuccess = true;
                    isInRegion = true;
                    break;
                }
            }
            if (isInRegion && child->GetHitTestMode() != HitTestMode::HTMDEFAULT) {
                break;
            }
        }
    }
    return dispatchSuccess;
}

父RenderNode的TouchTest方法中,会进行遍历调用每个子RenderNode的TouchTest方法,其实就是按照广度优先的方式执行每一个RenderNode的TouchTest方法

ArkUI Engine - 命中测试与点击事件回调注册

有意思的是,RenderNode的遍历也有着AndroidViewGroup的intercept机制,即子RenderNode可以设置自己按照某些情况不接受点击事件,即设置自己的HitTestMode,这里可以用于点击事件的冲突

   auto interceptTouchEvent =
            (child->IsTouchable() && (child->InterceptTouchEvent() || IsExclusiveEventForChild()) &&
                child->GetHitTestMode() != HitTestMode::HTMTRANSPARENT);

按照功能不同,HitTestMode 根据是否屏蔽事件分发,以及自身或者子RenderNode能响应的范围,分为以下4种

enum class HitTestMode {
    /**
     *  Both self and children respond to the hit test for touch events,
     *  but block hit test of the other nodes which is masked by this node.
     */
    HTMDEFAULT = 0,
    /**
     * Self respond to the hit test for touch events,
     * but block hit test of children and other nodes which is masked by this node.
     */
    HTMBLOCK,
    /**
     * Self and child respond to the hit test for touch events,
     * and allow hit test of other nodes which is masked by this node.
     */
    HTMTRANSPARENT,
    /**
     * Self not respond to the hit test for touch events,
     * but children respond to the hit test for touch events.
     */
    HTMNONE
};

同样的,当控件决定自行处理并不再分发事件时,比如mode为HTMBLOCK,此时还会进行补充校验,即判断自身是否能够响应本次的点击:即判断x,y是否能在自己的宽度与高度范围之内

自身x <= 点击事件 x坐标 <= 自身x+width

自身y<= 点击事件 y坐标 <= 自身y+height

  bool IsInRegion(const Point& point) const
    {
        return (point.GetX() >= x_) && (point.GetX() < (x_ + width_)) && (point.GetY() >= y_) &&
               (point.GetY() < (y_ + height_));
    }

是否加入命中测试结果

当满足前几个条件之后,那么RenderNode就可以有“资格”进行事件响应了,这里说有“资格”,是因为调用OnTouchTestHit后,控件可以选择把自己的点击加入到TouchTestResult,我们来认识一下这个方法

 virtual void OnTouchTestHit(
        const Offset& coordinateOffset, const TouchRestrict& touchRestrict, TouchTestResult& result)
    {}

OnTouchTestHit最关键的是第三个参数TouchTestResult类型的result,它其实是一个list

using TouchTestResult = std::list<RefPtr<TouchEventTarget>>;

这里如果选择加入命中测试的话,那么当前RenderNode会构建一个TouchEventTarget加入到TouchTestResult中,然后再由TouchEventTarget发起回调。

在ArkUI中,有一个经常被使用的TouchEventTarget实现类,它就是ClickRecognizer,用于封装了点击事件的回调以及相关流程调用处理


class ClickRecognizer : public MultiFingersRecognizer {
    DECLARE_ACE_TYPE(ClickRecognizer, MultiFingersRecognizer);

ClickRecognizer有很多有用的基础封装,比如SetOnClick设立一个点击事件,方便后续回调,还有其他接受点击事件以及拒绝的封装


    void OnAccepted() override;
    void OnRejected() override;
    void SetOnClick(const ClickCallback& onClick)
    {
        onClick_ = onClick;
    }

就拿官方代码举个例子,如果我们想要写一个ArkUI控件的点击事件,我们只需要3步:

1、创建一个ClickRecognizer

2、重写OnTouchTestHit函数,注册RenderMyCircleClickRecognizer,这样在接收到点击事件时即可触发创建ClickRecognizer时添加的事件回调;

3、实现在接收到点击事件之后的处理逻辑HandleMyCircleClickEvent

RenderMyCircle::RenderMyCircle()
{
    clickRecognizer_ = AceType::MakeRefPtr<ClickRecognizer>();
    clickRecognizer_->SetOnClick([wp = WeakClaim(this)](const ClickInfo& info) {
        auto myCircle = wp.Upgrade();
        if (!myCircle) {
            LOGE("WeakPtr of RenderMyCircle fails to be upgraded, stop handling click event.");
            return;
        }
        调用自定义的回调,比如回调到js
        myCircle->HandleMyCircleClickEvent(info);
    });
}
void RenderMyCircle::OnTouchTestHit(
    const Offset& coordinateOffset, const TouchRestrict& touchRestrict, TouchTestResult& result)
{
    clickRecognizer_->SetCoordinateOffset(coordinateOffset);
    关键在于通过result加入clickRecognizer_
    result.emplace_back(clickRecognizer_);
}
void RenderMyCircle::HandleMyCircleClickEvent(const ClickInfo& info)
{
    if (callbackForJS_) {
        auto result = std::string(""circleclick",{"radius":")
                    .append(std::to_string(NormalizeToPx(circleRadius_)))
                    .append(","edgewidth":")
                    .append(std::to_string(NormalizeToPx(edgeWidth_)))
                    .append("}");
        callbackForJS_(result);
    }
}

回到正题,OnTouchTestHit 是一个虚方法,因此每个实现类都会根据自身的一些特性去进行重写,最终命中测试的结果会被加入result这个list即可,比如Text控件对应的RenderText

if (needClickDetector_) {
        if (!clickDetector_) {
            clickDetector_ = AceType::MakeRefPtr<ClickRecognizer>();
            clickDetector_->SetOnClick([weak = WeakClaim(this)](const ClickInfo& info) {
                auto text = weak.Upgrade();
                if (text) {
                    text->HandleClick(info);
                }
            });
            clickDetector_->SetRemoteMessage([weak = WeakClaim(this)](const ClickInfo& info) {
                auto text = weak.Upgrade();
                if (text) {
                    text->HandleRemoteMessage(info);
                }
            });
        }
        clickDetector_->SetCoordinateOffset(coordinateOffset);
        clickDetector_->SetTouchRestrict(touchRestrict);
        clickDetector_->SetIsExternalGesture(true);
        result.emplace_back(clickDetector_);
        ....

当所有TouchEventTarget事件被收集后,后续的事件就可以被继续分发了

class ACE_EXPORT TouchEventTarget : public virtual AceType {
    DECLARE_ACE_TYPE(TouchEventTarget, AceType);
public:
    TouchEventTarget() = default;
    TouchEventTarget(std::string nodeName, int32_t nodeId) : nodeName_(std::move(nodeName)), nodeId_(nodeId) {}
    ~TouchEventTarget() override = default;
    // if return false means need to stop event dispatch.
    virtual bool DispatchEvent(const TouchEvent& point) = 0;
    // if return false means need to stop event bubbling.
    virtual bool HandleEvent(const TouchEvent& point) = 0;
    virtual bool HandleEvent(const AxisEvent& event)
    {

ClickRecognizer的父类NGGestureRecognizer就会重写HandleEvent方法,用于MOVE – UP这些其他事件的分发

bool NGGestureRecognizer::HandleEvent(const TouchEvent& point)
{
    if (!ShouldResponse()) {
        return true;
    }
    switch (point.type) {
        case TouchType::MOVE:
            HandleTouchMoveEvent(point);
            break;
        case TouchType::DOWN: {
            deviceId_ = point.deviceId;
            deviceType_ = point.sourceType;
            auto result = AboutToAddCurrentFingers(point.id);
            if (result) {
                HandleTouchDownEvent(point);
            }
            break;
        }
        case TouchType::UP:
            HandleTouchUpEvent(point);
            currentFingers_--;
            break;
        case TouchType::CANCEL:
            HandleTouchCancelEvent(point);
            currentFingers_--;
            break;
        default:
            break;
    }
    return true;
}

ArkUI点击事件流程

ArkUI点击事件流程包括:绑定过程与触发过程,通过FocusNode这个类贯穿全体

绑定过程

在ArkUI中,我们可以对组件添加点击事件,如下:

Row.onClick(() => {
  this.x += 10
})

以Row容器举例子,onClick的ts方法最终会被映射成JsOnClick方法的执行,这个方法主要是把我们自定的箭头函数,比如上文的=>xxx这个函数进行C++层的回调注册

void JSInteractableView::JsOnClick(const JSCallbackInfo& info)
{
    JSRef<JSVal> jsOnClickVal = info[0];
    if (jsOnClickVal->IsUndefined() && IsDisableEventVersion()) {
        ViewAbstractModel::GetInstance()->DisableOnClick();
        return;
    }
    if (!jsOnClickVal->IsFunction()) {
        return;
    }
    WeakPtr<NG::FrameNode> frameNode = NG::ViewStackProcessor::GetInstance()->GetMainFrameNode();
    auto jsOnClickFunc = AceType::MakeRefPtr<JsClickFunction>(JSRef<JSFunc>::Cast(info[0]));
    auto onTap = [execCtx = info.GetExecutionContext(), func = jsOnClickFunc, node = frameNode](GestureEvent& info) {
        JAVASCRIPT_EXECUTION_SCOPE_WITH_CHECK(execCtx);
        ACE_SCORING_EVENT("onClick");
        PipelineContext::SetCallBackNode(node);
        func->Execute(info);
    };
    auto onClick = [execCtx = info.GetExecutionContext(), func = jsOnClickFunc, node = frameNode](
                       const ClickInfo* info) {
        JAVASCRIPT_EXECUTION_SCOPE_WITH_CHECK(execCtx);
        ACE_SCORING_EVENT("onClick");
        PipelineContext::SetCallBackNode(node);
        func->Execute(*info);
    };
    // 注册点击事件
    ViewAbstractModel::GetInstance()->SetOnClick(std::move(onTap), std::move(onClick));
    auto focusHub = NG::ViewStackProcessor::GetInstance()->GetOrCreateMainFrameNodeFocusHub();
    CHECK_NULL_VOID(focusHub);
    focusHub->SetFocusable(true, false);
}

onClick变量中包含着当发生点击事件时需要触发的函数jsOnClickFunc,还有当前的执行环境以及上文说到的ClickInfo点击事件信息等。最后通过ViewAbstractModel的SetOnClick进行注册,ViewAbstractModel默认实现是ViewAbstractModelImpl

void ViewAbstractModelImpl::SetOnClick(GestureEventFunc&& tapEventFunc, ClickEventFunc&& clickEventFunc)
{
    auto inspector = ViewStackProcessor::GetInstance()->GetInspectorComposedComponent();
    CHECK_NULL_VOID(inspector);
    auto impl = inspector->GetInspectorFunctionImpl();
    RefPtr<Gesture> tapGesture = AceType::MakeRefPtr<TapGesture>(1, 1);
    tapGesture->SetOnActionId([func = std::move(tapEventFunc), impl](GestureEvent& info) {
        if (impl) {
            impl->UpdateEventInfo(info);
        }
        func(info);
    });
    auto click = ViewStackProcessor::GetInstance()->GetBoxComponent();
    click->SetOnClick(tapGesture);
    auto onClickId = EventMarker([func = std::move(clickEventFunc), impl](const BaseEventInfo* info) {
        const auto* clickInfo = TypeInfoHelper::DynamicCast<ClickInfo>(info);
        if (!clickInfo) {
            return;
        }
        auto newInfo = *clickInfo;
        if (impl) {
            impl->UpdateEventInfo(newInfo);
        }
        func(clickInfo);
    });
    // 拿的是Focusable 组件 
    auto focusableComponent = ViewStackProcessor::GetInstance()->GetFocusableComponent(false);
    if (focusableComponent) {
        focusableComponent->SetOnClickId(onClickId);
    }
}

点击事件生效前提是组件一定是可获取焦点的组件,比如我们总不可能在IF 这个组件里面添加点击事件。(鸿蒙的if foreach 最终都会被映射成对应的功能组件,没有switch组件因此我们不能用switch去涵盖内容控件的添加),至此完成了Component与点击事件的绑定。

当FocusableComponent完成绑定后,之后的FocusableElement会调用Update方法时同步对应的onClickId,之后通过FocusNode的SetOnClickCallback绑定了FocusNode点击事件 (本质就是绑定FocusableElement与点击事件)

void FocusableElement::Update()
{
    UpdateAccessibilityNode();
    auto focusableComponent = DynamicCast<FocusableComponent>(component_);
    if (!focusableComponent) {
        LOGE("Can not dynamicCast to focusableComponent!");
        return;
    }
   ......
    if (!onClickId.IsEmpty()) {
        auto context = context_.Upgrade();
        if (context) {
            ArkUI 会命中这里,
            if (context->GetIsDeclarative()) {
                SetOnClickCallback(AceAsyncEvent<void(const std::shared_ptr<ClickInfo>&)>::Create(onClickId, context_));
            } else {
                SetOnClickCallback(AceAsyncEvent<void()>::Create(onClickId, context_));
            }
        }
    }

这里我们要注意一下,FocusNode只是一个代表具备聚焦能力的类,区别于RenderNode,大家不要搞混了,FocusableElement继承了FocusGroup,FocusGroup继承了FocusNode,FocusableElement本质也是FocusNode!

class ACE_EXPORT FocusableElement final : public SoleChildElement, public FocusGroup {
    DECLARE_ACE_TYPE(FocusableElement, SoleChildElement, FocusGroup);

绑定过程其实就是绑定FocusNode与点击事件

触发过程

结束了上面绑定过程,那么肯定还有触发这一过程,当发生点击事件时,会由PipelineContext进行事件的分发,PipelineContext的上层就是具体的系统容器了

bool PipelineContext::OnKeyEvent(const KeyEvent& event)
{
    CHECK_RUN_ON(UI);
    if (!rootElement_) {
        LOGE("the root element is nullptr");
        EventReport::SendAppStartException(AppStartExcepType::PIPELINE_CONTEXT_ERR);
        return false;
    }
    rootElement_->HandleSpecifiedKey(event);
    SetShortcutKey(event);
    pressedKeyCodes = event.pressedCodes;
    isKeyCtrlPressed_ = !pressedKeyCodes.empty() && (pressedKeyCodes.back() == KeyCode::KEY_CTRL_LEFT ||
                                                        pressedKeyCodes.back() == KeyCode::KEY_CTRL_RIGHT);
    if ((event.code == KeyCode::KEY_CTRL_LEFT || event.code == KeyCode::KEY_CTRL_RIGHT) &&
        event.action == KeyAction::UP) {
        if (isOnScrollZoomEvent_) {
            zoomEventA_.type = TouchType::UP;
            zoomEventB_.type = TouchType::UP;
            LOGI("Send TouchEventA(%{public}f, %{public}f, %{public}zu)", zoomEventA_.x, zoomEventA_.y,
                zoomEventA_.type);
            OnTouchEvent(zoomEventA_);
            LOGI("Send TouchEventB(%{public}f, %{public}f, %{public}zu)", zoomEventB_.x, zoomEventB_.y,
                zoomEventB_.type);
            OnTouchEvent(zoomEventB_);

OnKeyEvent经过事件分发后,最终会分发到对应的FocusNode节点(包括FocusNode 与FocusGroup),之后就执行了我们上文注册阶段说的callback了。至此完成点击与响应的闭环

bool FocusNode::OnClick(const KeyEvent& event)
{
    if (onClickEventCallback_) {
        ... 触发回调
        onClickEventCallback_(info);
        return true;
    }
    return false;
}

触发过程:其实就是触发FocusNode的点击事件

后续

我们可以通过官网的文档OpenHarmony 4.1,后续更多的父子点击控制的新API,其实最终也会基于我们讲到的TouchTest实现

ArkUI Engine - 命中测试与点击事件回调注册

总结

通过本文我们将了解到ArkUI中命中测试的基础流程,通过命中测试我们知道ArkUIEngine是如何收集RenderNode的响应事件,同时我们也学习到了整个Engine中点击处理相关ClickRecognizer 类,以及点击事件注册FocusNode。从ArkUIEngine的事件封装来看,我们可以看到整个框架架构扩展性还是比较好的,通过OnTouchTestHit方法能让不同的控件实现自己的特殊逻辑响应。