《HarmonyOS技术精讲-UI开发 (基于NDK构建UI)》第3篇:自定义布局容器——用C++实现灵活的排列算法

从JS到C++:布局容器的性能选择

HarmonyOS NEXT应用开发中,布局性能优化是一个绕不开的话题。尤其在复杂界面或高频刷新场景下,使用ArkTS进行多层嵌套布局,内存分配和UI线程压力都会明显上升。

官方提供了FlexColumnRow等布局容器,覆盖了大部分场景。但如果你需要自定义排列规则,比如不规则瀑布流、根据子组件宽高动态调整间距,或者干脆想减少ArkTS侧的布局计算开销,这时候就需要考虑使用NDK实现自定义布局容器。

这篇文章会从零实现一个简单的线性布局容器CLayout(类似轻量级Flex),支持水平和垂直排列、自适应宽高,并把布局结果同步到ArkUI侧显示。

它解决什么问题

UI布局的核心流程可以简化为:组件树构建 -> measure(测量) -> layout(布局) -> 绘制 。默认情况下,这个流程全部在ArkTS/JS虚拟机中执行。当子组件数量较多(几百上千)或者需要频繁重新测量时,JS虚拟机频繁执行measurelayout的代价不可忽视。

基于NDK构建UI的能力,允许我们把布局算法用C++实现,只把最终的位置、大小结果回调给ArkTS进行渲染。这样做有几个好处:

对比项 ArkTS布局 C++布局
内存分配 每个组件对应JS对象,占用堆内存 C++原生结构体,内存可控
循环计算 JS引擎执行循环,速度取决于引擎优化 纯native代码,没有解释开销
缓存能力 需要自行缓存布局结果 可直接使用内存地址缓存
调试成本 方便查看堆栈 需要配合日志断点

这篇文章讲的方法,更适合子组件数量多、需要高频重新布局、或者布局算法复杂的场景。如果只是几个静态组件,不用折腾。

环境说明

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

核心实现:C++线性布局容器

整个实现分三部分:

  1. C++端布局容器类:负责组件树管理、measure、layout
  2. Napi接口:把C++方法暴露给ArkTS
  3. ArkTS调用层:创建容器、添加子组件、触发布局、获取结果

第一步:定义数据结构与布局容器类

cpp/目录下新建clayout.hclayout.cpp

cpp 复制代码
// clayout.h
#ifndef CLayout_H
#define CLayout_H

#include <vector>
#include "napi/native_api.h"

// 布局方向
enum class Direction { HORIZONTAL, VERTICAL };

// 子节点信息
struct ChildInfo {
    napi_ref jsNodeRef; // 持有JS对象的引用,用于回调位置
    float width;        // 测量后的宽度
    float height;       // 测量后的高度
    float x;            // 布局后的X坐标
    float y;            // 布局后的Y坐标
};

class CLayout {
public:
    explicit CLayout(Direction dir);
    ~CLayout();

    // 添加子组件
    void AddChild(napi_env env, napi_value jsChild);
    // 执行测量(这里是简化的示例,只根据子组件自身大小)
    void Measure(float maxWidth, float maxHeight);
    // 执行布局
    void Layout();
    // 获取所有子组件的位置信息
    std::vector<ChildInfo> GetLayoutResult();

private:
    Direction direction_;
    std::vector<ChildInfo> children_;
};

#endif
cpp 复制代码
// clayout.cpp
#include "clayout.h"
#include <cmath>

CLayout::CLayout(Direction dir) : direction_(dir) {}

CLayout::~CLayout() {
    // 注意:jsNodeRef需要统一释放,这里不做展开
}

void CLayout::AddChild(napi_env env, napi_value jsChild) {
    ChildInfo child;
    // 这里假设jsChild对象有"width"和"height"属性
    // 实际项目需要用napi获取属性值
    child.width = 100.0f;  // 占位逻辑,实际应读取
    child.height = 50.0f;  // 占位逻辑
    napi_create_reference(env, jsChild, 1, &child.jsNodeRef);
    children_.push_back(child);
}

void CLayout::Measure(float maxWidth, float maxHeight) {
    // 此示例简化:每个子组件大小固定
    // 真实场景应该调用子组件的measure方法
    for (auto &child : children_) {
        // 这里只是示意,实际应通过napi调用子组件的测量方法
        child.width = 100.0f;
        child.height = 50.0f;
    }
}

