不推荐用该方式来统计在多个线程执行的函数,否则可能造成方法次数变量或者执行时间变量的写冲突。
切面编程(AOP)是一种通过预编译方式和运行期间动态代理实现程序功能的统一维护的技术。AOP的核心思想是将程序的关注点(concern)分离,通过在程序中插入代码来实现横切关注点(cross-cutting concerns),从而实现对业务逻辑的各个部分进行隔离,降低它们之间的耦合度,提高程序的可维护性和可重用性,同时提高了开发的效率。
在AOP中,开发者可以通过定义切面(aspect)来封装横切关注点,而不需要直接修改业务逻辑代码。这种方式要求在不修改源代码的前提下添加功能,常用于将业务代码和非业务代码剥离,比如参数校验、日志记录、性能统计等非业务代码,以达到更好的代码解耦效果。
HarmonyOS主要通过插桩机制来实现切面编程,并提供了Aspect类,包括addBefore、addAfter和replace接口。这些接口可以在运行时对类方法进行前置插桩、后置插桩以及替换实现,为开发者提供了更灵活的操作方式。在具体业务场景中,不同的需求可能需要不同的埋点功能和日志记录。通过调用addBefore、addAfter和replace接口,可以实现对类方法的各种功能增强和定制化需求:
下面,本文将介绍对应接口的基本原理,并针对以上业务场景,具体说明怎么利用运行时插桩的接口完成对类方法的埋点和加日志功能。
addBefore、addAfter、replace接口的原理基于class的ECMAScript语义,即类的静态方法是类的属性,类的实例方法是类的原型对象(prototype)的属性。
类的实例会有一个属性__proto__(称为原型),它是指向类的prototype的引用(如下图2所示)。实例在调用方法时,实际上会先通过__proto__找到类的prototype,再在prototype中找到这个方法,再执行调用逻辑。类的原型对象(prototype)被这个类的所有实例共享,这意味着修改类的原型对象里面存储的方法,会对这个类的所有实例产生效果。
原型对象也有原型__proto__。类的继承就是通过原型来实现的。实例方法的调用实际上在运行时就是通过在原型串联的链上查找方法,找到方法再执行调用(如下图3所示)。
插桩和替换的操作本质上就是将回调参数和原方法组合成一个新的函数,再用新的函数替换原方法(如下图4所示)。
addBefore: 类方法前插桩
// 在类方法执行前插桩 static addBefore(targetClass, methodName, isStatic, before): void { let target = isStatic ? targetClass : targetClass.prototype; let origin = target[methodName]; // 定义新函数,里面先执行before,再执行老方法 let newFunc = function (...args) { before(this, ...args); return origin.bind(this)(...args); } // 将方法替换成新函数 target[methodName] = newFunc; }
addAfter: 类方法后插桩
// 在类方法执行后插桩 static addAfter(targetClass, methodName, isStatic, after) : void { let target = isStatic ? targetClass : targetClass.prototype; let origin = target[methodName]; // 定义新函数,里面先执行老方法,再执行after let newFunc = function (...args) { let ret = origin.bind(this)(...args); return after(this, ret, ...args); } // 将方法替换成新函数 target[methodName] = newFunc; }
replace: 替换类方法
static replace(targetClass, methodName, isStatic, instead) : void { let target = isStatic ? targetClass : targetClass.prototype; // 定义新函数,里面只执行instead let newFunc = function (...args) { return instead(this, ...args); } // 将方法替换成新函数 target[methodName] = newFunc; }
业务开发团队没有关注参数的合法性,运维团队发现了这个问题,需要紧急修复。然而,让业务开发团队修改的流程较为繁琐,因此运维团队决定临时采取插桩的方式给方法加上参数合法性校验的逻辑。
举例来说,A团队开发了基础能力模块并将能力封装在class A中。在应用集成基础能力模块时,发现需要对class A的方法加入参数校验的逻辑,以应对可能的非法输入。因此,运维团队决定在临时修复过程中,通过插桩的方式临时添加参数合法性校验的逻辑,以确保系统的稳定性和安全性。
在addBefore接口的回调参数中,可以访问原方法的参数,因此可以利用addBefore在方法前插入参数校验的逻辑。这是运行时行为,需要在addBefore执行后才会生效,因此通常在应用入口调用接口进行插桩。
在class A中,封装其基础能力,此处为获取数组指定下标的元素,具体代码实现如下:
// baseAbility.ts export class A { getElementByIndex<T>(arr: Array<T>, idx: number): T { return arr[idx]; } }
在主界面中集成基础能力,并校验参数类型、判断下标是否越界,具体代码实现如下:
// index.ets import {A} from './baseAbility'; import {util} from '@kit.ArkTS'; @Entry @Component struct Index { build() { // UI代码 … } } util.Aspect.addBefore(A, 'getElementByIndex', false, // 参数校验 (instance: A, arr: Object, idx: number) => { if (!(arr instanceof Array)) { throw Error('arg arr is expected to be an array'); } if (!(Number.isInteger(idx) && idx >= 0)) { throw Error('arg idx is expected to be a non-negative integer'); } if (idx >= arr.length) { throw Error('arg idx is expected to be smaller than arr.length'); } }); // 原方法执行 let buffer : Array<number> = [1,2,3,5]; let that = new A(); that.getElementByIndex(buffer,-1); that.getElementByIndex(buffer,5); that.getElementByIndex(123 as Object as Array<number> ,5)
在性能分析或调试场景中,性能管控团队需要统计应用运行过程中调用某个方法的次数或执行时间,如果让业务开发团队临时修改源代码并重新打包,效率较低且业务团队不一定有足够的人力资源来配合这一过程。因此,他们需要临时插入一个插桩来查看相关信息。
通过在方法前插入调用次数自增的逻辑,addBefore可以用于统计调用次数。对于执行时间的统计,我们可以利用addBefore记录开始时间,而用addAfter记录结束时间。
为了存储执行次数和执行时间,可以利用闭包变量或者其他能够覆盖每次执行的变量的生命周期。
统计执行次数,具体代码实现如下:
// somePackage.ets export class Test { foo(){} } // index.ets import {Test} from './somePackage'; import {util} from '@kit.ArkTS'; @Entry @Component struct Index { build() { // UI代码 … } } util.TextDecoder.toString(); // 调用次数自增 let countFoo = 0; util.Aspect.addBefore(Test, 'foo', false, () => { countFoo++; }); // 调用并打印日志 new Test().foo(); console.log('countFoo = ', countFoo); // [LOG]: "countFoo = ", 1 let a = new Test(); a.foo() console.log('countFoo = ', countFoo); // [LOG]: "countFoo = ", 2 function bar(a: Test) { a.foo(); console.log('countFoo = ', countFoo); new Test().foo(); console.log('countFoo = ', countFoo); } bar(a); // [LOG]: "countFoo = ", 3 // [LOG]: "countFoo = ", 4 console.log('countFoo = ', countFoo); // [LOG]: "countFoo = ", 4
统计执行时间,具体代码实现如下:
// somePackage.ets export class Test { doSomething() { // 实例方法 // ... } static test() { // 静态方法 // ... } } // index.ets import {Test} from './somePackage' import {util} from '@kit.ArkTS'; @Entry @Component struct Index { build() { // UI代码 … } } // 插入执行前后打印时间, 将插入动作封装成一个接口 function addTimePrinter(targetClass: Object, methodName: string, isStatic: boolean) { let t1 = 0; let t2 = 0; util.Aspect.addBefore(targetClass, methodName, isStatic, () => { t1 = new Date().getTime(); }); util.Aspect.addAfter(targetClass, methodName, isStatic, () => { t2 = new Date().getTime(); console.log("t2---t1 = " + (t2 - t1).toString()); }); } // 给Test的doSomething实例方法添加打印执行时间的逻辑 addTimePrinter(Test, 'doSomething', false); new Test().doSomething() // 给Test的test静态方法添加打印执行时间的逻辑 addTimePrinter(Test, 'test', true); Test.test()
不推荐用该方式来统计在多个线程执行的函数,否则可能造成方法次数变量或者执行时间变量的写冲突。
在应用中大量使用的三方库提供的方法,希望对方法返回值进行校验。
在addAfter的回调参数中,第二个参数是原方法的返回值,可以在回调中对这个返回值进行校验。
addAfter的回调返回值会代替原方法的返回值,如果不希望修改返回值,记得在回调中返回原方法的返回值。
对三方库方法返回的网址进行校验,校验不通过的抛出异常,具体实现代码如下:
// someThirdParty.ets export class WebHandler { getWebAddrHttps(): string { let ret = 'http'; // ... return ret; } } // index.ets import {WebHandler} from './someThirdParty'; import {util} from '@kit.ArkTS'; @Entry @Component struct Index { build() { // UI代码 … } } util.Aspect.addAfter(WebHandler, 'getWebAddrHttps', false, (instance: WebHandler, ret: string) => { if (!ret.startsWith('https')) { throw Error('Handler\'s method \'getWebAddrHttps\': return value does not start with \'https\''); } // 校验没问题,记得将原方法返回值返回 return ret; }); new WebHandler().getWebAddrHttps();
希望在方法执行时,检查成员变量是否正常,以确保数据的完整性和准确性。这样可以在方法执行过程中及时发现潜在的问题,并采取相应的处理措施。
在addBefore的回调参数中,第一个参数是原方法的this对象,可以通过这个参数获取成员变量或调用成员方法。通过访问this对象,可以实现对成员变量的实时监测和校验。
在getInfo方法中校验Person类的name和age属性是否正常,具体实现代码如下:
// somePackage.ets
export class Person {
name: string;
age: number;
constructor(n: string, a: number) {
this.name = n;
this.age = a;
}
getInfo(): string {
return 'name: ' + this.name + ', ' + 'age: ' + this.age.toString();
}
}
// index.ets
import {Person} from './somePackage';
import {util} from '@kit.ArkTS';
@Entry
@Component
struct Index {
build() {
// UI代码
…
}
}
// 校验name成员和age成员
util.Aspect.addBefore(Person, 'getInfo', false, (instance: Person) => {
if (instance.name.length == 0) {
throw Error('empty name');
}
if (instance.age < 0) {
throw Error('invalid age');
}
});
new Person('c', -1).getInfo();
在某些情况下需要对原方法进行替换,以确保应用程序的正常运行和性能优化。例如,方法的实现可能调用了禁用的接口,或者方法的性能表现不佳需要进行改进等情况。
replace的第四个参数是回调函数,该回调函数会代替原方法的执行。回调函数的第一个参数是this对象,而从第二个参数开始依次是原方法的参数。因此,通过replace的回调参数,我们可以获取原方法的所有执行上下文。这意味着可以利用replace接口来替换方法的实现,从而实现对原方法执行过程的全面控制和定制。
修改Test类的foo方法中的打印日志,具体实现代码如下:
// somePackage export class Test { foo(arg: string) { console.log(arg); } } // index.ets import {Test} from './somePackage'; import {util} from '@kit.ArkTS'; @Entry @Component struct Index { build() { // UI代码 … } } new Test().foo('123'); // [LOG]: "123" // 替换原方法 util.Aspect.replace(Test, 'foo', false, (instance: Test, arg: string) => { console.log(arg + ' __replaced implementation'); }); new Test().foo('123'); // [LOG]: "123 __replaced implementation"
某个子类调用了父类方法,实际业务中需要修改子类的方法实现,但同时希望不影响父类,从而不影响其它继承这个父类的子类。
利用replace接口以子类为targetClass参数,替换子类方法的实现。
这一操作的底层原理是基于JavaScript的原型链机制。通过replace接口,新函数会被放置到子类的原型上,这样当执行子类的方法时,原型链机制会首先在子类原型上查找新函数来执行,而不会执行父类的方法,也不会影响到父类的其他子类。
Base有两个子类Child1和Child2,两个子类都继承了foo方法。需要修改Child1的foo的实现,但不影响Base和Child2的foo方法。具体实现代码如下:
// base.ets export class Base { foo() { console.log('hello'); } } // child1 import {Base} from './base'; export class Child1 extends Base {} // child2 import {Base} from './base'; export class Child2 extends Base {} // index.ets import {util} from '@kit.ArkTS'; import {Child1} from './child1'; import {Child2} from './child2'; import {Base} from './base'; @Entry @Component struct Index { build() { // UI代码 … } } // 修改Child1的foo的实现 util.Aspect.replace(Child1, 'foo', false, () => { console.log('changed Child1 foo'); }); new Base().foo(); // [LOG]: "hello" new Child1().foo(); // [LOG]: "changed Child1 foo" new Child2().foo(); // [LOG]: "hello"
原Child继承Base的获取实时位置方法,但测试发现Child的getCurrentLocation方法在实际场景调用非常频繁,需要控制调用频率,采取的措施是想修改Child的getCurrentLocation方法的实现,通过将位置信息缓存起来,下次调用的时候如果距离上次调用时间少于一分钟,则直接返回缓存的位置;否则才允许调用系统接口。具体实现代码如下:
// base.ets import {geoLocationManager} from "@kit.LocationKit"; export class Base { getCurrentLocation() { return geoLocationManager.getCurrentLocation(); } } // child.ets import {Base} from "./base"; export class Child extends Base { // 继承父类的getCurrentLocation方法 } // index.ets import {Child} from './child'; import {util} from '@kit.ArkTS'; import {geoLocationManager} from "@kit.LocationKit"; @Entry @Component struct Index { build() { // UI代码 … } } let cached_location: Object | undefined; let time: number | undefined; util.Aspect.replace(Child, 'getCurrentLocation', false, () => { let newTime = new Date().getTime(); // 一分钟最多调用一次实时位置 if (!cached_location || !time || newTime - time > 60000) { time = newTime; cached_location = geoLocationManager.getCurrentLocation(); } // 返回缓存的位置信息 return cached_location; }); new Child().getCurrentLocation()
访问设备的位置信息,必须申请以下权限,并且获得用户授权:
具体方法可参考向用户申请授权。
希望在应用跳转时能够感知到目标应用的包名,实现对目标应用的识别和监控,确保跳转操作的安全性和准确性。
将这个问题用插桩的语言简化下,就是希望在EntryAbility的onCreate方法中对UIAbilityContext类的startAbility方法进行插桩,以获取Want参数的bundleName属性。由于UIAbilityContext是系统提供的类且没有导出,无法直接import,因此可以通过EntryAbility的context成员(该成员是从UIAbility继承而来)获取UIAbilityContext类对象,然后在onCreate方法中完成插桩操作。这样可以实现对目标方法的监控和定制,以满足特定需求。
通过类实例的constructor属性获取类对象,具体实现代码如下:
// EntryAbility.ets import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { util } from '@kit.ArkTS'; // 获取目标包名 export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { hilog.info(0x0000, 'testTag', '%{public}s', ' onCreate'); util.Aspect.addBefore(this.context.constructor, 'startAbility', false, (instance: Object, wantParam: Want) => { console.info('UIAbilityContext startAbility: want.bundleName is ' + wantParam.bundleName); }); this.context.startAbility(want, () => {}) } // 其他相关配置 … }
class Test { foo() {} } util.Aspect.addBefore(Test, 'foo', false, (instance: Test) => { instance.foo(); }); // 无限递归 new Test().foo();
如果确实有需要调用原方法的场景,实现方法参考如下示例。
class Test { foo() {} } // 将原方法实现先保存起来 let oringalFoo = new Test().foo; util.Aspect.addBefore(Test, 'foo', false, (instance: Test) => { // 如果原方法没有使用this,则可以直接调用原方法 oringalFoo(); // 如果原方法中使用了this,应该使用bind绑定instance,但是会有编译warning oringalFoo.bind(instance); });
@Component struct Index { foo(){} build(){}; } util.Aspect.replace(Index, 'foo', false, ...); util.Aspect.replace(Index, 'build', false, ...);
// 不推荐的用法示例: // 'somePackage'; class Test { foo(): string { return 'hello'; } } util.Aspect.addAfter(Test, 'foo', false, () => { console.log('execute foo'); }); // 正确的用法示例: class Test { foo(): string { return 'hello'; } } util.Aspect.addAfter(Test, 'foo', false, (instance: Test, ret: string) => { console.log('execute foo'); return ret; // 返回原方法的返回值 });
如果类方法的属性描述符的writable字段为false,比如冻结(freeze) 的场景, 则不能调用接口操作这个类方法。
方法的属性描述符的writable字段默认为true。