1. HarmonyOS 自定义节点
1.1. 概念
官方文档(https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-user-defined-capabilities-V5)
自定义能力是HarmonyOS ArkUI开发框架提供的对UI界面进行开发和设计的能力。现有的自定义能力包括:自定义节点。ArkUI开发框架提供的不同控制层级的自定义能力用于实现不同场景的应用的开发。自定义能力的控制层级越低接近基础能力,开发的灵活程度越高,开发难度越高、对于开发者能力的要求也越高。
自定义节点:具备底层实体节点的部分基础能力的节点对象,这些节点能够通过自定义占位节点与原生控件进行混合显示。自定义节点可以具备单个节点的测算布局、设置基础属性、设置事件监听、自定义绘制渲染内容的自定义能力。包括FrameNode、RenderNode、BuilderNode三类对象。FrameNode表示了组件的实体节点,RenderNode表示更加轻量级的渲染节点,BuilderNode对象提供了能够创建、更新原生组件以及组件树的能力。
1.1.1. FrameNode
FrameNode表示组件的实体节点,具体可以分为两大类能力:完全自定义节点的能力以及原生组件节点代理的能力。
完全自定义节点:提供完整的自定义能力,包括自定义测量、布局以及绘制,支持节点的动态增、删,设置通用属性,设置事件回调。适用于不自带渲染引擎,需要依赖系统的布局、事件、动画、渲染等能力的场景。
原生组件代理节点:提供原生组件的代理能力,提供遍历节点树的能力,通过组件树上的FrameNode可以遍历整个组件树,并通过节点访问组件的信息或者注册额外的事件监听回调。适用于结合无感监听的接口实现打点、广告SDK、中台DFX等业务。
1.1.2. RenderNode
RenderNode作为轻量级的渲染节点,仅提供了设置渲染相关属性、自定义绘制内容以及节点操作的能力。适用于仅依赖系统渲染与动画能力的自定义场景。
1.1.3. BuilderNode
BuilderNode通过无状态的UI方法全局@Builder生成组件树,组件树内的节点为原生组件。适用于需要基于系统能力创建特定原生组件树与其他自定义节点进行混合显示的场景。相比较原生组件,BuilderNode具备预创建的优势,可以控制开始创建的时间。由于持有实体节点对象,因此可以同步实现节点的复用,通过占位节点结合FrameNode、RenderNode的节点操作能力控制显示位置。
1.2. FrameNode
对于具备自己前端定义的三方框架,需要将特定的dsl转换成为ArkUI的声明式描述。这个转换过程需依赖额外的数据驱动绑定至Builder中,转换比较复杂且性能较低。这一类框架一般依赖系统ArkUI框架的布局、事件能力,以及最基础的节点操作和自定义能力,大部分组件通过自定义完成,但是需要使用部分原生组件混合显示。FrameNode的设计就是为了解决上述的问题。
import { BuilderNode, FrameNode, NodeController, router, UIContext } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { RouterParams } from '@zzsKit/zzsLib';
import { TitleBar } from '../../../components/common/TitleBar';const TEST_TAG: string = "FrameNode"class Params {text: string = "this is a text"
}@Builder
function buttonBuilder(params: Params) {Column({ space: 10 }) {Button(params.text).fontSize(12).borderRadius(8).borderWidth(2).backgroundColor(Color.Orange)Button(params.text).fontSize(12).borderRadius(8).borderWidth(2).backgroundColor(Color.Pink)}
}class MyNodeController extends NodeController {public buttonNode: BuilderNode<[Params]> | null = null;public frameNode: FrameNode | null = null;public childList: Array<FrameNode> = new Array<FrameNode>();public rootNode: FrameNode | null = null;private uiContext: UIContext | null = null;private wrapBuilder: WrappedBuilder<[Params]> = wrapBuilder(buttonBuilder);makeNode(uiContext: UIContext): FrameNode | null {this.uiContext = uiContext;if (this.rootNode == null) {this.rootNode = new FrameNode(uiContext);this.rootNode.commonAttribute.width("50%").height(100).borderWidth(1).backgroundColor(Color.Gray)}if (this.frameNode == null) {this.frameNode = new FrameNode(uiContext);this.frameNode.commonAttribute.width("100%").height(50).borderWidth(1).position({ x: 200, y: 0 }).backgroundColor(Color.Pink);this.rootNode.appendChild(this.frameNode);}if (this.buttonNode == null) {this.buttonNode = new BuilderNode<[Params]>(uiContext);this.buttonNode.build(this.wrapBuilder, { text: "This is a Button" })this.rootNode.appendChild(this.buttonNode.getFrameNode())}return this.rootNode;}operationFrameNodeWithFrameNode(frameNode: FrameNode | undefined | null) {if (frameNode) {console.log(TEST_TAG + " get ArkTSNode success.")console.log(TEST_TAG + " check rootNode whether is modifiable "+ frameNode.isModifiable());}if (this.uiContext) {let frameNode1 = new FrameNode(this.uiContext);let frameNode2 = new FrameNode(this.uiContext);frameNode1.commonAttribute.size({ width: 50, height: 50 }).backgroundColor(Color.Black).position({ x: 50, y: 60 })frameNode2.commonAttribute.size({ width: 50, height: 50 }).backgroundColor(Color.Orange).position({ x: 120, y: 60 })try {frameNode?.appendChild(frameNode1);console.log(TEST_TAG + " appendChild success ");} catch (err) {console.log(TEST_TAG + " appendChild fail :"+ (err as BusinessError).code + " : "+ (err as BusinessError).message);}try {frameNode?.insertChildAfter(frameNode2, null);console.log(TEST_TAG + " clearChildren success ");} catch (err) {console.log(TEST_TAG + " insertChildAfter fail : "+ (err as BusinessError).code + " : " + (err as BusinessError).message);}setTimeout(() => {try {frameNode?.removeChild(frameNode?.getChild(0))console.log(TEST_TAG + " removeChild success ");} catch (err) {console.log(TEST_TAG + " removeChild fail : "+ (err as BusinessError).code + " : " + (err as BusinessError).message);}}, 2000)setTimeout(() => {try {frameNode?.clearChildren();console.log(TEST_TAG + " clearChildren success ");} catch (err) {console.log(TEST_TAG + " clearChildren fail : "+ (err as BusinessError).code + " : " + (err as BusinessError).message);}}, 4000)}}testInterfaceAboutSearch(frameNode: FrameNode | undefined | null): string {let result: string = "";if (frameNode) {result = result + `current node is ${frameNode.getNodeType()} \n`;result = result + `parent node is ${frameNode.getParent()?.getNodeType()} \n`;result = result + `child count is ${frameNode.getChildrenCount()} \n`;result = result + `first child node is ${frameNode.getFirstChild()?.getNodeType()} \n`;result = result + `seconde child node is ${frameNode.getChild(1)?.getNodeType()} \n`;result = result + `previousSibling node is ${frameNode.getPreviousSibling()?.getNodeType()} \n`;result = result + `nextSibling node is ${frameNode.getNextSibling()?.getNodeType()} \n`;}return result;}checkAppendChild(parent: FrameNode | undefined| null, child: FrameNode | undefined | null) {try {if (parent && child) {parent.appendChild(child);console.log(TEST_TAG + " appendChild success ");}} catch (err) {console.log(TEST_TAG + " appendChild fail : "+ (err as BusinessError).code + " : "+ (err as BusinessError).message);}}
}@Entry
@Component
struct FrameNodePage {@State index: number = 0;@State result: string = ""private myNodeController: MyNodeController = new MyNodeController();@State pageTitle: string = "网格列表"aboutToAppear() {try {this.pageTitle = (router.getParams() as RouterParams).title} catch (e) {}}build() {Column() {TitleBar({ pageTitle: $pageTitle })Flex({direction: FlexDirection.Column,alignItems: ItemAlign.Center,justifyContent: FlexAlign.SpaceBetween}) {List({ space: 20, initialIndex: 0 }) {ListItem() {Column({ space: 5 }) {Text("验证FrameNode子节点的增、删、改功能")Button("对自定义FrameNode进行操作").fontSize(16).width(400).onClick(() => {// 对FrameNode节点进行增、删、改操作,正常实现。this.myNodeController.operationFrameNodeWithFrameNode(this.myNodeController?.frameNode);})Button("对BuilderNode中的代理节点进行操作").fontSize(16).width(400).onClick(() => {// 对BuilderNode代理节点进行增、删、改操作,捕获异常信息。this.myNodeController.operationFrameNodeWithFrameNode(this.myNodeController?.buttonNode?.getFrameNode());})Button("对原生组件中的代理节点进行操作").fontSize(16).width(400).onClick(() => {// 对代理节点进行增、删、改操作,捕获异常信息。this.myNodeController.operationFrameNodeWithFrameNode(this.myNodeController?.rootNode?.getParent());})}}ListItem() {Column({ space: 5 }) {Text("验证FrameNode添加子节点的特殊场景")Button("新增BuilderNode的代理节点").fontSize(16).width(400).onClick(() => {let buttonNode = new BuilderNode<[Params]>(this.getUIContext());buttonNode.build(wrapBuilder<[Params]>(buttonBuilder), { text: "BUTTON" })this.myNodeController.checkAppendChild(this.myNodeController?.frameNode,buttonNode?.getFrameNode());})Button("新增原生组件代理节点").fontSize(16).width(400).onClick(() => {this.myNodeController.checkAppendChild(this.myNodeController?.frameNode,this.myNodeController?.rootNode?.getParent());})Button("新增已有父节点的自定义节点").fontSize(16).width(400).onClick(() => {this.myNodeController.checkAppendChild(this.myNodeController?.frameNode,this.myNodeController?.rootNode);})}}ListItem() {Column({ space: 5 }) {Text("验证FrameNode节点的查询功能")Button("对自定义FrameNode进行操作").fontSize(16).width(400).onClick(() => {// 对FrameNode节点进行进行查询。当前节点为NodeContainer的子节点。this.result = this.myNodeController.testInterfaceAboutSearch(this.myNodeController?.rootNode);setTimeout(() => {// 对FrameNode节点进行进行查询。rootNode下的第一个子节点。this.result = this.myNodeController.testInterfaceAboutSearch(this.myNodeController?.frameNode);}, 2000)})Button("对BuilderNode中的代理节点进行操作").fontSize(16).width(400).onClick(() => {// 对BuilderNode代理节点进行进行查询。// 当前节点为BuilderNode中的Column节点。this.result = this.myNodeController.testInterfaceAboutSearch(this.myNodeController?.buttonNode?.getFrameNode());})Button("对原生组件中的代理节点进行操作").fontSize(16).width(400).onClick(() => {// 对代理节点进行查询。当前节点为NodeContainer。this.result = this.myNodeController.testInterfaceAboutSearch(this.myNodeController?.rootNode?.getParent());})}}}.height("50%")Text(`Result:\n${this.result}`).fontSize(16).width(400).height(200).padding(30).borderWidth(1)Column() {Text("This is a NodeContainer.").textAlign(TextAlign.Center).borderRadius(10).backgroundColor(0xFFFFFF).width('100%').fontSize(16)NodeContainer(this.myNodeController).borderWidth(1).width(400).height(150)}}.padding({left: 35,right: 35,top: 35,bottom: 35}).width("100%").height("100%")}}
}
1.3. RenderNode
对于不具备自己的渲染环境的三方框架,虽然实现了前端的解析以及布局、事件等处理,但需要依赖系统提供的基础渲染、动画的能力。FrameNode上的通用属性、通用事件对这一类框架是多余的,会进行多次冗余的操作,包括布局、事件等处理逻辑。
RenderNode是更加轻量级的渲染节点,仅包含渲染相关的能力。在该节点上暴露了设置基础的渲染属性的能力,并提供节点的动态增加、删除能力以及自定义绘制的能力。可以向三方框架提供基础的渲染、动画能力。
import { FrameNode, NodeController, RenderNode, router } from '@kit.ArkUI';
import { RouterParams } from '@zzsKit/zzsLib';
import { TitleBar } from '../../../components/common/TitleBar';const renderNode = new RenderNode();
renderNode.frame = {x: 0,y: 0,width: 200,height: 350
};
renderNode.backgroundColor = 0xffff0000;
for (let i = 0; i < 5; i++) {const node = new RenderNode();// 设置node节点的Frame大小node.frame = {x: 10,y: 10 + 60 * i,width: 50,height: 50};// 设置node节点的背景颜色node.backgroundColor = 0xff00ff00;// 将新增节点挂载在renderNode上renderNode.appendChild(node);
}class MyNodeController extends NodeController {private rootNode: FrameNode | null = null;makeNode(uiContext: UIContext): FrameNode | null {this.rootNode = new FrameNode(uiContext);const rootRenderNode = this.rootNode?.getRenderNode();if (rootRenderNode) {rootRenderNode.appendChild(renderNode);}return this.rootNode;}
}@Entry
@Component
struct RenderNodePage {private myNodeController: MyNodeController = new MyNodeController();@State pageTitle: string = "网格列表"aboutToAppear() {try {this.pageTitle = (router.getParams() as RouterParams).title} catch (e) {}}build() {Column() {TitleBar({ pageTitle: $pageTitle })Row() {NodeContainer(this.myNodeController).width(200).height(350)Button('getNextSibling').onClick(() => {const child = renderNode.getChild(1);const nextSibling = child!.getNextSibling()if (child === null || nextSibling === null) {console.log('the child or nextChild is null');} else {// 获取子节点的位置信息console.log(`the position of child is x: ${child.position.x},y: ${child.position.y}, ` +`the position of nextSibling is x: ${nextSibling.position.x},y: ${nextSibling.position.y}`);}})}}}
}
1.4. BuilderNode
BuilderNode提供能够挂载原生组件的能力,支持通过无状态的UI方法全局@Builder生成组件树,并通过getFrameNode获取组件树的根FrameNode节点。该节点可以通过NodeController直接返回,挂载在NodeContainer节点下,也可以在FrameNode树结构和RenderNode树结构嵌入声明式的组件结构,实现混合显示的能力。同时BuilderNode可以提供纹理导出的功能,导出的纹理用于在XComponent中进行同层渲染显示。
BuilderNode创建的ArkTS原生控件树支持与自定义节点(例如:FrameNode、RenderNode)进行关联使用,实现了原生组件与自定义节点的混合显示。对于使用自定义节点的能力进行对接的三方框架,BuilderNode为其提供了嵌入原生组件的能力。
BuilderNode提供了组件预创建的能力,能够自定义原生组件的创建开始的时间,在后续的业务中动态挂载显示。对于一些在创建初始化耗时较长的声明式组件,比如Web、XComponent等,预创建可以有效减少组件初始化的耗时。
1.4.1. 创建BuilderNode对象
BuilderNode对象为一个模板类,需要在创建的时候指定类型。该类型需要与后续build方法中传入的WrappedBuilder的类型保持一致,否则会存在编译告警导致编译失败。
创建原生组件树
通过BuilderNode的build可以实现原生组件树的创建。依照传入的WrappedBuilder对象创建组件树,并持有组件树的根节点。
创建离线节点以及原生组件树,结合FrameNode进行使用。
BuilderNode的根节点直接作为NodeController的makeNode返回值。
import { BuilderNode, FrameNode, NodeController, router, UIContext } from '@kit.ArkUI'
import { RouterParams } from '@zzsKit/zzsLib';
import { TitleBar } from '../../../components/common/TitleBar';class Params {text: string = ""constructor(text: string) {this.text = text;}
}@Builder
function buildText(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 = "DEFAULT";constructor(message: string) {super();this.message = message;}makeNode(context: UIContext): FrameNode | null {this.textNode = new BuilderNode(context);this.textNode.build(wrapBuilder<[Params]>(buildText),new Params(this.message))return this.textNode.getFrameNode();}
}@Entry
@Component
struct BuilderNodePage {@State message: string = "hello"@State pageTitle: string = "网格列表"aboutToAppear() {try {this.pageTitle = (router.getParams() as RouterParams).title} catch (e) {}}build() {Column() {TitleBar({ pageTitle: $pageTitle })Row() {Column() {NodeContainer(new TextNodeController(this.message)).width('100%').height(100).backgroundColor('#FFF0F0F0')}.width('100%').height('100%')}.height('100%')}}
}
1.4.2. 更新原生组件树
通过BuilderNode对象的build创建原生组件树。依照传入的WrappedBuilder对象创建组件树,并持有组件树的根节点。
自定义组件的更新遵循状态管理的更新机制。WrappedBuilder中直接使用的自定义组件其父组件为BuilderNode对象。因此,更新子组件即WrappedBuilder中定义的自定义组件,需要遵循状态管理的定义将相关的状态变量定义为@Prop或者@ObjectLink。装饰器的选择请参照状态管理的装饰器规格结合应用开发需求进行选择。
import { NodeController, BuilderNode, FrameNode, UIContext, router } from "@kit.ArkUI"
import { RouterParams } from '@zzsKit/zzsLib';
import { TitleBar } from '../../../components/common/TitleBar';class Params {text: string = ""constructor(text: string) {this.text = text;}
}// 自定义组件
@Component
struct TextBuilder {// 作为自定义组件中需要更新的属性,数据类型为基础属性,定义为@Prop@Prop message: string = "TextBuilder";build() {Row() {Column() {Text(this.message).fontSize(50).fontWeight(FontWeight.Bold).margin({ bottom: 36 }).backgroundColor(Color.Gray)}}}
}@Builder
function buildText(params: Params) {Column() {Text(params.text).fontSize(50).fontWeight(FontWeight.Bold).margin({ bottom: 36 })TextBuilder({ message: params.text }) // 自定义组件}
}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 {this.textNode = new BuilderNode(context);this.textNode.build(wrapBuilder<[Params]>(buildText), new Params(this.message))return this.textNode.getFrameNode();}update(message: string) {if (this.textNode !== null) {// 调用update进行更新。this.textNode.update(new Params(message));}}
}@Entry
@Component
struct BuilderNode2Page {@State message: string = "hello"private textNodeController: TextNodeController = new TextNodeController(this.message);private count = 0;@State pageTitle: string = "网格列表"aboutToAppear() {try {this.pageTitle = (router.getParams() as RouterParams).title} catch (e) {}}build() {Column() {TitleBar({ pageTitle: $pageTitle })Row() {Column() {NodeContainer(this.textNodeController).width('100%').height(200).backgroundColor('#FFF0F0F0')Button('Update').onClick(() => {this.count += 1;const message = "Update " + this.count.toString();this.textNodeController.update(message);})}.width('100%').height('100%')}.height('100%')}}
}
1.4.3. 注入触摸事件
BuilderNode中提供了postTouchEvent,可以通过该接口向BuilderNode中绑定的组件注入触摸事件,实现事件的模拟转发。
通过postTouchEvent向BuilderNode对应的节点树中注入触摸事件。
向BuilderNode中的Column组件转发另一个Column的接收事件,即点击下方的Column组件,上方的Colum组件也会收到同样的触摸事件。当Button中的事件被成功识别的时候,返回值为true。
import { NodeController, BuilderNode, FrameNode, UIContext, router } from '@kit.ArkUI';
import { RouterParams } from '@zzsKit/zzsLib';
import { TitleBar } from '../../../components/common/TitleBar';class Params {text: string = "this is a text"
}@Builder
function ButtonBuilder(params: Params) {Column() {Button(`button ` + params.text).borderWidth(2).backgroundColor(Color.Orange).width("100%").height("100%").gesture(TapGesture().onAction((event: GestureEvent) => {console.log("TapGesture");}))}.width(500).height(300).backgroundColor(Color.Gray)
}class MyNodeController extends NodeController {private rootNode: BuilderNode<[Params]> | null = null;private wrapBuilder: WrappedBuilder<[Params]> = wrapBuilder(ButtonBuilder);makeNode(uiContext: UIContext): FrameNode | null {this.rootNode = new BuilderNode(uiContext);this.rootNode.build(this.wrapBuilder, { text: "this is a string" })return this.rootNode.getFrameNode();}postTouchEvent(touchEvent: TouchEvent): void {if (this.rootNode == null) {return;}let result = this.rootNode.postTouchEvent(touchEvent);console.log("result " + result);}
}@Entry
@Component
struct BuilderNode3Page {private nodeController: MyNodeController = new MyNodeController();@State pageTitle: string = "网格列表"aboutToAppear() {try {this.pageTitle = (router.getParams() as RouterParams).title} catch (e) {}}build() {Column() {TitleBar({ pageTitle: $pageTitle })NodeContainer(this.nodeController).height(300).width(500)Column().width(500).height(300).backgroundColor(Color.Pink).onTouch((event) => {if (event != undefined) {this.nodeController.postTouchEvent(event);}})}}
}