Skip to content
🌊海洋蓝
🌸樱花粉
🍃森林绿
🔮幻夜紫
🌙暗夜黑

HarmonyOS 组件动态创建完全指南:从入门到实战

本文将带你深入了解 HarmonyOS 中的组件动态创建技术,通过实际案例和详细解释,帮助你掌握这一强大的开发技能。

🚀 开篇:为什么需要动态创建组件?

在传统的 HarmonyOS 开发中,我们通常在build()方法中创建组件。但想象一下这些场景:

  • 📱 新闻应用:需要根据服务器返回的广告类型动态插入不同的广告组件
  • 🎬 视频应用:首页轮播图需要根据实时数据动态更新
  • 💬 聊天应用:消息类型不同,需要动态创建不同的聊天气泡组件
  • 🛒 电商应用:商品详情页面布局需要根据商品类型动态调整

这些场景都有一个共同点:组件的类型和结构在开发时无法确定,需要在运行时根据数据动态创建

📚 基础概念:什么是组件动态创建?

传统方式 vs 动态创建

传统方式

typescript
// 只能在build()中创建组件
build() {
  Column() {
    Text('固定内容')
    Image($r('app.media.fixed_image'))
  }
}

动态创建方式

typescript
// 可以在任何时候创建组件,不局限于build()
private createDynamicComponent(data: any) {
  // 根据数据类型动态创建不同组件
  if (data.type === 'text') {
    return createTextComponent(data)
  } else if (data.type === 'image') {
    return createImageComponent(data)
  }
  // ... 更多类型
}

官方定义

为了解决页面、组件加载缓慢的问题, ArkUI 框架提供了动态操作以实现组件预创建,并允许应用在运行时根据实际需要加载渲染相应的组件。动态操作包含动态创建组件(动态添加组件)、动态卸载组件(动态删除组件)等相关操作。动态创建组件指在非 build 生命周期中进行组件创建,即在 build 生命周期前提前创建组件。通过动态创建组件,不但可以节省组件创建的时间,提升用户体验,还可以将独立的逻辑进行封装,有助于应用模块化开发。动态卸载组件是对动态创建的组件进行卸载、删除。

动态创建的核心优势

  1. ⚡ 性能提升:可以在空闲时间预创建组件,提升页面响应速度
  2. 🔄 灵活性:根据实际需要动态加载不同组件
  3. 📦 模块化:将独立逻辑封装,便于维护和复用
  4. 💾 内存优化:按需创建和销毁组件,避免内存浪费

🎯 核心原理:组件预创建机制

工作原理解析

在声明式范式中,组件仅在 build 环节中被创建,开发者无法在其他生命周期阶段进行组件的创建,从而引起页面加载慢等问题。与声明式范式不同, ArkUI 框架提供的 UI 动态操作支持组件的预创建。组件预创建可以满足开发者在非 build 生命周期中进行组件创建,创建后的组件可以进行属性设置、布局计算等操作。之后在页面加载时进行使用,可以极大提升页面响应速度。

如下图所示,利用组件预创建机制,可以利用动画执行过程空闲时间进行组件预创建和属性设置。在动画结束后,再进行属性和布局的更新,节省了组件创建的时间,从而加快了页面渲染。

图 1 组件预创建原理图

💡 理解要点:传统方式只能在 build 阶段创建组件,而动态创建可以在动画执行等空闲时间提前创建组件,等需要时直接使用,大大提升响应速度。

生命周期对比

传统组件生命周期

数据准备 → build() → 组件创建 → 布局计算 → 渲染显示

动态创建组件生命周期

空闲时间 → 组件预创建 → 数据准备 → 快速布局更新 → 渲染显示

🛠️ 技术深入:FrameNode 的强大之处

什么是 FrameNode?

FrameNode 是 HarmonyOS 提供的底层节点操作接口,它绕过了传统声明式开发的一些限制,让你能够:

  • 直接操作组件树结构
  • 避免创建自定义组件和状态变量的开销
  • 实现高效的按需更新

FrameNode 的三大优势

1. 🚀 减少自定义组件创建开销

在采用声明式开发范式中,若使用 ArkUI 的自定义组件对节点树中的每个节点进行定义,往往会遇到节点创建效率低下的问题。

这主要是因为每个节点在 ArkTS 引擎中都需要分配内存空间来存储应用程序的自定义组件和状态变量。在节点创建过程中,还必须执行组件 ID、组件闭包以及状态变量之间的依赖关系收集等操作。

相比之下,使用 ArkUI 的FrameNode,可以避免创建自定义组件对象和状态变量对象,无需进行依赖收集,从而显著提升组件创建的速度。

传统方式的问题

  • 每个组件都需要在 ArkTS 引擎中分配内存
  • 需要执行组件 ID、闭包、状态变量依赖收集等操作
  • 创建开销大,特别是复杂组件

FrameNode 的优势

  • 避免创建自定义组件对象
  • 无需状态变量对象和依赖收集
  • 显著提升创建速度

2. ⚡ 组件更新更快

在动态布局类框架的更新场景中,通常存在一个由树形数据结构 ViewModelA 创建的 UI 组件树 TreeA。当需要使用新的数据结构 ViewModelB 来更新 TreeA 时,尽管声明式开发范式可以实现数据驱动的自动更新,但这一过程中却伴随着大量的 diff 操作,如下图所示。对于 ArkTS 引擎而言,在对一个复杂组件树(深度超过 30 层,包含 100 至 200 个组件)执行 diff 算法时,几乎无法在 120Hz 的刷新率下保持满帧运行。然而,使用 ArkUI 的 FrameNode 扩展,框架能够自主掌控更新流程,实现高效的按需剪枝。特别是针对那些仅服务于少数特定业务的动态布局框架,利用这一扩展,可以实现快速的更新操作。

