Administrator
发布于 2026-09-03 / 5,231 阅读
0
0

鸿蒙 ArkUI 渲染管线:RenderNode、节点树与自定义绘制底层

鸿蒙百篇系列第 61 篇 · 高级

前两篇我们讨论了列表渲染与动画工程化,停留在 ArkUI 的"声明式"层面。但当你想做一个自绘图表、一个超 60FPS 的特效层,或者想把一个第三方渲染引擎(Skia、Flutter 自绘)跑在 ArkUI 的 FrameNode 树上时,光靠 @Builder、Canvas 已经不够用。这一篇我们要拆到底层:ArkUI 的渲染管线是怎么把组件树翻译成 RenderNode 树的,RenderNode 的 C API 怎么用,FrameNode 和 RenderNode 的边界在哪里。

一、为什么需要理解渲染管线

很多鸿蒙开发者用了一年 ArkUI,遇到性能瓶颈时往往只能"加大 cachedCount、降低数据量"。但当你看到 DevEco Profiler 里 GPU 耗时占比 70%,心里得有个更深的方案:

  • 把"高频变化的子节点"剥离到独立的 RenderNode,走最小重绘路径

  • 把"自定义绘制"从 @Component 改成 RenderNode + draw,绕开通用属性

  • 跨进程或第三方引擎用 NodeContent 把异构 UI 嵌进 ArkUI 树

这些都需要搞懂一条主线:声明式组件 → FrameNode → RenderNode → Render Service → GPU。

二、组件树到底长什么样

ArkUI 的组件树有三种节点,API 12 之后已经开放了完整的"自定义节点"能力:

节点类型

职责

暴露给开发者

FrameNode

通用属性、布局、事件

NodeController.makeNode(uiContext)

RenderNode

纯渲染属性(frame、transform、opacity、backgroundColor)

new RenderNode(),C API 风格

BuilderNode

把一棵 ArkUI 子树"打包"成节点,挂到父树上

NodeController 持有

文档里有一句话值得背下来:

RenderNode 是更加轻量的渲染节点,仅具备与渲染相关的功能。它提供了设置基础渲染属性的能力,以及节点的动态添加、删除和自定义绘制的能力。RenderNode 能够为第三方框架提供基础的渲染和动画支持。

换句话说,FrameNode 是"完整组件",RenderNode 是"只关心绘图的壳"。这种分层跟浏览器里 Element 和 PaintLayer 的关系很像。

三、写一个最小的 RenderNode 示例

我们从 API 12 开始就可以直接用 RenderNode 写"自绘组件"。下面这段代码在 Stage 模型下运行,画一个会动的圆:

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

class CircleRenderNode extends RenderNode {
  private progress: number = 0;

  // 自绘制回调,参数是 Canvas,画什么由你决定
  draw(context: DrawContext): void {
    const canvas = context.canvas;
    const w = this.frame.width;
    const h = this.frame.height;
    const r = Math.min(w, h) / 2;
    const cx = w / 2;
    const cy = h / 2;

    // 用 Canvas 2D 风格 API(实际是 RenderContext)
    canvas.fillStyle = '#1F8FFF';
    canvas.beginPath();
    canvas.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * (this.progress % 1));
    canvas.lineTo(cx, cy);
    canvas.fill();

    canvas.fillStyle = '#E5E5E5';
    canvas.beginPath();
    canvas.arc(cx, cy, r, 0, Math.PI * 2);
    canvas.fill();
  }

  setProgress(p: number): void {
    this.progress = p;
    // 标记需要重绘
    this.invalidate();
  }
}

class MyNodeController extends NodeController {
  private rootNode: FrameNode | null = null;
  private circle: CircleRenderNode | null = null;

  makeNode(uiContext: UIContext): FrameNode | null {
    this.rootNode = new FrameNode(uiContext);
    this.circle = new CircleRenderNode();
    this.circle.frame = { x: 0, y: 0, width: 200, height: 200 };
    this.rootNode.getRenderNode()?.appendChild(this.circle);
    return this.rootNode;
  }

  getCircle(): CircleRenderNode | null {
    return this.circle;
  }
}

@Entry
@Component
struct Index {
  private myController: MyNodeController = new MyNodeController();
  @State private progress: number = 0;

  aboutToAppear(): void {
    setInterval(() => {
      this.progress += 0.05;
      this.myController.getCircle()?.setProgress(this.progress);
    }, 50);
  }

  build() {
    Column() {
      NodeContainer(this.myController)
        .width(200)
        .height(200)
    }
    .width('100%')
    .height('100%')
  }
}

几个关键点:

  1. CircleRenderNode.draw 由系统在 vsync 阶段回调,参数 DrawContext 里就是 Canvas,可以直接画

  2. 改完属性后必须调 invalidate(),否则下一次帧不会重绘

  3. RenderNode 一定要先挂到一棵 FrameNode 上才能显示,单独的 new RenderNode() 画不出东西

四、节点树操作 API

真实项目里不会只是画一个圆,要做的是"动态添加、删除、移动节点"。RenderNode 的接口和 DOM 类似,但更精简:

操作

API

说明

加子节点

appendChild(node)

追加到尾部

插入到指定子节点后

insertChildAfter(child, sibling)

sibling 为 null 时插到最前

移除子节点

removeChild(node)

