《HarmonyOS技术精讲-UI开发 (基于NDK构建UI)》第3篇:自定义布局容器——用C++实现灵活的排列算法
从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代码没有解释开销缓存能力需要自行缓存布局结果可直接使用内存地址缓存调试成本方便查看堆栈需要配合日志断点这篇文章讲的方法更适合子组件数量多、需要高频重新布局、或者布局算法复杂的场景。如果只是几个静态组件不用折腾。环境说明DevEco Studio版本DevEco Studio 6.1.0及以上 HarmonyOS SDK版本HarmonyOS 6.1.0(23)及以上 目标设备手机 / 平板核心实现C线性布局容器整个实现分三部分C端布局容器类负责组件树管理、measure、layoutNapi接口把C方法暴露给ArkTSArkTS调用层创建容器、添加子组件、触发布局、获取结果第一步定义数据结构与布局容器类在cpp/目录下新建clayout.h和clayout.cpp。// clayout.h#ifndefCLayout_H#defineCLayout_H#includevector#includenapi/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::vectorChildInfoGetLayoutResult();private:Direction direction_;std::vectorChildInfochildren_;};#endif// clayout.cpp#includeclayout.h#includecmathCLayout::CLayout(Direction dir):direction_(dir){}CLayout::~CLayout(){// 注意jsNodeRef需要统一释放这里不做展开}voidCLayout::AddChild(napi_env env,napi_value jsChild){ChildInfo child;// 这里假设jsChild对象有width和height属性// 实际项目需要用napi获取属性值child.width100.0f;// 占位逻辑实际应读取child.height50.0f;// 占位逻辑napi_create_reference(env,jsChild,1,child.jsNodeRef);children_.push_back(child);}voidCLayout::Measure(floatmaxWidth,floatmaxHeight){// 此示例简化每个子组件大小固定// 真实场景应该调用子组件的measure方法for(autochild:children_){// 这里只是示意实际应通过napi调用子组件的测量方法child.width100.0f;child.height50.0f;}}voidCLayout::Layout(){floatcurrentX0.0f;floatcurrentY0.0f;for(autochild:children_){child.xcurrentX;child.ycurrentY;if(direction_Direction::HORIZONTAL){currentXchild.width;// 水平排列向右依次排列}else{currentYchild.height;// 垂直排列向下依次排列}}}std::vectorChildInfoCLayout::GetLayoutResult(){returnchildren_;}这一段代码的主要作用是定义了一个最基础的自定义布局容器。AddChild把子组件注册到容器中并记录引用Measure为每个子组件确定宽高Layout根据方向和measure结果给每个子组件分配一个位置。这里需要注意Measure环节在真实项目中需要调用子组件的Measure方法也就是通过napi调用JS侧的测量。为了方便演示这里用了固定值。实际项目里如果子组件大小未知需要从JS侧读取。第二步暴露Napi接口在napi_init.cpp中注册创建布局、添加子组件和获取布局结果的方法。// napi_init.cpp#includenapi/native_api.h#includeclayout.hstaticnapi_valueCreateLayout(napi_env env,napi_callback_info info){size_t argc1;napi_value args[1];napi_get_cb_info(env,info,argc,args,nullptr,nullptr);int32_tdir0;napi_get_value_int32(env,args[0],dir);// 创建C布局对象并包装成NativePointer传给JS// 注意这里为了演示简化了对象生命周期的管理实际需要妥善处理CLayout*layoutnewCLayout(static_castDirection(dir));napi_value result;napi_create_external(env,layout,[](napi_env env,void*data,void*hint){deletestatic_castCLayout*(data);},nullptr,result);returnresult;}staticnapi_valueAddChildToLayout(napi_env env,napi_callback_info info){size_t argc2;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 jsChildargs[1];layout-AddChild(env,jsChild);returnnullptr;}staticnapi_valueGetLayoutResult(napi_env env,napi_callback_info info){size_t argc1;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();autoresultlayout-GetLayoutResult();// 构建JS数组返回napi_value jsArray;napi_create_array(env,jsArray);for(size_t i0;iresult.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后续addChildToLayout和getLayoutResult通过这个指针操作同一个对象。这里有一个容易忽略的问题接口调用的时序。getLayoutResult内部同时调用了Measure和Layout这意味着每次获取布局结果的成本是一次完整的重新计算。如果布局逻辑复杂建议拆成measureLayout分开调用并增加缓存判断。第三步在ArkUI中调用C布局// CustomLayout.etsimportnativeLayoutfromlibentry.so;interfaceLayoutResult{x:number;y:number;width:number;height:number;}EntryComponentstruct CustomLayoutDemo{Stateresults:LayoutResult[][];privatelayoutPtr:Object|nullnull;aboutToAppear(){// 创建水平排列的布局容器this.layoutPtrnativeLayout.createLayout(0);// 0表示水平}build(){Column(){Button(触发C布局).onClick((){if(this.layoutPtr){// 假设我们有5个子组件constchildIds:Object[][];for(leti0;i5;i){// 简化直接传this作为子组件占位实际需要对应视图组件childIds.push(this);}for(letchildofchildIds){nativeLayout.addChildToLayout(this.layoutPtr,child);}constlayoutResults:LayoutResult[]nativeLayout.getLayoutResult(this.layoutPtr);this.resultslayoutResults;}})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)}}常见问题问题1Napi回调中的JS引用泄漏现象页面反复创建销毁时系统内存不断上涨。原因在CLayout::AddChild中使用napi_create_reference创建了强引用但在CLayout对象释放时没有及时调用napi_delete_reference。JS侧的组件对象永远不会被回收。解决方案在CLayout的析构函数中添加统一的引用释放逻辑CLayout::~CLayout(){// 这里假设我们有napi_env的引用策略实际需要存起来for(autochild:children_){// napi_delete_reference(env, child.jsNodeRef);}}真实项目中建议把napi_env也作为成员保存并且在对象释放时逐条删除引用。问题2measure与layout结果不同步现象第二次调用getLayoutResult时坐标与第一次不一致。原因Measure和Layout每次都会重新计算没有缓存。如果ArkTS侧两次调用间没有改变子组件大小布局算法里也使用了固定值结果应该一致。但一旦算法依赖于组件的动态状态比如组件当前屏幕宽度前后两次的环境不一样就会导致问题。解决方案引入缓存机制带上时间戳或版本号。只有当子组件大小、父容器大小、或者布局参数发生变化时才重新计算。最佳实践不要在ArkTS的build()中频繁触发C布局计算build()会随状态变化频繁执行每次调用Napi接口都会附带跨语言调用的成本。建议把布局计算放在aboutToAppear或者按钮点击事件中只在必要时更新。C侧的生命周期必须与ArkTS界面组件的生命周期同步当ArkTS页面被销毁时如果C对象还在存活就会造成内存泄漏。在aboutToDisappear中及时释放C对象或者使用智能指针。单个子组件的宽高建议从ArkTS侧传入不要在C中硬编码宽高。通过AddChild时传入子组件的width和height属性让C容器能够动态感知子组件的变化。FAQQ为什么这里直接在C里写死了子组件宽高实际项目可以用吗A不可以。这里为了演示流程简化了。实际项目中需要从子组件的width和height属性中读取或者调用子组件的Measure方法。否则布局结果和真实显示对不上。Q多个C布局容器实例怎么管理A可以用一个工厂类或者统一的容器池来管理。每个createLayout返回的指针必须在页面销毁时释放。建议用一个Map按页面ID存储指针在aboutToDisappear中释放。Q这种做法的性能优势明显吗A如果子组件数量少于50个JS引擎本身已经足够快没有太大收益。如果子组件数量超过200个并且布局算法复杂比如依赖子组件之间相互计算C方式的优势就比较明显了。另外在动画过程中反复测量和布局时C的稳定性也更好。如果你也遇到类似问题可以重点检查Napi接口的生命周期管理和Meausre/Layout的分离时机。官方文档对这个能力的描述比较简单建议结合实际运行效果和真机内存监控一起验证。