传统声明式更新的问题

  • 需要大量的 diff 操作
  • 复杂组件树(30 层深度,100-200 个组件)难以在 120Hz 下满帧运行

FrameNode 的优势

  • 自主掌控更新流程
  • 实现高效的按需剪枝
  • 快速的更新操作

3. 🎯 直接操作组件树

使用声明式开发范式还存在组件树结构更新操作困难的痛点,比如将组件树中的一个子树从当前子节点完整移到另一个子节点,使用声明式开发范式无法直接调整组件实例的结构关系,只能通过重新渲染整棵组件树的方式实现上述操作。而使用 ArkUI 的 FrameNode 扩展,则可以通过操作 FrameNode 来很方便的操控该子树,将其移植到另一个节点,这样只会进行局部渲染刷新,性能更优。

传统方式的限制

  • 无法直接调整组件实例的结构关系
  • 移动子树需要重新渲染整棵组件树

FrameNode 的灵活性

  • 可以直接操作组件树结构
  • 局部渲染刷新,性能更优
  • 灵活的节点移动和重组

🔧 实战教程:组件动态操作详解

动态添加组件:四个核心步骤

动态添加组件包括以下步骤:

  1. 创建自定义节点。
  2. 实现NodeController,用于自定义节点的创建、显示、更新等操作的管理,并负责将自定义节点挂载到NodeContainer上。
  3. 实现 NodeController 的 makeNode 方法,makeNode 会在 NodeController 实例绑定 NodeContainer 的时候进行回调,并将返回的节点挂载至 NodeContainer。
  4. 使用 NodeContainer 显示自定义节点。

步骤 1:创建自定义节点

首先,准备好需要挂载的节点,代码如下所示。

typescript
// 创建一个简单的文本节点
@Builder
function buildText(text: string) {
  Text(text)
    .fontSize(16)
    .fontColor(Color.Black)
    .padding(10)
}

步骤 2:实现 NodeController

NodeController 为抽象类,需要继承并实现 NodeController,代码如下所示。

typescript
class TextNodeController extends NodeController {
  private textContent: string = "";
  private builderNode: BuilderNode<[string]> | null = null;

  constructor(text: string) {
    super();
    this.textContent = text;
  }

  // 必须实现的方法:创建节点
  makeNode(uiContext: UIContext): FrameNode | null {
    // 创建 BuilderNode
    this.builderNode = new BuilderNode(uiContext);

    // 构建组件树
    this.builderNode.build(wrapBuilder(buildText), this.textContent);

    // 返回根节点
    return this.builderNode.getFrameNode();
  }

  // 更新节点内容
  updateText(newText: string) {
    this.textContent = newText;
    if (this.builderNode) {
      this.builderNode.update({ text: newText });
    }
  }
}

步骤 3:实现 makeNode 方法

首先,使用构造函数创建 BuilderNode 实例。创建 BuilderNode 对象的时候必须要传入对应的 UIContext 对象。若 BuilderNode 作为 RenderNode 的子节点存在,要求设置 RenderOptions 的 selfIdealSize 属性。

然后,使用 BuilderNode 的 build 方法,构建组件树。方法 build()需要传入两个参数,第一个参数为通过 wrapBuilder()封装的全局@Builder 方法。第二个参数为对应的@Builder 方法所需的参数对象。若@Builder 方法不带参数或者存在默认参数,则 build()的第二个参数可以不设置。

步骤 4:显示自定义节点

显示自定义节点依赖声明式渲染容器 NodeContainer 以及对应的控制类 NodeController。

NodeController 的 makeNode 方法返回的节点会显示在对应的 NodeContainer 中。由于 makeNode 需要返回的为一个 FrameNode,因此如果预期显示 BuilderNode 的时候需要调用对应的 BuilderNode 的 getFrameNode 的方法,获取其根节点,详细代码如上 TextNodeController 中所示。

然后,在页面内新增声明式渲染容器 NodeContainer,创建工具类 NodeController。通过 NodeController 将 MakeNode 中返回的节点在声明式渲染容器中进行显示。

typescript
@Entry
@Component
struct DynamicComponentPage {
  private textController: TextNodeController = new TextNodeController('初始文本')
  @State showNode: boolean = true

  build() {
    Column() {
      Button('切换显示')
        .onClick(() => {
          this.showNode = !this.showNode
        })

      Button('更新文本')
        .onClick(() => {
          this.textController.updateText('新的文本内容')
        })

      // 动态组件容器
      if (this.showNode) {
        NodeContainer(this.textController)
          .width('100%')
          .height(100)
      }
    }
    .padding(20)
  }
}

动态更新组件

更新自定义节点,可参考 BuilderNode 的update方法。

动态删除组件

通过条件控制语句可以将 NodeContainer 节点进行移除或者显示。如示例代码,将 this.isShow 更改为 false 则将节点从界面上移除。

动态更新组件

动态将 NodeContainer 上的节点替换,依赖于 NodeController 的 makeNode 和 rebuild 方法。rebuild 方法会触发 makeNode 的回调,刷新 NodeContainer 上显示的节点;makeNode 方法返回的为 null,则移除 NodeContainer 下挂载的节点。

开发者可以根据实际情况创建新的节点,参考示例代码如下所示:

NodeController 生命周期详解

NodeController 用于控制和反馈对应的 NodeContainer 上的节点的行为,需要与 NodeContainer 一起使用。下面,对其常用生命周期函数进行说明。

typescript
class MyNodeController extends NodeController {
  // 🔴 必须实现:创建节点
  makeNode(uiContext: UIContext): FrameNode | null {
    console.log("makeNode: 创建节点");
    // 返回要显示的节点
    return this.createMyNode(uiContext);
  }

  // 🟡 布局变化时调用
  aboutToResize(size: Size) {
    console.log("aboutToResize: 布局大小变化", size);
  }

  // 🟢 节点显示时调用
  aboutToAppear() {
    console.log("aboutToAppear: 节点即将显示");
  }

