背景
大多数软件产品上线前,都会采用有规则的日志来对软件进行相关数据的采集,这个过程称为:埋点,采集的数据主要用于产品分析。
埋点技术已在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星河版开发学习文档》
准备代码
或者+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() {
......
}
}
复制
运行效果
日志分析
- 所有的生命周期上的装饰器方法全部跑了一遍,即 "注解-" 开头的日志
- 生命周期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
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。