美文网首页
《深入探索Android热修复技术原理》读书笔记

《深入探索Android热修复技术原理》读书笔记

作者: CyanStone | 来源:发表于2018-11-01 17:40 被阅读0次
    热修复概述
    • assets中的文件也是资源,只是这些资源是不带资源ID的原始文件,因此可以直接指定路径来访问这些资源。
    • AndroidManifest出现的BUG是无法修复的,因为它是由系统进行解析的,系统会直接获取安装包里唯一的AndroidManifest文件,在解析过程中不会访问补丁包信息。
    • 如果想要增加四大组件,通常需要预先在安装包的AndroidManifest里边埋入代理的组件,在每次新增组件时,进行偷梁换柱,通过预埋的代理组件实现与系统进程间的通信。
    • 代码的修复,由于所有的Java代码最终都编译为classes.dex格式文件,因此任何的热修复方案,想要改变代码逻辑,都需要在补丁里包含一个新的dex文件。然后再程序运行的时候加载这个dex文件,并且改变执行流,从执行原有安装包里的classes.dex文件引导到执行新的dex文件。
    • 资源的修复,主要需要修改资源包的内容。正常情况下,资源包就是整个apk安装包,如果想要新增一个原有安装包里不存在的资源,就必须修改资源包的内容。所以,就必须想办法把原有安装包替换为新资源包,或者把新的资源包插入程序的查找过程中。
    • so库的修复,在Android系统中,所有的so库都是由System.load进行加载的,因此只要找到办法在加载的时候优先加载补丁包的so库,而不是原有安装包的so库,就能进行完整的底层代码替换了。
    • 代码修复主要有两大方案:一种是阿里系的底层替换方案,另一种是腾讯系的类加载方案。
      • 底层替换方案限制颇多,但时效性最好,加载轻快,立即见效;
      • 类加载方案时效性差,需要重新冷启动才能见效,但修复范围广,限制少。
    • 底层替换方案:是在已经加载的类中直接替换原有方法,是在原有类的基础上进行修改的,因而无法实现对原有类进行方法和字段的增减,因为这样会破坏原有类的结构;方法和字段的增加或减少,会导致方法和字段的索引发生变化;如果程序运行中某个类突然增加了一个字段,那么对于已经产生这个类的示例,它们还是原有的结构,这是无法改变的;
    • 类加载方案:类加载方案的原理是在app重新启动后让ClassLoader去加载新的类。因为在app启动到一半的时候,所有需要发生变更的类已经被加载过了,在Android系统上是无法对一个类进行卸载的,如果不重启,原有的类还在虚拟机中,就无法加载新类。因此,只有在下次启动的时候,在还没有运行到业务逻辑之前抢先加载补丁包中的新类,这样后续访问这个类时,就会被解析为新类。
    • 资源修复基本都参考了Instant Run的实现(需要处理兼容性问题和搜索到AssetManger所有的引用处),简单说,Instant Run中的资源热修复分为两步:
      • 构造一个新的AssetManager,并通过反射调用addAssetPath函数,然后把完整的新资源包加载到AssetManager中。这样就得到了一个含有所有新资源的AssetManager。
      • 找到所有之前引用原有AssetManager的地方,通过反射,把引用出替换为新的AssetManager。
    • so库的修复: so库的修复本质是对native方法的修复和替换。可以采用类似类修复反射注入的方式。把补丁so库的路径插入到nativeLibraryDirectories数组的最前边。

    热替换代码修复
    • 每一个Java方法在Art虚拟机中都对应着一个ArtMethod,ArtMethod记录了这个Java方法的所有信息,包括所属类、访问权限、代码执行地址等;
    • 通过env->FromRefletedMethod,可以由Method对象得到这方法所对应的ArtMethod的真正起始地址,然后就可以把它强转为ArtMethod指针,从而对其包含的内容进行修改;
    • 把旧方法的ArtMethod对象中所有的字段都换为新方法ArtMethod的成员字段后,执行时所有的数据就可以保持和新方法的数据一致。
    • dex2oat生成的AOT机器码时是有做一些检查和优化的,由于dex2oat编译机器码时确认了两个方法同属于一个类,所以机器码中就不存在权限检查的相关代码了。
    • 加载新类的ClassLoader需要与老的类的ClassLoader一致,通过反射依赖注入即可:
    Field classLoaderField = Class.class.getDeclaredField("classLoader");
    classLoaderField.setAccessible(true);
    classLoaderField.set(newClass, oldClass.getClassLoader);
    
    • 反射调用非静态方法时,由于方法所属类被替换成新的类,但是调用这个方法的类的实例可能还是老的类而导致出现非法参数异常。
      • o表示Method.invoke传入的第一个参数,也就是作用的对象;
      • c表示ArtMethod所属的类型;
      • 只有o是c的一个实例才能够通过验证,才能继续执行后面的反射调用流程;
    • 内部类在编译期会被编译为跟外部类一样的顶级类;
    • 静态内部类和非静态内部类的区别:非静态内部类会持有对外部类的引用,静态内部类不持有外部类的引用。所以我们在Android性能优化中建议Handler的实现尽量采用静态内部类,防止外部Activity类不能被回收导致的可能OOM。
    • 内部类实际上跟外部类一样是顶级类,但在编译期间会为内部类自动生成access数字编号的相关方法,来让外部类来访问内部类的私有成员变量和私有方法;同样,外部类也会自动生成access数字编号的相关方法,来让内部类来访问外部类的私有成员变量和私有方法
    • 内部类热部署方案:只要防止生成access**相关的方法,就能走热部署方案,也就是底层替换方法热修复,所以只要满足以下条件,就能避免编译器自动生成access**相关方法:
      • 一个外部类如果有内部类,把所有的method/field的私有访问权限改成protected或public或默认访问权限
      • 同时把内部类的所有method/field的私有访问权限改成protected或public或默认访问权限
    • 匿名内部类顾名思义就是没有名字的。匿名内部类的名称格式一般是外部类$数字编号,后面的数字编号,是编译期根据该匿名内部类在外部类中出现的先后关系,依次累加命名的。
    • 增加或减少一个匿名内部类,匿名内部类的编号会乱套(编号默认从1开始)
    • 匿名内部类热部署方案:应该极力避免出现增加或者减少一个匿名内部类,除非增加的匿名内部类是插入在外部类的末尾,或者减少的是外部类末尾的匿名内部类。
    • 静态field的初始化和静态代码块实际上是会被编译到<clinit>方法中,热修复热部署方案除了不支持method/field的新增,实际上也不支持<clinit>的修复,因为该方法是在加载class文件的时候虚拟机进行初始化的;
    • 静态代码块和静态域初始化在<clinit>中的先后关系就是两者出现在源码中的先后关系。
    • 以下三种情况都会尝试去加载一个类:
      • 创建一个类的对象(new-instance指令)
      • 调用类的静态方法(invoke-static指令)
      • 获取类的静态域的值(sget指令)
    • 非静态field和非静态代码块在<init>方法中的先后顺序也许在源码中出现的顺序一致。
    • 构造函数会被Android编译器自动翻译成<init>方法。
    • 由于不支持<clinit>方法的热部署,所以任何静态field初始化和静态代码块的变更都会被编译到<clinit>方法中,导致最后的热部署失败,只能冷启动生效;
    • 非静态field的初始化和非静态代码块的变更被编译到<init>构造函数中,可以进行热部署。
    • static和final static修饰field的区别:
      • final static修饰的是原始类型和String类型域(非引用类型,new String("XXX")),并不会被编译在clinit方法中,而是在类初始化执行initSfields方法时得到了初始化赋值。
      • final static修饰的引用类型,初始化仍然在clinit方法中。
    • 常量中,如果field是原始类型数据或者String类型,采用static final修饰会得到优化
    • final static域的修改的热部署方案:
      • 如果是被final static修饰的基本类型和String类型的field,可以执行热部署方案
      • 修改final static引用类型的域,是不允许的,因为会被编译到clinit方法中,所以此时没法走热部署。
    • 混淆导致的方法剪裁(对无用参数进行删减优化)和内联:混淆配置文件加上-dontoptimize项就不会去做方法的剪裁和内联
    • 混淆的optimization阶段,不仅会做方法内联和剪裁导致方法的增加或减少或方法签名的修改,还可能把方法修饰符优化成private/static/final属性,所以尽量都要加上-dontoptimiza配置。
    • Android虚拟机与JVM(Hotspot)不同,针对class文件的预校验,在class文件中加上了StackMap/StackMap Table信息,使得HotSpot在类加载的时候执行类检验阶段省去了一些不必要的步骤,加快了加载的速度。但是Android的虚拟机执行的是Dex文件格式,所以混淆库的预编译在Android中是没有任何意义的,反而会降低打包的速度,Android虚拟机中会有一套代码检验逻辑(dvmVerifyClass),所以在配置Android的混淆文件时一般都配置上-dontpreverify。
    • Java中泛型的实现采用的是擦除法,即编译为字节码的时候会将类型信息进行擦除(Signature字段会记录类型信息),会产生类型擦除与多态之间的冲突:
    class A<T> {
        private T t;
        public T get();
        public void set(T t) {
            this.t = t;
        }
    }
    
    class B extends A<Number> {
        private Number n;
        @override
        public Number get() {
            return n;
        }
        @override
        public void set(Number n) {
            this.n = n;
        }
    }
    
    • 解决以上的冲突的时候,JVM采用了桥接的方式来解决:JVM会生成与基类方法签名一样的桥接方法,但桥接方法内部调用的是我们覆写的方法。
    • 子类中真正重写基类方法的是编译器自动生成的桥接方法。而类B的@override只不过是假象,桥接方法的内部实现是去调用自己重写的方法而已,所以虚拟机采用了生成桥接方法的方式解决了类型擦除与多态的冲突。
    • 虚拟机通过参数类型和返回值类型共同来确定一个方法,所以比阿尼一起为了实现泛型的多态允许“方法参数相同,返回类型不同”的两个方法共存。
    • 针对泛型的热修复热部署方案:如果由B extends A变未了B extends A<Number>,那么就可能会生成对应的桥接方法,此时新增了方法,只能走冷部署。
    • 函数式接口的主要特性:一个接口具有唯一的抽象方法,我们把满足这样的接口称之为函数式接口。比如java.lang.Runnable和java.util.Comparator都是典型的函数式接口。
    • 函数式接口跟匿名内部类的区别如下:
      • 关键字this,匿名内部类的this关键字指向匿名类自己的实例对象本身,而Lambda表达式的this关键字指向包围Lambda表达式的类;
      • 编译方式,Java编译器将Lambda表达式编译成类的私有方法,自动生成私有静态lambdamain*()方法,这个方法执行的其实就是lambda表达式里面的逻辑 ,使用Java7的invokedynamic字节码指令来动态绑定这个方法。而匿名内部类被Java编译器编译成外部类$数字编号的新类。
    • .dex字节码文件(Android)和.class字节码文件(Java)对Lambda表达式处理的异同点:
      • 共同点:编译期间都会为外部类合成一个static辅助方法,该方法的内部逻辑实现Lambda表达式。
      • 不同点:
        • 1 .class字节码中通过invoke-dynamic指令执行Lambda表达式。而.dex字节码中执行的Lambda表达式跟普通方法调用没有任何区别;
        • 2 .class字节码中运行时生成新类,.dex字节码中编译期间生成类;
    • 针对Lambda表达式热修复热部署方案总结:
      • 增加或减少一个Lambda表达式会导致类方法比较错乱,所以会导致热部署失败;
      • 修改一个Lambda表达式,可能会导致新增field(Lambda表达式访问外部类的非静态成员时,需要持有外部类的引用,会增加一个外部类引用的成员变量),所以此时也会导致热部署失败;
      • 补丁类如果引用了非public类,那么两个类一定要满足两个ClassLoader是一样的,否则会在连接阶段校验不通过。

    冷启动代码修复
    • 在第一次安装apk的时候,会对原dex执行dexopt,此时加入apk只存在一个dex,dvmVerifyClass(clazz)就返回结果是true,然后apk中所有的类都会被打上CLASS_ISPREVERIFIED标志,接下来执行执行dvmOptimizeClass,类接着被打上CLASS_ISOPTIMIZED标志。
    • dvmVerifyClass:类校验,简单的说,就是防止检验类的合法性被篡改。此时会对类的每个方法进行校验。如果类的所有方法中直接引用到的类(第一层关系,不会进行递归搜索)和当前类都在同一个dex中的话,dvmVerifyClass就返回true。
    • dvmOptimizeClass:类优化,简单来说这个过程会把部分指令优化成虚拟机内部指令,比如方法调用指令invoke-变成invoke--quick,quick指令会从类的vtable表中直接获取,vtable简单来说就是类的所有方法的一张大表(包括继承自父类的方法)。因此提升了方法的执行速率。
    • 为了解决补丁类和引用类不在同一个dex文件引起的pre-verified异常,一个单独的无关的帮助类被放到一个单独的dex中,原dex中所有类的构造函数引用这个类,一般的实现方法都是侵入dex打包流程,利用.class字节码修改技术,在所有的.class文件的构造函数中引用这个帮助类。
    • 正常情况下,类的校验和优化会在apk进行第一次安装的时候执行dexopt操作(校验成功会为所有的类打上CLASS_ISPREVERIFIED标志)。如果类没有被打上CLASS_ISPREVERIFIED/CLASS_ISOPTIMIZED的标志,那么类的校验和优化都将在类的初始化阶段进行。所以,上述的插桩操作发生后,如果同一时间加载大量的类,会导致非常耗时。
    • 若采用插桩的方式,会导致所有的 类都是非preverify的,从而导致校验和优化操作会在类加载的时候触发。
    • Dalvik只加载classes.dex文件,art虚拟机支持加载多dex;
    • DexFile.loadDex尝试把一个dex文件解析并加载到native内存中,在加载到native内存之前,如果dex不存在对应的odex,那么Dalvik下会执行dexopt,Art下会执行dexoat,最后得到的都是一个优化后的odex。实际上最后虚拟机执行的是这个odex,而不是dex
    • sophix热修复冷启动方案:
      • 在Dalvik下采用自行研发的全量dex方案;
      • 在Art下本质上虚拟机已经支持多dex的加载,所以把补丁dex做成主dex(classes.dex)即可;
    • 加载类时的方法调用链:dvmResolveClass->dvmLinkClass->creatVtable
    • 其实在虚拟机中加载每个类都会为这个类生成一张vtable表,vtable表就是当前类的所有virtual方法的一个数组,当前类和所有继承父类的public/protected/default方法就是virtual方法,因为public/protected/default修饰的方法是可以被继承的。private/static方法(例外,还包括被final修饰的方法)不属于这个范畴,因为不能被继承;
    • 关于vtable的创建:
      • 子类vtable的大小等于子类virtual方法数+父类vtable的大小;
      • 整体复制父类的vtable到子类的vtable(父类的vtable在前,自己的方法在后,复写的情况除外)
      • 遍历子类的virtual方法集合,如果方法原型一致,说明是复写父类的方法,那么在相同索引位置处,子类重写方法覆盖掉vtable中父类的方法;
      • 若方法原型不一致,那么把该方法添加到vtable的末尾;
    • 对于变量的解析:是从当前变量的静态类型(引用类型)而不是实际类型中去查找,如果找不到,再去父类中递归查找;field和static方法不具备多态性;
    • dvmOptimizeClass方法会重写invoke-virtual为虚拟机内部指令invoke-virtual-quick,这个指令后跟的立即数就是该方法在类的vtable中的索引值;
    • 为了解决Dalvik虚拟机下类的pre-verify问题(如果一个类中直接引用到的所有非系统类都和该类在同一个dex中的话,那么这个类就会被打上ClALL_ISPREVERIFIED标志,会导致加载类的时候出现异常),腾讯的三大热修复分别采用了以下方案:
      • QQ空间的处理方式是在每个类中插入一个来自其他dex的hack.class,由此让所有的类都无法满足pre-verified条件;
      • Tinker的方式是合成全量的dex文件,这样所有类都在全量dex中解决,从此消除类重复而带来的冲突;
      • QFix的方式是获取虚拟机中的某些底层函数,提前解析所有补丁类。以此绕过pre-verify检查;
    • sophix的解决方案是:采用Tinker那样的方式合成全量包。但是Tinker的合成方案是从dex方法和指令维度进行全量合成,粒度过细。sophix采用的是从类的维度进行合成全量包。它既不像方法和指令维度那样细微,也不想bsbiff比较那样粗糙。在类的维度,可以达到时间和控件平衡的最佳效果;
    • 针对上述方案,sophix并没有去真正的合并两个dex,而具体的实现是:补丁+去除了补丁类的基线包;
    • Android原生的multi-dex的实现是把一个apk里边用到的所有类拆分到classes.dex、classes2.dex、classes3.dex等中,每个dex只包含一部分类的定义,但是单个dex也是可以加载的。只要把所有的dex加载进去,本dex中不存在的类就可以在运行期间在其他的dex中找到;
    • 要把某个类从dex中移除,需要从class_defs属性中把该类定义的入口移除即可。只要移除类定义的入口,对于类的具体内容不进行删除,这样可以最大限度的减少offset的修改。需要做的是直接找到pHeader->classDefsOff偏移处,遍历所有的DexClassDef,如果发现这个DexClassDef的类名包含在补丁中,就把它移除(从数组中删除数据),最后,修改pHeader->classDefsSize的数量。
    • 针对Application的处理:由于Application必然是加载在原来的dex里面的(未与补丁dex进行合成的),只有补丁加载后使用的类,会在新的完整的dex里面找到。所以加载补丁后,如果Application类使用其他新的dex里面的类,由于不在一个dex里,如果Application被打上了pre-verified标志,就会抛出pre-verified异常。对此,只要把Application类的pre-verified标志清空即可。
    • 类的标志位于ClassObject的accessFlags中,在dvmResovleClass中找到新dex里的类后,由于CLASS_ISPREVERIFIED标志被清空后,就不会判断所在dex是否相同,从而成功避免抛出异常。
    struct ClassObject : Object {
        //...
        u4  accessFlags;
        //...
    }
    
    //pre-verified标志的定义是:
    CLASS_ISPREVERIFIED = (1<<16);
    
    //我们只需要在JNI层清楚它就行:
    clazzObj->accessFlags &=~CLASS_ISPREVERIFIED;
    
    • 热修复初始化本身也是一段代码,必须调用这段代码,热修复操作才能执行完成。如果要使热修复类之前使用的其他类最少,只能放在Application类入口中。
    • ContentProvider的onCreate方法调用先于Activity的onCreate方法,可能会导致某些类在加载的时候先于补丁类优先加载,所以如果在AndroidManifest文件中注册了ContentProvider的时候,不能将热修复代码的初始化放在Activity中。
    • 放在Application中,又有两种选择:放在onCreate方法中或者attachBaseContext中。建议放在attchBaseContext中。
      • attachBaseContext方法是Application中最早执行的代码,此时APP申请的权限还没授予完成,所以会遇到无法访问网络的问题。因此在attachBaseContext方法里可以执行初始化 ,但不可以进行网络请求下载新补丁;
      • 放在onCreate方法里,会遇到与Activity同样的问题, ContentProvider的onCreate方法调用先于Application的onCreate方法,真是的启动顺序是按照如下顺序进行的:
    Application.attachBaseContext ->  ContentProvider.onCreate   -> Application.onCreate   ->   Activity.onCreate
    

    资源热修复技术
    • Android资源的热修复,是指在APP不重新安装的情况下,利用下发的补丁包直接更新APP中的资源。
    • 市面上很多资源热修复方案采用的是Intant-Run实现的:
      1. 构造一个新的AssetManger,并通过反射调用addAssetPath,把这个完成的新资源包加入到AssetManager中。这样就得到了一个含有所有新资源的AssetManger;
      2. 找到所有之前引用到原有AssetManager的地方,通过反射,把引用处替换为AssetManager。
    • 整个resources.arsc文件,实际上是由一个个ResChunk(以下简称chunk)拼接起来的,从文件头开始,每个chunk的头部都是哥ResChunk_header结构,它指示了这个chunk的大小和数据类型。
    struct ResChunk_header
    {
        uint16_t type;
        uint16_t headerSize;
        uint32_t size;
    }
    
    • 通过ResChunk_header中的type成员,就可以知道这个chunk是什么类型,该如何对其数据进行解析
    • 解析完一个chunk以后,从这个chunk+size位置开始,就可以得到下一个chunk的起始位置,这样就可以依次读取完整个文件的数据内容;
    • 一般来说,一个resources.arce里边包含若干个package,不过默认情况下,由打包工具aapt打出来的包只有一个Package。这个Package里包含了APP中的所有的资源信息。
    • 资源信息主要是指每个资源的名称以及它们对应的编号。Android中每个资源,都有它唯一的编号。编号是一个32位数字,用十六进制来标识就是0xPPTTEEEE。PP为Package的id,TT为type id,EEEE为entry id。
    • 默认由Android SDK编出来的APK,是由AAPT工具进行打包的,其资源包的Package id就是0x7f;
    • 系统的资源包,也就是framework-res.jar,package id为ox01。
    • 调用addAssetPath以后,补丁包里边的资源完全不会生效,所以采用类似Instant-Run这种方案,一定需要一个全新的AssetManager时,再加入完整的新资源包,替换掉原有的AssetManger。
    • sophix资源修复的方案:构造一个package id为0x66的资源包,这个包里只包含改变了的资源项,直接在原有的AssetManager中addAssetPath这个包即可。
    • Android7.0以上,WebView的初始化触发了相关资源的注入,因而系统直接构造新的ResourceImpl,替换掉了原先的ResourceImpl,而加载过补丁资源的AssetManger由于是通过ResourceImpl进行引用的,也一起被这次替换弄丢了。解决这个问题的思路是:反射修改了LoadedApk的mSplitResDirs字段,加入补丁资源。这样,在重新构建AssetManger时,系统就会自动把补丁资源添加到新构建的AssetManger之中

    so库热修复技术
    • Java API提供以下两个接口加载一个so库:
      • System.loadLibrary(String libName):传进去的参数是so库名称,标识so库文件,位于apk压缩文件中的libs目录,最后复制到apk安装目录下;
      • System.load(String pathName):传进去的参数是so库在磁盘中的完整路径,加载一个自定义外部so库文件;
      • 上述两种方式,其实最后调用的都是nativeLoad这个native方法去加载so库的,这个方法的参数file:so库在磁盘中的完整路径名;
    • native方法有动态注册和静态注册两种:
      • 动态注册的native方法必须实现JNI_OnLoad方法,同时实现一个JNINativeMethod[]数组;动态注册的native方法映射通过加载so库过程中调用JNI_OnLoad方法调用完成;
      • 静态注册的native方法必须是“Java_” + 类完整路径 + 方法名 的格式;静态注册的native方法映射是在该native方法第一次执行的时候才完成映射,当然前提是这个so库已经加载过;
    public class MainActivity extends Activity {
        static {
            System.loadLibrary("jnitest");
        }
        public static native String stringFromJNI();
        public static native void test;
    }
    
    //静态注册stringFromJNI方法
    extern "C" jstring Java_com_test_example_MainActivity_stringFromJNI(JNIEnv *env, jclass clazz) {
        std::string hello = "jni stringFrom old....";
        return env->NewStrignUTF(hello.c_str());
    }
    
    //动态注册text方法
    void test(JNIEnv *env, jclass clazz) {
        LOGD(jni test old .....);
    }
    JNINativeMethod nativeMethods[] = {
        {"test" , "()V", (void*) test}
    };
    #define JNIREG_CLASS "com/test/example/MainActivity";
    JNIEXPORT jnint JNICALL JNI_OnLoad(javaVM *vm, void *reserved) {
        LOGD("lod JNI_OnLoad");
        ...
        jclass claz = env->FindClass(JNIREG_CLASS);
        if(env->RegisterNatives(claz, nativeMethods, sizeof(nativeMethods)/sizeof(nativeMethods[0])) != JNI_OK)) {
         return JNI_ERR;
      }
      return JNI_VERSION_1_4;
    }
    
    • 在Art下,对于动态注册native方法的实时生效,只需要先加载原来的so库,再加载补丁so库,就能完成Java层native方法到native层patch后新方法映射,这样就完成动态注册的native方法的patch实时修复。
    • 但是在Dalvik下,对于动态注册native方法的实时修复,不能采用上述方法,因为加载修复后的补丁so拿到的还是原so库文件的句柄,所以执行的仍然是原来so库的JNI_OnLoad方法。为了解决这个问题,需要对补丁中的so进行改名,确保bname是全局唯一的。
    • 对于静态注册native方法的修复,sopix采用的是反射注入方案,冷启动生效,把补丁so库路径插入到nativeLibraryDirectories数组最前面,这样就能够使得加载so库时加载的是补丁库(原理很简单,就是查找到目录下第一个与需要加载的库文件名相同的so库,需要做适配,并找出合适架构的so库来插入路径,sdk>=21时,直接反射拿到ApplicationInfo对象的primaryCpuAbi即可,sdk<21时,由于不支持64位,所以直接把Build.CPU_ABI,Build.CPU_ABI2作为primaryCpuAbi即可)。

    相关文章

      网友评论

          本文标题:《深入探索Android热修复技术原理》读书笔记

          本文链接:https://www.haomeiwen.com/subject/mqnxxqtx.html