头图

背景

大多数软件产品上线前,都会采用有规则的日志来对软件进行相关数据的采集,这个过程称为:埋点,采集的数据主要用于产品分析。

埋点技术已在PC端, 移动端非常成熟,并且有大批量以此为生的公司。

本篇将探究一下HarmonyOS中的埋点,目标是统计用户浏览页面轨迹

准备

  1. 了解移动端的埋点技术方案
  2. 了解HarmonyOS页面生命周期

搜狗高速浏览器截图20240326151450.png

声明周期

先回顾一下有关页面显示的生命周期

UIAbility

在HarmonyOS中这个算是一个页面容器,因为它仅仅加载带@Entry装饰的自定义组件,几乎所有和业务相关的逻辑都是从自定义组件中触发。

这个容器共有四个状态,创建(Create),回到前台(Foreground), 回到后台(Background), 销毁(Destrory)

状态CreateForegroundBackgroundDestroy
API接口onCreateonForeground()onBackground()onDestroy()

被@Entry修饰的自定义组件

在HarmonyOS中的业务页面,实际上指的就是这个。这个对移动端的Web开发人员 ,React Native开发人员,Flutter开发人员比容易接受。

注意:这种自定义组件的生命周期,容易产生混淆

被 @Entry 修饰

总共有三个生命周期接口

  1. [onPageShow],页面每次显示时触发一次
  2. [onPageHide],页面每次隐藏时触发一次
  3. [onBackPress],当用户点击返回按钮时触发

被 @Component 修饰

  1. [aboutToAppear],组件即将出现时回调该接口
  2. [aboutToDisappear],自定义组件析构销毁之前执行

预研小结

  • 对于UIAbility的生命周期监测,可以监听事件‘[abilityLifecycle]‘事件,进而实现应用全局监测
  • 对于@Entry修饰的组件生命周期监测,目前还没有可统一监听的事件,只能手动在相应的方法中添加埋点

本篇探究的对象就是针对@Entry修饰的组件,实现生命周期的统一监听

探究

1)注解/装饰器方案

HarmonyOS 应用研发语言ArkTS,是基于TypeScript扩展而来,因此,理论上是可以自定义装饰器来完成对函数执行时的统计。

[TypeScript装饰器]可以了解一下:《鸿蒙NEXT星河版开发学习文档》

准备代码

或者+mau123789记住是v喔,直接拿取鸿蒙文档
1
定义一个统计方法
export function Harvey(params?: string) {
  return function(target:any, methodName:any, desc:any){

    console.log(params);

    console.log(JSON.stringify(target));
    console.log(JSON.stringify(methodName));
    console.log(JSON.stringify(desc));

  }
}
复制
布局测试页面
......
//引入自定义方法装饰器文件
import { Harvey } from './HarveyEventTrack';


@Entry
@Component
struct RadomIndex {
 
  @Harvey('注解-aboutToAppear')
  aboutToAppear(){
     console.log('方法内-aboutToAppear')
  }

  @Harvey('注解-aboutToDisappear')
  aboutToDisappear(){
    console.log('方法内-aboutToDisappear')
  }

  @Harvey('注解-onPageShow')
  onPageShow(){
    console.log('方法内-onPageShow')
  }

  @Harvey('注解-onPageHide')
  onPageHide(){
    console.log('方法内-onPageHide')
  }

  @Harvey('注解-onBackPress')
  onBackPress(){
    console.log('方法内-onBackPress')
  }

  @Harvey('注解-build')
  build() {
     ......
  }

}
复制

运行效果

日志分析

  1. 所有的生命周期上的装饰器方法全部跑了一遍,即 "注解-" 开头的日志
  2. 生命周期API最后运行,即 “方法内-” 开头的日志

    

结论

自定义装饰器没法满足统一埋点需求的

2)TypeScript AST

结论

这种方案暂时没有尝试成功

相关链接

3) 脚本硬插入代码

这个方案比较原始,属于最笨的方法。

  • 适用编译场景: 打包机编译
  • 原因:编译前会直接修改源文件

大概流程如下

最终效果

2xw.png

尝试

创建埋点文件
  1. 在项目项目根目录下创建一个“Project”的文件夹
  2. Project文件夹下创建埋点文件
import common from '@ohos.app.ability.common';

export default class PageLifecycle{

  public static record(uiContext: common.UIAbilityContext, fileName: string,  funName: string){
    console.log('埋点:' + uiContext.abilityInfo.bundleName + ' -> ' + uiContext.abilityInfo.moduleName + ' -> '+
    uiContext.abilityInfo.name + ' -> ' + fileName + ' ' +
    '-> ' +
    funName)
  }

}

复制
插入时机
  • entry 模块中的 hvigorfile.ts

    注意: hvigorfile.ts 文件中提示文件不能修改,暂时不用去关心它

