《HarmonyOS技术精讲-UI开发 (基于NDK构建UI)》第2篇:Native组件的生命周期与事件处理

一个很奇怪的问题

HarmonyOS NEXT 开发里,用 ArkTS 写 UI 组件很舒服,但一旦需要高性能的 C++ 渲染,很多人会卡在同一个地方:如何让 Native 组件正确地响应触摸事件

官方文档把 ArkUI_NativeComponent 接口和回调列了出来,但实际使用时细节远不止这些。

我遇到过几次这样的问题:使用原生 C++ 构建了一个按钮,触摸按下没有高亮反馈,甚至有时点击事件根本不触发。查了很多遍回调注册,发现 OnTouch 回调确实被调用了,但状态切换死活不对。

原因其实不在于事件接收,而在于生命周期回调的同步时机

Native 组件生命周期回调解决了什么

在 ArkUI 里,一个 Native 组件(比如用 NodeContent + ArkUI_NativeComponent 构建的)与普通 ArkTS 组件不同,它没有一个天然的 build 方法,也不在编译阶段生成渲染节点。

它需要手动声明:

  • 什么时候构建OnBuild
  • 什么时候更新OnUpdate
  • 什么时候销毁OnDestruct
  • 自定义渲染内容OnDraw

这四个回调封装了组件从创建到销毁的全生命周期。

此外,还需要单独注册触摸事件OnTouch)和按键事件OnKey)回调,才能让组件响应交互。

什么时候用它?

  • 你需要直接操控 GPU 绘制,追求极致性能,比如 60fps 动画、粒子系统。
  • 你需要复用已有的 C++ 渲染引擎,比如游戏引擎、图形引擎。

什么时候不用?

  • 标准 UI 交互(按钮、图片、文字),用 ArkTS 组件已经足够,没必要引入 C++ 复杂度。
  • 不需要高性能自定义绘制,用 CanvasShape 就可以。

环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(API 23)

核心实现:一个可交互的 Native 按钮

下面要实现一个支持触摸高亮切换的 C++ 按钮。包含两个文件:

  • NativeButton.cpp:按钮的 C++ 实现,包含生命周期回调和事件处理。
  • NativeButton.h:头文件。

以及 ArkTS 侧调用代码。

1. C++ 头文件:声明接口

cpp 复制代码
// NativeButton.h
#ifndef NATIVE_BUTTON_H
#define NATIVE_BUTTON_H

#include <arkui/native_interface.h>
#include <arkui/native_node.h>
#include <ace/xcomponent/native_interface_xcomponent.h>

struct ButtonData {
    bool isPressed;
};

class NativeButton {
public:
    NativeButton();
    ~NativeButton();
    
    // ArkUI_NativeComponent 回调函数
    static ArkUI_NodeHandle createNode();
    static void onBuild(ArkUI_NodeHandle* node);
    static void onDraw(ArkUI_NodeHandle* node, const ArkUI_DrawContext* context);
    static void onUpdate(ArkUI_NodeHandle* node);
    static void onDestruct(ArkUI_NodeHandle* node);
    
    // 事件回调
    static int32_t onTouchEvent(ArkUI_NodeHandle* node, const ArkUI_TouchEvent* touchEvent);
    static int32_t onKeyEvent(ArkUI_NodeHandle* node, const ArkUI_KeyEvent* keyEvent);
    
    // 获取当前按钮状态(用于事件响应)
    static ButtonData* getButtonData(ArkUI_NodeHandle* node);
};

#endif

2. C++ 实现文件:生命周期与事件处理

cpp 复制代码
// NativeButton.cpp
#include "NativeButton.h"
#include <unordered_map>
#include <cstdlib>

static std::unordered_map<ArkUI_NodeHandle*, ButtonData> g_buttonDataMap;

NativeButton::NativeButton() {}
NativeButton::~NativeButton() {
    // 清理所有已注册的按钮数据
    g_buttonDataMap.clear();
}

ArkUI_NodeHandle* NativeButton::createNode() {
    // 创建一个空节点,作为容器
    ArkUI_NodeHandle* node = (ArkUI_NodeHandle*)malloc(sizeof(ArkUI_NodeHandle));
    ArkUI_Node* nativeNode = OH_ArkUI_Node::Create(ARKUI_NODE_CUSTOM);
    *node = nativeNode;
    return node;
}

void NativeButton::onBuild(ArkUI_NodeHandle* node) {
    // 构建阶段:设置默认属性
    ButtonData data;
    data.isPressed = false;
    g_buttonDataMap[*node] = data;
    // 设置圆角矩形背景初始状态
    OH_ArkUI_Node_SetAttribute(*node, ARKUI_NODE_ATTRIBUTE_BACKGROUND_COLOR, "#3F51B5");
}

