写给那些在 ArkUI 里摸爬滚打的开发者们


开篇:为什么我们要折腾组件封装与复用

写代码这事儿,说白了就是重复劳动。今天写个按钮,明天写个列表,后天又要写个按钮。你要是每次都从零开始,那这辈子也别想下班了。

HarmonyOS 的 ArkUI 框架给咱们提供了一套挺不错的机制,让你能把那些重复的东西封装起来,下次直接用。但这事儿没那么简单,封装得不好,复用不起来;复用得不对,性能反而更差。

我花了些时间,把华为官方文档里关于组件封装和复用的内容扒拉了一遍,结合自己踩过的坑,写了这么一篇文章。不整那些虚头巴脑的概念,就讲实际开发中怎么用、怎么避坑。


一、组件动态操作:让组件在需要的时候才出现

1.1 什么是动态操作

先说个场景:你有个页面,上面有一堆组件。用户刚打开页面的时候,其实只需要看到一部分内容,但如果你把所有组件都创建好了等着,那启动速度肯定慢。

动态操作就是解决这个问题的。它允许你在非 build 生命周期中创建组件,也就是提前创建好,等需要的时候直接拿出来用。

这里有个核心概念叫组件预创建。在声明式范式中,组件只能在 build 环节中被创建,你没法在其他生命周期阶段创建组件。但 ArkUI 提供了 UI 动态操作,支持组件的预创建。

在这里插入图片描述

图:组件动态操作架构示意

1.2 动态操作的核心:FrameNode 和 NodeController

要用动态操作,你得先了解两个东西:FrameNode 和 NodeController。

FrameNode 是 ArkUI 里的自定义节点。跟普通的自定义组件不一样,FrameNode 不需要创建组件对象和状态变量,也不需要收集依赖关系,所以创建速度特别快。

NodeController 是个抽象类,你得继承它才能实现动态操作。它主要负责管理自定义节点的创建、显示、更新等操作。

在这里插入图片描述

图:NodeController 生命周期流程

下面这段代码展示了怎么实现一个最简单的动态节点:

import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI';

class Params {
  text: string = 'Hello World';
  constructor(text: string) {
    this.text = text;
  }
}

@Builder
function testBuilder(params: Params) {
  Column() {
    Text(params.text)
      .fontSize(50)
      .fontWeight(FontWeight.Bold)
      .margin({bottom: 36})
  }
}

class TextNodeController extends NodeController {
  private textNode: BuilderNode<[Params]> | null = null;
  private message: string = '';

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

  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();
  }
}

这段代码干了这么几件事:

  1. 定义了一个 Params 类,用来传递参数
  2. @Builder 装饰器定义了一个构建函数 testBuilder
  3. 继承 NodeController 实现 TextNodeController
  4. makeNode() 方法里创建并返回节点

1.3 怎么显示动态节点

创建好 NodeController 之后,你得用 NodeContainer 来显示它:

@Entry
@Component
struct Index {
  @State message: string = "hello";
  private textNodeController: TextNodeController = new TextNodeController(this.message);

  build() {
    Row() {
      Column() {
        NodeContainer(this.textNodeController)
          .width('100%')
          .height(100)
          .backgroundColor('#FFF0F0F0')
      }
      .width('100%')
      .height('100%')
    }
    .height('100%')
  }
}

NodeContainer 会调用 NodeControllermakeNode() 方法,把返回的节点显示出来。

在这里插入图片描述

图:动态节点显示流程

1.4 动态更新和删除

有时候你需要替换节点,或者把节点删掉。这时候可以用 rebuild() 方法:

class TextNodeController extends NodeController {
  private textNode: BuilderNode<[Params]> | null = null;
  private message: string = '';

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

  makeNode(context: UIContext): FrameNode | null {
    if (this.textNode == null) {
      this.textNode = new BuilderNode(context);
      this.textNode.build(wrapBuilder<[Params]>(testBuilder), new Params(this.message));
    }
    return this.textNode.getFrameNode();
  }

  replaceBuilderNode(newNode: BuilderNode<Object[]>) {
    this.textNode = newNode;
    // rebuild 方法会重新调用 makeNode
    this.rebuild();
  }
}

删除节点更简单,让 makeNode() 返回 null 就行:

remove() {
  this.isRemove = true;
}

makeNode(context: UIContext): FrameNode | null {
  if (this.isRemove) {
    return null;
  }
  // ...
}

1.5 NodeController 的生命周期

NodeController 有几个重要的生命周期函数,你得知道:

  • makeNode():必须重写,用于构建节点树,返回节点挂载到 NodeContainer
  • aboutToResize():节点布局时回调,入参是布局大小
  • aboutToAppear():节点出现时回调
  • aboutToDisappear():节点消失时回调
  • onTouchEvent():收到触摸事件时回调
  • rebuild():必须实现,用于刷新节点

在这里插入图片描述

1.6 实战案例:列表流广告

说个实际场景:列表流广告。就是在你刷新闻、刷商品的时候,中间穿插的广告条目。

这种广告的布局和内容在开发阶段是不确定的,可能是图文、视频或者其他形式。通常是在运行阶段,根据服务器下发的数据来构建布局。

在这里插入图片描述

实现方案大概是这样的:

  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;
  private isRemove: boolean = false;
  private uiContext?: UIContext;

  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.uiContext = uiContext;
    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())
}

关闭广告的时候,调用 node.remove()node.rebuild() 就行:

Button($r('app.string.text_dialog_shield'))
  .onClick(() => {
    let node: AdNodeController | undefined = nodeMap.get(this.adId);
    if (node !== undefined) {
      node.remove();
      node.rebuild();
    }
    this.dialogController.close();
  })

1.7 性能对比

官方给了个性能对比数据。同样的场景,用声明式开发范式完成时延是 13.7ms,用 FrameNode 扩展模式下是 6.1ms。

这个数据仅供参考,实际会因设备和场景不同而有差异。但能看出来,动态操作在性能上确实有优势。


这篇我们讲到这里就结束了。下一篇我们将讲解组件封装的三种方式。

Logo

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

更多推荐