  // 🔴 节点消失时调用
  aboutToDisappear() {
    console.log("aboutToDisappear: 节点即将消失");
  }
}
  • makeNode 必须要重写的方法,用于构建节点树、返回节点挂载在对应 NodeContainer 中。在对应 NodeContainer 创建绑定当前 NodeController 的时候调用、或者通过 rebuild 方法调用刷新。
  • aboutToResize 当 controller 对应的 NodeContainer 在 Mesure 的时候进行回调,入参为节点的布局大小。
  • aboutToAppear 当 controller 对应的 NodeContainer 在 onAppear 的时候进行回调。
  • aboutToDisappear 当 controller 对应的 NodeContainer 在 onDisappear 的时候进行回调。

📱 实战案例一:列表流广告组件

场景分析

App 广告有一种场景是列表流广告,即在应用的列表流中穿插展示广告条目,旨在将广告无缝融入用户的浏览体验中,使其看起来像是正常的内容(广告条目需要加标记区别展示),从而吸引用户的注意力并提高参与度,例如新闻列表中的广告条目、商品列表中的广告条目等。

这种广告的布局和内容在开发阶段不确定(可能是图文、视频等形式中的一种),其通常是在运行阶段,依赖服务器下发的数据进行逻辑映射后,再执行布局的构建、内容的加载显示。所以在实际的开发中,应用需要使用动态创建组件的能力去实现该列表流广告。

挑战

  • 广告类型在开发时不确定(可能是图文、视频等)
  • 广告内容需要从服务器动态获取
  • 需要支持广告的关闭和屏蔽功能

实现方案解析

  1. 使用列表数据构建 List 布局,根据数据类型分别执行对应逻辑,如果是广告类型,使用 NodeContainer 进行预占位。
  2. 当 NodeContainer 渲染时,发起请求获取广告信息等数据。解析数据明确广告类型后,构建具体的广告布局,比如图文布局、视频布局等。
  3. 布局构建完成后,返回 rootNode 实现组件上树,最后在容器中渲染显示。

开发步骤详解

1. 加载列表数据

模拟从服务器端获取列表数据,分别生成列表数据对象和广告数据对象。

示例代码中用 CardData(true, id)表示广告数据对象。

2. 列表及广告项布局

在 ListItem 布局逻辑中,需要判断该项是否广告节点:若是则预埋 NodeContainer 容器占位;若不是,通过 CardComponent()进行列表内容项的布局及渲染。

3. 加载广告布局

getAdNodeController()方法是通过 queryAdById()模拟广告类型信息的获取,并在完成信息获取后构建相应的 NodeController。

4. 广告占位节点逻辑

上一步返回的 AdNodeController 继承自 NodeController,因占位结点 NodeContainer 需要设置控制类对象 AdNodeController 做为参数。其实现中的 initAd()方法通过 this.adNode.build()接口将广告组件添加到 rootNode 上。当 NodeContrainer 进行绘制时,会调用 makeNode()方法,将构建好的 rootNode 返回实现组件上树。

5. 构建广告组件

上一步 this.adNode.build()接口构建时,调用了 wrapBuilder(adBuilder),这里使用封装的广告组件 AdComponent,它通过模拟获取的广告类型去判断,进一步构建图文广告组件,或视频广告组件。

6. 关闭/屏蔽广告功能

广告组件需要提供关闭功能,当点击确认屏蔽按钮后,通过 node.remove()通知 AdNodeController 标记该广告移除,设置 this.isRemove 为 true,再通过 node.rebuild()接口触发组件重绘,此时系统会再次执行 makeNode 接口,根据 this.isRemove 标记返回 null 结点,实现广告组件下树。

📱 实战案例二:动态生成页面

场景描述

下面使用视频首页刷新图片资源作为场景,来介绍如何使用 ArkUI 的 FrameNode 来实现动态布局。

ArkUI 的声明式扩展使用

一个简化的动态布局类框架的 DSL 一般会使用 JSON、XML 等数据交换格式来描述 UI,下面使用 JSON 为例进行说明。本案例相关核心字段含义如下表所示:

标签含义
type描述 UI 组件的类型,通常与原生组件存在一一对应的关系,也可能是封装的某种组件
content文本,图片类组件的内容
css描述 UI 组件的布局特性
children当前组件的子组件

1. 定义视频应用首页 UI 描述数据

在 resources/rawfile 目录下创建 structure.json 文件,内容如下。

2. 定义相应数据结构

用于接收 UI 描述数据,代码示例如下。

3. 自定义 DSL 解析逻辑

且使用 carouselNodes 保存轮播图节点,方便后续操作节点更新,代码示例如下。

4. 使用 NodeContainer 组件

嵌套 ArkUI 的 FrameNode 扩展和 ArkUI 的声明式语法。

性能对比分析

以场景示例中的两种方案实现,通过 DevEco Studio 的 Profile 工具抓取 Trace 进行性能分析比对。

  1. 以上示例场景在声明式开发范式下的完成时延为 13.7ms(根据设备和场景不同,数据会有差异,本数据仅供参考),如下图所示。

  2. 以上示例场景在 FrameNode 扩展模式下的完成时延为 6.1ms(根据设备和场景不同,数据会有差异,本数据仅供参考),如下图所示。

📊 性能分析:FrameNode 方式避免了大量的状态管理和组件创建开销,在复杂页面中性能优势更加明显,性能提升达到55%

📋 最佳实践和注意事项

✅ 最佳实践

  1. 🎯 合理使用场景

    • 适用于需要动态创建组件的场景
    • 适用于性能要求较高的场景
    • 适用于需要复杂组件树操作的场景
  2. 🏗️ 代码组织

    typescript
    // 推荐:将不同类型的组件创建逻辑分离
    class ComponentFactory {
      static createTextComponent(data: TextData): FrameNode { ... }
      static createImageComponent(data: ImageData): FrameNode { ... }
      static createVideoComponent(data: VideoData): FrameNode { ... }
    }
  3. 🔄 生命周期管理

    typescript
    class MyNodeController extends NodeController {
      aboutToDisappear() {
        // 清理资源,避免内存泄漏
        this.cleanup();
      }
    
      private cleanup() {
        // 释放监听器、定时器等资源
      }
    }

