【有奖活动】比鸿蒙官方Aspect更强的AOP 方案-货拉拉开源AspectPro

2,852 阅读13分钟

一、AspectPro是什么

AspectPro是一款轻量级的鸿蒙运行时hook框架(配合aspectProPlugin可实现大部分代码hook)

AspectPro核心功能具备如下能力:

  • 1.对齐鸿蒙系统的Aspect能力
  • 2.简化使用姿势,无需关心是否为静态方法
  • 3.支持Hook 方法的行为 (如Button的onClick事件)
  • 4.支持Hook 方法内部class的方法 (如HttpClient.Builder().build())
  • 5.支持Hook writeable为false的方法需配合aspect-pro-plugin使用 (如router.pushUrl)
  • 6.支持Hook 方法并改变参数、返回值

aspect-pro-plugin是一款轻量级的鸿蒙编译时代码修改框架。

  • 1.支持扫描指定文件夹|文件 :-hook xxx
  • 2.支持keep指定文件夹|文件 :-keep xxx
  • 3.支持替换-hook文件夹|文件下指定代码 :-replace xxx to yyy (xxx为替换前代码 yyy替换后代码)
  • 4.支持replace时自动导包 -replace xxx to yyy [import xxx import bbb]
  • 5.支持扩展(aspectProPluginHvigorfileCode 是plugin源码, 重命名为hvigorfile即可本地开发)
  • 6.支持自定义配置规则 (参考aspectProPluginConfig.txt)

Github 项目地址与使用文档见: AspectPro

二、AOP是什么?

切面编程(AOP)是一种通过预编译方式和运行期间动态代理实现程序功能的统一维护的技术。AOP的核心思想是将程序的关注点(concern)分离,通过在程序中插入代码来实现横切关注点(cross-cutting concerns),从而实现对业务逻辑的各个部分进行隔离,降低它们之间的耦合度,提高程序的可维护性和可重用性,同时提高了开发的效率。

二、现状-系统AOP相关Api有哪些?

HarmonyOS主要通过插桩机制来实现切面编程,并提供了Aspect类,包括addBefore、addAfter和replace接口。这些接口可以在运行时对类方法进行前置插桩、后置插桩以及替换实现,为开发者提供了更灵活的操作方式。在具体业务场景中,不同的需求可能需要不同的埋点功能和日志记录。

系统 API 说明(可满足绝大数场景)

三、为什么-要自研AspectPro?

核心原因: Android 项目的监控埋点数据通过AOP实现业务 无感监控, 鸿蒙化过程中使用系统Aspect Api封装sdk, 遇到下面3、4、5 无法直接实现, 一步步分析、解决后,计划将相关经验以及代码整理成AspectPro并分享出来, 希望对大家实现或增强鸿蒙AOP时提供一些经验。

监控 场景:

  1. 应用前后台切换
  2. 页面生命周期
  3. 控件点击事件
  4. 网络请求
  5. 页面路由跳转
  6. ...

