鸿蒙NEXT开发【应用埋点开发实践】开发运维

概述

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

  • 页面中可视区域或者组件的点击量,统计点击频率,分析用户的偏好行为。
  • 监听页面中组件滑动的开始与结束,计算滑动偏移量以及曝光比例。
  • 监听页面切换,统计页面的停留时间以及切换的来源页和目标页,分析页面浏览数和跳出率。
  • 分析页面加载性能,计算加载过程各个节点的耗时,可针对某个关键点进行优化。

埋点分类

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

  • 点击埋点:用户在任意区域的一次点击,比如一个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监听为例,下面介绍具体方案实现。

实现步骤

  1. 首先实现一个简单页面,并且在组件上绑定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%')
  }
}
  1. 在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');
  }
}
  1. 接着可以根据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));
        //   }
        // });
      }
    }
  }
}
  1. 然后通过@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`);
    });
  }
}
  1. 最后在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则记为一次有效曝光。为避免在每一个页面注入冗长代码,建议使用自定义“埋点钩子”组件进行封装,以下是具体实现步骤。

实现步骤

  1. 首先自定义一个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);
      }
    })
  }
}
  1. 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);
  }
}
  1. 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);
    })
  }
}
  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,主要用于标识页面的来源和去向信息。

字段类型含义
contextUIContext页面上下文信息
fromNavDestinationInfoNavBar
toNavDestinationInfoNavBar
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页面显示。回调传参同样包含页面上下文、触发事件的页面名称等等。

字段名类型含义
contextUIContext页面上下文信息
indexnumber触发页面在路由栈中的位置
nameString触发页面名称
pathString触发页面路径
state[RouterPageState]页面状态
pageIdString页面唯一标识

页面加载性能:

页面加载性能可以通过计算首帧绘制与绘制结束的时间差来判断。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方法共同实现埋点操作,将埋点数据写入本地设备文件。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值