不传参则清空全部

查询

getChild(index)、getFirstChild()、getNextSibling()

返回 RenderNode | null

销毁

dispose()

解除实体节点的绑定

下面的代码演示一个会"塌缩"的下拉菜单,核心思路是用 RenderNode 而不是 @Component:

class MenuNode extends RenderNode {
  private items: RenderNode[] = [];

  addItem(label: string): void {
    const item = new RenderNode();
    item.frame = { x: 0, y: this.items.length * 48, width: 240, height: 48 };
    item.backgroundColor = 0xFF1A1A1A;

    // 节点本身只画背景,文本用子节点 + TextNode 表达
    const text = new RenderNode();
    text.frame = { x: 16, y: 0, width: 200, height: 48 };
    item.appendChild(text);

    this.appendChild(item);
    this.items.push(item);
  }

  draw(context: DrawContext): void {
    // 可以在这里画统一的边框、阴影
    const c = context.canvas;
    c.strokeStyle = '#666';
    c.strokeRect(0, 0, this.frame.width, this.frame.height);
  }

  collapse(): void {
    while (this.getFirstChild() !== null) {
      const first = this.getFirstChild() as RenderNode;
      this.removeChild(first);
    }
    this.items = [];
    this.invalidate();
  }
}

这种用法比写 10 个 @Component 再用 if 控制显隐省事得多,节点树直接就是你想要的样子。

五、NodeContent:跨边界容器

NodeContent 是 API 12 之后另一个重量级能力。它允许你把一棵 BuilderNode 挂到任意位置,包括:

  • 同一进程内的不同 FrameNode 子树

  • 跨进程(被 UIExtensionComponent 使用)

常见场景是动态卡片、占位图、骨架屏。NodeContent 和 NodeController 的关系可以这么理解:

  • NodeController 负责"生产节点",回答 makeNode(uiContext) 是谁

  • NodeContent 负责"挂载节点",是一个容器

骨架屏示例:

class SkeletonController extends NodeController {
  private builderNode: BuilderNode<void> | null = null;

  makeNode(uiContext: UIContext): FrameNode | null {
    if (this.builderNode === null) {
      this.builderNode = new BuilderNode(uiContext);
      this.builderNode.build(wrapBuilder(skeletonBuilder));
    }
    return this.builderNode.getFrameNode();
  }
}

@Builder function skeletonBuilder() {
  Column() {
    Row().width(120).height(16).backgroundColor('#E5E5E5').margin({ bottom: 12 })
    Row().width('100%').height(16).backgroundColor('#E5E5E5').margin({ bottom: 8 })
    Row().width('60%').height(16).backgroundColor('#E5E5E5')
  }
  .padding(16)
  .backgroundColor('#F5F5F5')
}

// 使用
NodeContainer(this.skeletonController)
  .width('100%')
  .height(120)

数据回来之后如何拆掉骨架屏?调用 builderNode.dispose() 即可:

async loadData() {
  this.showSkeleton();
  const data = await api.fetchDetail();
  this.skeletonController.dispose();
  this.renderReal(data);
}

六、三类"接入异构 UI"的方式

把 Flutter、自绘游戏、Three.js 等场景接到鸿蒙里来,绕不开 NodeContent + XComponent。它们的关系如下:

接入方式

关键组件

渲染位置

性能

RenderNode + draw

RenderNode

同一棵 FrameNode 子树

受 vsync 控制,最稳

XComponent (SURFACE)

NativeWindow

独立 Surface,与 ArkUI 合成

高,可走独立合成路径

UIExtensionComponent

跨进程 NodeContent

独立进程渲染

隔离性好,跨进程开销

第一种适合"在 UI 里加一点动效"。第二种适合"游戏、视频编辑、相机的合成"。第三种适合"卡片、动态化"。理解了这个分层之后,遇到具体需求就能快速定位用哪种。

七、实战建议

  1. 不要为了用 RenderNode 而用 RenderNode。普通业务页面用 @Component + @State 已经够清晰,滥用 RenderNode 反而让维护变难

  2. RenderNode 适合"高频重绘、小改动"。如股票 K 线、动效拖拽、波形图。其它场景没有收益

  3. draw 里的逻辑不要有副作用。系统多次回调时不保证执行频率,也不能保证每一帧都回调

  4. 记得释放。RenderNode、BuilderNode 都要主动 dispose(),否则引用链断不掉

  5. 跨进程用 NodeContent,但要注意回调里传的是 UIContext 不是 Context,不能乱用

八、和前几篇的衔接

  • 第 52 篇讲 LazyForEach 缓存策略,那是"如何不画"。这一篇讲"怎么画才快",两个方向相反但互补

  • 第 53 篇讲动画曲线,那是从动效层面调。这一篇是从渲染节点层面调

  • 第 66 篇会展开讲 XComponent 和 EGL 自绘,那是 RenderNode 的"放大版"

九、小结

ArkUI 的渲染管线是"两层节点 + Render Service"的结构。FrameNode 负责布局和事件,RenderNode 负责纯渲染。掌握节点树 API、draw 回调、NodeContent,挂载第三方 UI 时就能选对路;性能瓶颈到来时,知道从哪一层下刀。

下一篇我们转向分布式数据管理,看看跨设备同步在工程里是怎么落地的。


评论