AspectPro 至少需要支持以下能力:

  1. 支持系统Aspect的能力
  2. 支持hook特定函数参数方法的执行 (如:Button#onClick点击事件ClickEvent)
  3. 支持hook特定方法每次返回不同的类定义实例 (如:HttpClient.Builder().build())
  4. 支持hook Property writeable = false 的方法 (如:router.pushUrl)

四、说明-Aspect无法满足需求的原因?

4.1 为什么无法hook控件点击事件?

util.Aspect.addBefore(Button,"onClick", true,()=>{
  Logger.w(TAG, "1.util.Aspect add before ---- Button#onClick() ,do your business ...");
})

拿Button举例 -> 上述代码可成功在Button.onClick(ckEvent:ClickEvent)函数之前插入勾子函数,

问题 ->                  Button.onClick(ck),onClick函数实际上是把ck函数注册到响应,

因此之后点击事件投递给ck函数而不是onClick函数。

所有我们目标hook是ck这个函数,这点可通过断点查看调用堆栈确认。

如图:

4.1 总结: 实际需要hook的是特定函数参数方法的执行, 比如Button.onClick(ck)的ck函数。

4.2 为什么无法hook网络请求?

前言: 鸿蒙系统网络库目前是基于Axios&httpClient封装的,其中httpClient提供的EventListener可监控网络请求耗时, Interceptor则可获取网络request、responses信息。

因此AOP的hook点可选取 HttpClient.Builder, hook 后添加监控的EventListener 和 Interceptor, 实现无感监控。

// 伪代码
util.Aspect.addBefore(HttpClient.Builder, "build", false, (instance: any) => {
  instance?.addEventListener(new MyEventListener2(instance?._eventListeners)
  )
  instance?.addInterceptor(new MyInterceptor()
  )
})

问题 -> HttpClient.Builder类是定义在 静态方法Builder()内部的, 每次通过new HttpClient.Builder()产生的都是一个新的类定义对象,这些不同的类定义没有统一的prototype, 且addEventListener、addInterceptor都是实例方法.

如图:

从系统Aspect的实现原理可知

addBefore、addAfter、replace接口的原理基于class的ECMAScript语义,即类的静态方法是类的属性,类的实例方法是类的原型对象(prototype)的属性

4.2 总结: 系统Aspect只适用于处理 1.有统一原型对象(prototype)的属性 2.同一类对象的属性 , 而上述场景不满足。

此场景需要hook的是不同类定义的实例方法, 这些方法在不同类的prototype对象上.

4.3 为什么无法hook页面 路由 跳转?

前言: 当前业务代码路由跳转使用的是鸿蒙系统api,router.pushXXX系列

注意如果是通过 this.getUIContext().getRouter().pushXXX系列api调用是可以直接hook的,

因为getUIContext().getRouter()返回的Router Class对象的属性可直接修改 , 而router的属性不可修改

为了避免业务改造以及降低后续维护成本,hook方案需兼容router.pushXXX方式。

如图:

// 伪代码
import router from '@ohos.router';
util.Aspect.addBefore(router,"pushUrl", true,()=>{
  Logger.w(TAG, "1.util.Aspect add before ---- Router#pushUrl() ,do your business ...");
})

说明 -> 不知道大家是否还记得上面的系统api截图的参数说明,参数target类型为class,

  而此处router是namespce非class, 所以容易误以为是这个原因导致的。

贴图回顾下:

4.3 这里额外说明下:虽然系统api 参数要求是类对象,大家不要被"外在"的非类对象所迷惑,

实际开发过程中多调试确认下, 比如struct编译后生成的也是Class类对象,当前也是可以直接Hook的。

比如Button、struct:

ok,我们回来,继续分析问题原因 ->根本原因在于router的属性不可写,导致任何修改都不会生效

包括JS已知hook手段:函数替换、属性定义、原型链替换、Proxy 都无法无法做到无感hook

因为无论哪种方案 ,最终都绕不开赋值 originXX = replaceXX, 而属性不可写也就无法正常赋值, 因此都会失败,

当然使用Proxy手动替换调用代码是可行的,但是做不到无感。

如图:

4.3 总结: router对象属性不可写,因此无法hook.

五、AspectPro如何扩展实现上述功能?

相关知识点:

  1. 原型链
  2. 属性定义
  3. hvigor编译流程 & hvigor plugin开发

2、3 上面都有官方链接, 原型链如图:

休息片刻

ok,相关知识大家应该都阅读差不多了, 接下来, 开始一步步实现AspectPro.

5.1 支持系统Aspect的能力

// 关键代码
static addAfter(target, action, fn): void {
  AspectPro.wrapMethod(target, action, (origin) => function (...args) {
    let result = origin.apply(this, args);
    fn.apply(this, args);
    return result;
  });
}

static addBefore(target, action, fn): void {
  AspectPro.wrapMethod(target, action, (origin) => function (...args) {
    fn.apply(this, args);
    return origin.apply(this, args);
  });
}

static replace(target, action, fn): void {
  AspectPro.wrapMethod(target, action, () => function (...args) {
    return fn.apply(this, args);
  });
}

private  static wrapMethod(target, action, wrapper) {
  let origin = target.prototype[action] || target[action];
  if (origin) {
    let isPrototype = !!target.prototype[action];
    let destination = isPrototype ? target.prototype : target;
    destination[action] = wrapper(origin);
  } else {
    Logger.e(HOOK_TAG, `hook failed originMethod:${origin}`)
  }
}

5.1 小结: 原理就一句话根据ECMAScript语义,

类的静态方法是类的属性,类的实例方法是类的原型对象(prototype)的属性。

原理明白了,实践往往很简单,相信看到这里的同学,都已经能实现基础版Aspect。

ok, 我们继续

5.2 支持hook特定函数参数方法的执行 (如:Button#onClick点击事件ClickEvent)

function hookMethod(target, action, beforeFn?, afterFn?) {
  wrapMethod(target, action, (originalMethod) => function (callback) {
    const wrappedCallback = (...args) => {
      beforeFn?.apply(this, args);
      callback.apply(this, args);
      afterFn?.apply(this, args);
      if (!beforeFn && !afterFn) {
        Logger.w(TAG, `hookMethodAction()->${target + '->' + action}, but do nothing ,just log ???`)
      }
    };
    originalMethod.call(this, wrappedCallback);
  });
}

5.2 小结: 原理还是一句话即构造一个第一个参数为函数的wrappFn函数,持有clickEvent并替换Button#onClick(wrappFn)。

我们再继续

5.3 支持hook特定方法每次返回不同的类定义实例 (如:HttpClient.Builder().build())

先看关键代码

// 关键代码
function hookMethod(
  target: any,
  propertyName: string,
  methodName: string,
  beforeFn?: (context: any, args: any[]) => void,
  afterFn?: (context: any, args: any[], result: any) => void) {
  const propertyDescriptor = Object.getOwnPropertyDescriptor(target, propertyName);
  if (propertyDescriptor && propertyDescriptor.get) {
    Object.defineProperty(target, propertyName, {
      get() {
        const originTarget = propertyDescriptor.get!.call(this);
        const originMethod = originTarget.prototype[methodName];

        originTarget.prototype[methodName] = function (...args: any[]) {


          beforeFn?.call(this, this, args);

          let result = originMethod.apply(this, args);

          afterFn?.call(this, this, args, result);

          if (!beforeFn && !afterFn) {
            Logger.w(TAG,
              `hookMethodProperty()->${target.name}: Modified ${propertyName}.${methodName}() , but do nothing ,just log ???`);
          }

          return result;
        };

        return originTarget;
      }
    });
  } else {
    Logger.e(TAG, `hookMethodProperty()->Property ${propertyName} is not a getter on target class`);
  }
}

5.3 小结: 原理麻烦点,2句话即1.任何实例属性获取都需要执行get()2.实例方法在prototype对象上

结合上面2点,虽然每次new HttpClient.Builder()都是不同类定义,但相同点都是通过get() 获取属性Builder ,

因此统一拦截get,然后在prototype上找到实例方法Builder.build()函数,通过函数替换 即可完成hook。

这里大家消化几分钟, 我们再继续看最后一点

5.4 支持hook Property writeable = false 的方法 (如:router.pushUrl)

前面已经说过:JS已知hook手段:函数替换、属性定义、原型链替换、Proxy 都无法无法做到无感hook

因为无论哪种方案 最终都绕不开赋值 originXX = replaceXX, 而属性不可写也就无法正常赋值, 因此都会失败, 这里由于代码相对复杂,我们先总结方案,再看关键代码, 最后对AspectPro完整方案做一个总结。

5.4 完整小结: 原理2句话即1.使用编译时插桩将writeable为false的函数代码A替换为其他代码B

2.运行时再对B进行hook结合上面2点即可实现对writeable为false的函数hook。

aspectProPlugin原理小结

5.4 plugin小结:1.读取配置文件,确定需要修改的文件集 & 修改规则集

2.编排plugin任务修改源码 ->编译生成.abc ->reset源码修改 ->使用.abc中间产物编译最终产物

aspectProPlugin任务说明 & 任务编排代码

/*
* AspectPro-Plugin原理                  (源码回滚方案)
*
* 1.-> aspectProPluginConfig.txt       配置源码相对路径(相对于RootDir)
* 2.-> aspectProPluginInjectTask       根据配置修改源码
* 3.-> compileArkTs task               源码编译-生成.abc文件
* 4.-> resetAspectProPluginInjectTask  根据配置回滚源码修改
*
* AspectPro-Plugin源码                 获取方式
*
* 1.-> 找到oh_modules/xxx/aspectpro/aspectProPluginHvigorfileCode.ts 文件,重命名为hvigorfile.ts 或copy 源码到hvigorfile.ts进行本地调试plugin。
* 2.-> 将下面代码copy到 entry/hvigorfile.ts 文件内,即可本地调试plugin。
*
* Hvigor-Plugin                       发布
*
* 1.-> https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/ide-hvigor-plugin-0000001778674577-V5
*
*/
function aspectProPlugin(): HvigorPlugin {
  return {
    pluginId: 'localAspectProPlugin',
    apply: (node: HvigorNode) => {
      node.registerTask({
        name: 'aspectProPluginInjectTask',
        run: (taskContext) => {
          filesAndRules = injectAspectProPlugin(node);
        },
        postDependencies: ['default@CompileArkTS']
      });

      node.registerTask({
        name: 'resetAspectProPluginInjectTask',
        run: () => {
          reInjectAspectProPlugin(filesAndRules.allFiles, filesAndRules.replaceRules);
        },
        dependencies: ['default@CompileArkTS'],
        postDependencies: ['assembleHap']
      });
    }
  };
}

aspectProPlugin代码正则替换关键代码

function processFile(filePath, replaceRules, isReverse) {
  let contentLines = fs.readFileSync(filePath, 'utf-8').split('\n');
  let modified = false;

  if (isReverse) {
    replaceRules.forEach(({ imports }) => {
      imports.forEach(importStatement => {
        contentLines = contentLines.filter(line => line.trim() !== importStatement.trim());
      });
    });
  }

  replaceRules.forEach(({ pattern, replacement, imports }) => {
    const searchPattern = isReverse ? escapeRegExp(replacement) : pattern;
    const replaceValue = isReverse ? unescapeRegExp(pattern) : replacement;
    const regex = new RegExp(searchPattern, 'g');

    contentLines.forEach((line, index) => {
      if (line.match(regex)) {
        contentLines[index] = line.replace(regex, replaceValue);
        modified = true;
      }
    });

    if (!isReverse && modified) {
      imports.forEach(statement => {
        if (!contentLines.includes(statement)) {
          contentLines.unshift(statement);
        }
      });
    }
  });

  if (modified) {
    fs.writeFileSync(filePath, contentLines.join('\n'), 'utf-8');
    console.warn(TAG, isReverse ? `File reset: ${filePath}` : `File modified: ${filePath}`);
  }
}

aspectProPluginConfig配置文件说明

# 此配置文件仅做使用说明,实际只有需放到 你的工程目录下 比如entry/aspectProPluginConfig.txt

# 配置规则说明 - plugin 按行读取配置文件 (默认读取hvigor-file同级目录 所有.js 、.ts 、 .ets文件)
# -hook path | file : 配置需要hook 处理的文件目录 | 文件
# -keep path | file : 配置需要额外 keep 的目录 | 文件  (非必需, 当-hook 的文件目录中有一些特殊文件,不需要处理时,配合使用)
# -replace pattern replacement [import xxx';import xxx] : 配置需要进行替换的正则表达式和对应的替换内容 [import aaa;import bbb] 同时需要新导入的依赖

# 比如:
-hook ./src/main/ets/
-keep ./src/main/ets/hook/
-replace router.pushUrl( this.getUIContext().getRouter().pushUrl(
#-replace router.pushUrl( this.getUIContext().getRouter().pushUrl( [import { Logger } from '@huolala/logger';]

#支持三方库代码替换
# 比如:
-hook ./oh_modules/@huolala/logger/src/main/com.wp/Logger.ts
-replace Logger.i( Logger.e(

# AspectPro库都keep
-keep ./oh_modules/@huolala/aspectpro/

比如:这个配置实现编译阶段自动将writeable为false的router替换为可修改的getUIContext().getRouter()对象,

运行时我们只需对getUIContext().getRouter()对象进行hook,即可实现对源码router.pushUrl的地方都生效。

-replace router.pushUrl( this.getUIContext().getRouter().pushUrl(

这里说明下: plugin先修改后还原,为什么要用这种方案???

因为:暂时没找到其他好的编译流程切入点且操作.abc二进制文件成本过高。

最开始的方案是直接plugin中拿到node.nodeDir.filePath目录并直接修改, 修改后发现原文件直接变动了,不符合预期.(预期是:插件仅影响产物不影响源码

编译流程概括起来:将一系列文件(资源、源码、so等)作为输入 -> 传入构建流水线按序执行一系列(tasks) -> 调用各种编译工具(tools) -> 最终输出一系列产物(hap、mapping)

因此解决这个问题很容易想到的方案有:

  1. 修改输入 -> 从源头影响最终产物

  2. 修改中间产物 -> 间接修改最终产物

  3. 直接修改最终产物

我们上述plugin方案目前也是方案一, 修改输入。 要达到不影响源码的目的,只需找到一个task 的输入是源码且可拦截就可以实现插入我们plugin逻辑但不影响源码。

从构建任务图很容易找到一个可能的task: "CompileArkTs/BuildJs"

从build-logs信息也可进一步确认此任务输入是源文件“aceModuleRoot” & 依赖三方库, 输出有cachePath & aceModuleBuild(compile 调用loader工具编译生成二进制文件路径)

我们预期修改后流程: 原始任务的输入 -> 我们plugin -> plugin处理后的文件 -> CompileArkTs/BuildJs -> 后续构建tasks

参考官方文档 & 编写任务尝试后目前此路径未走通。

之后尝试修改cache目录下的ts | pb 文档, 验证不会影响load_out/.abc文件, 也就是task内部这2个文件目录没有关联

最后一个修改.abc二进制文件,暂未调研

综上原因,当前阶段采用了修改源码然后reset的方案,此方案在性能以及精确度(正则而非AST)上都有优化空间,期待后续有更多优秀的官方或民间方案推出。

六、AspectPro实践总结

  • 90%场景使用系统Aspect或者AspectPro Sdk 即可满足需求。

  • 少数特殊场景(如需hook函数参数、hook的实例方法无统一原型对象): 使用 AspectPro Sdk即可

  • 属性不可改场景: 需使用AspectPro Plugin & Sdk 结合使用。

七、AspectPro Sdk & Plugin 快速食用

  • 1.添加sdk依赖 (ohpm i @huolala/aspectpro)
  • 2.调用hookMethod api
// 1.hook HttpClient#Builder()#build()
AspectPro.hookMethod({
  target: router,
  methodNameOrProperty: 'pushUrl',
  beforeFn: () => {
    Logger.w(TAG, "AspectPro hookedMethod-> before ---- Router#pushUrl() " +
      ",do your business ...");
  }
})

// 2.hook router#pushUrl()
AspectPro.hookMethod({
  target: HttpClient,
  methodNameOrProperty: 'Builder',
  beforeFn: (context, args) => {
    const builderContext = context as InstanceType<typeof HttpClient.Builder>;
    builderContext._eventListeners = new MyEventListener();
    builderContext.addInterceptor(new MyInterceptor());
  },
  propertyMethodNameOrType: 'build'
})

// 3.hook TestClass1#a()
AspectPro.hookMethod({
  target: TestClass1,
  methodNameOrProperty: 'a',
  afterFn: () => {
    Logger.w(TAG, "AspectPro hookedMethod-> afterFn ---- TestClass1#a() ,do your business ...");
  }
})


AspectPro.addBefore(Button, "onClick", () => {
  Logger.w(TAG, "1.AspectPro add before ---- Button#onClick()#action ,do your business ...");
}, true)



AspectPro.addAfter(TestClass1, "b", () => {
  Logger.w(TAG, "1.AspectPro add after ---- TestClass1#b() ,do your business ...");
})

AspectPro.replace(TestClass1, "c", (origin:Function, ...args:object[]) => {
   // 1.可以修改参数
   let changedArgs = [...args]
   changedArgs[0]  = new String("change param 1")

   // 2.可以按需调用被hook的原始方法
   const result:string = origin(...changedArgs)

   Logger.w(TAG, "1.AspectPro replace ---- TestClass1#c() ,do your business ..."
     + "result:"  + result);
   // 3.可以修改原始方法返回值
   return result
 })

 // 测试replace 替换逻辑
 let newResult = new TestClass1().c("1234")
 Logger.w(TAG, "newResult ..." + newResult);
  • 3.工程中添加plugin依赖(hvigor/hvigor-config.json5)

  • 4.模块中使用plugin(如:entry/hvigorfile.ts)

  • 5.模块中copy|新建配置文件aspectProPluginConfig.txt
  • 6.配置文件中添加规则

  • 7.ability中调用AspectPro.hookMethod({}) 执行hook

八、补充活动

有奖活动火热进行中,参与赢取好礼!

活动规则:我们将从Github AspectPro的star点赞者、issue提问/提建议以及PR发起者中随机抽取6位幸运读者,赠送好礼。截止日期2024年9月23日;

奖品设置:前三名将获得《鸿蒙HarmonyOS应用开发入门》,后三名将获得真丝睡眠眼罩;

img_v3_02e1_d0ef8d01-1585-4a3c-86e3-ba69c45d957g.jpg

img_v3_02e1_98b6cbd9-ff6d-4526-81d1-05b31f25191g.jpg

联系方式:9月23日,我们会在github联系获奖者,并在评论区公布获奖名单。

九、相关链接