脚本代码
import * as fs from 'fs';
import * as path from 'path';

const INSERT_FUNCTION: string[] = [
  'aboutToAppear',
  'aboutToDisappear',
  'onPageShow',
  'onPageHide',
  'onBackPress',
]

const PAGELIFECYCLE_NAME = 'PageLifecycle.ets'

//开始复制埋点文件
copyConfigFile(process.cwd() + `/Project/${PAGELIFECYCLE_NAME}`, __dirname + `/src/main/ets/${PAGELIFECYCLE_NAME}`)

//遍历所有带@Entry装饰器的自定义组件
findAllPagesFiles(__dirname + '/src/main/ets/', __dirname + '/src/main/ets/', PAGELIFECYCLE_NAME);

/**
 * 文件遍历方法
 * @param filePath 需要遍历的文件路径
 */
function findAllPagesFiles(codeRootPath: string, filePath: string, configFileName: string) {
  // 根据文件路径读取文件,返回一个文件列表
  fs.readdir(filePath, (err, files) => {
    if (err) {
      console.error(err);
      return;
    }

    // 遍历读取到的文件列表
    files.forEach(filename => {

      // path.join得到当前文件的绝对路径
      const filepath: string = path.join(filePath, filename);

      // 根据文件路径获取文件信息
      fs.stat(filepath, (error, stats) => {

        if (error) {
          console.warn('获取文件stats失败');
          return;
        }

        const isFile = stats.isFile();
        const isDir = stats.isDirectory();
        if (isFile) {

          let checkPages: boolean = false

          let config: string = fs.readFileSync(__dirname + '/src/main/resources/base/profile/main_pages.json','utf8');

          let temps = JSON.parse(config)

          temps.src.forEach( (value) => {
            if(filepath.endsWith(value+'.ets') || filepath.endsWith(value+'.ts')){
              checkPages = true
              return
            }
          })

          if(!checkPages){
            return
          }

          fs.readFile(filepath, 'utf-8', (err, data) => {
            if (err) throw err;

            let content = (data as string)

            content = formatCode(content)

            //开始计算相对路径
            let tempFilePath: string = filepath.substring(codeRootPath.length+1)

            let slashCount: number = 0

            for(let char of tempFilePath){
              if(char == '/'){
                slashCount++
              }
            }

            //导入PageLife.ts文件
            if(configFileName.indexOf('.') != -1){
              configFileName = configFileName.substring(0, configFileName.indexOf('.'))
            }

            let importPath: string = 'import ' + configFileName + ' from ''
            for(let k = 0; k < slashCount; k++){
              importPath += '../'
            }

            importPath += configFileName + '''

            content = insertImport(content, importPath)

            //导入@ohos.app.ability.common
            content = insertImport(content, "import common from '@ohos.app.ability.common'", '@ohos.app.ability.common')

            content = insertVariable(content, "private  autoContext = getContext(this) as common.UIAbilityContext")

            INSERT_FUNCTION.forEach( value => {
              content = insertTargetFunction(content, value, `PageLifecycle.record(this.autoContext, '${filename}', '${value}')`)
            })

            fs.writeFile(filepath, content, (err) => {
              if (err) throw err;
            });

          });

        }

        if (isDir) {
          findAllPagesFiles(codeRootPath, filepath, configFileName);
        }

      });
    });
  });
}

/**
 * 复制埋点入口文件至目标地址
 *
 * @param originFile
 * @param targetFilePath
 */
function copyConfigFile(originFile: string, targetFilePath: string){
  let config = fs.readFileSync(originFile,'utf8');
  console.log(config)

  fs.writeFileSync(targetFilePath, config)
}


/**
 * 格式化代码,用于删除所有注释
 * @param inputContent
 * @returns
 */
function formatCode(inputContent: string): string{
  inputContent = deleteMulComments(inputContent)
  inputContent = deleteSingleComments(inputContent)
  return inputContent
}

/**
 * 删除多行注释
 * @param inputContent
 * @returns
 */
function deleteMulComments(inputContent: string): string{
  //删除注释
  let mulLinesStart = -1
  let mulLinesEnd = -1

  mulLinesStart = inputContent.indexOf('/*')

  if(mulLinesStart != -1){
    mulLinesEnd = inputContent.indexOf('*/', mulLinesStart)
    if(mulLinesEnd != -1){
      inputContent = inputContent.substring(0, mulLinesStart) + inputContent.substring(mulLinesEnd+'*/'.length)

      return deleteMulComments(inputContent)
    }
  }

  return inputContent
}

/**
 * 删除单行注释
 * @param inputContent
 * @returns
 */
function deleteSingleComments(inputContent: string): string{
  //删除注释
  let mulLinesStart = -1
  let mulLinesEnd = -1

  let splitContent = inputContent.split(/\r?\n/)

  inputContent = ''

  splitContent.forEach( value => {
    // console.log('输入 >> ' + value)

    let tempvalue = value.trim()

    //第一种注释, 单行后边没有跟注释
    // m = 6
    if(tempvalue.indexOf('//') == -1){
      if(tempvalue.length != 0){
        inputContent = inputContent + value + '\n'
      }
      //第二种注释,一整行都为注释内容
      //这是一个演示注释
    } else if(tempvalue.startsWith('//')){
      // inputContent = inputContent + '\n'
    } else {

      //第三种注释
      // m = 'h//' + "//ell" + `o` //https://www.baidu.com

      let lineContentIndex = -1

      let next: number = 0

      let label: string[] = []
      label.push(''')
      label.push("`")
      label.push(""")

      let shunxu: number[] = []

      while (true) {

        for(let k = 0; k < label.length; k++){
          let a = tempvalue.indexOf(label[k], next)
          let b = tempvalue.indexOf(label[k], a+1)
          if(a != -1 && b != -1){
            shunxu.push(a)
          }
        }

        //第四种注释
        // m = 2 //这是一个演示注释
        if(shunxu.length == 0){
          if(tempvalue.indexOf('//', next) != -1){
            inputContent = inputContent +  value.substring(0, value.indexOf('//', next)) + '\n'
          } else {
            inputContent = inputContent +  value.substring(0) + '\n'
          }
          break
        } else {

          //获取最先出现的
          let position = Math.min(...shunxu);
          let currentChar = tempvalue.charAt(position)
          let s = tempvalue.indexOf(currentChar, next)
          let e = tempvalue.indexOf(currentChar, s+1)

          if(s != -1 && e != -1 ){
            next = e + 1
          }

          while (shunxu.length != 0){
            shunxu.pop()
          }

        }

      }
    }

  })

  while (splitContent.length != 0){
    splitContent.pop()
  }
  splitContent = null

  return inputContent
}

function insertImport(inputContent: string, insertContent: string, keyContent?: string): string{
  let insertContentIndex: number = inputContent.indexOf(insertContent)

  if(keyContent){
    insertContentIndex = inputContent.indexOf(keyContent)
  }

  if(insertContentIndex == -1){
    inputContent = insertContent + '\n' + inputContent
  }

  return inputContent
}

function insertVariable(inputContent: string, insertContent: string): string{
  if(inputContent.indexOf(insertContent) == -1){
    let tempIndex = inputContent.indexOf('@Entry')
    tempIndex = inputContent.indexOf('{', tempIndex)
    inputContent = inputContent.substring(0, tempIndex+1) + '\n'  + insertContent + '\n' + inputContent.substring(tempIndex+1)
  }

  return inputContent
}

function insertTargetFunction(inputContent: string, funName: string, insertContent: string): string{
  let funNameIndex: number = inputContent.indexOf(funName)

  if(funNameIndex != -1){
    let funStartLabelIndex: number = inputContent.indexOf('{', funNameIndex)

    let funEndLabelIndex: number = findBrace(inputContent, funStartLabelIndex).endIndex

    if(funEndLabelIndex != -1){
      let funContent: string = inputContent.substring(funStartLabelIndex, funEndLabelIndex)

      let insertContentIndex: number = funContent.indexOf(insertContent)

      if(insertContentIndex == -1){
        inputContent = inputContent.substring(0, funStartLabelIndex+1)
        + '\n'
        + insertContent
        + '\n'
        + inputContent.substring(funStartLabelIndex+1)
      }
    }

  } else {

    let findEntryIndex = inputContent.indexOf('@Entry')

    findEntryIndex = inputContent.indexOf('{', findEntryIndex)

    let codeEndIndex = findBrace(inputContent, findEntryIndex).endIndex

    if(codeEndIndex != -1){

      inputContent = inputContent.substring(0, codeEndIndex)
      + '\n'
      + funName +'(){'
      + '\n'
      + insertContent
      + '\n'
      + '}'
      + '\n'
      + inputContent.substring(codeEndIndex)

    } else {
      throw Error('解析错误')
    }
  }

  return inputContent
}


function findBrace(inputContent: string, currentIndex: number): BraceIndex{
  let computer: BraceIndex = new BraceIndex()
  computer.startIndex = currentIndex

  let count: number = 0

  if(currentIndex != -1){
    count++
    currentIndex++
  }

  let tempChar: string = ''

  while(count != 0){

    tempChar = inputContent.charAt(currentIndex)

    if(tempChar == '}'){
      count--
    } else if(tempChar == '{'){
      count++
    }

    if(count == 0){
      computer.endIndex = currentIndex
      break
    }

    currentIndex++
  }

  return computer

}

class BraceIndex{
  public startIndex: number = 0
  public endIndex: number = 0
}

烧脑猴
24 声望21 粉丝

5年JAVA,3年Android。现转入鸿蒙开发行业,每日分享一些鸿蒙技术!