本文​​Demo​​地址:https://github.com/ClericYi/Asm_Demo

前言

最近的工作内容主要其实并不是说主攻插桩,但是这一次使用​​Lancet​​​插桩给项目本来带来了极大的收益,这和工程的设计相关,当初的设计就是在对抖音中一个原有组件尽可能小的修改情况下,完成我新功能的接入,方案从​​SPI​​​ --> 主工程​​Lancet​​​ --> ​​Lancet​​下沉到一个自定义组件中,一次次尝试确实也是领会这个黑科技的恐怖之处了。

先了解以下当时的场景:一起用Gradle Transform API + ASM完成代码织入呀~_ide

先比较一期和二期的优势和劣势:实践发现一期最后相较于二期的优势仅仅只有不影响主工程,而劣势主要表现在三个方面:

  1. ​api​​​改动时,​​impl​​和​​组件​​需要联动修改。
  2. 当时的环境决定,使用​​SPI​​方案时,会导致大量的本不需要过早获取的数据被获取了,导致运行时工程性能降低,另外还有反射在损耗性能。

但是二期方案也存在劣势,我们也说了影响主工程,而且说​​Lancet​​的生效时机需要进行把握,不可能让他全局生效因为本身就是特定情况下,全局时会影响编译速度,另外这在后期的维护上成本也有一定的增加。

以上的总结最后引出了方案三,不影响主工程,并且不需要把握生效时机,只需要某组件给出​​Hook​​点,就可以轻松完成工作。

本文只探讨怎么去实现​​AscpectJ​​这一类AOP方案的方法。

热门的插桩方案探索

浏览了一下​​Github​​​上比较热门的插桩方案,看到普遍进行使用的就是​​AspectJ​​​还有​​Lancet​​​,而作为​​AspectJ​​​他的延伸中的拓展库​​AspectJX​​,因为比较好的兼容性而受到广泛使用。

AspectJX的使用方法

​AspectJX​​​是基于 ​​gradle android​​插件1.5及以上版本设计使用的。

插件引入

// root -> build.gradle
dependencies {
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.8'
}
// app -> build.gradle
apply plugin: 'android-aspectjx'

如何使用

这里用的是一个他的权限请求库​​Android_Permission_AspectjX​​​,注意使用过程中发现一个​​Bug​​​,给作为基类的​​Activity​​套上注解时并不会生效,基类的方法是没问题的。

