简介
本文详细介绍了UI动态操作的基本用法,着重介绍了BuilderNode自定义节点和NodeController生命周期等内容。结合这些基础用法,提供了三方广告的场景案例作为参考。案例展示了如何使用UI动态操作实现三方广告,其最后的实现效果图如下所示。
图1 实现效果图
UI动态操作包含组件动态创建、卸载等相关操作。在声明式范式中,组件仅在build环节中被创建,开发者无法在其他生命周期阶段进行组件的创建,从而引起页面加载慢等问题。与声明式范式不同,ArkUI框架提供的UI动态操作支持组件的预创建。组件预创建可以满足开发者在非build生命周期中进行组件创建,创建后的组件可以进行属性设置、布局计算等操作。之后在页面加载时进行使用,可以极大提升页面响应速度。如下图所示,利用组件预创建机制,可以利用动画执行过程空闲时间进行组件创建,在动画结束后,再进行更新。
图2 组件预创建原理图
UI动态操作基础用法
BuiderNode自定义节点基础用法
自定义节点BuilderNode能够挂载原生组件,下面详细介绍下BuilderNode创建、显示、更新、移除和替换。
- 创建自定义节点。
首先,准备好需要挂载的节点,代码如下所示。
// src\main\ets\pages\Index.ets
import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI';
class Params {
text: string = 'Hello World';
constructor(text: string) {
this.text = text;
}
}
@Builder
function buildText(params: Params) {
Column() {
Text(params.text)
.fontSize(50)
.fontWeight(FontWeight.Bold)
.margin({bottom: 36})
}
}
......
其次,使用构造函数创建BuilderNode实例。创建的时候需要声明BuilderNode中所使用的WrappedBuilder的类型。创建BuilderNode对象的时候必须要传入对应的UIContext对象。若BuilderNode作为RenderNode的子节点存在,要求设置RenderOptions的selfIdealSize属性。
然后,使用BuilderNode的build方法,构建组件树。方法build()需要传入两个参数,第一个参数为通过wrapBuilder()封装的全局@Builder方法。第二个参数为对应的@Builder方法所需的参数对象。若@Builder方法不带参数或者存在默认参数,则build()的第二个参数可以不设置。
// src\main\ets\pages\Index.ets
......
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);
// 设置selfIdealSize属性
// this.textNode = new BuilderNode(context, {selfIdealSize: {width: 100, height :100}});
// 使用build方法构建组件树
this.textNode.build(wrapBuilder<[Params]>(buildText), new Params(this.message));
// 返回需要显示的节点
return this.textNode.getFrameNode();
}
}
......
- 显示自定义节点
显示自定义节点依赖声明式渲染容器NodeContainer以及对应的控制类NodeController。
首先,重写NodeController中的makeNode方法。MakeNode方法中返回的节点会显示在对应的NodeContainer中。由于makeNode需要返回的为一个FrameNode,因此如果预期显示BuidlerNode的时候需要调用对应的BuidlerNode的getFrameNode的方法,获取其根节点,详细代码如上TextNodeController中所示。
然后,在页面内新增声明式渲染容器NodeContainer,创建工具类NodeController。通过NodeController将MakeNode中返回的节点在声明式渲染容器中进行显示。
// src\main\ets\pages\Index.ets
......
@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%')
}
}
- 更新自定义节点
更新自定义节点,可参考BuilderNode的update方法。
- 移除自定义节点
通过条件控制语句可以将NodeContainer节点进行移除或者显示。如示例代码,将this.isShow更改为false则将节点从界面上移除。
// src\main\ets\pages\Index.ets
......
@Entry
@Component
struct Index {
@State message: string = "hello";
@State isShow: boolean = true;
private textNodeController: TextNodeController = new TextNodeController(this.message);
build() {
Row() {
Column() {
if (this.isShow) {
NodeContainer(this.textNodeController)
.width('100%')
.height(100)
.backgroundColor('#FFF0F0F0')
}
Button('isShow')
.onClick(() => {
this.isShow = false;
})
}
.width('100%')
.height('100%')
}
.height('100%')
}
}
- 替换自定义节点
动态将NodeContainer上的节点替换,依赖于NodeController的makeNode和rebuild方法。rebuild方法会触发makeNode的回调,刷新NodeContainer上显示的节点;makeNode方法返回的为null,则移除NodeContainer下挂载的节点。
......
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时,才会执行下列代码;替换节点时,textNode不为null
if (this.textNode == null) {
this.textNode = new BuilderNode(context);
this.textNode.build(wrapBuilder<[Params]>(buildText), new Params(this.message));
}
return this.textNode.getFrameNode();
}
replaceBuilderNode(newNode: BuilderNode<Object[]>) {
this.textNode = newNode;
// rebuild方法会重新调用makeNode方法
this.rebuild();
}
}
......
开发者可以根据实际情况创建新的节点,参考示例代码如下所示:
......
@Entry
@Component
struct Index {
@State message: string = "hello";
@State isShow: boolean = true;
private textNodeController: TextNodeController = new TextNodeController(this.message);
// private count = 0;
build() {
Row() {
Column() {
if (this.isShow) {
NodeContainer(this.textNodeController)
.width('100%')
.height(100)
.backgroundColor('#FFF0F0F0')
}
Button('replaceNode')
.onClick(() => {
this.textNodeController.replaceBuilderNode(this.buildNewNode());
})
}
.width('100%')
.height('100%')
}
.height('100%')
}
buildNewNode(): BuilderNode<[Params]> {
let uiContext: UIContext = this.getUIContext();
let message = 'newNode';
let textNode = new BuilderNode<[Params]>(uiContext);
textNode.build(wrapBuilder<[Params]>(buildText), new Params(message))
return textNode;
}
}
NodeController生命周期
NodeController用于控制和反馈对应的NodeContainer上的节点的行为,需要与NodeContainer一起使用。下面,对其常用生命周期函数进行说明。
- makeNode必须要重写的方法,用于构建节点树、返回节点挂载在对应NodeContainer中。在对应NodeContainer创建绑定当前NodeController的时候调用、或者通过rebuild方法调用刷新。
- aboutToResize当controller对应的NodeContainer在Mesure的时候进行回调,入参为节点的布局大小。
- aboutToAppear当controller对应的NodeContainer在onAppear的时候进行回调。
- aboutToDisappear当controller对应的NodeContainer在onDisappear的时候进行回调。
export abstract class NodeController {
abstract makeNode(uiContext: UIContext): FrameNode | null;
aboutToResize?(size: Size): void;
aboutToAppear?(): void;
aboutToDisappear?(): void;
rebuild(): void;
onTouchEvent?(event: TouchEvent): void;
}
三方广告SDK实践案例
场景描述
应用的广告业务一般都由三方广告商提供。三方广告商通过广告SDK的方式,提供广告内容与广告页面交互逻辑,并且支持通过点击查看广告详情。应用接入广告SDK提供的广告服务,需要在应用页面提供布局空间为广告预留布局空间。在应用开发过程中,通过接入广告SDK,调用广告服务的接口,在预留的的布局空间,向应用用户展示广告信息。以应用开屏广告为例,如下图所示,在应用启动时且在应用主界面显示之前,会展示广告和广告倒计时,用户可以点击跳过广告直接进入应用主界面。
图3 应用场景图
实现方案介绍
ArkUI开发框架是基于ArkTS的声明式开发范式的应用开发框架,和传统的命令式UI相比,声明式UI使用抽象的语法声明来描述用户界面,而命令式UI侧重编写详细的操作来构建用户界面。对于三方广告场景,声明式UI无法提前灵活的声明广告SDK提供的广告组件。
ArkUI框架从API11开始新增开放命令式的渲染节点,通过声明式容器预留节点位置和命令式渲染节点,支持动态操作UI。UI 动态化技术可解决业务模块动态加载的诉求。在应用业务场景中,通常框架开发与业务开发不是同一人员,因此,框架无法通过声明式的方式,提前加入业务方逻辑。如下图所示,业务模块A,B,C 需显示在主框架中,主框架不感知具体业务的模块逻辑,仅需把业务模块动态添加或移除。
图4 模块关系图
对于三方广告SDK场景,如下图所示。广告SDK负责维护广告组件,使用自定义节点BuilderNode挂载原生图文等组件节点来提供广告内容与广告页面交互逻辑;接入广告SDK的应用通过NodeContainer预留布局空间,用于展示广告SDK提供的广告组件。NodeController用于绑定自定义广告组件节点与NodeContainer布局组件。
图5 三方广告SDK场景图
案例参考
以一个案例来展示三方广告SDK如何提供广告组件,以及应用如何集成广告SDK提供的广告组件。案例效果如下:
图6 实现效果图
整体实现步骤如下所示:
- 广告SDK创建@Builder自定义构建函数。
- 广告SDK创建自定义广告节点控制器。
- 广告SDK对外提供广告节点控制器。
- 应用界面展示广告。
详细说明实现步骤如下。首先,广告SDK创建@Builder自定义构建函数。广告SDK提供各种类型的广告,需要在广告SDK中创建广告组件的@Builder自定义构建函数。以开屏广告为例来讲解,代码如下。其中ADNodeBuilder()为自定义构建函数,用于构建广告组件节点,该广告节点示例代码中包含一个广告图片、一段文本和一个可以跳转广告详情的按钮。ADNodeParams参数类用于为ADNodeBuilder() 自定义构建函数提供参数化数据,以展示不同的广告信息。
// adsdk\src\main\ets\model\ADNode.ets
import Constants from '../common/Constants';
import storeCurrentTime from '../Utils/TimeUtils';
// 广告SDK对外提供的广告组件节点的参数
class ADNodeParams {
ad_img_url: string = ''
ad_link: string = ''
ad_text: string = ''
}
// 广告SDK对外提供的广告组件节点的自定义构建函数
@Builder
function ADNodeBuilder(params: ADNodeParams) {
//该广告节点示例,包含一个广告图片和按钮,可以跳转到指定的链接
Stack({ alignContent: Alignment.Bottom }) {
Image(params.ad_img_url)
.objectFit(ImageFit.Contain)
.height(Constants.IMAGE_HEIGHT)
.width(Constants.IMAGE_WIDTH)
Button(params.ad_text)
.backgroundColor(Color.Gray)
.alignSelf(ItemAlign.End)
}.height(Constants.AD_NODE_HEIGHT)
.width(Constants.AD_NODE_WIDTH)
.onClick(() => {
// 跳转至对应的应用或页面
})
}
......
其次,广告SDK创建自定义广告节点控制器。ADNodeController类继承NodeController,用于绑定NodeContainer容器,它持有一个BuilderNoder自定义节点,可以挂载原生组件。NodeController可通过BuilderNode持有的FrameNode将其挂载到NodeContainer上。
NodeController类中的函数makeNode函数会在NodeController实例绑定的NodeContainer组件创建的时候回调,回调方法将返回一个节点,将该节点挂载至NodeContainer。
// adsdk\src\main\ets\model\ADNode.ets
......
// 自定义广告节点控制器
class ADNodeController extends NodeController {
private node: BuilderNode<Object[]> | null = null;
constructor(node: BuilderNode<Object[]>) {
super();
this.node = node;
}
// 当NodeController绑定的NodeContainer挂载显示时,触发此回调
// 可以加一个打点,记录标明广告被真实的显示出来的时间
aboutToAppear(): void {
console.info("ADController aboutToAppear");
storeCurrentTime(Constants.AD_SHOW_START_TIME);
}
// 当NodeController绑定的NodeContainer卸载消失时,触发此回调
// 可以加一个打点,记录标明广告退出的时间
aboutToDisappear(): void {
console.info("ADController aboutToDisappear");
storeCurrentTime(Constants.AD_SHOW_END_TIME);
}
// 当NodeController实例绑定的NodeContainer创建的时候进行回调。回调方法将返回一个节点,将该节点挂载至NodeContainer。
makeNode(uiContext: UIContext): FrameNode | null {
if (this.node == null) {
return null;
}
return this.node.getFrameNode();
}
// 更新渲染节点
update(params: ADNodeParams) {
if (this.node != null) {
console.info(`update params:${JSON.stringify(params)}`)
this.node.update(params)
}
}
}
......
然后,广告SDK对外提供广告节点控制器。Advertising单例类用于对外提供广告。广告类型有开屏广告、插屏广告、Banner广告、贴片广告等,getADNode()函数根据广告类型,获取对应的广告组件的NodeController节点控制器实例,获取到的NodeController实例和应用NodeContainer组件绑定,用于在应用侧展示广告。getADNode()函数会进一步调用私有函数createSplashADNodeController(),该函数首先使用广告组件的自定义函数ADNodeBuilder()及其需要的参数实例化BuilderNode自定义节点,然后创建一个ADNodeController()实例并返回。
// adsdk\src\main\ets\model\ADNode.ets
......
// 三方广告SDK对外提供接口的交互类
export class Advertising {
private static instance: Advertising;
public static readonly SPLASH_SCREEN_AD = 0;
// 获取交互类单例
public static getInstance(): Advertising {
if (Advertising.instance == null) {
Advertising.instance = new Advertising();
}
return Advertising.instance;
}
// 根据广告类型,获取对应的广告组件的NodeController节点控制器实例
public getADNode(type: Number, uiContext: UIContext): NodeController | null {
// 根据应用指定的类型,返回特定的广告节点控制器实例
if (type === Advertising.SPLASH_SCREEN_AD) {
return this.createSplashADNodeController(uiContext);
}
return null;
}
// 构建广告组件的自定义节点,并使用自定义节点创建NodeController广告控制器实例
private createSplashADNodeController(uiContext: UIContext): NodeController {
let node = new BuilderNode<[ADNodeParams]>(uiContext);
node.build(new WrappedBuilder<[ADNodeParams]>(ADNodeBuilder), {
ad_img_url: Constants.ad_img_url,
ad_link: Constants.ad_link,
ad_text: Constants.ad_text
})
return new ADNodeController(node);
}
}
接着,应用界面展示广告。应用页面的aboutToAppear()方法中调用广告SDK接口getADNode(),获取NodeController广告节点控制器实例,和应用页面广告NodeContainer布局空间进行绑定。通过传入的广告类型参数的不同,展示不同的广告图文组件,从而完成广告的动态展示。
// entry\src\main\ets\pages\SplashScreenPage.ets
import { NodeController, router } from '@kit.ArkUI';
import { CommonConstants } from '../constants/CommonConstants';
import { Advertising } from '@adsdk/sdkdemo'
// SplashScreenPage为应用的入口页面,在该页面上展示广告,用户可以点击跳过广告,也可以等待广告倒计时结束进入应用主页。
@Entry
@Component
struct SplashScreenPage {
@State pageShowTime: number = CommonConstants.TIME_DEFAULT_VALUE;
@State intervalID: number = CommonConstants.INTERVAL_ID_DEFAULT;
private controller: NodeController | null = null;
aboutToAppear(): void {
// 获取当前页的UIContext
let uiContext = this.getUIContext();
// 调用三方广告SDK提供的单例接口获取广告节点控制器
this.controller = Advertising.getInstance()
.getADNode(Advertising.SPLASH_SCREEN_AD, uiContext);
}
build() {
Column() {
Stack({ alignContent: Alignment.TopStart }) {
Image($r('app.media.ic_splash_page_background'))
.width(CommonConstants.IMAGE_WIDTH)
.height(CommonConstants.IMAGE_HEIGHT)
// 使用NodeContainer进行布局占位,为广告节点提供布局空间
NodeContainer(this.controller)
AdvertiseIcon()
SkipButton({ secondsCount: (CommonConstants.DELAY_SECONDS - this.pageShowTime) })
}
.layoutWeight(CommonConstants.STACK_LAYOUT_WEIGHT)
.width(CommonConstants.STACK_WIDTH)
}
.alignItems(HorizontalAlign.Start)
.width(CommonConstants.COLUMN_WIDTH)
.height(CommonConstants.COLUMN_HEIGHT)
}
/**
* When the SplashScreenPage is displayed, switch to the next page after 3 seconds.
*/
onPageShow() {
this.intervalID = setInterval(() => {
this.pageShowTime += CommonConstants.INCREMENT_VALUE;
if (this.pageShowTime > CommonConstants.DELAY_SECONDS) {
router.replaceUrl({
url: CommonConstants.MAIN_PAGE_URL
});
}
}, CommonConstants.INTERVAL_DELAY);
}
/**
* When the SplashScreenPage is hide, clear interval.
*/
onPageHide() {
clearInterval(this.intervalID);
}
}
@Component
struct SkipButton {
@Prop secondsCount: number = 0;
build() {
Flex({
direction: FlexDirection.Row,
justifyContent: FlexAlign.End
}) {
Text($r('app.string.skip', this.secondsCount))
.backgroundColor($r('app.color.rect_area_background'))
.borderRadius(CommonConstants.SKIP_BUTTON_RADIUS)
.fontColor(Color.White)
.width($r('app.float.skip_text_width'))
.height($r('app.float.skip_text_height'))
.fontSize($r('app.float.skip_text_size'))
.margin({
right: $r('app.float.skip_text_margin_right'),
top: $r('app.float.skip_text_margin_top')
})
.textAlign(TextAlign.Center)
.border({ width: CommonConstants.SKIP_TEXT_BORDER_WIDTH })
.borderColor($r('app.color.rect_stroke'))
.onClick(() => {
router.replaceUrl({
url: CommonConstants.MAIN_PAGE_URL
});
})
}
}
}
@Component
struct AdvertiseIcon {
build() {
Flex({
direction: FlexDirection.Column,
justifyContent: FlexAlign.End
}) {
Image($r('app.media.ic_advertise'))
.objectFit(ImageFit.Contain)
.width($r('app.float.advertise_button_width'))
.height($r('app.float.advertise_button_height'))
}
.margin($r('app.float.advertise_icon_margin'))
}
}
最后,我们介绍一下进阶用法。上文介绍了广告SDK场景的基础实现方案。投放广告时,通常需要统计广告的显示时长等。我们通过在自定义节点控制器NodeController的生命周期函数中打点标记广告开始显示和消失的时间,从而可以实现广告显示时间的统计。下面,我们进一步完善广告SDK。
在ADNodeController类中,当NodeController绑定的NodeContainer挂载显示时,触发aboutToAppear()生命周期回调,可以在此函数中记录标明广告被真实的显示出来的时间。当NodeController绑定的NodeContainer卸载消失时,触发aboutToDisappear()生命周期回调,可以在此函数中记录标明广告退出的时间。其中storeCurrentTime()工具类函数用于保存广告展示的开始和结束时间数据。
// 当NodeController绑定的NodeContainer挂载显示时,触发此回调
// 可以加一个打点,记录标明广告被真实的显示出来的时间
aboutToAppear(): void {
console.info("ADController aboutToAppear");
storeCurrentTime(Constants.AD_SHOW_START_TIME);
}
// 当NodeController绑定的NodeContainer卸载消失时,触发此回调
// 可以加一个打点,记录标明广告退出的时间
aboutToDisappear(): void {
console.info("ADController aboutToDisappear");
storeCurrentTime(Constants.AD_SHOW_END_TIME);
}
广告展示时间数据可以上传运营平台,本案例中,为方便演示,在应用主界面展示广告显示的时间,对应代码如下。在应用主界面展示执行前,在aboutToAppear()函数中获取广告开始展示和消失的时间,赋值给状态变量@State ad_show_time,实现在界面上展示广告实际的显示时长。
// entry\src\main\ets\pages\MainPage.ets
import { CommonConstants } from '../constants/CommonConstants';
/**
* 开屏广告展示之后,进入应用主页,页面上展示广告组件显示时长
*/
@Entry
@Component
struct MainPage {
@State ad_show_time: number = 0;
// 获取广告开始展示和消失的时间,并在界面上展示广告实际的显示时长
aboutToAppear(): void {
setTimeout(() => {
let startTime: number = AppStorage.get(CommonConstants.AD_SHOW_START_TIME) as number;
let endTime: number = AppStorage.get(CommonConstants.AD_SHOW_END_TIME) as number;
this.ad_show_time = Math.round((endTime - startTime) / CommonConstants.MS_TO_SECOND);
}, CommonConstants.TIMEOUT)
}
// 自定义构建函数,展示广告实际显示时长
build() {
Row() {
Column() {
Text($r('app.string.main_page_content'))
.fontSize($r('app.float.hello_world_font_size'))
.fontWeight(FontWeight.Bold)
Blank()
Text(this.ad_show_time.toString())
.fontSize($r('app.float.hello_world_font_size'))
.fontWeight(FontWeight.Bold)
}
.width(CommonConstants.MAIN_PAGE_COLUMN_WIDTH)
}
.height(CommonConstants.MAIN_PAGE_ROW_WIDTH)
}
}