• 作者:老汪软件技巧
  • 发表时间:2024-08-18 07:03
  • 浏览量:

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

最近公司开源了一个鸿蒙Aop框架,AspectPro,其中有一个利用plugin 代码复写的方式进行对writable为false的方法进行hook,同时也能够实现属性hook,方法hook等,感兴趣的小伙伴也可以看看

当程序开发到一定程度的时候,我们不可避免的遇到针对方法进行hook的需求,比如针对方法调用的监听,比如修改方法的参数亦或者是返回值。同样在ArkUI开发中,我也同样遇到了相关的需求,比如我想监控某个控件的刷新(rerender),或者是修改一些控件的默认行为等等。 本文涉及的demo代码均在此Minihook 有需要的小伙伴自取

JavaScript的动态特性

ArkTS的编译IR,其实就属于TS/JS,我们在ArkUI Engine系列中多次提到了上面的概念,ArkUI Engine - JS View 与C++

下面是大概的方舟前端编译流程:

在JavaScript中,类中的方法并不像Java一样在编译期间就会固定下来,而是可以在运行时进行动态修改。比如我们随时可以在运行时针对某个类进行方法的增删改,比如下面一个例子针对Person类新加一个方法

class Person {
  constructor(name) {
    this.name = name;
  }
  greet() {
    console.log(`Hello, my name is ${this.name}`);
  }
}
// 创建一个Person实例
const person1 = new Person('Alice');
person1.greet(); // 输出:Hello, my name is Alice
// 动态添加一个新方法
Person.prototype.sayGoodbye = function() {
  console.log('Goodbye!');
};
// 调用新添加的方法
person1.sayGoodbye(); // 输出:Goodbye!

之所以能够做到,是因为每个 JavaScript 对象都有一个 proto 属性,指向它的原型对象。当我们访问一个对象的属性或方法时,如果对象本身没有这个属性或方法,就会沿着原型链向上查找,直到找到该属性或方法,或者到达原型链的顶端(Object.prototype)。

JavaScript允许我们修改对象,因此实现方法hook可以说是天生就支持的特性,我们再来认识一下JavaScript的方法查找过程:

如果类本身能够找到方法,比如静态方法,那么就直接执行,否则进行2查找prototype对象中,有没有对应的方法,有则执行,否则出现error

ArkTS中在编译期禁止了针对方法的动态修改,但是没关系,我们都知道其实前端编译产物是JS,之后是abc字节码,因此我们依旧可以在运行时进行方法的修改,方法的修改代码我们可以放在ts/js中,只需要外部传递一个对象进来即可。

实现方法hook

当然,并不是所有方法都能够被修改,比如在JavaScript中,通过getOwnPropertyDescriptor 方法返回属性描述符对象,其中有以下几个属性:

value: 属性的值。

writable: 一个布尔值,表示该属性是否可写。如果为 false,则不能修改该属性的值。

get: 一个函数,当读取该属性时会被调用。

set: 一个函数,当给该属性赋值时会被调用。

enumerable: 一个布尔值,表示该属性是否可枚举。如果为 false,则在使用 for...in 循环或 Object.keys() 方法时不会枚举到该属性。

configurable: 一个布尔值,表示该属性是否可配置。如果为 false,则不能删除该属性,也不能修改该属性的描述符。

因此如果我们遇到writable为false的方法,我们就无法进行方法的直接替换,同时ArkUI中,有很多方法被提前注册进了Native引擎,因此这些方法即使修改成功了也无法达到我们运行时替换想要的效果。

鸿蒙官方的实现

从上面介绍我们了解了JavaScript的方法特性,我们也了解到了一个类的方法要么在类对象本身,或者在其prototype中,因此我们只需要针对类对象的属性以及prototype本身进行查找就能够找到方法本身,然后再替换掉这个方法即可。

当然,利用上面的原理,鸿蒙官方HarmonyOS也并提供了,包括addBefore、addAfter和replace接口。这些接口可以在运行时对类方法进行前置插桩、后置插桩以及替换方法实现。

但是,官方工具有个非常大的缺陷是,我们无法通过这些方法只修改函数的入参以及返回值,我们只能替换方法的实现,究其原因是被插桩的原函数没有暴露给开发者,因此我们希望有一个机制可以把原函数暴露给开发者。

实现更加灵活的MiniHook