// 1. app --> build.gradle
compile 'com.firefly1126.permissionaspect:permissionaspect:1.0.1'
// 2. 自定义Application
onCreate(){
PermissionCheckSDK.init(Application);
}
// 3. 使用注解的方式添加权限@NeedPermission
@NeedPermission(permissions = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
public class BActivity extends Activity {}

//作用于类的方法
@NeedPermission(permissions = {Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS})
private void startBActivity(String name, long id){
startActivity(new Intent(MainActivity.this, BActivity.class));
}

非常简单的使用了两个注解就已经完成权限的申请。

这个库的一些坑

这样就已经完成库的导入了,但是查阅一些度娘的资料会发现这样的问题发生库的冲突。比如与​​支付宝sdk​​发生冲突,以下是一段用于复现代码。

PayTask alipay = new PayTask(this);

一起用Gradle Transform API + ASM完成代码织入呀~_ide_02

这是由于​​AspectJX​​​本身造成的,默认会处理所有的二进制代码文件和库,为了提升编译效率及规避部分第三方库出现的编译兼容性问题,​​AspectJX​​​提供​​include​​​,​​exclude​​​命令来过滤需要处理的文件及排除某些文件(包括​​class​​​文件及​​jar​​文件)。当然为了解决这样的问题,开发者也提供了解决方案,也就是白名单。

aspectjx {
//排除所有package路径中包含`android.support`的class文件及库(jar文件)
exclude 'android.support'
// exclude '*'
// 关闭AspectJX功能,默认开启
enabled false
}

Lancet的使用

文章只做涉略,更为具体的使用请查看仓库:https://github.com/eleme/lancet

  1. 插件引入
// root --> build.gradle
dependencies {
classpath 'com.android.tools.build:gradle:3.3.2'
classpath 'me.ele:lancet-plugin:1.0.6'
}
// build.gralde
apply plugin: 'me.ele.lancet'
dependencies {
compileOnly 'me.ele:lancet-base:1.0.6'
}
  1. ​Lancet​​的使用
public class LancetHooker {
@Insert(value = "eat", mayCreateSuper = true)
@TargetClass(value = "com.example.lancet.Cat", scope = Scope.SELF)
public void _eat(){
((Cat)This.get()).bark();
//这里可以使用 this 访问当前 Cat 类的成员,仅用于Insert 方式的非静态方法的Hook中.(暂时)
System.out.println(">>>>>>>" + this);
Origin.callVoid();
}

@Insert(value = "bark", mayCreateSuper = true)
@TargetClass(value = "com.example.lancet.Cat", scope = Scope.SELF)
public void _bark(){
System.out.println("调用了bark");
Origin.callVoid();
}
}

当定义了​​Hook​​点,并且在编译时被搜索到,最后编译完成之后的效果就会为如下所示。

public class Cat {

class _lancet {
private _lancet(){
}
// 比如调用原本调用bark的方法,会重写为调用com_example_lancet_LancetHooker__bark
// 如果内部存在Origin.Call()这一类的方法时,会对原本的方法在自己的调用点上进行过程
@Insert(mayCreateSuper = true, value = "bark")
@TargetClass(scope = Scope.SELF, value = "com.example.lancet.Cat")
static void com_example_lancet_LancetHooker__bark(Cat cat){
System.out.println("调用了bark");
cat.bark$___twin___();
}

@Insert(mayCreateSuper = true, value = "eat")
@TargetClass(scope = Scope.SELF, value = "com.example.lancet.Cat")
static void com_example_lancet_LancetHooker__eat(Cat cat){
cat.bark();
PrintStream printStream = System.out;
printStream.println(">>>>>>>" + cat);
cat.eat$___twin___();
}
}

public void bark(){
_lancet.com_example_lancet_LancetHooker__bark(this);
}

public void eat(){
_lancet.com_example_lancet_LancetHooker__eat(this);
}

/* access modifiers changed from: private */
public void eat$___twin___() {
System.out.println("猫吃老鼠");
}

public String toString(){
return "猫";
}

/* access modifiers changed from: private */
public void bark$___twin___() {
System.out.println("猫叫了叫");
}
}

可以发现它的做法是对源代码进行修改,而修改的方式是建设一个静态内部类,和对应的内部方法,通过重新设置调用链来进行结果的完成,那​​AspectJ​​呢,他是否是通过这样的方式来进行完成的呢?

AspectJ是如果实现的?

权限的申请只通过几个注解就能够完成,那他是怎么做的呢?我们可以通过​​jadx-gui​​来反编译代码进行查看。

因为AspectJX默认对所有文件生效,所以是否添加注解都会被劫持,除非使用上文中的开白名单

public final class MainActivity extends BaseActivity {
private static final /* synthetic */ JoinPoint.StaticPart ajc$tjp_0 = null;
private HashMap _$_findViewCache;

/* compiled from: MainActivity.kt */
public class AjcClosure1 extends AroundClosure {
public AjcClosure1(Object[] objArr){
super(objArr);
}

public Object run(Object[] objArr){
Object[] objArr2 = this.state;
MainActivity.onCreate_aroundBody0((MainActivity) objArr2[0], (Bundle) objArr2[1], (JoinPoint) objArr2[2]);
return null;
}
}

static {
ajc$preClinit();
}

private static /* synthetic */ void ajc$preClinit() {
Factory factory = new Factory("MainActivity.kt", MainActivity.class);
ajc$tjp_0 = factory.makeSJP(JoinPoint.METHOD_EXECUTION, (Signature) factory.makeMethodSig("4", "onCreate", "com.example.stub.MainActivity", "android.os.Bundle", "savedInstanceState", "", "void"), 12);
}

public void _$_clearFindViewByIdCache() {
HashMap hashMap = this._$_findViewCache;
if (hashMap != null) {
hashMap.clear();
}
}

public View _$_findCachedViewById(int i) {
if (this._$_findViewCache == null) {
this._$_findViewCache = new HashMap();
}
View view = (View) this._$_findViewCache.get(Integer.valueOf(i));
if (view != null) {
return view;
}
View findViewById = findViewById(i);
this._$_findViewCache.put(Integer.valueOf(i), findViewById);
return findViewById;
}

static final /* synthetic */ void onCreate_aroundBody0(MainActivity ajc$this, Bundle savedInstanceState, JoinPoint joinPoint){
super.onCreate(savedInstanceState);
ajc$this.setContentView((int) R.layout.activity_main);
}

/* access modifiers changed from: protected */
public void onCreate(Bundle savedInstanceState){
JoinPoint makeJP = Factory.makeJP(ajc$tjp_0, (Object) this, (Object) this, (Object) savedInstanceState);
PermissionAspect.aspectOf().adviceOnActivityCreate(new AjcClosure1(new Object[]{this, savedInstanceState, makeJP}).linkClosureAndJoinPoint(69648));
}
}

通过编译后的源码查看可以发现,你所写的代码已经被通过一些特殊的方式来进行了修改,所以我们就应该有了自己的目标了,注解 + 自动化代码修改完成任务。

如何完成自动化代码修改

这里我们首先需要借用的能力是​​Gradle Transform Api​​​中的遍历,而这个功能在你创建一个​​Android​​​工程的时候​​Android Studio​​已经自然而然给你集成了这一项能力。

这个​​Api​​​的能力只有在​​Gradle Version 1.5+​​的时候才开放

那它的运作方式是怎么样的呢?小二,上图。一起用Gradle Transform API + ASM完成代码织入呀~_android_03

上述本是​​Apk​​​完整的打包流程,但是如果使用了​​Transform Api​​​将会多出我们红框中的部分。当然如果三方的​​.class Files​​​的文件内存在注解也是可能会被抓住的。所以这里我们知道了一个目标是被编译过后的​​.class​​文件们,而代码的修改逻辑肯定是和我们的希望实现的逻辑有关的。

看过了上面反编译出来的一个代码修改模式,我们可以先思考一下这种代码修改可以如何去进行。比如说

public void fun(Login login){
login.on();
}

但是我们想直接劫持这样的方法,因为这个方法它只做了一个登陆操作,但是我想做身份验证呢?如果代码中只有一处还好说,但是如果多处呢?可能我的代码就变成了如下

public void fun(Login login){
if(login.check()) login.on();
else login.close()
}

上述代码还是比较简单的,但是有些时候这种逻辑的重复书写是时常存在的,而且随着代码容量的增加而导致维护难度提高,如果有一天身份验证方法变了,那就凉透了。这就是插桩经常会被用到的地方 —— ​​AOP​​面向切面,在代码实现时,你需要干的事情是给对应的方法加上一个注解,处理逻辑统一完成。

插桩实现

第一个环节:如何将插桩的能力植入

这里真的真的看了很多网上资料,质量参差不齐,花了整整一天时间,终于把整个东西跑起来了🤣 🤣 🤣 ,下面文章内将给出我认为最简便的创建工程的方案。

如果只是想要本地测试的话,这里给出的是最简便的方案,使用​​buildSrc​​​(大小写也要一致哦!)来作为​​Android Library​​​的名字可以省去​​99%​​的麻烦。

最后会在文末给一个可以用于发版使用的实现方案介绍。

那要先进入第一步,插件的使用。

为了能够引入​​Gradle​​​的能力,请将仓库内的​​build.gradle​​的内容修改成如下的形式。

apply plugin: 'groovy'

dependencies {
implementation gradleApi()//gradle sdk

implementation 'com.android.tools.build:gradle:3.5.4'
implementation 'com.android.tools.build:gradle-api:3.5.4'

//ASM依赖
implementation 'org.ow2.asm:asm:8.0'
implementation 'org.ow2.asm:asm-util:8.0'
implementation 'org.ow2.asm:asm-commons:8.0'
}

repositories {
google()
jcenter()
}

上述内容完成​​sync​​以后,就需要生成一个插件能够进行使用。

/**
* Create by yiyonghao on 2020-08-08
* Email: yiyonghao@bytedance.com
*/
public class AsmPlugin implements Plugin<Project> {
@Override
public void apply(Project project){
System.out.println("=========== doing ============");
}
}

并且在主工程的​​app --> build.gradle​​​中添加语句​​apply plugin: com.example.buildsrc.AsmPlugin(包名.插件名)​​。

很多工程说用​​Groovy​​​来做,其实没有必要,直接​​Java​​就可以了。

如果到这一步,在build过程中能够打印出​​=========== doing ============​​这个数据,说明插件已经生效,那现在就要进入下一步,如何完成代码的插桩了。

在不引入​​ASM​​​之前,整体​​Gradle Transform API​​为我们提供了什么样的能力呢?先明确目标,如果想要代码的插桩,我们一定要进行下面这样的几个步骤:

  1. 源码文件获取(可能是​​.class​​,也可能是​​.jar​​)
  2. 文件修改
源码文件获取

为了获取文件的路径,我们使用的能力就是​​Gradle Transform API​​​所提供的​​Transform​​​类,其中的​​transform()​​方法中的变量其实已经自动为我们提供了很多他自身所具备的能力,就比如说文件遍历。

public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation);
//消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
Collection<TransformInput> inputs = transformInvocation.getInputs();
//OutputProvider管理输出路径,如果消费型输入为空,你会发现OutputProvider == null
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

