往期推文全新看点(文中附带全新鸿蒙5.0全栈学习笔录)
✏️ 鸿蒙应用开发与鸿蒙系统开发哪个更有前景?
✏️ 嵌入式开发适不适合做鸿蒙南向开发?看完这篇你就了解了~
✏️ 对于大前端开发来说,转鸿蒙开发究竟是福还是祸?
✏️ 鸿蒙岗位需求突增!移动端、PC端、IoT到底该怎么选?
✏️ 记录一场鸿蒙开发岗位面试经历~
✏️ 持续更新中……
概述
切面编程(AOP)是一种通过预编译方式和运行期间动态代理实现程序功能的统一维护的技术。AOP的核心思想是将程序的关注点(concern)分离,通过在程序中插入代码来实现横切关注点(cross-cutting concerns),从而实现对业务逻辑的各个部分进行隔离,降低它们之间的耦合度,提高程序的可维护性和可重用性,同时提高了开发的效率。
在AOP中,开发者可以通过定义切面(aspect)来封装横切关注点,而不需要直接修改业务逻辑代码。这种方式要求在不修改源代码的前提下添加功能,常用于将业务代码和非业务代码剥离,比如参数校验、日志记录、性能统计等非业务代码,以达到更好的代码解耦效果。
HarmonyOS主要通过插桩机制来实现切面编程,并提供了 Aspect类 ,包括addBefore、addAfter和replace接口。这些接口可以在运行时对类方法进行前置插桩、后置插桩以及替换实现,为开发者提供了更灵活的操作方式。在具体业务场景中,不同的需求可能需要不同的埋点功能和日志记录。通过调用addBefore、addAfter和replace接口,可以实现对类方法的各种功能增强和定制化需求:
- 对于方法校验类的需求,我们可以在方法执行前或执行后进行参数校验,确保参数、返回值等的合法性。
- 针对方法执行时间和次数统计的需求,我们可以在方法执行前和执行后分别插入统计逻辑,记录方法的执行时间和次数。通过addBefore和addAfter接口的组合运用,可以方便地实现对方法执行情况的监控和统计,为性能优化提供数据支持。
- 对于替换类的需求,我们可以使用AOP的replace接口,动态替换原有方法的实现逻辑。这种灵活的替换机制可以在不改变原有方法调用的情况下,实现对方法功能的替换或增强,为项目的功能扩展提供便利。
- 针对拉起应用时获取目标包名信息的需求,我们可以在应用启动时获取目标包名信息并记录日志。通过在应用启动时调用addBefore接口,可以实现对应用启动过程的监控和记录,为应用性能优化和故障排查提供帮助。
下面,本文将介绍对应接口的基本原理,并针对以上业务场景,具体说明怎么利用运行时插桩的接口完成对类方法的埋点和加日志功能。
插桩原理介绍
addBefore、addAfter、replace接口的原理基于class的ECMAScript语义,即类的静态方法是类的属性,类的实例方法是类的原型对象(prototype)的属性。
图1class的ECMAScript语义示意
原理解析
类的实例会有一个属性__proto__(称为原型),它是指向类的prototype的引用(如下图2所示)。实例在调用方法时,实际上会先通过__proto__找到类的prototype,再在prototype中找到这个方法,再执行调用逻辑。类的原型对象(prototype)被这个类的所有实例共享,这意味着修改类的原型对象里面存储的方法,会对这个类的所有实例产生效果。
图2类的实例化示意
原型对象也有原型__proto__。类的继承就是通过原型来实现的。实例方法的调用实际上在运行时就是通过在原型串联的链上查找方法,找到方法再执行调用(如下图3所示)。
图3类的原型与继承
插桩和替换的操作本质上就是将回调参数和原方法组合成一个新的函数,再用新的函数替换原方法(如下图4所示)。
**图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;
}
场景1:方法参数校验
业务开发团队没有关注参数的合法性,运维团队发现了这个问题,需要紧急修复。然而,让业务开发团队修改的流程较为繁琐,因此运维团队决定临时采取插桩的方式给方法加上参数合法性校验的逻辑。
举例来说,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)
场景2:统计方法执行次数、时间
在性能分析或调试场景中,性能管控团队需要统计应用运行过程中调用某个方法的次数或执行时间,如果让业务开发团队临时修改源代码并重新打包,效率较低且业务团队不一定有足够的人力资源来配合这一过程。因此,他们需要临时插入一个插桩来查看相关信息。
场景分析
通过在方法前插入调用次数自增的逻辑,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()
说明
不推荐用该方式来统计在多个线程执行的函数,否则可能造成方法次数变量或者执行时间变量的写冲突。
场景3:校验方法返回值
在应用中大量使用的三方库提供的方法,希望对方法返回值进行校验。
场景分析
在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();
场景4:在方法中校验成员变量
希望在方法执行时,检查成员变量是否正常,以确保数据的完整性和准确性。这样可以在方法执行过程中及时发现潜在的问题,并采取相应的处理措施。
场景分析
在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();
场景5:替换方法实现
在某些情况下需要对原方法进行替换,以确保应用程序的正常运行和性能优化。例如,方法的实现可能调用了禁用的接口,或者方法的性能表现不佳需要进行改进等情况。
场景分析
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"
场景6:替换子类继承的方法实现
某个子类调用了父类方法,实际业务中需要修改子类的方法实现,但同时希望不影响父类,从而不影响其它继承这个父类的子类。
场景分析
利用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()
说明
访问设备的位置信息,必须申请以下权限,并且获得用户授权:
- ohos.permission.LOCATION
- ohos.permission.APPROXIMATELY_LOCATION
场景7:拉起应用时获取目标包名信息
希望在应用跳转时能够感知到目标应用的包名,实现对目标应用的识别和监控,确保跳转操作的安全性和准确性。
场景分析
将这个问题用插桩的语言简化下,就是希望在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, () => {})
}
// 其他相关配置
…
}
附录:接口使用注意事项
- 插桩的目标类通常需要导入进来,对于没有导出的场景,如果有实例,可以通过实例的constructor属性获取目标类。
- 插桩的目标方法名不能被混淆,以保证插桩接口的正确运行。
- 对父类作为目标类插桩会影响所有子类;对子类作为目标类插桩不会影响父类(无论方法是否是继承自父类的),但是会影响子类的所有子类。
- 接口的第四个参数是回调函数,回调函数中第一个参数是执行方法调用的this对象。如果通过这个调用原方法,并且没有退出机制,容易造成无限递归调用。如果需要调用原方法,需要在接口调用前将原方法存储起来。不推荐的用法参考如下示例。
class Test {
foo() {}
}
util.Aspect.addBefore(Test, 'foo', false, (instance: Test) => {
instance.foo();
});
// 无限递归
new Test().foo();
如果确实有需要调用原方法的场景,实现方法参考如下示例。
class Test {
foo() {}
}
// 将原方法实现先保存起来
let originalFoo = new Test().foo;
util.Aspect.addBefore(Test, 'foo', false, (instance: Test) => {
// 如果原方法没有使用this,则可以直接调用原方法originalFoo();
// 如果原方法中使用了this,应该使用bind绑定instance,但是会有编译warningoriginalFoo.bind(instance);
});
- 不推荐对struct的方法插桩/替换实现。因为ArkUI的struct从设计上是一种介于函数和类之间的特殊存在,struct的去糖属于黑盒机制,开发者不应该感知。虽然目前struct底层实现是类,使用接口可能也不会导致编译报错,但是仍然不推荐对struct的方法插桩/替换实现,因为可能随着ArkUI的演进,底层实现会改变,从而导致一些难以预料的问题。不推荐的用法参考如下示例。
@Component
struct Index {
foo(){}
build(){};
}
util.Aspect.replace(Index, 'foo', false, ...);
util.Aspect.replace(Index, 'build', false, ...);
- 由于addAfter的回调参数的返回值会劫持原方法的返回值,因此需要注意在回调参数中返回和原方法匹配的返回值,如果不修改返回值,请直接将原方法的返回值返回(即回调参数的第二个参数)
// 不推荐的用法示例:
// '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字段为true,就可以使用对应接口进行插桩和替换。
说明
如果类方法的属性描述符的writable字段为false,比如冻结(freeze) 的场景, 则不能调用接口操作这个类方法。
方法的属性描述符的writable字段默认为true。
- 使用Aspect类接口进行插桩,对AoT和JIT编译后的性能没有明显影响。