HarmonyOS 组件封装实战:从 UI 动态操作到组件抽象
做 HarmonyOS 开发的同学,估计都遇到过这种场景:页面上有一堆长得差不多的卡片,滑动起来卡得要命;或者同样的按钮样式,每个页面都要复制粘贴一遍代码。这活儿干多了,自己都烦。今天咱们聊的,就是怎么把这些重复劳动变成可复用的组件,让代码真正"活"起来。组件动态操作:怎么在运行时动态创建、更新、删除组件组件封装:怎么把公共样式、布局、逻辑打包成可复用的组件场景:一个图片 + 文字的卡片组件,图片
写在前面
做 HarmonyOS 开发的同学,估计都遇到过这种场景:
页面上有一堆长得差不多的卡片,滑动起来卡得要命;或者同样的按钮样式,每个页面都要复制粘贴一遍代码。
这活儿干多了,自己都烦。
今天咱们聊的,就是怎么把这些重复劳动变成可复用的组件,让代码真正"活"起来。
这篇文章是系列第一篇,主要讲两件事:
- 组件动态操作:怎么在运行时动态创建、更新、删除组件
- 组件封装:怎么把公共样式、布局、逻辑打包成可复用的组件
一、组件动态操作:别只在 build() 里干活
1.1 问题出在哪
ArkUI 是声明式开发范式,组件通常只在 build() 生命周期里创建。但有些场景,你等不到 build() 执行:
- 列表流广告:滑动到某个位置,才从服务器拉广告数据,然后动态插入广告卡片
- 动态页面:根据后端下发的 JSON 配置,实时生成 UI 布局
- 动画执行期间:利用动画的空闲时间预创建组件,动画结束后直接显示
这时候,你就需要组件动态操作。
1.2 核心概念:FrameNode + NodeController
先说两个关键角色:
FrameNode:ArkUI 的自定义节点,可以理解为"轻量级组件"。它不需要创建自定义组件对象和状态变量,创建速度比声明式组件快得多。
NodeController:管理自定义节点的生命周期,负责创建、显示、更新、删除等操作。你需要继承这个抽象类,实现 makeNode() 方法。
1.3 动态添加组件:四步走
// 1. 创建自定义节点(用@Builder 定义 UI)
@Builder
function testBuilder(params: Params) {
Column() {
Text(params.text)
.fontSize(50)
.fontWeight(FontWeight.Bold)
}
}
// 2. 实现 NodeController
class TextNodeController extends NodeController {
private textNode: BuilderNode<[Params]> | null = null;
private message: string = '';
constructor(message: string) {
super();
this.message = message;
}
// 3. 实现 makeNode() 方法
makeNode(context: UIContext): FrameNode | null {
// 创建 BuilderNode 实例
this.textNode = new BuilderNode(context);
// 构建组件树
this.textNode.build(wrapBuilder<[Params]>(testBuilder), new Params(this.message));
// 返回要显示的节点
return this.textNode.getFrameNode();
}
}
// 4. 在页面中用 NodeContainer 显示
@Entry
@Component
struct Index {
private textNodeController: TextNodeController = new TextNodeController("hello");
build() {
Column() {
NodeContainer(this.textNodeController)
.width('100%')
.height(100)
}
}
}
关键点:
BuilderNode需要传入UIContext才能创建build()方法第一个参数是用wrapBuilder()封装的全局@Builder 方法makeNode()返回的节点会显示在对应的NodeContainer中
1.4 动态删除和更新
删除组件:用条件控制语句就能实现
@State isShow: boolean = true;
build() {
Column() {
if (this.isShow) {
NodeContainer(this.textNodeController)
}
Button('隐藏').onClick(() => {
this.isShow = false;
})
}
}
更新组件:依赖 rebuild() 方法
class TextNodeController extends NodeController {
// ... 省略部分代码
replaceBuilderNode(newNode: BuilderNode<Object[]>) {
this.textNode = newNode;
// rebuild 会重新触发 makeNode 回调
this.rebuild();
}
}
1.5 实战案例:列表流广告
这是华为官方文档里的一个经典场景:在新闻列表里穿插广告卡片。
实现思路:
- 用列表数据构建 List 布局,根据数据类型分别执行对应逻辑
- 如果是广告类型,用
NodeContainer预占位 - 当
NodeContainer渲染时,发起请求获取广告信息 - 解析数据明确广告类型后,构建具体的广告布局(图文/视频)
- 布局构建完成后,返回 rootNode 实现组件上树
核心代码:
// 广告占位节点逻辑
export class AdNodeController extends NodeController {
private rootNode: FrameNode | null = null;
private adNode: BuilderNode<[AdParams]> | null = null;
makeNode(): FrameNode | null {
if (this.isRemove) {
return null;
}
if (this.rootNode != null) {
return this.rootNode;
}
return null;
}
// 初始化广告组件
initAd(uiContext: UIContext, id: string, adType: string) {
this.rootNode = new FrameNode(this.uiContext);
this.adNode = new BuilderNode(this.uiContext);
this.adNode.build(wrapBuilder(adBuilder), { id: id, isVideo: adType === 'video' });
this.rootNode.getRenderNode()?.appendChild(this.adNode.getFrameNode()?.getRenderNode());
}
// 关闭广告
remove() {
this.isRemove = true;
}
}
// 列表布局
List({ space: 3 }) {
LazyForEach(this.data, (item: CardData) => {
ListItem() {
if (item.isAdCard()) {
// 广告项:预埋 NodeContainer 占位
NodeContainer(getAdNodeController(this.getUIContext(), item.getId()))
.width($r('app.string.percent_100'));
} else {
// 普通项:正常渲染
CardComponent({ cardData: item });
}
}
}, (item: CardData) => item.getId())
}
二、组件封装:把重复代码打包带走
2.1 为什么要封装
还是那个道理:同样的代码,写一遍是本事,写三遍是事故。
组件封装有三个典型场景:
- 公共样式封装:按钮、输入框等组件的样式统一
- 自定义组件封装:布局 + 逻辑一起打包
- 组件工厂类封装:动态获取不同类型的组件
2.2 公共样式封装:AttributeModifier
场景:登录页面的登录按钮,和购物页面的结算按钮,样式完全一样。难道每个页面都要写一遍 .width(200).height(50).fontSize(20)?
解法:用 AttributeModifier 属性修改器。
// 1. 定义样式修饰器
export class MyButtonModifier implements AttributeModifier<ButtonAttribute> {
private buttonType: ButtonType = ButtonType.Normal;
applyNormalAttribute(instance: ButtonAttribute): void {
instance.type(this.buttonType);
instance.width(200);
instance.height(50);
instance.fontSize(20);
instance.fontColor('#0A59F7');
instance.backgroundColor('#0D000000');
}
applyPressedAttribute(instance: ButtonAttribute): void {
instance.fontColor('#0A59F7');
instance.backgroundColor('#26000000');
}
}
// 2. 在组件中使用
@Entry
@Component
struct AttributeStylePage {
modifier = new MyButtonModifier().type(ButtonType.Capsule);
build() {
Column() {
Button('Capsule Button')
.attributeModifier(this.modifier)
}
}
}
注意:AttributeModifier 目前只支持系统组件,不支持自定义组件。
2.3 自定义组件封装:把变化暴露出去
场景:一个图片 + 文字的卡片组件,图片和文字的样式可以自定义,但布局固定是纵向排列。
解法:用 @Component 封装自定义组件,不变的部分写死在内部,变化的部分用参数暴露。
// 1. 封装自定义组件
@Component
export struct CustomImageText {
@Prop imageAttribute: AttributeModifier<ImageAttribute> = new CustomImageModifier(100, 100);
@Prop textAttribute: AttributeModifier<TextAttribute> = new CustomTextModifier();
@Prop imageSrc: PixelMap | ResourceStr | DrawableDescriptor;
@Prop text: string;
onClickEvent?: () => void;
build() {
Column({ space: 12 }) {
Image(this.imageSrc)
.attributeModifier(this.imageAttribute);
Text(this.text)
.attributeModifier(this.textAttribute);
}.onClick(() => {
if (this.onClickEvent !== undefined) {
this.onClickEvent();
}
})
}
}
// 2. 使用方可以自定义样式
@Component
struct CommonComponent {
imageAttribute: CustomImageModifier = new CustomImageModifier(330, 330);
build() {
CustomImageText({
imageAttribute: this.imageAttribute,
imageSrc: $r('app.media.image'),
text: 'Scenery',
onClickEvent: () => {
this.getUIContext().getPromptAction().showToast({ message: 'Clicked' });
}
})
}
}
2.4 组件工厂类封装:动态获取组件
场景:团队 A 实现了一个组件工厂,封装了 Radio、Checkbox 等多个组件。业务团队 B 希望根据组件名动态获取对应的组件模板。
解法:用 @Builder + wrapBuilder + Map 实现组件工厂。
// 1. 用@Builder 封装组件
@Builder
function myRadio() {
Text('性别选择');
Row() {
Radio({ value: '1', group: 'radioGroup' });
Text('男');
}
Row() {
Radio({ value: '0', group: 'radioGroup' });
Text('女');
}
}
@Builder
function myCheckBox() {
Text('兴趣爱好');
Row() {
Checkbox({ name: '1', group: 'checkboxGroup' });
Text('阅读');
}
Row() {
Checkbox({ name: '0', group: 'checkboxGroup' });
Text('运动');
}
}
// 2. 存入工厂 Map
let factoryMap: Map<string, object> = new Map();
factoryMap.set('Radio', wrapBuilder(myRadio));
factoryMap.set('Checkbox', wrapBuilder(myCheckBox));
export { factoryMap };
// 3. 使用方根据 key 获取组件
import { factoryMap } from '../view/FactoryMap';
@Component
struct ComponentFactory {
build() {
Column({ space: 12 }) {
let myRadio: WrappedBuilder<[]> = factoryMap.get('Radio') as WrappedBuilder<[]>;
let myCheckbox: WrappedBuilder<[]> = factoryMap.get('Checkbox') as WrappedBuilder<[]>;
myRadio.builder();
myCheckbox.builder();
}
}
}
限制:
wrapBuilder只支持传入全局@Builder 方法- 返回的
WrappedBuilder对象的builder()方法只能在 struct 内部使用
三、常见问题:这几个坑我帮你踩了
3.1 如何调用子组件中的方法
方法一:Controller 类(推荐)
// 定义 Controller
export class Controller {
action = () => {};
}
// 子组件
@Component
export struct ChildComponent {
controller: Controller | undefined = undefined;
aboutToAppear(): void {
if (this.controller) {
this.controller.action = this.switchColor;
}
}
private switchColor = () => {
// 子组件的方法
};
}
// 父组件
@Entry
@Component
struct Index {
private childRef = new Controller();
build() {
Column() {
ChildComponent({ controller: this.childRef });
Button('切换颜色').onClick(() => {
this.childRef.action(); // 间接调用子组件方法
})
}
}
}
方法二:@Watch
// 子组件
@Component
export struct ChildComponent {
@Link @Watch('switchColor') checkFlag: boolean;
private switchColor() {
if (this.checkFlag) {
this.bgColor = Color.Red;
} else {
this.bgColor = Color.White;
}
}
}
// 父组件
@Entry
@Component
struct Index {
@State childCheckFlag: boolean = false;
build() {
Column() {
ChildComponent({ checkFlag: this.childCheckFlag });
Button('切换颜色').onClick(() => {
this.childCheckFlag = !this.childCheckFlag;
})
}
}
}
方法三:Emitter 事件通信
适合跨组件、跨页面的场景,这里不展开。
3.2 如何调用父组件中的方法
简单,把父组件的方法当参数传给子组件就行:
// 子组件
@Component
export struct ChildComponent {
call = () => {};
build() {
Button('调用父组件方法')
.onClick(() => {
this.call();
})
}
}
// 父组件
@Entry
@Component
struct Index {
parentAction() {
this.getUIContext().getPromptAction().showToast({ message: 'Parent Action' });
}
build() {
ChildComponent({ call: this.parentAction });
}
}
3.3 如何实现类似插槽的功能
用 @BuilderParam:
// 子组件
@Component
export struct ChildComponent {
@BuilderParam customBuilderParam: () => void = this.customBuilder;
@Builder
customBuilder() {}
build() {
Column() {
Text('子组件固定内容');
this.customBuilderParam(); // 可变内容
}
}
}
// 父组件
@Entry
@Component
struct Index {
@Builder
componentBuilder() {
Text('父组件传入的内容');
}
build() {
ChildComponent() {
this.componentBuilder();
}
}
}
四、性能对比:动态操作到底快在哪
华为官方文档里有个性能测试数据:
场景:视频首页的动态布局
| 方案 | 完成时延 |
|---|---|
| 声明式开发范式 | 13.7ms |
| FrameNode 扩展模式 | 6.1ms |
快了 55%。
原因很简单:
- FrameNode 不需要创建自定义组件对象和状态变量
- 无需进行依赖收集
- 可以直接操作组件树,避免全量 diff
小结
这篇文章讲了两个核心内容:
- 组件动态操作:用
FrameNode+NodeController在非 build 生命周期创建组件,适合动态布局、预创建等场景 - 组件封装:用
AttributeModifier封装公共样式,用@Component封装自定义组件,用wrapBuilder实现组件工厂
核心思想就一个:把重复的代码打包成可复用的单元,让开发效率飞起来。
记住啊,代码写得好,下班下得早。
更多推荐

所有评论(0)