for (TransformInput input : inputs) {
for (JarInput jarInput : input.getJarInputs()) {
File dest = outputProvider.getContentLocation(
jarInput.getFile().getAbsolutePath(),
jarInput.getContentTypes(),
jarInput.getScopes(),
Format.JAR);
}
for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
File dest = outputProvider.getContentLocation(directoryInput.getName(),
directoryInput.getContentTypes(), directoryInput.getScopes(),
Format.DIRECTORY);
transformDir(directoryInput.getFile(), dest);
}
}
}

通过如上的方式,就可以扫到我们的文件了,那就应该要接入第二个步骤,如何进行文件的修改?

文件修改

在上文中我从来没有提及过​​Gradle Transform API​​关于修改代码的逻辑,这是为什么呢?

还不是因为他并不提供这样专项的功能,所以这里就要引入我们经常听说的大将​​ASM​​​来完成字节码的修改了。这里开始将注意点放置到我们的两个类​​AsmClassAdapter​​​和​​AsmMethodVisitor​​​还有​​AsmTransform.weave()​​。

关于​​ASM​​​最最最最常涉及的是下面几个核心类。一起用Gradle Transform API + ASM完成代码织入呀~_ide_04

当然我现在给出的​​Demo​​​中有两个类,​​AsmClassAdapter​​​就是继承了​​ClassVisitor​​​用来访问​​Class​​​也就是我们的一个个类,而​​AsmMethodVisitor​​​就是通过​​ClassVisitor​​的数据传递然后用于访问类中存在的方法的。

