目录

应用埋点

概述

埋点是指将信息采集程序和原本的功能代码结合起来,针对特定用户行为收集、处理和发送一些信息,用来跟踪应用使用情况。包括访问数、访客数、停留时长、页面浏览数和跳出率。以下是几种常见业务场景:

埋点分类

按照用户行为不同,埋点可以分为点击埋点、曝光埋点以及页面埋点等。

方案介绍

接下来会从(1)组件动态绑定埋点数据;(2)点击埋点方案;(3)曝光埋点方案;(4)页面埋点方案四部分介绍。整体方案使用全局无感监听能力Class (UIObserver)和setOnVisibleAreaApproximateChange属性实现埋点功能。

[h2]绑定埋点数据

首先,为需要埋点的组件指定对应的ID值和埋点数据。例如,Button组件的ID可以设为“button-1”,并通过customProperty属性设置key和value,其中key为组件ID,value为埋点数据。为了方便获取,可以将埋点数据统一定义在DataResource中。

// entry\src\main\ets\pages\ClickPage.ets
Button($r('app.string.click_tracing_point_single_component'))
  .width('100%')
  .id('button-1')
  .fontWeight(FontWeight.Bold)
  .customProperty('button-1', DataResource['Index']['button-1'])
  .onClick(() => {
    hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'btn');
  })

DataResource根据Page名、组件名和索引封装,Page名为最外层key,组件名+索引为里层key。value值根据实际业务配置。

// entry\src\main\ets\viewModel\DataResource.ets
export const DataResource: Record<string, Record<string, DataResourceType>> = {
  'Index': {
    'button-1': { id: 'button-1' },
    'button-2': { id: 'button-2' }
  },
  'Page2': {
    'component-1': { id: 'text-2' }
  }
}

export interface DataResourceType {
  id: string
}

[h2]点击埋点

配置完埋点数据并绑定组件后,在EntryAbility中注册点击事件监听,在事件回调中获取触发节点。Class (UIObserver)一共提供了两种监听事件:

这两种方法都能在用户点击组件时触发回调。本示例使用 `willClick` 监听方式,下面介绍具体实现方案。