void CLayout::Layout() {
    float currentX = 0.0f;
    float currentY = 0.0f;

    for (auto &child : children_) {
        child.x = currentX;
        child.y = currentY;
        if (direction_ == Direction::HORIZONTAL) {
            currentX += child.width;  // 水平排列,向右依次排列
        } else {
            currentY += child.height; // 垂直排列,向下依次排列
        }
    }
}

std::vector<ChildInfo> CLayout::GetLayoutResult() {
    return children_;
}

这一段代码的主要作用是 :定义了一个最基础的自定义布局容器。AddChild把子组件注册到容器中并记录引用;Measure为每个子组件确定宽高;Layout根据方向和measure结果,给每个子组件分配一个位置。

这里需要注意:Measure环节在真实项目中需要调用子组件的Measure方法,也就是通过napi调用JS侧的测量。为了方便演示,这里用了固定值。实际项目里如果子组件大小未知,需要从JS侧读取。

第二步:暴露Napi接口

napi_init.cpp中注册创建布局、添加子组件和获取布局结果的方法。

cpp 复制代码
// napi_init.cpp
#include "napi/native_api.h"
#include "clayout.h"

static napi_value CreateLayout(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    int32_t dir = 0;
    napi_get_value_int32(env, args[0], &dir);

    // 创建C++布局对象,并包装成NativePointer传给JS
    // 注意:这里为了演示简化了对象生命周期的管理,实际需要妥善处理
    CLayout *layout = new CLayout(static_cast<Direction>(dir));
    napi_value result;
    napi_create_external(env, layout, [](napi_env env, void *data, void *hint) {
        delete static_cast<CLayout *>(data);
    }, nullptr, &result);

    return result;
}

static napi_value AddChildToLayout(napi_env env, napi_callback_info info) {
    size_t argc = 2;
    napi_value args[2];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    CLayout *layout;
    napi_get_value_external(env, args[0], (void **)&layout);
    napi_value jsChild = args[1];

    layout->AddChild(env, jsChild);
    return nullptr;
}

static napi_value GetLayoutResult(napi_env env, napi_callback_info info) {
    size_t argc = 1;
    napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    CLayout *layout;
    napi_get_value_external(env, args[0], (void **)&layout);

    layout->Measure(300.0f, 600.0f);
    layout->Layout();
    auto result = layout->GetLayoutResult();

    // 构建JS数组返回
    napi_value jsArray;
    napi_create_array(env, &jsArray);
    for (size_t i = 0; i < result.size(); ++i) {
        napi_value obj;
        napi_create_object(env, &obj);
        napi_value x, y, w, h;
        napi_create_double(env, result[i].x, &x);
        napi_create_double(env, result[i].y, &y);
        napi_create_double(env, result[i].width, &w);
        napi_create_double(env, result[i].height, &h);
        napi_set_named_property(env, obj, "x", x);
        napi_set_named_property(env, obj, "y", y);
        napi_set_named_property(env, obj, "width", w);
        napi_set_named_property(env, obj, "height", h);
        napi_set_element(env, jsArray, i, obj);
    }

    return jsArray;
}

EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor desc[] = {
        {"createLayout", nullptr, CreateLayout, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"addChildToLayout", nullptr, AddChildToLayout, nullptr, nullptr, nullptr, napi_default, nullptr},
        {"getLayoutResult", nullptr, GetLayoutResult, nullptr, nullptr, nullptr, napi_default, nullptr},
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

这一段的核心作用是注册接口 。通过napi_create_external把C++对象的指针暴露给JS,后续addChildToLayoutgetLayoutResult通过这个指针操作同一个对象。

这里有一个容易忽略的问题:接口调用的时序getLayoutResult内部同时调用了MeasureLayout,这意味着每次获取布局结果的成本是一次完整的重新计算。如果布局逻辑复杂,建议拆成measureLayout分开调用,并增加缓存判断。

第三步:在ArkUI中调用C++布局

typescript 复制代码
// CustomLayout.ets
import nativeLayout from 'libentry.so';

interface LayoutResult {
    x: number;
    y: number;
    width: number;
    height: number;
}

@Entry
@Component
struct CustomLayoutDemo {
    @State results: LayoutResult[] = [];
    private layoutPtr: Object | null = null;

    aboutToAppear() {
        // 创建水平排列的布局容器
        this.layoutPtr = nativeLayout.createLayout(0); // 0表示水平
    }

    build() {
        Column() {
            Button("触发C++布局")
                .onClick(() => {
                    if (this.layoutPtr) {
                        // 假设我们有5个子组件
                        const childIds: Object[] = [];
                        for (let i = 0; i < 5; i++) {
                            // 简化:直接传this作为子组件占位,实际需要对应视图组件
                            childIds.push(this);
                        }
                        for (let child of childIds) {
                            nativeLayout.addChildToLayout(this.layoutPtr, child);
                        }

                        const layoutResults: LayoutResult[] = nativeLayout.getLayoutResult(this.layoutPtr);
                        this.results = layoutResults;
                    }
                })

            ForEach(this.results, (item: LayoutResult) => {
                // 根据C++布局结果,在对应位置渲染子组件
                // 注意:真实项目应该使用Positioned或者Stack
                Text(`x:${item.x.toFixed(0)} y:${item.y.toFixed(0)} w:${item.width.toFixed(0)} h:${item.height.toFixed(0)}`)
                    .position({ x: item.x, y: item.y }) // 需要父容器是Stack
                    .width(100)
                    .height(50)
                    .backgroundColor(Color.Green)
            }, (item: LayoutResult, index: number) => index.toString())
        }
        .width('100%')
        .height('100%')
        .alignItems(HorizontalAlign.Start)
    }
}

常见问题

问题1:Napi回调中的JS引用泄漏

现象 :页面反复创建销毁时,系统内存不断上涨。

原因 :在CLayout::AddChild中使用napi_create_reference创建了强引用,但在CLayout对象释放时,没有及时调用napi_delete_reference。JS侧的组件对象永远不会被回收。

解决方案 :在CLayout的析构函数中添加统一的引用释放逻辑:

cpp 复制代码
CLayout::~CLayout() {
    // 这里假设我们有napi_env的引用策略,实际需要存起来
    for (auto &child : children_) {
        // napi_delete_reference(env, child.jsNodeRef);
    }
}

真实项目中,建议把napi_env也作为成员保存,并且在对象释放时逐条删除引用。

问题2:measure与layout结果不同步

现象 :第二次调用getLayoutResult时,坐标与第一次不一致。

原因MeasureLayout每次都会重新计算,没有缓存。如果ArkTS侧两次调用间没有改变子组件大小,布局算法里也使用了固定值,结果应该一致。但一旦算法依赖于组件的动态状态(比如组件当前屏幕宽度),前后两次的环境不一样就会导致问题。

解决方案:引入缓存机制,带上时间戳或版本号。只有当子组件大小、父容器大小、或者布局参数发生变化时才重新计算。

最佳实践

  1. 不要在ArkTS的build()中频繁触发C++布局计算

    build()会随状态变化频繁执行,每次调用Napi接口都会附带跨语言调用的成本。建议把布局计算放在aboutToAppear或者按钮点击事件中,只在必要时更新。

  2. C++侧的生命周期必须与ArkTS界面组件的生命周期同步

    当ArkTS页面被销毁时,如果C++对象还在存活,就会造成内存泄漏。在aboutToDisappear中及时释放C++对象,或者使用智能指针。

  3. 单个子组件的宽高建议从ArkTS侧传入

    不要在C++中硬编码宽高。通过AddChild时传入子组件的widthheight属性,让C++容器能够动态感知子组件的变化。

FAQ

Q:为什么这里直接在C++里写死了子组件宽高,实际项目可以用吗?

A:不可以。这里为了演示流程简化了。实际项目中需要从子组件的widthheight属性中读取,或者调用子组件的Measure方法。否则布局结果和真实显示对不上。

Q:多个C++布局容器实例怎么管理?

A:可以用一个工厂类或者统一的容器池来管理。每个createLayout返回的指针,必须在页面销毁时释放。建议用一个Map按页面ID存储指针,在aboutToDisappear中释放。

Q:这种做法的性能优势明显吗?

A:如果子组件数量少于50个,JS引擎本身已经足够快,没有太大收益。如果子组件数量超过200个,并且布局算法复杂(比如依赖子组件之间相互计算),C++方式的优势就比较明显了。另外,在动画过程中反复测量和布局时,C++的稳定性也更好。

如果你也遇到类似问题,可以重点检查Napi接口的生命周期管理和Meausre/Layout的分离时机。官方文档对这个能力的描述比较简单,建议结合实际运行效果和真机内存监控一起验证。