《HarmonyOS技术精讲-UI开发 (基于NDK构建UI)》第3篇:自定义布局容器——用C++实现灵活的排列算法
2026/6/25 13:54:42 网站建设 项目流程

从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代码,没有解释开销
缓存能力需要自行缓存布局结果可直接使用内存地址缓存
调试成本方便查看堆栈需要配合日志断点

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

环境说明

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

// clayout.h#ifndefCLayout_H#defineCLayout_H#include<vector>#include"napi/native_api.h"// 布局方向enumclassDirection{HORIZONTAL,VERTICAL};// 子节点信息structChildInfo{napi_ref jsNodeRef;// 持有JS对象的引用,用于回调位置floatwidth;// 测量后的宽度floatheight;// 测量后的高度floatx;// 布局后的X坐标floaty;// 布局后的Y坐标};classCLayout{public:explicitCLayout(Direction dir);~CLayout();// 添加子组件voidAddChild(napi_env env,napi_value jsChild);// 执行测量(这里是简化的示例,只根据子组件自身大小)voidMeasure(floatmaxWidth,floatmaxHeight);// 执行布局voidLayout();// 获取所有子组件的位置信息std::vector<ChildInfo>GetLayoutResult();private:Direction direction_;std::vector<ChildInfo>children_;};#endif
// clayout.cpp#include"clayout.h"#include<cmath>CLayout::CLayout(Direction dir):direction_(dir){}CLayout::~CLayout(){// 注意:jsNodeRef需要统一释放,这里不做展开}voidCLayout::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);}voidCLayout::Measure(floatmaxWidth,floatmaxHeight){// 此示例简化:每个子组件大小固定// 真实场景应该调用子组件的measure方法for(auto&child:children_){// 这里只是示意,实际应通过napi调用子组件的测量方法child.width=100.0f;child.height=50.0f;}}voidCLayout::Layout(){floatcurrentX=0.0f;floatcurrentY=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(){returnchildren_;}

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

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

第二步:暴露Napi接口

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

// napi_init.cpp#include"napi/native_api.h"#include"clayout.h"staticnapi_valueCreateLayout(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_tdir=0;napi_get_value_int32(env,args[0],&dir);// 创建C++布局对象,并包装成NativePointer传给JS// 注意:这里为了演示简化了对象生命周期的管理,实际需要妥善处理CLayout*layout=newCLayout(static_cast<Direction>(dir));napi_value result;napi_create_external(env,layout,[](napi_env env,void*data,void*hint){deletestatic_cast<CLayout*>(data);},nullptr,&result);returnresult;}staticnapi_valueAddChildToLayout(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);returnnullptr;}staticnapi_valueGetLayoutResult(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();autoresult=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);}returnjsArray;}EXTERN_C_STARTstaticnapi_valueInit(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);returnexports;}EXTERN_C_END

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

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

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

// CustomLayout.etsimportnativeLayoutfrom'libentry.so';interfaceLayoutResult{x:number;y:number;width:number;height:number;}@Entry@Componentstruct CustomLayoutDemo{@Stateresults:LayoutResult[]=[];privatelayoutPtr:Object|null=null;aboutToAppear(){// 创建水平排列的布局容器this.layoutPtr=nativeLayout.createLayout(0);// 0表示水平}build(){Column(){Button("触发C++布局").onClick(()=>{if(this.layoutPtr){// 假设我们有5个子组件constchildIds:Object[]=[];for(leti=0;i<5;i++){// 简化:直接传this作为子组件占位,实际需要对应视图组件childIds.push(this);}for(letchildofchildIds){nativeLayout.addChildToLayout(this.layoutPtr,child);}constlayoutResults:LayoutResult[]=nativeLayout.getLayoutResult(this.layoutPtr);this.results=layoutResults;}})ForEach(this.results,(item:LayoutResult)=>{// 根据C++布局结果,在对应位置渲染子组件// 注意:真实项目应该使用Positioned或者StackText(`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的析构函数中添加统一的引用释放逻辑:

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的分离时机。官方文档对这个能力的描述比较简单,建议结合实际运行效果和真机内存监控一起验证。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询