private static void weave(String inputPath, String outputPath){
try {
// 。。。。。
// 而文件结构的访问通过ASM基于的能力来进行识别
ClassReader cr = new ClassReader(is);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
AsmClassAdapter adapter = new AsmClassAdapter(cw);
cr.accept(adapter, 0);
// 。。。。。
} catch (IOException e) {
e.printStackTrace();
}
}

其实本质上就是​​ASM​​对一个文件进行分析操作以后,让我们只关注想要插入什么,以什么样的方法去进行插入,然后他会使用对应的方案对字节码进行整改。

AsmClassAdapter和AsmMethodVisitor的简单实现
public class AsmClassAdapter extends ClassVisitor implements Opcodes {
public AsmClassAdapter(ClassVisitor classVisitor){
super(ASM7, classVisitor);
}

@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions){
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
return (mv == null) ? null : new AsmMethodVisitor(mv); // 1 -->
}
}

而​​MethodVisitor​​方法对于我们而言,就是对方法的一个插桩方案。

public class AsmMethodVisitor extends MethodVisitor{
public AsmMethodVisitor(MethodVisitor methodVisitor){
super(ASM7, methodVisitor);
}

@Override
public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface){
//方法执行之前打印
mv.visitLdcInsn(" before method exec");
mv.visitLdcInsn(" [ASM 测试] method in " + owner + " ,name=" + name);
mv.visitMethodInsn(INVOKESTATIC,
"android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(POP);
// 原有方法
super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);

//方法执行之后打印
mv.visitLdcInsn(" after method exec");
mv.visitLdcInsn(" method in " + owner + " ,name=" + name);
mv.visitMethodInsn(INVOKESTATIC,
"android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(POP);
}
}

你可以实现更多类似这样的方法。而这样做过之后,我们是否已经完成了所谓了字节码的修改了呢?

第二步:文件覆盖

可能你跑不通,这里直接给出一个答案,并没有完成!!我们我们虽然会所把字节码修改了,但是你是否有完成文件的覆盖呢?

所以你能够在​​Demo​​中发现存在这样的代码,比如:

  1. ​weave()​​方法
