概述
埋点是指将信息采集程序和原本的功能代码结合起来,针对特定用户行为收集、处理和发送一些信息,用来跟踪应用使用情况。包括访问数、访客数、停留时长、页面浏览数和跳出率。以下是几种常见业务场景:
- 页面中可视区域或者组件的点击量,统计点击频率,分析用户的偏好行为。
- 监听页面中组件滑动的开始与结束,计算滑动偏移量以及曝光比例。
- 监听页面切换,统计页面的停留时间以及切换的来源页和目标页,分析页面浏览数和跳出率。
- 分析页面加载性能,计算加载过程各个节点的耗时,可针对某个关键点进行优化。
埋点分类
按照用户行为不同,埋点可以分为点击埋点、曝光埋点以及页面埋点等。
- 点击埋点:用户在任意区域的一次点击,比如一个icon或一张图片。区别于被动的用户曝光行为,点击属于主动行为。
- 曝光埋点:统计页面局部区域是否被用户有效浏览,比如瀑布流中的每一个卡片的曝光比例以及曝光时长,属于被动行为。
- 页面埋点:统计用户在固定页面的停留时间,页面加载性能以及页面跳转时的来源页和去向页信息。
方案介绍
接下来会从(1)组件动态绑定埋点数据;(2)点击埋点方案;(3)曝光埋点方案;(4)页面埋点方案四部分介绍。整体方案使用全局无感监听能力[UIObserver]和[setOnVisibleAreaApproximateChange]属性实现埋点功能。
绑定埋点数据
首先针对需要埋点的组件指定对应的ID值以及埋点数据。比如Text组件可以指定ID为“text-1”,并且通过customProperty自定义属性设置key和value,key为组件ID,value为埋点数据。为方便拿取,可以将埋点数据统一定义在DataResource中。
import { DataResource } from '../common/DataResource';
import { router } from '@kit.ArkUI';
@Entry
@Component
struct Index {
onPageShow(): void {
const uiContext: UIContext = this.getUIContext();
console.log('text-1', JSON.stringify(uiContext.getFrameNodeById('text-1')?.getCustomProperty('text-1')));
}
build() {
Column() {
Text('text-1')
.id('text-1')
.fontSize(50)
.fontWeight(FontWeight.Bold)
.customProperty('text-1', DataResource['Index']['text-1'])
.onClick(() => {
console.log('111111');
})
Text('text-2')
.id('text-2')
.fontSize(50)
.fontWeight(FontWeight.Bold)
.customProperty('text-2', DataResource['Index']['text-2'])
.onClick(() => {
})
Button('Navigation')
.onClick(() => {
router.pushUrl({ url: 'pages/NavigationPage' })
})
Button('WaterFlow')
.onClick(()=>{
router.pushUrl({url: 'pages/WaterFlowPage'})
})
}
.height('100%')
.width('100%')
}
}
其中DataResource在本示例中是根据Page名、组件名以及索引进行封装,以Page名作为最外层key层,以组件名+索引为里层key值。value值(埋点数据)可以根据实际业务进行配置。
export const DataResource: Record<string, Record<string, DataResourceType>> = {
'Index': {
'text-1': { id: 'text-1' },
'text-2': { id: 'text-2' },
},
'Page2': {
'component-1': {id: 'text-2' },
// ...
}
}
export interface DataResourceType {
id: string
}
点击埋点
配置完埋点数据以及成功绑定组件后,可以在EntryAbility里统一注册点击事件监听,在事件回调中获取点击的触发节点。[UIObserver]一共提供了两种监听事件:
- [on(“willClick”)]:用于监听点击事件指令下发情况,所注册回调将于点击事件触发前触发。
- [on(“didClick”)]:用于监听点击事件指令下发情况,所注册回调将于点击事件触发后触发。
这两种方式均可以实现用户点击组件时触发回调,但要注意必须在组件上添加onClick属性。本示例中以willClick监听为例,下面介绍具体方案实现。
实现步骤
- 首先实现一个简单页面,并且在组件上绑定ID以及埋点数据,ID可以根据组件名-索引来命名;比如下面代码示例中有两个Text组件,ID值分别为“text-1”与“text-2”。
import { DataResource } from '../common/DataResource';
import { router } from '@kit.ArkUI';
@Entry
@Component
struct Index {
onPageShow(): void {
const uiContext: UIContext = this.getUIContext();
console.log('text-1', JSON.stringify(uiContext.getFrameNodeById('text-1')?.getCustomProperty('text-1')));
}
build() {
Column() {
Text('text-1')
.id('text-1')
.fontSize(50)
.fontWeight(FontWeight.Bold)
.customProperty('text-1', DataResource['Index']['text-1'])
.onClick(() => {
console.log('111111');
})
Text('text-2')
.id('text-2')
.fontSize(50)
.fontWeight(FontWeight.Bold)
.customProperty('text-2', DataResource['Index']['text-2'])
.onClick(() => {
})
Button('Navigation')
.onClick(() => {
router.pushUrl({ url: 'pages/NavigationPage' })
})
Button('WaterFlow')
.onClick(()=>{
router.pushUrl({url: 'pages/WaterFlowPage'})
})
}
.height('100%')
.width('100%')
}
}
- 在EntryAbility中统一注册UIObserver的wilClick事件监听,并且在事件回调中获取触发的组件节点FrameNode。
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { FrameNode, window } from '@kit.ArkUI';
import { hiAppEvent } from '@kit.PerformanceAnalysisKit';
import CallbackManager from '../common/CallBackManager';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
console.info('testTag', '%{public}s', 'Ability onCreate');
}
onDestroy(): void {
console.info('testTag', '%{public}s', 'Ability onDestroy');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
console.info('testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
const uiContext: UIContext = windowStage.getMainWindowSync().getUIContext();
AppStorage.setOrCreate('uiContext', uiContext);
uiContext.getUIObserver()?.on('willClick', (_event: ClickEvent, node?: FrameNode) => {
const clickCallback = CallbackManager.getInstance().getClickCallback();
clickCallback(node, uiContext);
})
uiContext.getUIObserver()
.on('scrollEvent', (info) => CallbackManager.getInstance().getScrollEvent(info))
uiContext.getUIObserver().on('navDestinationSwitch', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
uiContext.getUIObserver().on('routerPageUpdate', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
if (err.code) {
console.error('testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
const onTrigger = CallbackManager.getInstance().getOnTrigger();
hiAppEvent.addWatcher({
name: 'watcher1',
appEventFilters: [
{
domain: 'test_domain',
eventTypes: [hiAppEvent.EventType.FAULT, hiAppEvent.EventType.BEHAVIOR]
}
],
triggerCondition: {
row: 10,
size: 1000,
timeOut: 1
},
onTrigger: onTrigger
})
console.info('testTag', 'Succeeded in loading the content.');
});
}
onWindowStageDestroy(): void {
const uiContext: UIContext | undefined = AppStorage.get('uiContext');
uiContext?.getUIObserver().off('willClick');
uiContext?.getUIObserver().off('scrollEvent');
uiContext?.getUIObserver().off('navDestinationSwitch');
uiContext?.getUIObserver().off('routerPageUpdate');
// Main window is destroyed, release UI related resources
console.info('testTag', '%{public}s', 'Ability onWindowStageDestroy');
}
onForeground(): void {
// Ability has brought to foreground
console.info('testTag', '%{public}s', 'Ability onForeground');
}
onBackground(): void {
// Ability has back to background
console.info('testTag', '%{public}s', 'Ability onBackground');
}
}
- 接着可以根据FrameNode获取当前组件所在的Page和ID值,并且通过[getCustomProperty]获取当前组件绑定的埋点数据。此外FrameNode还提供一些方法获取组件的基础属性,比如组件大小、组件位置以及是否可见等一些信息。
import { FrameNode, uiObserver } from '@kit.ArkUI';
import { hiAppEvent } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
export default class CallbackManager {
public static callbackManagerIns: CallbackManager | undefined;
constructor() {
}
public static getInstance(): CallbackManager {
if (!CallbackManager.callbackManagerIns) {
CallbackManager.callbackManagerIns = new CallbackManager();
}
return CallbackManager.callbackManagerIns;
}
/**
* 获取AreaChangeCallback回调
*
* @returns
*/
public getAreaChangeCallback() {
return (node: FrameNode | null, ratio: number) => {
console.log(`Node ${node?.getId()}:${node?.getNodeType()} is visibleRatio is ${ratio}`);
hiAppEvent.write({
domain: 'test_domain',
name: 'test_event',
eventType: hiAppEvent.EventType.FAULT,
params: {},
}, (err: BusinessError) => {
if (err) {
console.error('hiAppEvent', `code: ${err.code}, message: ${err.message}`);
return;
}
console.info('hiAppEvent', `success to write event`);
});
}
}
/**
* 获取ClickCallback回调
*
* @returns
*/
public getClickCallback() {
return (node: FrameNode | undefined, uiContext: UIContext) => {
console.log('FrameNode', JSON.stringify(node));
const uniqueId = node?.getUniqueId();
const ID = node?.getId();
const pageInfo = uiContext.getPageInfoByUniqueId(uniqueId);
const trackData = node?.getCustomProperty(ID);
let eventParams: Record<string, string | number> = {
'component_id': ID ?? '',
'pageInfo': JSON.stringify(pageInfo ?? {}),
'tackData': JSON.stringify(trackData ?? {})
};
hiAppEvent.write({
domain: 'test_domain',
name: 'test_event',
eventType: hiAppEvent.EventType.FAULT,
params: eventParams,
}, (err: BusinessError) => {
if (err) {
console.error('hiAppEvent', `code: ${err.code}, message: ${err.message}`);
return;
}
console.info('hiAppEvent', `success to write event`);
});
}
}
/**
* 获取SwitchCallback回调
*
* @returns
*/
public getSwitchCallback() {
return (info: uiObserver.NavDestinationSwitchInfo | uiObserver.RouterPageInfo)=>{
console.log(JSON.stringify(info));
}
}
/**
*
* @param info
*/
public getScrollEvent(info: uiObserver.ScrollEventInfo) {
const type = info.scrollEvent;
if (type === uiObserver.ScrollEventType.SCROLL_START) {
console.log(JSON.stringify(info));
}
}
/**
*
* @returns
*/
public getOnTrigger() {
return (curRow: number, curSize: number, holder: hiAppEvent.AppEventPackageHolder) => {
if (holder == null) {
console.error('hiAppEvent', 'holder is null');
return;
}
console.info('hiAppEvent', `curRow=${curRow}, curSize=${curSize}`);
let eventPkg: hiAppEvent.AppEventPackage | null = null;
while ((eventPkg = holder.takeNext()) != null) {
for (const eventInfo of eventPkg.data) {
console.info('hiAppEvent', `eventPkg.data=${eventInfo}`);
}
// let options: http.HttpRequestOptions = {
// method: http.RequestMethod.POST,
// // 当使用POST请求时此字段用于传递请求体内容,具体格式与服务端协商确定
// extraData: eventPkg.data,
// };
// let httpRequest = http.createHttp();
// httpRequest.request('EXAMPLE_URL', options, (err: Error, data: http.HttpResponse) => {
// if (!err) {
// console.info('Result:' + data.result);
// console.info('code:' + data.responseCode);
// console.info('type:' + JSON.stringify(data.resultType));
// console.info('header:' + JSON.stringify(data.header));
// console.info('cookies:' + data.cookies); // 自API version 8开始支持cookie
// } else {
// console.info('error:' + JSON.stringify(err));
// }
// });
}
}
}
}
- 然后通过@kit.PerformanceAnalysisKit的[write]方法将需要的数据写入当天的事件文件中。需要注意的是eventParams的参数值只能是number、string、boolean以及数组类型。
public getClickCallback() {
return (node: FrameNode | undefined, uiContext: UIContext) => {
console.log('FrameNode', JSON.stringify(node));
const uniqueId = node?.getUniqueId();
const ID = node?.getId();
const pageInfo = uiContext.getPageInfoByUniqueId(uniqueId);
const trackData = node?.getCustomProperty(ID);
let eventParams: Record<string, string | number> = {
'component_id': ID ?? '',
'pageInfo': JSON.stringify(pageInfo ?? {}),
'tackData': JSON.stringify(trackData ?? {})
};
hiAppEvent.write({
domain: 'test_domain',
name: 'test_event',
eventType: hiAppEvent.EventType.FAULT,
params: eventParams,
}, (err: BusinessError) => {
if (err) {
console.error('hiAppEvent', `code: ${err.code}, message: ${err.message}`);
return;
}
console.info('hiAppEvent', `success to write event`);
});
}
}
- 最后在onWindowStageDestroy调用UIObserver的[off]接口取消监听事件。
onWindowStageDestroy(): void {
const uiContext: UIContext | undefined = AppStorage.get('uiContext');
uiContext?.getUIObserver().off('willClick');
uiContext?.getUIObserver().off('scrollEvent');
uiContext?.getUIObserver().off('navDestinationSwitch');
uiContext?.getUIObserver().off('routerPageUpdate');
// Main window is destroyed, release UI related resources
console.info('testTag', '%{public}s', 'Ability onWindowStageDestroy');
}
除了点击事件外,UIObserver还可以通过[on.(“scrollEvent”)]监听组件滑动。在滑动开始与结束触发回调并得到滑动偏移量。以[瀑布流]为例,对WaterFlow和FlowItem设置组件ID。
import { WaterFlowDataSource } from '../common/WaterFlowDataSource';
import { TrackNode, Track } from './TrackNode';
@Entry
@Component
struct WaterFlowPage {
@State minSize: number = 80;
@State maxSize: number = 180;
@State fontSize: number = 24;
@State colors: number[] = [0xFFC0CB, 0xDA70D6, 0x6B8E23, 0x6A5ACD, 0x00FFFF, 0x00FF7F];
scroller: Scroller = new Scroller();
dataSource: WaterFlowDataSource = new WaterFlowDataSource();
private itemWidthArray: number[] = [];
private itemHeightArray: number[] = [];
// 计算FlowItem宽/高
getSize() {
let ret = Math.floor(Math.random() * this.maxSize);
return (ret > this.minSize ? ret : this.minSize);
}
// 设置FlowItem的宽/高数组
setItemSizeArray() {
for (let i = 0; i < 100; i++) {
this.itemWidthArray.push(this.getSize());
this.itemHeightArray.push(this.getSize());
}
}
aboutToAppear() {
this.setItemSizeArray();
}
@Builder
itemFoot() {
Column() {
Text(`Footer`)
.fontSize(10)
.backgroundColor(Color.Red)
.width(50)
.height(50)
.align(Alignment.Center)
.margin({ top: 2 })
}
}
build() {
Column({ space: 2 }) {
TrackNode({ track: new Track().id('WaterFlow-1') }) {
WaterFlow() {
LazyForEach(this.dataSource, (item: number, index) => {
FlowItem() {
TrackNode({ track: new Track().id(`flowItem_${index}`) }) {
Column() {
Text('N' + item).fontSize(12).height('16')
Image('res/waterFlowTest(' + item % 5 + ').jpg')
.objectFit(ImageFit.Fill)
.width('100%')
.layoutWeight(1)
}
.id(`flowItem_${index}`)
}
}
.width('100%')
.height(500)
.backgroundColor(this.colors[item % 5])
}, (item: string) => item)
}
.id('WaterFlow-1')
.columnsTemplate('1fr 1fr')
.columnsGap(10)
.rowsGap(5)
.backgroundColor(0xFAEEE0)
.width('100%')
.height('100%')
.onReachStart(() => {
console.info('waterFlow reach start');
})
.onScrollStart(() => {
console.info('waterFlow scroll start');
})
.onScrollStop(() => {
console.info('waterFlow scroll stop');
})
.onScrollFrameBegin((offset: number, state: ScrollState) => {
console.info('waterFlow scrollFrameBegin offset: ' + offset + ' state: ' + state.toString());
return { offsetRemain: offset };
})
}
}
}
}
接着在EntryAbility里统一注册scrollEvent的事件监听,在回调中获取[ScrollEventInfo]信息,包括id、uniqueId、scrollEvent以及offset。
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
console.info('testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
const uiContext: UIContext = windowStage.getMainWindowSync().getUIContext();
AppStorage.setOrCreate('uiContext', uiContext);
uiContext.getUIObserver()?.on('willClick', (_event: ClickEvent, node?: FrameNode) => {
const clickCallback = CallbackManager.getInstance().getClickCallback();
clickCallback(node, uiContext);
})
uiContext.getUIObserver()
.on('scrollEvent', (info) => CallbackManager.getInstance().getScrollEvent(info))
uiContext.getUIObserver().on('navDestinationSwitch', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
uiContext.getUIObserver().on('routerPageUpdate', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
if (err.code) {
console.error('testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
const onTrigger = CallbackManager.getInstance().getOnTrigger();
hiAppEvent.addWatcher({
name: 'watcher1',
appEventFilters: [
{
domain: 'test_domain',
eventTypes: [hiAppEvent.EventType.FAULT, hiAppEvent.EventType.BEHAVIOR]
}
],
triggerCondition: {
row: 10,
size: 1000,
timeOut: 1
},
onTrigger: onTrigger
})
console.info('testTag', 'Succeeded in loading the content.');
});
}
说明
scrollEvent监听事件中回调参数的id值只能精确到外层组件WaterFlow,无法精确到里层FlowItem。如果想要在滑动过程中获取各个Item组件的曝光比例,可以参考第三小节曝光埋点。
曝光埋点
曝光埋点需要监听页面中每个组件的出现与消失,比如用户在滑动瀑布流时某个Item出现的时长超过500ms则记为一次有效曝光。为避免在每一个页面注入冗长代码,建议使用自定义“埋点钩子”组件进行封装,以下是具体实现步骤。
实现步骤
-
首先自定义一个TrackNode“钩子”组件,需要支持嵌套子组件、组件ID值注入以及注册监听事件等等。因此TrackNode中的build组件需由外部调用方决定,并且在onDidBuild生命周期中将组件信息注入进去,onDidBuild主要做了三件事:
(1)调用TrackManager的addTrack将当前组件与TrackShadow对象绑定起来。
(2)通过[setOnVisibleAreaApproximateChange]监听埋点组件的可视区域的变化;其中ratio值可以自定义设置,比如本示例设置了0.0、0.5、1.0。
(3)根据当前组件获取它的父亲节点,并且判断父亲节点有无埋点钩子,如果没有,则继续往上追溯,直到parent节点为null;如果有,则在父节点的子组件集合中添加当前节点。
注意在aboutToDisappear生命周期中必须调用TrackManager里的removeTrack将当前的组件信息删除。
onDidBuild(): void {
// 构建埋点的虚拟树,获取的node为当前页面的根节点(用例中为Row)。
let uid = this.getUniqueId();
let node: FrameNode | null = this.getUIContext().getFrameNodeByUniqueId(uid);
console.log('Track onDidBuild node:' + node?.getNodeType());
if (node === null) {
return;
}
this.trackShadow.node = node;
this.trackShadow.id = node?.getId();
this.trackShadow.track = this.track;
TrackManager.get().addTrack(this.trackShadow.id, this.trackShadow);
// 通过setOnVisibleAreaApproximateChange监听记录埋点组件的可视区域。
node?.commonEvent.setOnVisibleAreaApproximateChange(
{ ratios: [0, 0.5, 1], expectedUpdateInterval: 500 },
(ratioInc: boolean, ratio: number) => {
const areaChangeCb = CallbackManager.getInstance().getAreaChangeCallback();
areaChangeCb(node, ratio);
this.trackShadow.visibleRatio = ratio;
})
let parent: FrameNode | null = node?.getParent();
console.log(parent?.getId());
let attachTrackToParent: (parent: FrameNode | null) => boolean =
(parent: FrameNode | null) => {
while (parent !== null) {
let parentTrack = TrackManager.get().getTrackById(parent?.getId());
if (parentTrack !== undefined) {
parentTrack.childIds.add(this.trackShadow.id);
this.trackShadow.parentId = parentTrack.id;
return true;
}
parent = parent.getParent();
}
return false;
}
let attached = attachTrackToParent(parent);
if (!attached) {
node?.commonEvent.setOnAppear(() => {
let attached = attachTrackToParent(parent);
if (attached) {
console.log('Track lazy attached:' + this.trackShadow.id);
}
})
}
}
- TrackManager主要封装了埋点钩子的一些操作类方法,包括绑定、删除以及导出。绑定是指将当前组件ID与TrackShadow对象存入全局Map中;导出是指以根节点开始,递归输出所有子组件的曝光比例;删除是指根据具体ID值删除Map中对应的数据。
export class TrackManager {
static instance: TrackManager;
private trackMap: Map<string, TrackShadow> = new Map();
private rootTrack: TrackShadow | null = null;
static get(): TrackManager {
if (TrackManager.instance !== undefined) {
return TrackManager.instance;
}
TrackManager.instance = new TrackManager();
return TrackManager.instance;
}
addTrack(id: string, track: TrackShadow) {
if (this.trackMap.size == 0) {
this.rootTrack = track;
}
console.log('Track add id:' + id);
this.trackMap.set(id, track);
}
removeTrack(id: string) {
let current = this.getTrackById(id);
if (current !== undefined) {
this.trackMap.delete(id);
let parent = this.getTrackById(current?.parentId);
parent?.childIds.delete(id);
}
}
getTrackById(id: string): TrackShadow | undefined {
return this.trackMap.get(id);
}
updateVisibleInfo(track: TrackShadow): void {
// do something
}
dump(): void {
this.rootTrack?.dump(0);
}
}
- TrackShadow对象中包含FrameNode、track、childIds、parentId等等。其中FrameNode指组件节点,track包含ID值,childIds指子组件列表,parentId指父组件的ID值。
export class Track {
public areaPercent: number = 0;
private trackId: string = "";
constructor() {
}
id(newId: string): Track {
this.trackId = newId;
return this;
}
}
/**
* 埋点数据类
*/
export class TrackShadow {
public node: FrameNode | null = null;
public id: string = "";
public track: Track | null = null;
public childIds: Set<string> = new Set();
public parentId: string = "";
public visibleRect: common2D.Rect = {
left: 0,
top: 0,
right: 0,
bottom: 0
}
public visibleRatio: number = 0;
// 通过全局dump输出埋点树的信息
dump(depth: number = 0): void {
console.log('Track Dp:' + depth + ' areaPer:' + this.track?.areaPercent + ' visibleRatio:' + this.visibleRatio);
this.childIds.forEach((value: string) => {
TrackManager.get().getTrackById(value)?.dump(depth + 1);
})
}
}
- 然后根据上述TrackNode组件改造一下瀑布流代码:用TrackNode钩子将WaterFlow和FlowItem包起来,并且传递一个track对象,id为组件的唯一标识。
import { WaterFlowDataSource } from '../common/WaterFlowDataSource';
import { TrackNode, Track } from './TrackNode';
@Entry
@Component
struct WaterFlowPage {
@State minSize: number = 80;
@State maxSize: number = 180;
@State fontSize: number = 24;
@State colors: number[] = [0xFFC0CB, 0xDA70D6, 0x6B8E23, 0x6A5ACD, 0x00FFFF, 0x00FF7F];
scroller: Scroller = new Scroller();
dataSource: WaterFlowDataSource = new WaterFlowDataSource();
private itemWidthArray: number[] = [];
private itemHeightArray: number[] = [];
// 计算FlowItem宽/高
getSize() {
let ret = Math.floor(Math.random() * this.maxSize);
return (ret > this.minSize ? ret : this.minSize);
}
// 设置FlowItem的宽/高数组
setItemSizeArray() {
for (let i = 0; i < 100; i++) {
this.itemWidthArray.push(this.getSize());
this.itemHeightArray.push(this.getSize());
}
}
aboutToAppear() {
this.setItemSizeArray();
}
@Builder
itemFoot() {
Column() {
Text(`Footer`)
.fontSize(10)
.backgroundColor(Color.Red)
.width(50)
.height(50)
.align(Alignment.Center)
.margin({ top: 2 })
}
}
build() {
Column({ space: 2 }) {
TrackNode({ track: new Track().id('WaterFlow-1') }) {
WaterFlow() {
LazyForEach(this.dataSource, (item: number, index) => {
FlowItem() {
TrackNode({ track: new Track().id(`flowItem_${index}`) }) {
Column() {
Text('N' + item).fontSize(12).height('16')
Image('res/waterFlowTest(' + item % 5 + ').jpg')
.objectFit(ImageFit.Fill)
.width('100%')
.layoutWeight(1)
}
.id(`flowItem_${index}`)
}
}
.width('100%')
.height(500)
.backgroundColor(this.colors[item % 5])
}, (item: string) => item)
}
.id('WaterFlow-1')
.columnsTemplate('1fr 1fr')
.columnsGap(10)
.rowsGap(5)
.backgroundColor(0xFAEEE0)
.width('100%')
.height('100%')
.onReachStart(() => {
console.info('waterFlow reach start');
})
.onScrollStart(() => {
console.info('waterFlow scroll start');
})
.onScrollStop(() => {
console.info('waterFlow scroll stop');
})
.onScrollFrameBegin((offset: number, state: ScrollState) => {
console.info('waterFlow scrollFrameBegin offset: ' + offset + ' state: ' + state.toString());
return { offsetRemain: offset };
})
}
}
}
}
最后滚动瀑布流时,不仅可以监听每一个Item的曝光比,也可以向上追溯到根节点,统计根节点中每一个子组件的曝光比例。
页面埋点
页面埋点本示例中分为两类,一类是监听页面切换;另一类是采集页面加载性能。以下从Navigation和Router两种路由方案来讲解。
Navigation路由:
针对Navigation方案,UIObserver提供了navDestinationSwitch事件监听页面的切换,并且支持在回调中获取当前页面的切换信息。首先在EntryAbility中统一注册UIObserver的navDestinationSwitch事件监听。
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
console.info('testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
const uiContext: UIContext = windowStage.getMainWindowSync().getUIContext();
AppStorage.setOrCreate('uiContext', uiContext);
uiContext.getUIObserver()?.on('willClick', (_event: ClickEvent, node?: FrameNode) => {
const clickCallback = CallbackManager.getInstance().getClickCallback();
clickCallback(node, uiContext);
})
uiContext.getUIObserver()
.on('scrollEvent', (info) => CallbackManager.getInstance().getScrollEvent(info))
uiContext.getUIObserver().on('navDestinationSwitch', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
uiContext.getUIObserver().on('routerPageUpdate', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
if (err.code) {
console.error('testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
const onTrigger = CallbackManager.getInstance().getOnTrigger();
hiAppEvent.addWatcher({
name: 'watcher1',
appEventFilters: [
{
domain: 'test_domain',
eventTypes: [hiAppEvent.EventType.FAULT, hiAppEvent.EventType.BEHAVIOR]
}
],
triggerCondition: {
row: 10,
size: 1000,
timeOut: 1
},
onTrigger: onTrigger
})
console.info('testTag', 'Succeeded in loading the content.');
});
}
回调函数中的info包括context、from、to以及operation,主要用于标识页面的来源和去向信息。
字段 | 类型 | 含义 |
---|---|---|
context | UIContext | 页面上下文信息 |
from | NavDestinationInfo | NavBar |
to | NavDestinationInfo | NavBar |
operation | [NavigationOperation] | 页面操作 |
此外还可以通过UIObserver的[on(“navDestinationUpdate”)]事件监听页面的显示与隐藏,回调传参中包含页面名称、状态信息以及页面的唯一标识ID。
Router路由:
针对Router路由方案,UIObserver提供了[on(“routerPageUpdate”)]监听事件,在页面切换过程中触发相应回调。
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
console.info('testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
const uiContext: UIContext = windowStage.getMainWindowSync().getUIContext();
AppStorage.setOrCreate('uiContext', uiContext);
uiContext.getUIObserver()?.on('willClick', (_event: ClickEvent, node?: FrameNode) => {
const clickCallback = CallbackManager.getInstance().getClickCallback();
clickCallback(node, uiContext);
})
uiContext.getUIObserver()
.on('scrollEvent', (info) => CallbackManager.getInstance().getScrollEvent(info))
uiContext.getUIObserver().on('navDestinationSwitch', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
uiContext.getUIObserver().on('routerPageUpdate', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
if (err.code) {
console.error('testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
const onTrigger = CallbackManager.getInstance().getOnTrigger();
hiAppEvent.addWatcher({
name: 'watcher1',
appEventFilters: [
{
domain: 'test_domain',
eventTypes: [hiAppEvent.EventType.FAULT, hiAppEvent.EventType.BEHAVIOR]
}
],
triggerCondition: {
row: 10,
size: 1000,
timeOut: 1
},
onTrigger: onTrigger
})
console.info('testTag', 'Succeeded in loading the content.');
});
}
比如调用Router.pushUrl从A页面跳转到B页面时,该回调会被触发三次:第一次触发的页面名称为PageB,页面状态为[ABOUT_TO_APPEAR]即将显示;第二次触发的页面名称为PageA,页面状态为ON_PAGE_HIDE页面隐藏;第三次触发的页面名称为PageB,页面状态为ON_PAGE_SHOW页面显示。回调传参同样包含页面上下文、触发事件的页面名称等等。
字段名 | 类型 | 含义 |
---|---|---|
context | UIContext | 页面上下文信息 |
index | number | 触发页面在路由栈中的位置 |
name | String | 触发页面名称 |
path | String | 触发页面路径 |
state | [RouterPageState] | 页面状态 |
pageId | String | 页面唯一标识 |
页面加载性能:
页面加载性能可以通过计算首帧绘制与绘制结束的时间差来判断。UIObserver同样提供了on(“willDraw”)事件和on(“didLayout”)事件,可以在首帧监听中记录初始时间,在完成绘制时记录结束时间。此事件监听需要在页面中注册,Navigation与Router路由相同,本示例以Navigation为例。在aboutToAppear注册on(“willDraw”)和on(“didLayout”)事件。
@Entry
@Component
struct NavigationPage {
pageInfos: NavPathStack = new NavPathStack();
isUseInterception: boolean = false;
startTime: number = 0;
endTime: number = 0;
aboutToAppear(): void {
const uiContext = this.getUIContext();
// 注册监听事件
uiContext.getUIObserver().on('willDraw', () => {
this.startTime = Date.now();
})
uiContext.getUIObserver().on('didLayout', () => {
this.endTime = Date.now();
})
}
aboutToDisappear(): void {
const uiContext = this.getUIContext();
uiContext.getUIObserver().off('willDraw');
uiContext.getUIObserver().off('didLayout');
}
// interception - 改变navigation的时候会触发willShow、didShow以及modeChange
registerInterception() {
this.pageInfos.setInterception({
willShow: (from: NavDestinationContext | 'navBar', to: NavDestinationContext | 'navBar',
operation: NavigationOperation, animated: boolean) => {
if (!this.isUseInterception) {
return;
}
if (typeof to === 'string') {
console.log('target page is navigation home');
return;
}
// redirect target page.Change pageTwo to pageOne.
let target: NavDestinationContext = to as NavDestinationContext;
if (target.pathInfo.name === 'pageTwo') {
target.pathStack.pop();
target.pathStack.pushPathByName('pageOne', null);
}
},
didShow: (from: NavDestinationContext | 'navBar', to: NavDestinationContext | 'navBar',
operation: NavigationOperation, isAnimated: boolean) => {
if (!this.isUseInterception) {
return;
}
if (typeof from === 'string') {
console.log('current transition is from navigation home');
} else {
console.log(`current transition is from ${(from as NavDestinationContext).pathInfo.name}`);
}
if (typeof to === 'string') {
console.log('current transition to is navBar');
} else {
console.log(`current transition is to ${(to as NavDestinationContext).pathInfo.name}`);
}
},
modeChange: (mode: NavigationMode) => {
if (!this.isUseInterception) {
return;
}
console.log(`current navigation mode is ${mode}`);
}
})
}
build() {
Navigation(this.pageInfos) {
Column() {
Button('pushPath', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
.onClick(() => {
this.pageInfos.pushPath({ name: 'pageOne' }); //将name指定的NavDestination页面信息入栈
})
Button('use interception', { stateEffect: true, type: ButtonType.Capsule })
.width('80%')
.height(40)
.margin(20)
.onClick(() => {
this.isUseInterception = !this.isUseInterception;
if (this.isUseInterception) {
// 注册拦截器
this.registerInterception();
} else {
// 不使用拦截器
this.pageInfos.setInterception(undefined);
}
})
Button('compute speed')
.onClick(() => {
const time: number = this.endTime - this.startTime;
})
}
}.title('NavIndex')
}
}
埋点数据上传
如果需要将埋点数据上传至服务器,可以通过[@kit.PerformanceAnalysisKit]的[addWatcher]方法添加订阅事件观察者、onTrigger回调以及回调触发条件。可以自定义设置回调触发条件,比如在示例代码中当事件size大于等于1000字节时才会触发,然后在onTrigger回调中调用http的request方法发起网络请求,将示例中的EXAMPLE_URL替换为服务器的IP地址即可。
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
console.info('testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
const uiContext: UIContext = windowStage.getMainWindowSync().getUIContext();
AppStorage.setOrCreate('uiContext', uiContext);
uiContext.getUIObserver()?.on('willClick', (_event: ClickEvent, node?: FrameNode) => {
const clickCallback = CallbackManager.getInstance().getClickCallback();
clickCallback(node, uiContext);
})
uiContext.getUIObserver()
.on('scrollEvent', (info) => CallbackManager.getInstance().getScrollEvent(info))
uiContext.getUIObserver().on('navDestinationSwitch', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
uiContext.getUIObserver().on('routerPageUpdate', (info) => {
const switchCallback = CallbackManager.getInstance().getSwitchCallback();
switchCallback(info);
})
if (err.code) {
console.error('testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
return;
}
const onTrigger = CallbackManager.getInstance().getOnTrigger();
hiAppEvent.addWatcher({
name: 'watcher1',
appEventFilters: [
{
domain: 'test_domain',
eventTypes: [hiAppEvent.EventType.FAULT, hiAppEvent.EventType.BEHAVIOR]
}
],
triggerCondition: {
row: 10,
size: 1000,
timeOut: 1
},
onTrigger: onTrigger
})
console.info('testTag', 'Succeeded in loading the content.');
});
}
总结
本文主要从绑定埋点数据出发,介绍了三种埋点的开发实现:包括点击、曝光以及页面埋点。最后可以调用hiAppEvent的addWatcher添加订阅对象和onTrigger回调,在回调中实现数据报上传的逻辑。
- 点击埋点:使用UIObserver的on(“willClick”)跟hiAppEvent的write方法共同实现埋点操作,将埋点数据写入本地设备文件。
- 曝光埋点:使用setOnVisibleAreaApproximateChange跟hiAppEvent的write方法共同实现埋点操作,将埋点数据写入本地设备文件。
- 页面埋点:使用UIObserver的on(“navDestinationSwitch”)跟hiAppEvent的write方法共同实现埋点操作,将埋点数据写入本地设备文件。