
从JS到C++:布局容器的性能选择
HarmonyOS NEXT应用开发中,布局性能优化是一个绕不开的话题。尤其在复杂界面或高频刷新场景下,使用ArkTS进行多层嵌套布局,内存分配和UI线程压力都会明显上升。
官方提供了Flex、Column、Row等布局容器,覆盖了大部分场景。但如果你需要自定义排列规则,比如不规则瀑布流、根据子组件宽高动态调整间距,或者干脆想减少ArkTS侧的布局计算开销,这时候就需要考虑使用NDK实现自定义布局容器。
这篇文章会从零实现一个简单的线性布局容器CLayout(类似轻量级Flex),支持水平和垂直排列、自适应宽高,并把布局结果同步到ArkUI侧显示。
它解决什么问题
UI布局的核心流程可以简化为:组件树构建 -> measure(测量) -> layout(布局) -> 绘制 。默认情况下,这个流程全部在ArkTS/JS虚拟机中执行。当子组件数量较多(几百上千)或者需要频繁重新测量时,JS虚拟机频繁执行measure和layout的代价不可忽视。
基于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++线性布局容器
整个实现分三部分:
- C++端布局容器类:负责组件树管理、measure、layout
- Napi接口:把C++方法暴露给ArkTS
- ArkTS调用层:创建容器、添加子组件、触发布局、获取结果
第一步:定义数据结构与布局容器类
在cpp/目录下新建clayout.h和clayout.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,后续addChildToLayout和getLayoutResult通过这个指针操作同一个对象。
这里有一个容易忽略的问题:接口调用的时序 。getLayoutResult内部同时调用了Measure和Layout,这意味着每次获取布局结果的成本是一次完整的重新计算。如果布局逻辑复杂,建议拆成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时,坐标与第一次不一致。
原因 :Measure和Layout每次都会重新计算,没有缓存。如果ArkTS侧两次调用间没有改变子组件大小,布局算法里也使用了固定值,结果应该一致。但一旦算法依赖于组件的动态状态(比如组件当前屏幕宽度),前后两次的环境不一样就会导致问题。
解决方案:引入缓存机制,带上时间戳或版本号。只有当子组件大小、父容器大小、或者布局参数发生变化时才重新计算。
最佳实践
-
不要在ArkTS的build()中频繁触发C++布局计算
build()会随状态变化频繁执行,每次调用Napi接口都会附带跨语言调用的成本。建议把布局计算放在
aboutToAppear或者按钮点击事件中,只在必要时更新。 -
C++侧的生命周期必须与ArkTS界面组件的生命周期同步
当ArkTS页面被销毁时,如果C++对象还在存活,就会造成内存泄漏。在
aboutToDisappear中及时释放C++对象,或者使用智能指针。 -
单个子组件的宽高建议从ArkTS侧传入
不要在C++中硬编码宽高。通过
AddChild时传入子组件的width和height属性,让C++容器能够动态感知子组件的变化。
FAQ
Q:为什么这里直接在C++里写死了子组件宽高,实际项目可以用吗?
A:不可以。这里为了演示流程简化了。实际项目中需要从子组件的width和height属性中读取,或者调用子组件的Measure方法。否则布局结果和真实显示对不上。
Q:多个C++布局容器实例怎么管理?
A:可以用一个工厂类或者统一的容器池来管理。每个createLayout返回的指针,必须在页面销毁时释放。建议用一个Map按页面ID存储指针,在aboutToDisappear中释放。
Q:这种做法的性能优势明显吗?
A:如果子组件数量少于50个,JS引擎本身已经足够快,没有太大收益。如果子组件数量超过200个,并且布局算法复杂(比如依赖子组件之间相互计算),C++方式的优势就比较明显了。另外,在动画过程中反复测量和布局时,C++的稳定性也更好。
如果你也遇到类似问题,可以重点检查Napi接口的生命周期管理和Meausre/Layout的分离时机。官方文档对这个能力的描述比较简单,建议结合实际运行效果和真机内存监控一起验证。