实现步骤

  1. 首先实现一个简单页面,在组件上绑定ID和埋点数据。ID可以根据组件名-索引命名,例如代码示例中的两个组件,ID值分别为“button-1”和“button-2”。
    // entry\src\main\ets\pages\ClickPage.ets
    Row() {
      Text($r('app.string.click_tracing_point_composite_component'))
        // ...
      Image($r('app.media.ic_public_arrow_right'))
        // ...
    }
    // ...
    .id('button-2')
    .margin({ top: 12 })
    .customProperty('button-2', DataResource['Index']['button-2'])
    .onClick(() => {
      hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'row');
    })
    
    Row() {
      // entry\src\main\ets\pages\ClickPage.ets
      Button($r('app.string.click_tracing_point_single_component'))
        .width('100%')
        .id('button-1')
        .fontWeight(FontWeight.Bold)
        .customProperty('button-1', DataResource['Index']['button-1'])
        .onClick(() => {
          hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'btn');
        })
    }
  2. 在EntryAbility中注册UIObserver的willClick事件监听,并在事件回调中获取触发的组件节点FrameNode。
    // entry\src\main\ets\entryability\EntryAbility.ets
    uiContext.getUIObserver()?.on('willClick', (_event: ClickEvent, node?: FrameNode) => {
      const clickCallback = CallbackManager.getInstance().getClickCallback();
      clickCallback(node, uiContext);
    })
  3. 接着可以根据FrameNode获取当前组件所在的Page和ID值,并且通过getCustomProperty获取当前组件绑定的埋点数据。此外FrameNode还提供一些方法获取组件的基础属性,比如组件大小、组件位置以及是否可见等一些信息。具体可以参考FrameNode官方文档。
    // entry\src\main\ets\viewModel\CallBackManager.ets
    /**
     * Obtains the ClickCallback callback.
     *
     */
    public getClickCallback() {
      return (node: FrameNode | undefined, uiContext: UIContext) => {
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `FrameNode: ${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 ?? {}),
          'trackData': JSON.stringify(trackData ?? {})
        };
        hiAppEvent.write({
          domain: 'test_domain',
          name: 'test_event',
          eventType: hiAppEvent.EventType.FAULT,
          params: eventParams
        }, (err: BusinessError) => {
          if (err) {
            hilog.error(0x0000, 'CallBackManager', '%{public}s', `code: ${err.code}, message: ${err.message}`);
            return;
          }
          hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `getClickCallback, success to write event`);
        });
      };
    }
  4. 然后通过@kit.PerformanceAnalysisKit的hiAppEvent.write()方法将需要的数据写入当天的事件文件中。需要注意的是eventParams的参数值只能是number、string、boolean以及数组类型。
    hiAppEvent.write({
      domain: 'test_domain',
      name: 'test_event',
      eventType: hiAppEvent.EventType.FAULT,
      params: eventParams
    }, (err: BusinessError) => {
      if (err) {
        hilog.error(0x0000, 'CallBackManager', '%{public}s', `code: ${err.code}, message: ${err.message}`);
        return;
      }
      hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `getClickCallback, success to write event`);
    });
  5. 最后在onWindowStageDestroy()调用UIObserver的off('willClick')接口取消监听事件。
    // entry\src\main\ets\entryability\EntryAbility.ets
    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
      hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'Ability onWindowStageDestroy');
    }

除了点击事件外,UIObserver还可以通过on('scrollEvent')监听组件滑动。在滑动开始与结束触发回调并得到滑动偏移量。以瀑布流为例,对WaterFlow和FlowItem设置组件ID。

// entry\src\main\ets\pages\WaterFlowPage.ets
TrackNode({ track: new Track().id('WaterFlow-1') }) {
  WaterFlow() {
    LazyForEach(this.dataSource, (item: number, index: number) => {
      FlowItem() {
        TrackNode({ track: new Track().id(`flowItem_${index}`) }) {
          WaterFlowCard({ item: item, index: index }).id(`flowItem_${index}`)
        }
      }
      // ...
    }, (item: number) => item.toString())
  }
  .id('WaterFlow-1')
  // ...
  .onReachStart(() => {
    hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'waterFlow reach start');
  })
  .onScrollStart(() => {
    hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'waterFlow scroll start');
  })
  .onScrollStop(() => {
    hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'waterFlow scroll stop');
  })
  .onScrollFrameBegin((offset: number, state: ScrollState) => {
    hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `waterFlow scrollFrameBegin offset: ${offset}`);
    hilog.info(0x0000, 'ApplicationTrack', '%{public}s',
      `waterFlow scrollFrameBegin state: ${state.toString()}`);
    return { offsetRemain: offset };
  })
}

接着在EntryAbility里统一注册scrollEvent的事件监听,在回调中获取ScrollEventInfo信息,包括id、uniqueId、scrollEvent以及offset。

// entry\src\main\ets\entryability\EntryAbility.ets
uiContext.getUIObserver()
  .on('scrollEvent', (info) => CallbackManager.getInstance().getScrollEvent(info))

在scrollEvent监听事件中,回调参数的id值仅能精确到外层组件WaterFlow,无法精确到内层FlowItem。 若要在滑动过程中获取各Item组件的曝光比例,请参考第三小节的曝光埋点。

[h2]曝光埋点

曝光埋点需要监测页面中每个组件的出现与消失。例如,当用户滑动瀑布流时,如果某个项目出现的时长超过500毫秒,则记录为一次有效曝光。为避免在每个页面注入冗长代码,建议使用自定义的“埋点钩子”组件进行封装。以下是具体实现步骤。

实现步骤

  1. 首先自定义一个TrackNode“钩子”组件,该组件需支持嵌套子组件、组件ID值注入以及注册监听事件。因此,TrackNode中的build()方法需由外部调用方决定,并且在onDidBuild()生命周期中将组件信息注入。onDidBuild()主要完成以下三件事:

    (1)调用TrackManager的addTrack()方法,将当前组件与TrackShadow对象绑定。

    (2)通过setOnVisibleAreaApproximateChange()监听埋点组件的可视区域的变化;其中ratio值可以自定义设置,比如本示例设置了0.0、0.5、1.0。

    (3)根据当前组件获取其父节点,判断父节点是否有埋点钩子。如果没有,继续往上追溯,直到父节点为null。如果有,则在父节点的子组件集合中添加当前节点。

    在aboutToDisappear()生命周期中,调用TrackManager的removeTrack()方法删除当前组件信息。

    // entry\src\main\ets\viewModel\TrackNode.ets
    // onDidBuild Life Cycle.
    onDidBuild(): void {
      // Construct the virtual tree of the tracing point.
      // The obtained node is the root node of the current page (row in the test case).
      let uid = this.getUniqueId();
      let node: FrameNode | null = this.getUIContext().getFrameNodeByUniqueId(uid);
      hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `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);
      // The setOnVisibleAreaApproximateChange monitors and records the visible area of the tracing point component.
      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;
          hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `ratioInc: ${ratioInc}`);
          hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `ratio: ${ratio}`);
        });
    
      let parent: FrameNode | null = node?.getParent();
      hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `Parent getId: ${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) {
            hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `Track lazy attached: ${this.trackShadow.id}`);
          }
        });
      }
    }
  2. TrackManager封装了埋点钩子的操作方法,包括绑定、删除和导出。绑定是将当前组件ID与TrackShadow对象存入全局Map中。导出是以根节点为起点,递归输出所有子组件的曝光比例。删除是根据具体ID值从Map中移除对应数据。
    // entry\src\main\ets\viewModel\TrackNode.ets
    /**
     * Tracing point data operation class
     */
    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): void {
        if (this.trackMap.size === 0) {
          this.rootTrack = track;
        }
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `Track add id: ${id}`);
        this.trackMap.set(id, track);
      }
    
      removeTrack(id: string): void {
        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);
      }
    
      dump(): void {
        this.rootTrack?.dump(0);
      }
    }
  3. TrackShadow对象包含FrameNode、track、childIds和parentId。FrameNode表示组件节点,track包含ID值,childIds表示子组件列表,parentId表示父组件的ID值。
    // entry\src\main\ets\viewModel\TrackNode.ets
    export class Track {
      public areaPercent: number = 0;
      public trackId: string = '';
    
      constructor() {
      }
    
      id(newId: string): Track {
        this.trackId = newId;
        return this;
      }
    }
    
    /**
     * Tracing point data.
     */
    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;
    
      // Output the information about the tracing point tree through global dump.
      dump(depth: number = 0): void {
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `Track Dp: ${depth}`);
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `AreaPer: ${this.track?.areaPercent}`);
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `VisibleRatio: ${this.visibleRatio}`);
        this.childIds.forEach((value: string) => {
          TrackManager.get().getTrackById(value)?.dump(depth + 1);
        });
      }
    }
  4. 根据上述TrackNode组件改造瀑布流代码:使用TrackNode钩子将WaterFlow和FlowItem组件包裹起来,并传递一个包含id的track对象,id作为组件的唯一标识。
    // entry\src\main\ets\pages\WaterFlowPage.ets
    TrackNode({ track: new Track().id('WaterFlow-1') }) {
      WaterFlow() {
        LazyForEach(this.dataSource, (item: number, index: number) => {
          FlowItem() {
            TrackNode({ track: new Track().id(`flowItem_${index}`) }) {
              WaterFlowCard({ item: item, index: index }).id(`flowItem_${index}`)
            }
          }
          // ...
        }, (item: number) => item.toString())
      }
      .id('WaterFlow-1')
      // ...
      .onReachStart(() => {
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'waterFlow reach start');
      })
      .onScrollStart(() => {
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'waterFlow scroll start');
      })
      .onScrollStop(() => {
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'waterFlow scroll stop');
      })
      .onScrollFrameBegin((offset: number, state: ScrollState) => {
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s', `waterFlow scrollFrameBegin offset: ${offset}`);
        hilog.info(0x0000, 'ApplicationTrack', '%{public}s',
          `waterFlow scrollFrameBegin state: ${state.toString()}`);
        return { offsetRemain: offset };
      })
    }

滚动瀑布流时,不仅可以监听每个Item的曝光比例,还可以追溯到根节点,统计根节点中每个子组件的曝光比例。

  • 滑动容器中的子组件曝光,例如List、Grid、Swiper、WaterFlow,可以参考本章节进行实现。
  • 当该曝光埋点在组件未发生变化时,不会触发回调记录一次有效曝光。例如:在瀑布流场景下,如果某个Item已经达到设定的500ms,但用户后续不再滑动或直接退出应用,则此次曝光将不会被记录。

[h2]页面埋点

页面埋点在本示例中分为两类:监听页面切换和采集页面加载性能。以下将从Navigation和Router两种路由方案进行讲解。

Navigation路由:

针对Navigation方案,UIObserver提供`navDestinationSwitch`事件,用于监听页面切换,并支持在回调中获取当前页面的切换信息。在`EntryAbility`中统一注册`UIObserver`的`navDestinationSwitch`事件监听。

// entry\src\main\ets\entryability\EntryAbility.ets
uiContext.getUIObserver().on('navDestinationSwitch', (info) => {
  const switchCallback = CallbackManager.getInstance().getSwitchCallback();
  switchCallback(info);
})

回调函数中的info包含context、from、to和operation,用于标识页面的来源和去向。

字段

类型

含义

context

UIContext

页面上下文信息

from

NavDestinationInfo | NavBar

来源页

to

NavDestinationInfo | NavBar

去向页

operation

NavigationOperation

页面操作

此外还可以通过UIObserver的on("navDestinationUpdate")事件监听页面的显示与隐藏,回调传参中包含页面名称、状态信息以及页面的唯一标识ID。

Router路由:

针对Router路由方案,UIObserver提供了on('routerPageUpdate')监听事件,在页面切换过程中触发相应回调。

// entry\src\main\ets\entryability\EntryAbility.ets
uiContext.getUIObserver().on('routerPageUpdate', (info) => {
  const switchCallback = CallbackManager.getInstance().getSwitchCallback();
  switchCallback(info);
})

比如调用pushPath()从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")事件。

import { hilog } from '@kit.PerformanceAnalysisKit';
import { router } from '@kit.ArkUI';

@Entry
@Component
struct NavigationPage {
  // ...

  aboutToAppear(): void {
    const uiContext = this.getUIContext();
    // Registering a Listening Event
    uiContext.getUIObserver().on('willDraw', () => {
      this.startTime = Date.now();
    })
    uiContext.getUIObserver().on('didLayout', () => {
      this.endTime = Date.now();
    })
  }

  // ...
  getResourceString(resource: Resource): string {
    let resourceString: string = '';
    try {
      resourceString = this.getUIContext().getHostContext()!.resourceManager.getStringSync(resource.id);
    } catch (error) {
      hilog.error(0x0000, '[getResourceString]', `getResourceString err: ${JSON.stringify(error)}`);
    }
    return resourceString;
  }
  // ...
  build() {
    Navigation(this.pageInfos) {
      Column() {
        Button('pushPath', { stateEffect: true, type: ButtonType.Capsule })
          .width('100%')
          .onClick(() => {
            // Put the NavDestination page information specified by name into the stack.
            this.pageInfos.pushPath({ name: 'pageOne' });
          })
        Button('Use interception', { stateEffect: true, type: ButtonType.Capsule })
          // ...
          .onClick(() => {
            this.isUseInterception = !this.isUseInterception;
            if (this.isUseInterception) {
              // Register Interceptor.
              this.registerInterception();
            } else {
              // Do not use interceptors.
              this.pageInfos.setInterception(undefined);
            }
          })
        Button($r('app.string.back'), { stateEffect: true, type: ButtonType.Capsule })
          // ...
          .onClick(() => {
            this.getUIContext().getRouter().back();
          })
      }
      // ...
    }
    // ...
  }
}

埋点数据上传

如果需要将埋点数据上传至服务器,可以通过@ohos.hiviewdfx.hiAppEvent (应用事件打点)的hiAppEvent.addWatcher()方法添加订阅事件观察者、onTrigger()回调以及回调触发条件。可以自定义设置回调触发条件,比如在示例代码中当事件size大于等于1000字节时才会触发,然后在onTrigger()回调中调用http的request方法发起网络请求,将示例中的EXAMPLE_URL替换为服务器的IP地址即可。
// entry\src\main\ets\entryability\EntryAbility.ets
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
})
hilog.info(0x0000, 'ApplicationTrack', '%{public}s', 'Succeeded in loading the content.');

总结

本文从绑定埋点数据入手,介绍三种埋点的开发实现:点击、曝光和页面埋点。最后调用hiAppEvent的addWatcher()方法添加订阅对象和onTrigger()回调,在回调中实现数据上报逻辑。

示例代码