背景
大多数软件产品上线前,都会采用有规则的日志来对软件进行相关数据的采集,这个过程称为:埋点,采集的数据主要用于产品分析。
埋点技术已在PC端, 移动端非常成熟,并且有大批量以此为生的公司。
本篇将探究一下HarmonyOS中的埋点,目标是统计用户浏览页面轨迹
准备
- 了解移动端的埋点技术方案
- 了解HarmonyOS页面生命周期

声明周期
先回顾一下有关页面显示的生命周期
UIAbility
在HarmonyOS中这个算是一个页面容器,因为它仅仅加载带@Entry装饰的自定义组件,几乎所有和业务相关的逻辑都是从自定义组件中触发。
这个容器共有四个状态,创建(Create),回到前台(Foreground), 回到后台(Background), 销毁(Destrory)
| 状态 | Create | Foreground | Background | Destroy | 
|---|---|---|---|---|
| API接口 | onCreate | onForeground() | onBackground() | onDestroy() | 
被@Entry修饰的自定义组件
在HarmonyOS中的业务页面,实际上指的就是这个。这个对移动端的Web开发人员 ,React Native开发人员,Flutter开发人员比容易接受。
注意:这种自定义组件的生命周期,容易产生混淆
被 @Entry 修饰
总共有三个生命周期接口
- [onPageShow],页面每次显示时触发一次
- [onPageHide],页面每次隐藏时触发一次
- [onBackPress],当用户点击返回按钮时触发
被 @Component 修饰
- [aboutToAppear],组件即将出现时回调该接口
- [aboutToDisappear],自定义组件析构销毁之前执行
预研小结
- 对于UIAbility的生命周期监测,可以监听事件‘[abilityLifecycle]‘事件,进而实现应用全局监测
- 对于@Entry修饰的组件生命周期监测,目前还没有可统一监听的事件,只能手动在相应的方法中添加埋点
本篇探究的对象就是针对@Entry修饰的组件,实现生命周期的统一监听
探究
1)注解/装饰器方案
HarmonyOS 应用研发语言ArkTS,是基于TypeScript扩展而来,因此,理论上是可以自定义装饰器来完成对函数执行时的统计。
[TypeScript装饰器]可以了解一下:《鸿蒙NEXT星河版开发学习文档》
准备代码
定义一个统计方法
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() {
     ......
  }
}运行效果
日志分析
- 所有的生命周期上的装饰器方法全部跑了一遍,即 "注解-" 开头的日志
- 生命周期API最后运行,即 “方法内-” 开头的日志


结论
自定义装饰器没法满足统一埋点需求的
2)TypeScript AST
结论
这种方案暂时没有尝试成功
相关链接
3) 脚本硬插入代码
这个方案比较原始,属于最笨的方法。
- 适用编译场景: 打包机编译
- 原因:编译前会直接修改源文件
大概流程如下

最终效果


尝试
创建埋点文件
- 在项目项目根目录下创建一个“Project”的文件夹
- 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
}