⚠️ 注意事项

  1. 内存管理

    • 及时释放不再使用的节点
    • 避免循环引用导致内存泄漏
  2. 错误处理

    typescript
    makeNode(uiContext: UIContext): FrameNode | null {
      try {
        return this.createNode(uiContext)
      } catch (error) {
        console.error('创建节点失败:', error)
        return null  // 返回null表示创建失败
      }
    }
  3. 性能考虑

    • 避免在 makeNode 中执行耗时操作
    • 合理使用缓存减少重复创建

🎉 总结

通过本文的学习,你已经掌握了 HarmonyOS 组件动态创建的核心技术:

🔑 关键收获

  1. 理解动态创建的价值:提升性能、增强灵活性
  2. 掌握核心 API:NodeController、NodeContainer、FrameNode
  3. 学会实际应用:列表流广告、动态页面生成
  4. 了解最佳实践:性能优化、内存管理、错误处理

🚀 下一步行动

  1. 实践练习:尝试实现一个简单的动态组件
  2. 深入研究:探索更多 FrameNode 的高级用法
  3. 性能优化:使用 Profile 工具分析和优化性能
  4. 项目应用:在实际项目中应用这些技术

💡 温馨提示:动态创建组件是一个强大的工具,但不要过度使用。在简单场景中,传统的声明式开发方式可能更合适。选择合适的技术方案,才能达到最佳的开发效果。


希望这篇文章能帮助你更好地理解和使用 HarmonyOS 的组件动态创建技术!如果有任何问题,欢迎交流讨论。

FrameNode 是 HarmonyOS 提供的底层节点操作接口,它绕过了传统声明式开发的一些限制,让你能够:

  • 直接操作组件树结构
  • 避免创建自定义组件和状态变量的开销
  • 实现高效的按需更新

FrameNode 的三大优势

1. 🚀 减少自定义组件创建开销

在采用声明式开发范式中,若使用 ArkUI 的自定义组件对节点树中的每个节点进行定义,往往会遇到节点创建效率低下的问题。

这主要是因为每个节点在 ArkTS 引擎中都需要分配内存空间来存储应用程序的自定义组件和状态变量。在节点创建过程中,还必须执行组件 ID、组件闭包以及状态变量之间的依赖关系收集等操作。

相比之下,使用 ArkUI 的FrameNode,可以避免创建自定义组件对象和状态变量对象,无需进行依赖收集,从而显著提升组件创建的速度。

传统方式的问题

  • 每个组件都需要在 ArkTS 引擎中分配内存
  • 需要执行组件 ID、闭包、状态变量依赖收集等操作
  • 创建开销大,特别是复杂组件

FrameNode 的优势

  • 避免创建自定义组件对象
  • 无需状态变量对象和依赖收集
  • 显著提升创建速度

2. ⚡ 组件更新更快

在动态布局类框架的更新场景中,通常存在一个由树形数据结构 ViewModelA 创建的 UI 组件树 TreeA。当需要使用新的数据结构 ViewModelB 来更新 TreeA 时,尽管声明式开发范式可以实现数据驱动的自动更新,但这一过程中却伴随着大量的 diff 操作,如下图所示。对于 ArkTS 引擎而言,在对一个复杂组件树(深度超过 30 层,包含 100 至 200 个组件)执行 diff 算法时,几乎无法在 120Hz 的刷新率下保持满帧运行。然而,使用 ArkUI 的 FrameNode 扩展,框架能够自主掌控更新流程,实现高效的按需剪枝。特别是针对那些仅服务于少数特定业务的动态布局框架,利用这一扩展,可以实现快速的更新操作。

传统声明式更新的问题

  • 需要大量的 diff 操作
  • 复杂组件树(30 层深度,100-200 个组件)难以在 120Hz 下满帧运行

FrameNode 的优势

  • 自主掌控更新流程
  • 实现高效的按需剪枝
  • 快速的更新操作

3. 🎯 直接操作组件树

使用声明式开发范式还存在组件树结构更新操作困难的痛点,比如将组件树中的一个子树从当前子节点完整移到另一个子节点,使用声明式开发范式无法直接调整组件实例的结构关系,只能通过重新渲染整棵组件树的方式实现上述操作。而使用 ArkUI 的 FrameNode 扩展,则可以通过操作 FrameNode 来很方便的操控该子树,将其移植到另一个节点,这样只会进行局部渲染刷新,性能更优。

传统方式的限制

  • 无法直接调整组件实例的结构关系
  • 移动子树需要重新渲染整棵组件树

FrameNode 的灵活性

  • 可以直接操作组件树结构
  • 局部渲染刷新,性能更优
  • 灵活的节点移动和重组

🔧 实战教程:组件动态操作详解

动态添加组件:四个核心步骤

动态添加组件包括以下步骤:

  1. 创建自定义节点。
  2. 实现NodeController,用于自定义节点的创建、显示、更新等操作的管理,并负责将自定义节点挂载到NodeContainer上。
  3. 实现 NodeController 的 makeNode 方法,makeNode 会在 NodeController 实例绑定 NodeContainer 的时候进行回调,并将返回的节点挂载至 NodeContainer。
  4. 使用 NodeContainer 显示自定义节点。

步骤 1:创建自定义节点

首先,准备好需要挂载的节点,代码如下所示。

typescript
// 创建一个简单的文本节点
@Builder
function buildText(text: string) {
  Text(text)
    .fontSize(16)
    .fontColor(Color.Black)
    .padding(10)
}

步骤 2:实现 NodeController