private static void weave(String inputPath, String outputPath){
try {
// 存在新文件的创建
FileInputStream is = new FileInputStream(inputPath);
ClassReader cr = new ClassReader(is);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
AsmClassAdapter adapter = new AsmClassAdapter(cw);
cr.accept(adapter, 0);
FileOutputStream fos = new FileOutputStream(outputPath);
fos.write(cw.toByteArray());
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
  1. ​FileUtils.copyFile(jarInput.getFile(), dest);​​​存在​​jar​​包的位置迁移,这都是为了将新的代码进行存储

完成到这里,我们在去看一下最后生成的代码到底是什么样的。(文件路径:app --> build --> intermediates --> transform --> 包名 --> debug --> 一直到你的文件)比如说我本地生成的​​MainActivity.java​​。

public class MainActivity extends AppCompatActivity {
public MainActivity(){
Log.i(" before method exec", " [ASM 测试] method in androidx/appcompat/app/AppCompatActivity ,name=<init>");
super();
Log.i(" after method exec", " method in androidx/appcompat/app/AppCompatActivity ,name=<init>");
}

protected void onCreate(Bundle savedInstanceState){
Log.i(" before method exec", " [ASM 测试] method in androidx/appcompat/app/AppCompatActivity ,name=onCreate");
super.onCreate(savedInstanceState);
Log.i(" after method exec", " method in androidx/appcompat/app/AppCompatActivity ,name=onCreate");
Log.i(" before method exec", " [ASM 测试] method in com/example/asm/MainActivity ,name=setContentView");
this.setContentView(2131361820);
Log.i(" after method exec", " method in com/example/asm/MainActivity ,name=setContentView");
Log.i(" before method exec", " [ASM 测试] method in android/util/Log ,name=e");
Log.e("aa", "aa");
Log.i(" after method exec", " method in android/util/Log ,name=e");
}
}

如果说你觉得好麻烦啊,那你也可以使用一个插件​​ASM Bytecode Outline​​的工具来完成插桩后代码的查看

每一个方法最后都被我们插入了我们要插入的代码,那ok,说明离我们通过注解来进行插桩的目标已经迈出了一大步。

如何通过注解完成

既然要用注解来完成事件,那这个时候我们就创建一个注解,但是请注意其中的​​@Retention​​注解写法,是需要在编译期的时候进行生效的。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface ASM {}

然后你可以在​​MainActivity.java​​​中加入方法,并加上这个注解。那接下来的事情是什么呢?想必就是扫到这个注解了,也就是使用了​​visitAnnotation()​​的方法。

@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible){
return super.visitAnnotation(descriptor, visible);
}

但是纵观继承过来的方法,很显然并不能说它本身并不能去修改这个注解所对应的方法,所以我们最后的妥协只能是通过加入标示符号,当要进行方法插入的时候告诉​​visitMethodInsn()​​我这段代码他是需要去进行插入的。

@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible){
if(ANNOTATION_TRACK_METHOD.equals(descriptor)) isMatch = true;
return super.visitAnnotation(descriptor, visible);
}

而​​visitMethodInsn()​​这个方法在插入之前需要先进行判定,如此需要才进行插桩。以下就是插桩之后的结果:

public class MainActivity extends AppCompatActivity {
public MainActivity(){
}

protected void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
this.setContentView(2131361820);
Log.e("aa", "aa");
}

@Cat
public void fun(){
Log.d("tag", "onCreate start");
Log.d("tag", "onCreate end");
}

@ASM
public void fun1(){
}
}

发布一个可以给别人用的插件

这个时候你不要在去在意​​Module​​​的名字了,定义你想要的名字。为了方便起见,可以选择先拷贝一份之前​​buildSrc​​​中写好的代码。既然是要发布,那我们首先要干的事情就是使用​​Gradle​​​进行​​upload​​操作了。

// 在你新设置的Module --> build.gradle中加入以下代码,你可以diy
uploadArchives {
repositories.mavenDeployer {
repository(url: uri('../repo'))
pom.groupId = 'com.example.asm'
pom.artifactId = 'asm_plugin'
pom.version = '1.0.0'
}
}

但是这个时候发布了并且在主工程进行引入的话,其实还是找不到我们的​​Plugin​​​插件的。一起用Gradle Transform API + ASM完成代码织入呀~_ide_05

因为他还需要一步操作,创建如下的目录,这是为了让我们发布的文件能够被发现一起用Gradle Transform API + ASM完成代码织入呀~_android_06

implementation-class = com.example.asm_plugin.AsmPlugin // 插件在包中位置给出

最后在​​root --> build.gralde​​​中引入​​repo​​​,就可以像​​buildSrc​​一样生效了。

buildscript {
repositories {
google()
jcenter()
maven {
url uri("repo")
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.4'
classpath 'com.example.asm:asm_plugin:1.0.0'
}
}

一起用Gradle Transform API + ASM完成代码织入呀~_ide_07

参考资料

  • Android aop AspectJX与第三方库冲突的解决方案:https://www.jianshu.com/p/3899f0431895
  • Android全埋点解决方案之ASM:https://www.sensorsdata.cn/blog/20181206-9/

一起用Gradle Transform API + ASM完成代码织入呀~_ide_08