一、什么是字节码插桩
字节码即是我们编写的java文件通过javac编译之后得到的.class,那么字节码插桩则是在class文件中插入一些额外的代码段,从而达到改变原有代码执行逻辑的目的。
二、字节码插桩对安卓开发有什么用
1、apk瘦身
举个🌰:R文件内联。
我们的安卓项目目录结构一般是这样的:一个app模块,若干个libarary模块,和引入的若干个aar,虽然我们在这些模块中使用资源文件的方式基本相同,都是通过R文件进行引用,但是在项目编译打包后,引用方式却有所变化,看如下例子。
1、app模块中引用资源文件
setContentView(R.layout.activity_main)
编译后的字节码如下
LDC 234908321
INVOKEVIRTUAL com/example/spider/MainActivity.setContentView (I)V
2、libarary模块中引用资源文件
setContentView(R.layout.activity_lib)
编译后的字节码如下
GETSTATIC com/example/test/R$layout.activity_lib : I
INVOKEVIRTUAL com/example/test/TestActivity.setContentView (I)V
可以看到同样是引用资源文件,app模块是直接将常量值索引内联到了字节码中,而lib模块则需要先从R.java文件中去读取。了解安卓打包机制的同学都知道,在AGP4.1.0之前的版本中,R文件是有传递性的,比如app模块依赖了A模块,A模块又依赖了B模块,那么app模块通过A模块间接地依赖了B模块,A模块中会有B模块R文件的一份拷贝,app模块中会同时有A、B两个模块R文件的一份拷贝,一个模块的依赖链越长,那么该模块R文件的拷贝数量就越多,这些R文件最终都会被打包到apk文件中,导致包体积指数型增长。
如果能像app模块那样,把对R文件静态成员变量的引用去掉,直接使用内联索引的方式,就可以在proGuard的时候,将无用的R文件去除,从而达到apk瘦身的目的。这时候,我们就可以借助字节码插桩技术,在编译期间对R文件进行内联。详细方案,可参考字节ByteX框架的shrink-r-plugin插件。
2、安全合规整改
近年来工信部披露了大量违规获取用户个人信息、过度索取权限的应用并要求其下架整改。这些安全合规问题大致可以分为两类:
1、调用了禁止使用的接口,比如获取设备唯一标识等;
2、运行时,在未满足条件的情况下,调用了敏感接口,比如在用户同意隐私协议之前,获取手机本地应用列表等信息;
对于这些问题,我们一般的做法是先收集调用位置信息,然后针对性整改。
那么如何收集调用位置信息呢?
对于第一类问题,我们可以通过代码静态扫描完成收集。编译期的代码扫描通常是这样的,在编译阶段,获取到整个项目的字节码数据进行遍历,匹配对禁用接口的调用,然后将调用位置记录到文件中,一般在编译完成后我们就能得到完整的调用列表。
对于第二类问题,我们可以在编译期进行插桩,运行时进行收集。编译期间,在敏感接口的调用位置(为什么不是在敏感接口里面,因为敏感接口一般是系统api,不参与我们的项目编译,没法进行插桩)插入一段代码逻辑,用来收集该接口被调用的位置、传入参数以及调用链等信息,这里我们以简单打印调用堆栈为例,插入的代码如下:
源码
Log.d("tag",Log.getStackTraceString(new Throwable()));
字节码
ldc #6 <tag>
new #7 <java/lang/Throwable>
dup
invokespecial #8 <java/lang/Throwable.<init> : ()V>
invokestatic #9 <android/util/Log.getStackTraceString : (Ljava/lang/Throwable;)Ljava/lang/String;>
invokestatic #10 <android/util/Log.d : (Ljava/lang/String;Ljava/lang/String;)I>
然后在未满足条件的情况下(如不同意隐私协议)使用app,看是否有触发对应的日志打印,从而收集违规行为列表。
什么,上面插入的字节码有点多,开发起来困难易出错?那我们可以换种方式插桩,把要插桩的字节码封装到一个工具类中,然后我们只需要插入对工具类的调用代码就可以了,如下:
public class Utils {
public static void printTrace(){
Log.d("tag",Log.getStackTraceString(new Throwable()));
}
}
那么我们需要插桩的代码就只有如下这么一句
invokestatic #6 <com/year/trace/Utils.printTrace : ()V>
只要思想不滑坡,办法总比困难多!
收集到违规行为列表之后,我们就需要对其进行整改了,为了避免业务场景出错,一般我们不建议使用暴力插桩的方式,但是有时候三方sdk它要么推不动,要么进度缓慢,这时候,我们就必须采取必要措施了。
对于第一类问题,我们可以直接拦截掉字节码的调用,或者将对系统api的调用替换成对我们自己封装的方法调用,在方法中进行空实现。
对于第二类问题,一样可以替换成对我们自己封装方法的调用,在我们的封装方法中,进行条件判断,满足条件的情况下才去调用系统敏感接口。
3、性能优化
举个🌰:SharedPreferences全局替换
我们都知道,安卓系统原生的数据持久化工具 SharedPreferences存在比较多的性能问题,比如,数据格式冗余、读写效率低、导致应用anr等。正因如此,业内出现了一些原生sp的替代方案,比如腾讯的mmkv,百度的swankv等,这些方案,在数据存储格式,数据读写等方面做了大量的优化,将对原生sp的使用替换成这类优化方案,将使应用在性能方面取得不小的提升。
那么怎么将应用中对原生sp的使用全局替换成其他优化方案呢,我们以原生sp切换成mmkv为例。
MMKV跟原生sp一样都实现了SharedPreferences接口,所以,我们可以像使用原生sp一样使用mmkv,不过SharedPreferences中有几个接口在mmkv中是没有实现的:
如果项目中没有对上面这几个接口的调用,那可以直接使用mmkv进行替换,如果调用到了,我们需要对mmkv进行封装,实现上面几个接口,然后用我们的封装类进行替换。至于怎么封装这里我们就不深入了,网上有大量实例可供参考,我们主要讲下怎么替换。
首先我们定义一个获取MMKV实例的接口
public class SharedPreferencesFactory {
public static SharedPreferences getMMKV(String name, int mode) {
return new MMKVWrapper(name, mode);
}
}
在项目编译阶段,查找到对如下接口的调用
invokevirtual <android/content/Context.getSharedPreferences : (Ljava/lang/String;I)Landroid/content/SharedPreferences;>
替换成下面对我们自己封装方法的调用就可以了,方法签名基本是一致的
invokestatic <com/year/trace/SharedPreferencesFactory.getMMKV : (Ljava/lang/String;I)Landroid/content/SharedPreferences;>
只要我们Hook住了SharedPreferences实例的创建,后续对原生sp接口的调用,都会自动切换到对mmkv实例的调用。对了,记得在mmkv封装类中进行旧数据迁移。
4、定位问题
举个🌰:这个线程是谁创建的
如上图所示,我们在做性能优化的时候,经常会去分析一些有问题的线程,我们想知道这个线程是谁创建的,一般会从线程名中提取一段特征字符串进行全局搜索,但是,如果这个线程是在sdk中创建并且我们没有源码依赖,搜出来的结果大体是这样的:
这时候我们就可以通过字节码插桩的方式来定位线程的创建位置。常规的方案是hook线程的创建,这里我们介绍另外一种简单的方案,直接匹配特征字符串。比如上面这个线程名为track_thread_2 27037,那么我们可以提取特征字符串"track_thread",如果这个字符串在代码中是这样定义的
public static final String THREAD_NAME_PREFIX = "track_thread"
那么它被使用时出现在字节码中的样子应该是这样的
ldc "track_thread"
那么我们就可以在MethodVisitor的visitLdcInsn函数中进行字符串匹配,查找到使用的地方进行位置打印,即可定位到这个字符串的使用位置,进而定位到线程的创建位置。
5、aop
举个🌰:打印函数执行耗时
之前有个比较有名的库叫hugo,可以简单地实现函数耗时统计,它是基于AspectJ实现的,使用代码插桩,我们也可以实现类似功能,asm库提供了简单封装类AdviceAdapter,方便我们在函数的开始和结束位置进行插桩,代码如下
public class TimeTrackAdapter2 extends AdviceAdapter {
private int index;
private String methodName;
protected TimeMethodAdapter2(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
super(api, methodVisitor, access, name, descriptor);
this.methodName = name.replace("/", ".");
}
@Override
protected void onMethodEnter() {
super.onMethodEnter();
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
index = newLocal(Type.LONG_TYPE);
mv.visitVarInsn(Opcodes.LSTORE, index);
}
@Override
protected void onMethodExit(int opcode) {
super.onMethodExit(opcode);
mv.visitLdcInsn(methodName);
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
mv.visitVarInsn(LLOAD, index);
mv.visitInsn(LSUB);
mv.visitMethodInsn(INVOKESTATIC, "com/year/trace/TimeTracker", "recordTime", "(Ljava/lang/String;J)V", false);
}
}
插桩前:
public void test() {
Thread.sleep(900);
}
插桩后:
public void test() {
long var1 = System.currentTimeMillis();
Thread.sleep(900);
long var3 = System.currentTimeMillis() - var1;
TimeTracker.recordTime("com.year.trace.Test.test", var3);
}