NodeController 为抽象类,需要继承并实现 NodeController,代码如下所示。

typescript
class TextNodeController extends NodeController {
  private textContent: string = "";
  private builderNode: BuilderNode<[string]> | null = null;

  constructor(text: string) {
    super();
    this.textContent = text;
  }

  // 必须实现的方法:创建节点
  makeNode(uiContext: UIContext): FrameNode | null {
    // 创建 BuilderNode
    this.builderNode = new BuilderNode(uiContext);

    // 构建组件树
    this.builderNode.build(wrapBuilder(buildText), this.textContent);

    // 返回根节点
    return this.builderNode.getFrameNode();
  }

  // 更新节点内容
  updateText(newText: string) {
    this.textContent = newText;
    if (this.builderNode) {
      this.builderNode.update({ text: newText });
    }
  }
}

步骤 3:实现 makeNode 方法

首先,使用构造函数创建 BuilderNode 实例。创建 BuilderNode 对象的时候必须要传入对应的 UIContext 对象。若 BuilderNode 作为 RenderNode 的子节点存在,要求设置 RenderOptions 的 selfIdealSize 属性。

然后,使用 BuilderNode 的 build 方法,构建组件树。方法 build()需要传入两个参数,第一个参数为通过 wrapBuilder()封装的全局@Builder 方法。第二个参数为对应的@Builder 方法所需的参数对象。若@Builder 方法不带参数或者存在默认参数,则 build()的第二个参数可以不设置。

步骤 4:显示自定义节点

显示自定义节点依赖声明式渲染容器 NodeContainer 以及对应的控制类 NodeController。

NodeController 的 makeNode 方法返回的节点会显示在对应的 NodeContainer 中。由于 makeNode 需要返回的为一个 FrameNode,因此如果预期显示 BuilderNode 的时候需要调用对应的 BuilderNode 的 getFrameNode 的方法,获取其根节点,详细代码如上 TextNodeController 中所示。

然后,在页面内新增声明式渲染容器 NodeContainer,创建工具类 NodeController。通过 NodeController 将 MakeNode 中返回的节点在声明式渲染容器中进行显示。

typescript
@Entry
@Component
struct DynamicComponentPage {
  private textController: TextNodeController = new TextNodeController('初始文本')
  @State showNode: boolean = true

  build() {
    Column() {
      Button('切换显示')
        .onClick(() => {
          this.showNode = !this.showNode
        })

      Button('更新文本')
        .onClick(() => {
          this.textController.updateText('新的文本内容')
        })

      // 动态组件容器
      if (this.showNode) {
        NodeContainer(this.textController)
          .width('100%')
          .height(100)
      }
    }
    .padding(20)
  }
}

动态更新组件

更新自定义节点,可参考 BuilderNode 的update方法。

动态删除组件

通过条件控制语句可以将 NodeContainer 节点进行移除或者显示。如示例代码,将 this.isShow 更改为 false 则将节点从界面上移除。

动态更新组件

动态将 NodeContainer 上的节点替换,依赖于 NodeController 的 makeNode 和 rebuild 方法。rebuild 方法会触发 makeNode 的回调,刷新 NodeContainer 上显示的节点;makeNode 方法返回的为 null,则移除 NodeContainer 下挂载的节点。

开发者可以根据实际情况创建新的节点,参考示例代码如下所示:

NodeController 生命周期详解

NodeController 用于控制和反馈对应的 NodeContainer 上的节点的行为,需要与 NodeContainer 一起使用。下面,对其常用生命周期函数进行说明。

typescript
class MyNodeController extends NodeController {
  // 🔴 必须实现:创建节点
  makeNode(uiContext: UIContext): FrameNode | null {
    console.log("makeNode: 创建节点");
    // 返回要显示的节点
    return this.createMyNode(uiContext);
  }

  // 🟡 布局变化时调用
  aboutToResize(size: Size) {
    console.log("aboutToResize: 布局大小变化", size);
  }

  // 🟢 节点显示时调用
  aboutToAppear() {
    console.log("aboutToAppear: 节点即将显示");
  }

  // 🔴 节点消失时调用
  aboutToDisappear() {
    console.log("aboutToDisappear: 节点即将消失");
  }
}
  • makeNode 必须要重写的方法,用于构建节点树、返回节点挂载在对应 NodeContainer 中。在对应 NodeContainer 创建绑定当前 NodeController 的时候调用、或者通过 rebuild 方法调用刷新。
  • aboutToResize 当 controller 对应的 NodeContainer 在 Mesure 的时候进行回调,入参为节点的布局大小。
  • aboutToAppear 当 controller 对应的 NodeContainer 在 onAppear 的时候进行回调。
  • aboutToDisappear 当 controller 对应的 NodeContainer 在 onDisappear 的时候进行回调。

📱 实战案例一:列表流广告组件

场景分析

App 广告有一种场景是列表流广告,即在应用的列表流中穿插展示广告条目,旨在将广告无缝融入用户的浏览体验中,使其看起来像是正常的内容(广告条目需要加标记区别展示),从而吸引用户的注意力并提高参与度,例如新闻列表中的广告条目、商品列表中的广告条目等。

这种广告的布局和内容在开发阶段不确定(可能是图文、视频等形式中的一种),其通常是在运行阶段,依赖服务器下发的数据进行逻辑映射后,再执行布局的构建、内容的加载显示。所以在实际的开发中,应用需要使用动态创建组件的能力去实现该列表流广告。

挑战

  • 广告类型在开发时不确定(可能是图文、视频等)
  • 广告内容需要从服务器动态获取
  • 需要支持广告的关闭和屏蔽功能

实现方案解析

  1. 使用列表数据构建 List 布局,根据数据类型分别执行对应逻辑,如果是广告类型,使用 NodeContainer 进行预占位。
  2. 当 NodeContainer 渲染时,发起请求获取广告信息等数据。解析数据明确广告类型后,构建具体的广告布局,比如图文布局、视频布局等。
  3. 布局构建完成后,返回 rootNode 实现组件上树,最后在容器中渲染显示。

