写在前面

做 HarmonyOS 开发的同学,估计都遇到过这种场景:

页面上有一堆长得差不多的卡片,滑动起来卡得要命;或者同样的按钮样式,每个页面都要复制粘贴一遍代码。

这活儿干多了,自己都烦。

今天咱们聊的,就是怎么把这些重复劳动变成可复用的组件,让代码真正"活"起来。

这篇文章是系列第一篇,主要讲两件事:

  1. 组件动态操作:怎么在运行时动态创建、更新、删除组件
  2. 组件封装:怎么把公共样式、布局、逻辑打包成可复用的组件

一、组件动态操作:别只在 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 实战案例:列表流广告

这是华为官方文档里的一个经典场景:在新闻列表里穿插广告卡片。

实现思路

  1. 用列表数据构建 List 布局,根据数据类型分别执行对应逻辑
  2. 如果是广告类型,用 NodeContainer 预占位
  3. NodeContainer 渲染时,发起请求获取广告信息
  4. 解析数据明确广告类型后,构建具体的广告布局(图文/视频)
  5. 布局构建完成后,返回 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 为什么要封装

还是那个道理:同样的代码,写一遍是本事,写三遍是事故。

组件封装有三个典型场景:

  1. 公共样式封装:按钮、输入框等组件的样式统一
  2. 自定义组件封装:布局 + 逻辑一起打包
  3. 组件工厂类封装:动态获取不同类型的组件

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%

原因很简单:

  1. FrameNode 不需要创建自定义组件对象和状态变量
  2. 无需进行依赖收集
  3. 可以直接操作组件树,避免全量 diff

小结

这篇文章讲了两个核心内容:

  1. 组件动态操作:用 FrameNode + NodeController 在非 build 生命周期创建组件,适合动态布局、预创建等场景
  2. 组件封装:用 AttributeModifier 封装公共样式,用 @Component 封装自定义组件,用 wrapBuilder 实现组件工厂

核心思想就一个:把重复的代码打包成可复用的单元,让开发效率飞起来。


记住啊,代码写得好,下班下得早。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