鸿蒙百篇系列第 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 之后已经开放了完整的"自定义节点"能力:
文档里有一句话值得背下来:
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%')
}
}
几个关键点:
CircleRenderNode.draw由系统在 vsync 阶段回调,参数DrawContext里就是Canvas,可以直接画改完属性后必须调
invalidate(),否则下一次帧不会重绘RenderNode一定要先挂到一棵FrameNode上才能显示,单独的new RenderNode()画不出东西
四、节点树操作 API
真实项目里不会只是画一个圆,要做的是"动态添加、删除、移动节点"。RenderNode 的接口和 DOM 类似,但更精简:
下面的代码演示一个会"塌缩"的下拉菜单,核心思路是用 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。它们的关系如下:
第一种适合"在 UI 里加一点动效"。第二种适合"游戏、视频编辑、相机的合成"。第三种适合"卡片、动态化"。理解了这个分层之后,遇到具体需求就能快速定位用哪种。
七、实战建议
不要为了用 RenderNode 而用 RenderNode。普通业务页面用
@Component+@State已经够清晰,滥用 RenderNode 反而让维护变难RenderNode 适合"高频重绘、小改动"。如股票 K 线、动效拖拽、波形图。其它场景没有收益
draw 里的逻辑不要有副作用。系统多次回调时不保证执行频率,也不能保证每一帧都回调
记得释放。
RenderNode、BuilderNode都要主动dispose(),否则引用链断不掉跨进程用 NodeContent,但要注意回调里传的是 UIContext 不是 Context,不能乱用
八、和前几篇的衔接
第 52 篇讲
LazyForEach缓存策略,那是"如何不画"。这一篇讲"怎么画才快",两个方向相反但互补第 53 篇讲动画曲线,那是从动效层面调。这一篇是从渲染节点层面调
第 66 篇会展开讲 XComponent 和 EGL 自绘,那是 RenderNode 的"放大版"
九、小结
ArkUI 的渲染管线是"两层节点 + Render Service"的结构。FrameNode 负责布局和事件,RenderNode 负责纯渲染。掌握节点树 API、draw 回调、NodeContent,挂载第三方 UI 时就能选对路;性能瓶颈到来时,知道从哪一层下刀。
下一篇我们转向分布式数据管理,看看跨设备同步在工程里是怎么落地的。