安卓字节码插桩(一)

本文探讨了字节码插桩在安卓开发中的应用,包括APK瘦身(通过内联R文件资源减少体积)、安全合规整改(收集接口调用位置),性能优化(如SharedPreferences替换)以及线程定位和AOP示例。介绍了如何在编译期间进行插桩以改变代码逻辑和实现功能扩展。
摘要由CSDN通过智能技术生成

一、什么是字节码插桩

        字节码即是我们编写的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);
}

三、安卓项目中怎么进行插桩

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值