开发步骤详解

1. 加载列表数据

模拟从服务器端获取列表数据,分别生成列表数据对象和广告数据对象。

示例代码中用 CardData(true, id)表示广告数据对象。

typescript
// 列表项数据
class CardData {
  isAd: boolean; // 是否为广告
  id: string; // 唯一标识
  title?: string; // 标题(普通内容)
  content?: string; // 内容(普通内容)

  constructor(isAd: boolean, id: string, title?: string, content?: string) {
    this.isAd = isAd;
    this.id = id;
    this.title = title;
    this.content = content;
  }
}

// 广告数据
interface AdData {
  type: "image" | "video"; // 广告类型
  content: string; // 广告内容
  imageUrl?: string; // 图片广告URL
  videoUrl?: string; // 视频广告URL
}

2. 列表及广告项布局

在 ListItem 布局逻辑中,需要判断该项是否广告节点:若是则预埋 NodeContainer 容器占位;若不是,通过 CardComponent()进行列表内容项的布局及渲染。

typescript
@Component
struct AdListPage {
  @State listData: CardData[] = []

  aboutToAppear() {
    // 模拟混合数据:普通内容 + 广告占位
    this.listData = [
      new CardData(false, '1', '新闻标题1', '新闻内容1'),
      new CardData(false, '2', '新闻标题2', '新闻内容2'),
      new CardData(true, 'ad_1'),  // 广告占位
      new CardData(false, '3', '新闻标题3', '新闻内容3'),
      new CardData(true, 'ad_2'),  // 广告占位
    ]
  }

  build() {
    List() {
      ForEach(this.listData, (item: CardData) => {
        ListItem() {
          if (item.isAd) {
            // 🎯 广告占位:使用 NodeContainer
            NodeContainer(this.getAdNodeController(item.id))
              .width('100%')
              .height(200)
              .backgroundColor(Color.Gray)
          } else {
            // 📰 普通内容
            this.buildNormalCard(item)
          }
        }
      })
    }
  }

  @Builder
  buildNormalCard(item: CardData) {
    Column() {
      Text(item.title)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
      Text(item.content)
        .fontSize(14)
        .fontColor(Color.Gray)
    }
    .padding(15)
    .backgroundColor(Color.White)
  }

  // 获取广告控制器
  private getAdNodeController(adId: string): NodeController {
    return new AdNodeController(adId)
  }
}

3. 加载广告布局

getAdNodeController()方法是通过 queryAdById()模拟广告类型信息的获取,并在完成信息获取后构建相应的 NodeController。

4. 广告占位节点逻辑

上一步返回的 AdNodeController 继承自 NodeController,因占位结点 NodeContainer 需要设置控制类对象 AdNodeController 做为参数。其实现中的 initAd()方法通过 this.adNode.build()接口将广告组件添加到 rootNode 上。当 NodeContrainer 进行绘制时,会调用 makeNode()方法,将构建好的 rootNode 返回实现组件上树。

typescript
class AdNodeController extends NodeController {
  private adId: string;
  private adData: AdData | null = null;
  private adNode: BuilderNode<[AdData]> | null = null;
  private isRemove: boolean = false;

  constructor(adId: string) {
    super();
    this.adId = adId;
  }

  makeNode(uiContext: UIContext): FrameNode | null {
    // 如果已被移除,返回null
    if (this.isRemove) {
      return null;
    }

    // 初始化广告
    this.initAd(uiContext);

    return this.adNode?.getFrameNode() || null;
  }

  private async initAd(uiContext: UIContext) {
    try {
      // 🌐 模拟从服务器获取广告数据
      this.adData = await this.queryAdById(this.adId);

      // 🏗️ 创建广告组件
      this.adNode = new BuilderNode(uiContext);
      this.adNode.build(wrapBuilder(this.buildAdComponent), this.adData);
    } catch (error) {
      console.error("加载广告失败:", error);
    }
  }

  // 模拟获取广告数据
  private async queryAdById(adId: string): Promise<AdData> {
    // 模拟网络请求延迟
    await new Promise((resolve) => setTimeout(resolve, 1000));

    // 模拟不同类型的广告数据
    const adTypes: AdData[] = [
      {
        type: "image",
        content: "精彩图片广告",
        imageUrl: "https://example.com/ad1.jpg",
      },
      {
        type: "video",
        content: "精彩视频广告",
        videoUrl: "https://example.com/ad1.mp4",
      },
    ];

    return adTypes[Math.floor(Math.random() * adTypes.length)];
  }

  // 移除广告
  private removeAd() {
    this.isRemove = true;
    this.rebuild(); // 触发重建,makeNode将返回null
  }
}

5. 构建广告组件

上一步 this.adNode.build()接口构建时,调用了 wrapBuilder(adBuilder),这里使用封装的广告组件 AdComponent,它通过模拟获取的广告类型去判断,进一步构建图文广告组件,或视频广告组件。

typescript
// 🎨 构建广告组件
@Builder
buildAdComponent(adData: AdData) {
  Column() {
    // 广告标识
    Text('广告')
      .fontSize(12)
      .fontColor(Color.White)
      .backgroundColor(Color.Orange)
      .padding(4)
      .borderRadius(4)
      .alignSelf(ItemAlign.Start)

    // 根据广告类型渲染不同组件
    if (adData.type === 'image') {
      this.buildImageAd(adData)
    } else if (adData.type === 'video') {
      this.buildVideoAd(adData)
    }

    // 关闭按钮
    Row() {
      Spacer()
      Button('关闭广告')
        .fontSize(12)
        .onClick(() => {
          this.removeAd()
        })
    }
    .width('100%')
    .padding(10)
  }
  .width('100%')
  .backgroundColor(Color.White)
  .borderRadius(8)
  .padding(10)
}