我们根据上面的原理,依旧可以实现一个自定义的hook方法框架,同时我们可以把原函数按照“尾参数”的形式,暴露给开发者,使用者编写hook类,方法参数列表如下【原参数1,原参数2,.... ,原方法】,这样使用者就无需关注调用addBefore、addAfter还是replace,只需要关注一个方法即可!完整代码在这里: Minihook

export  function hookFunc(target, action, temp) {
  let origin = target[action]
  let protoTypeOrigin = target.prototype[action]
  let destination = origin ?? protoTypeOrigin
  if (!destination) {
    throw  new Error(`target not found `);
  }
  let isPrototype = protoTypeOrigin != null && protoTypeOrigin != undefined
 if (destination) {
    // 替换原有函数为插桩函数
    let copyOrigin = isPrototype ? target.prototype : target
    const descriptor = Object.getOwnPropertyDescriptor(copyOrigin, action);
    // writable 为false方法无法替换
    if (descriptor && !descriptor.writable) {
      throw  new Error(`target is an unwritable obj `);
    }
    copyOrigin[action] = function (...args: any[]) {
      if (temp) {
        args.push(destination)
        return temp.apply(this, args);
      }
    }
  }
}

同时我们也提供装饰器的方式,提供一个函数装饰器,用于把被修饰的函数当作替换函数

// hook 装饰器
export  function MiniHook(callTarget: any, action: any) {
  return  function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
    const tempMethod = descriptor.value;
    hookFunc(callTarget, action, tempMethod)
  };
}

通过上面两个关键实现,我们想要hook一个方法时,就变得非常简单,下面我们以Test类的几个方法举例子

export  class Test {
  myhook() {
    hilog.error(0, "hello", "myhook")
  }
  // 带参数的情况
  myhook2(param: number) {
    hilog.error(0, "hello", "myhook2 " + param)
  }
  // 带返回值的情况
  myhook3(param: number): number {
    hilog.error(0, "hello", "myhook3 " + param)
    return param + 1
  }
}

hook方法时,我们只需要注册一个函数,并且每个函数的的参数对应着原始参数,并在最后添加一个原函数对象,使用姿势如下:

export  class MyHook {
 // MiniHook 第一个参数为hook的类,第二参数为hook的函数, 定义一个需要替换的函数,其中必须要有一个参数,即origin 为你要hook的原函数对象
  @MiniHook(Test,"myhook")
  stubFunc1(origin: () => void) {
    hilog.error(0, "hello", "前面插入一条日志")
    origin()
    hilog.error(0, "hello", "后面插入一条日志")
  }
  @MiniHook(Test,"myhook2")
  stubFunc2(param: number, origin: (param: number) => void) {
    hilog.error(0, "hello", "修改param参数为2")
    origin(2)
  }
  @MiniHook(Test,"myhook3")
  stubFunc3(param: number, origin: (param: number) => number) {
    let result = origin(param)
    hilog.error(0, "hello", "stubFunc 原函数返回值" + result)
    return 3
  }
}

通过上面的例子我们可以看到,整个框架使用起来非常方便,作为使用者,我们只需要编写一个hook函数,这个函数可以是某个类函数,方便我们做层级划分或者功能划分。接着在hook函数上可以通过我们预先编写的装饰器@MiniHook,输入指定的参数,第一个为类对象(类构造函数),第二个参数为我们想要hook的方法名,接着我们编写hook参数列表时,按照被hook函数的参数列表依次填写即可,最后别忘了补充一个原函数对象,因为这个对象会在hook执行过程中被我们添加到最后。

copyOrigin[action] = function (...args: any[]) {
      if (temp) {
        args.push(destination)
        return temp.apply(this, args);
      }
    }

装饰器的进一步封装,降低了使用者的成本,同时我们使用者也无需关注调用哪个api,只需要定义好自己的hook函数就能够实行修改参数、修改返回值、前后或者替换插桩等操作,方便我们用于后续的行为监控或者其他目的。

总结

通过了解JavaScript的方法查找机制与鸿蒙官方提供的Aop机制,我们能够快速了解到实现一个方法hook所需要的步骤,同时我们剖析了官方实现的缺陷,并通过针对原函数的改进以及装饰器的封装,实现了一个更加便捷功能更加全面的MiniHook,希望能够对读者有所帮助!