void NativeButton::onDraw(ArkUI_NodeHandle* node, const ArkUI_DrawContext* context) {
    // 自定义绘制:这里可以绘制更复杂的图形
    // 但按钮场景下,使用系统属性即可,所以此处保持默认
    // 注意:onDraw 中不能修改节点属性,只用于绘制
}

void NativeButton::onUpdate(ArkUI_NodeHandle* node) {
    // 更新阶段:根据状态刷新UI
    ButtonData& data = g_buttonDataMap[*node];
    if (data.isPressed) {
        // 高亮状态:修改背景色
        OH_ArkUI_Node_SetAttribute(*node, ARKUI_NODE_ATTRIBUTE_BACKGROUND_COLOR, "#283593");
    } else {
        // 正常状态
        OH_ArkUI_Node_SetAttribute(*node, ARKUI_NODE_ATTRIBUTE_BACKGROUND_COLOR, "#3F51B5");
    }
}

void NativeButton::onDestruct(ArkUI_NodeHandle* node) {
    // 销毁阶段:清理数据,防止内存泄漏
    auto it = g_buttonDataMap.find(*node);
    if (it != g_buttonDataMap.end()) {
        g_buttonDataMap.erase(it);
    }
    free(node);
}

int32_t NativeButton::onTouchEvent(ArkUI_NodeHandle* node, const ArkUI_TouchEvent* touchEvent) {
    // 触摸事件处理
    auto it = g_buttonDataMap.find(*node);
    if (it == g_buttonDataMap.end()) {
        return 0;
    }
    
    ButtonData& data = it->second;
    ArkUI_TouchEventType type = touchEvent->type;
    
    switch (type) {
        case ARKUI_TOUCH_EVENT_DOWN:
            data.isPressed = true;
            // 触发更新回调,刷新UI
            onUpdate(node);
            break;
        case ARKUI_TOUCH_EVENT_UP:
        case ARKUI_TOUCH_EVENT_CANCEL:
            data.isPressed = false;
            onUpdate(node);
            break;
        default:
            break;
    }
    
    // 返回1表示事件已处理,不再向下传递
    return 1;
}

int32_t NativeButton::onKeyEvent(ArkUI_NodeHandle* node, const ArkUI_KeyEvent* keyEvent) {
    // 按键事件:比如空格或回车模拟点击
    auto it = g_buttonDataMap.find(*node);
    if (it == g_buttonDataMap.end()) {
        return 0;
    }
    
    ButtonData& data = it->second;
    if (keyEvent->action == ARKUI_KEY_ACTION_DOWN) {
        if (keyEvent->code == ARKUI_KEYCODE_ENTER || keyEvent->code == ARKUI_KEYCODE_SPACE) {
            data.isPressed = true;
            onUpdate(node);
        }
    } else if (keyEvent->action == ARKUI_KEY_ACTION_UP) {
        data.isPressed = false;
        onUpdate(node);
    }
    
    return 1;
}

ButtonData* NativeButton::getButtonData(ArkUI_NodeHandle* node) {
    auto it = g_buttonDataMap.find(*node);
    if (it != g_buttonDataMap.end()) {
        return &(it->second);
    }
    return nullptr;
}

代码说明:

  • 使用 staticunordered_map 管理每个 NodeHandle 对应的 ButtonData,避免了全局变量冲突,也方便在 onDestruct 中精确清理。
  • onUpdate 回调是触发状态切换的关键。触摸事件处理中,先修改 isPressed 状态,再主动调用 onUpdate,而不是等待系统调度。这种方式响应更及时,避免了触摸状态丢失的问题。
  • onTouchEvent 返回 1,表示事件已消费,防止事件继续传递给父组件,造成按钮底部阴影区域被错误触摸。

3. ArkTS 侧调用代码

typescript 复制代码
// Index.ets
import nativeButton from 'liblibrary.so'; // 假设你的so库名为liblibrary.so

@Entry
@Component
struct NativeButtonPage {
  