@Builder
buildImageAd(adData: AdData) {
  Column() {
    Image(adData.imageUrl)
      .width('100%')
      .height(120)
      .borderRadius(8)

    Text(adData.content)
      .fontSize(16)
      .padding(10)
  }
}

@Builder
buildVideoAd(adData: AdData) {
  Column() {
    // 视频播放器占位
    Stack() {
      Rectangle()
        .width('100%')
        .height(120)
        .fill(Color.Black)

      Text('▶️')
        .fontSize(30)
        .fontColor(Color.White)
    }
    .borderRadius(8)

    Text(adData.content)
      .fontSize(16)
      .padding(10)
  }
}

6. 关闭/屏蔽广告功能

广告组件需要提供关闭功能,当点击确认屏蔽按钮后,通过 node.remove()通知 AdNodeController 标记该广告移除,设置 this.isRemove 为 true,再通过 node.rebuild()接口触发组件重绘,此时系统会再次执行 makeNode 接口,根据 this.isRemove 标记返回 null 结点,实现广告组件下树。

关键技术点解析

  1. 🎯 占位策略:使用 NodeContainer 作为广告占位符
  2. 🔄 异步加载:在 makeNode 中异步获取广告数据
  3. 🎨 动态渲染:根据广告类型动态创建不同组件
  4. ❌ 优雅移除:通过 rebuild 机制实现广告移除

📱 实战案例二:动态生成页面

场景描述

下面使用视频首页刷新图片资源作为场景,来介绍如何使用 ArkUI 的 FrameNode 来实现动态布局。

ArkUI 的声明式扩展使用

一个简化的动态布局类框架的 DSL 一般会使用 JSON、XML 等数据交换格式来描述 UI,下面使用 JSON 为例进行说明。本案例相关核心字段含义如下表所示:

标签含义
type描述 UI 组件的类型,通常与原生组件存在一一对应的关系,也可能是封装的某种组件
content文本,图片类组件的内容
css描述 UI 组件的布局特性
children当前组件的子组件

1. 定义视频应用首页 UI 描述数据

在 resources/rawfile 目录下创建 structure.json 文件,内容如下。

json
{
  "type": "Column",
  "css": {
    "width": "100%",
    "height": "100%",
    "backgroundColor": "#f5f5f5"
  },
  "children": [
    {
      "type": "Text",
      "content": "视频首页",
      "css": {
        "fontSize": "24px",
        "fontWeight": "bold",
        "color": "#333",
        "padding": "20px"
      }
    },
    {
      "type": "Carousel",
      "css": {
        "width": "100%",
        "height": "200px",
        "margin": "10px"
      },
      "children": [
        {
          "type": "Image",
          "content": "https://example.com/image1.jpg",
          "css": {
            "width": "100%",
            "height": "100%",
            "borderRadius": "8px"
          }
        },
        {
          "type": "Image",
          "content": "https://example.com/image2.jpg",
          "css": {
            "width": "100%",
            "height": "100%",
            "borderRadius": "8px"
          }
        }
      ]
    },
    {
      "type": "Grid",
      "css": {
        "width": "100%",
        "height": "300px",
        "padding": "20px"
      },
      "children": [
        {
          "type": "Text",
          "content": "热门视频1",
          "css": {
            "fontSize": "16px",
            "backgroundColor": "#fff",
            "padding": "15px",
            "borderRadius": "8px"
          }
        },
        {
          "type": "Text",
          "content": "热门视频2",
          "css": {
            "fontSize": "16px",
            "backgroundColor": "#fff",
            "padding": "15px",
            "borderRadius": "8px"
          }
        }
      ]
    }
  ]
}

2. 定义相应数据结构

用于接收 UI 描述数据,代码示例如下。

typescript
// UI组件描述接口
interface UIComponent {
  type: string; // 组件类型
  content?: string; // 文本/图片内容
  css?: CSSProperties; // 样式属性
  children?: UIComponent[]; // 子组件
}

// CSS样式属性
interface CSSProperties {
  width?: string;
  height?: string;
  fontSize?: string;
  fontWeight?: string;
  color?: string;
  backgroundColor?: string;
  padding?: string;
  margin?: string;
  borderRadius?: string;
}

3. 自定义 DSL 解析逻辑

且使用 carouselNodes 保存轮播图节点,方便后续操作节点更新,代码示例如下。

typescript
class DynamicPageController extends NodeController {
  private pageData: UIComponent | null = null;
  private rootNode: BuilderNode<[]> | null = null;
  private carouselNodes: FrameNode[] = []; // 保存轮播图节点,便于后续更新

  constructor() {
    super();
    this.loadPageData();
  }

  makeNode(uiContext: UIContext): FrameNode | null {
    if (!this.pageData) return null;

    this.rootNode = new BuilderNode(uiContext);

    // 解析JSON数据并创建组件树
    const frameNode = this.parseComponent(this.pageData, uiContext);

    return frameNode;
  }

  // 🌐 加载页面数据
  private async loadPageData() {
    try {
      const rawData = await getContext().resourceManager.getRawFileContent(
        "structure.json"
      );
      const jsonStr = new TextDecoder().decode(rawData);
      this.pageData = JSON.parse(jsonStr);

      // 触发页面重建
      this.rebuild();
    } catch (error) {
      console.error("加载页面数据失败:", error);
    }
  }