  build() {
    Column() {
      Text('Native 按钮组件示例')
        .fontSize(24)
        .textAlign(TextAlign.Center)
        .width('100%')
        .margin({ bottom: 20 })
      
      // 创建一个Native容器节点
      NodeContainer()
        .width(200)
        .height(60)
        .backgroundColor('#E0E0E0')
        .onAppear(() => {
          // 在组件挂载后,通知C++侧创建原生节点
          nativeButton.createNativeButton();
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

注意 :这里的 ArkTS 代码只负责创建容器布局,真正的原生按钮节点由 C++ 侧通过 OH_ArkUI_Node::Create 创建并挂载到容器中。onAppear 回调是一个常见的启动点。实际项目里,你可能需要在 NodeControllerbuildNode 方法里注册回调。

常见问题与解决

问题1:触摸事件触发后,高亮状态一闪而过

现象:按下按钮背景色变深,但抬手后颜色没有恢复正常,或者恢复正常有延迟。

原因onUpdate 回调没有被及时调用,或者 onTouchEventUPCANCEL 事件没有被正确接收。系统不会自动在触摸事件之后调用 onUpdate,它只会在组件属性变化或布局变化时触发。

解决 :像上面示例代码一样,在 onTouchEvent 中手动调用 onUpdate。而且要注意,不要依赖 OnTouch 事件返回后系统再调 onUpdate,它们之间没有隐性顺序依赖。

cpp 复制代码
// 正确写法:手动触发
case ARKUI_TOUCH_EVENT_DOWN:
    data.isPressed = true;
    onUpdate(node);  // 立即刷新UI
    break;

问题2:组件销毁后,onTouchEventonKeyEvent 仍在回调

现象 :页面已经返回,但日志显示 onTouchEvent 还在被调用,甚至导致野指针。

原因onDestruct 回调执行时,NodeHandle 已经被释放,但事件回调注册在 NodeHandle 之前,系统并不会自动注销事件回调。如果 onDestruct 中清理了数据,而后续回调又尝试访问,就会出问题。

解决 :在 onDestruct 中,不仅清理数据,还要确保事件回调不会被再次调用。一个稳妥的方法是使用标记位

cpp 复制代码
void NativeButton::onDestruct(ArkUI_NodeHandle* node) {
    // 先标记节点已销毁
    auto it = g_buttonDataMap.find(*node);
    if (it != g_buttonDataMap.end()) {
        it->second.isDestroyed = true;
        g_buttonDataMap.erase(it);
    }
    // 释放node内存
    free(node);
}

并在 onTouchEvent 开头检查标记:

cpp 复制代码
int32_t NativeButton::onTouchEvent(ArkUI_NodeHandle* node, const ArkUI_TouchEvent* touchEvent) {
    auto it = g_buttonDataMap.find(*node);
    if (it == g_buttonDataMap.end() || it->second.isDestroyed) {
        return 0;
    }
    // ...
}

最佳实践总结

  1. 不要在 onDraw 中修改节点属性onDraw 只在绘制阶段被调用,修改属性会触发不必要的重排和重绘,影响性能。
  2. 将状态集中管理 。使用静态的 unordered_map 管理每个节点的状态,而不是在 onBuild 中创建大量的临时变量,避免状态丢失。
  3. 触摸事件处理返回值要正确。返回 1 表示已处理,事件终止;返回 0 表示未处理,事件会继续传递给父节点。按钮场景应该返回 1。
  4. 千万不要在 OnBuild 中频繁创建对象OnBuild 在组件创建时只会调用一次,但如果你在后续更新中重新创建节点,每次都会新建 map 条目,导致旧数据泄漏。

Demo 入口

完整的 ArkTS 入口文件这里再贴一次:

typescript 复制代码
@Entry
@Component
struct Index {
  build() {
    Column() {
      // 这个 NodeContainer 会作为原生按钮的宿主
      NodeContainer()
        .width(200)
        .height(60)
        .backgroundColor('#E0E0E0')
        .onAppear(() => {
          NativeButtonPlugin.createButton();
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

FAQ

Q1:为什么真机上触摸高亮效果正常,但在模拟器上不起作用?

A:模拟器通常使用软件渲染,而 OnDraw 回调依赖 GPU 加速。在软件渲染模式下,OnDraw 的调用频率可能降低,导致 onUpdate 没有按预期触发。解决方法是在模拟器上使用硬件渲染模式(在 DevEco Studio 中选择"硬件加速")。

Q2:页面返回后,按钮的状态为什么仍然保留?

A:这是因为 onDestruct 没有正确清理 map。当页面销毁时,onDestruct 会被调用,但如果你没有移除对应的 map 条目,下次创建新节点时,可能会遇到旧的 NodeHandle 地址重复,导致混乱。确保在 onDestructerase 掉该条目。

Q3:为什么有时候 OnTouchEvent 没有被调用?

A:最常见的原因是节点没有设置点击区域或背景色。如果节点大小为0(即没有宽高),或者背景色透明,系统会认为该节点不可点击,不会分发触摸事件。解决办法:确保节点设置了明确的宽高和背景色。