  // 🏗️ 解析组件并创建FrameNode
  private parseComponent(
    component: UIComponent,
    uiContext: UIContext
  ): FrameNode {
    const frameNode = new FrameNode(uiContext);

    // 设置组件属性
    this.applyStyles(frameNode, component.css);

    // 根据组件类型创建对应的原生组件
    switch (component.type) {
      case "Column":
        this.createColumnComponent(frameNode, component, uiContext);
        break;
      case "Row":
        this.createRowComponent(frameNode, component, uiContext);
        break;
      case "Text":
        this.createTextComponent(frameNode, component);
        break;
      case "Image":
        this.createImageComponent(frameNode, component);
        break;
      case "Carousel":
        this.createCarouselComponent(frameNode, component, uiContext);
        break;
      case "Grid":
        this.createGridComponent(frameNode, component, uiContext);
        break;
      default:
        console.warn("未知组件类型:", component.type);
    }

    return frameNode;
  }

  // 📐 应用样式
  private applyStyles(frameNode: FrameNode, css?: CSSProperties) {
    if (!css) return;

    if (css.width) frameNode.commonAttribute.width(this.parseSize(css.width));
    if (css.height)
      frameNode.commonAttribute.height(this.parseSize(css.height));
    if (css.backgroundColor)
      frameNode.commonAttribute.backgroundColor(css.backgroundColor);
    if (css.padding)
      frameNode.commonAttribute.padding(this.parseSize(css.padding));
    if (css.margin)
      frameNode.commonAttribute.margin(this.parseSize(css.margin));
    if (css.borderRadius)
      frameNode.commonAttribute.borderRadius(this.parseSize(css.borderRadius));
  }

  // 📏 解析尺寸值
  private parseSize(size: string): number | string {
    if (size.endsWith("px")) {
      return parseInt(size.replace("px", ""));
    } else if (size.endsWith("%")) {
      return size;
    }
    return size;
  }

  // 🔄 更新轮播图
  updateCarouselImages(newImages: string[]) {
    // 清空现有轮播图
    this.carouselNodes.forEach((node) => {
      node.dispose();
    });
    this.carouselNodes = [];

    // 创建新的轮播图节点
    newImages.forEach((imageUrl) => {
      const imageNode = new ImageNode(this.rootNode!.getUIContext());
      imageNode.initialize(imageUrl);
      this.carouselNodes.push(imageNode);
    });

    // 触发重建
    this.rebuild();
  }
}

4. 使用 NodeContainer 组件

嵌套 ArkUI 的 FrameNode 扩展和 ArkUI 的声明式语法。

typescript
@Entry
@Component
struct DynamicPage {
  private pageController: DynamicPageController = new DynamicPageController()

  build() {
    Column() {
      // 🎮 控制按钮
      Row() {
        Button('刷新轮播图')
          .onClick(() => {
            const newImages = [
              'https://example.com/new1.jpg',
              'https://example.com/new2.jpg',
              'https://example.com/new3.jpg'
            ]
            this.pageController.updateCarouselImages(newImages)
          })

        Button('重新加载页面')
          .onClick(() => {
            this.pageController.rebuild()
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceAround)
      .padding(10)

      // 🎯 动态页面容器
      NodeContainer(this.pageController)
        .width('100%')
        .layoutWeight(1)
    }
  }
}

性能对比分析

以场景示例中的两种方案实现,通过 DevEco Studio 的 Profile 工具抓取 Trace 进行性能分析比对。

  1. 以上示例场景在声明式开发范式下的完成时延为 13.7ms(根据设备和场景不同,数据会有差异,本数据仅供参考),如下图所示。

  2. 以上示例场景在 FrameNode 扩展模式下的完成时延为 6.1ms(根据设备和场景不同,数据会有差异,本数据仅供参考),如下图所示。

📊 性能分析:FrameNode 方式避免了大量的状态管理和组件创建开销,在复杂页面中性能优势更加明显,性能提升达到55%

📋 最佳实践和注意事项

✅ 最佳实践

  1. 🎯 合理使用场景

    • 适用于需要动态创建组件的场景
    • 适用于性能要求较高的场景
    • 适用于需要复杂组件树操作的场景
  2. 🏗️ 代码组织

    typescript
    // 推荐:将不同类型的组件创建逻辑分离
    class ComponentFactory {
      static createTextComponent(data: TextData): FrameNode { ... }
      static createImageComponent(data: ImageData): FrameNode { ... }
      static createVideoComponent(data: VideoData): FrameNode { ... }
    }
  3. 🔄 生命周期管理

    typescript
    class MyNodeController extends NodeController {
      aboutToDisappear() {
        // 清理资源,避免内存泄漏
        this.cleanup();
      }
    
      private cleanup() {
        // 释放监听器、定时器等资源
      }
    }

⚠️ 注意事项

  1. 内存管理

    • 及时释放不再使用的节点
    • 避免循环引用导致内存泄漏
  2. 错误处理

    typescript
    makeNode(uiContext: UIContext): FrameNode | null {
      try {
        return this.createNode(uiContext)
      } catch (error) {
        console.error('创建节点失败:', error)
        return null  // 返回null表示创建失败
      }
    }
  3. 性能考虑

    • 避免在 makeNode 中执行耗时操作
    • 合理使用缓存减少重复创建

🎉 总结

通过本文的学习,你已经掌握了 HarmonyOS 组件动态创建的核心技术:

🔑 关键收获

  1. 理解动态创建的价值:提升性能、增强灵活性
  2. 掌握核心 API:NodeController、NodeContainer、FrameNode
  3. 学会实际应用:列表流广告、动态页面生成
  4. 了解最佳实践:性能优化、内存管理、错误处理

🚀 下一步行动

  1. 实践练习:尝试实现一个简单的动态组件
  2. 深入研究:探索更多 FrameNode 的高级用法
  3. 性能优化:使用 Profile 工具分析和优化性能
  4. 项目应用:在实际项目中应用这些技术

💡 温馨提示:动态创建组件是一个强大的工具,但不要过度使用。在简单场景中,传统的声明式开发方式可能更合适。选择合适的技术方案,才能达到最佳的开发效果。


希望这篇文章能帮助你更好地理解和使用 HarmonyOS 的组件动态创建技术!如果有任何问题,欢迎交流讨论。

Released under the MIT License.