聊聊Android 热修复Nuwa有哪些坑

原创地址:http://blog.csdn.net/sbsujjbcy/article/details/51028027

前面写了两篇关于Nuwa的文章

然后我说了Nuwa有坑,有人就问Nuwa到底有哪些坑,这篇文章对自己在Nuwa上走过的坑做一个总结,如果你遇到了其他坑,欢迎留言,我会统一加到文章中去。当然有些也不算是Nuwa的坑,算是ClassLoader这种方式进行热修复暴露出来的问题吧。

坑一、混淆有哪些坑

  • excludeClass没有参考混淆产物mapping.txt,导致无法exclude掉一些不需要处理的类

在不混淆的情况下,Nuwa在这一方面是没有什么问题的,但是一旦混淆了,有些类你不想让他注入字节码,它却注入了,这是为什么呢,原因是Nuwa处理的是混淆后的jar,混淆后的jar包名和类名发生了变化,你再使用配置进去的excludeClass是无法主动不进行字节码注入处理的,除非你加进去的是混淆后的类名,但是在没混淆前,我们是根本不知道混淆后的类名的,有人说,我可以先混淆一遍,混淆完了查看一下mapping文件,找到对应的混淆后的类名,加到excludeClass中去,可以是可以,难道你不觉得蛋疼吗,而且这样也很有可能出现差错。那么有没有更好的方法呢?当然有。

混淆后在outputs目录下会产生一个mapping.txt文件,我们能不能解析这个文件,将混淆后的类还原为原来的类名呢,这个文件的大致内容就像下面这样。

android.support.graphics.drawable.AnimatedVectorDrawableCompat$1 -> android.support.a.a.c:
    android.support.graphics.drawable.AnimatedVectorDrawableCompat this$0 -> a
    629:629:void <init>(android.support.graphics.drawable.AnimatedVectorDrawableCompat) -> <init>
    632:633:void invalidateDrawable(android.graphics.drawable.Drawable) -> invalidateDrawable
    637:638:void scheduleDrawable(android.graphics.drawable.Drawable,java.lang.Runnable,long) -> scheduleDrawable
    642:643:void unscheduleDrawable(android.graphics.drawable.Drawable,java.lang.Runnable) -> unscheduleDrawable
android.support.graphics.drawable.AnimatedVectorDrawableCompat$AnimatedVectorDrawableCompatState -> android.support.a.a.d:
    int mChangingConfigurations -> a
    android.support.graphics.drawable.VectorDrawableCompat mVectorDrawable -> b
    java.util.ArrayList mAnimators -> c
    android.support.v4.util.ArrayMap mTargetNameMap -> d
    473:503:void <init>(android.content.Context,android.support.graphics.drawable.AnimatedVectorDrawableCompat$AnimatedVectorDrawableCompatState,android.graphics.drawable.Drawable$Callback,android.content.res.Resources) -> <init>
    507:507:android.graphics.drawable.Drawable newDrawable() -> newDrawable
    512:512:android.graphics.drawable.Drawable newDrawable(android.content.res.Resources) -> newDrawable
    517:517:int getChangingConfigurations() -> getChangingConfigurations
android.support.graphics.drawable.AnimatedVectorDrawableCompat$AnimatedVectorDrawableDelegateState -> android.support.a.a.e:
    android.graphics.drawable.Drawable$ConstantState mDelegateState -> a
    424:426:void <init>(android.graphics.drawable.Drawable$ConstantState) -> <init>
    430:434:android.graphics.drawable.Drawable newDrawable() -> newDrawable
    439:443:android.graphics.drawable.Drawable newDrawable(android.content.res.Resources) -> newDrawable
    448:452:android.graphics.drawable.Drawable newDrawable(android.content.res.Resources,android.content.res.Resources$Theme) -> newDrawable
    457:457:boolean canApplyTheme() -> canApplyTheme
    462:462:int getChangingConfigurations() -> getChangingConfigurations

仔细观察一下,还是挺有规律的,第一行是原始类名对应的混淆类名,中间用->分割,之后是原始变量名对应的混淆后的变量名,还是用->分割,但是开头缩进了四个空格。最后是方法的混淆,最前面是方法的行数,使用:分割,两个数字分割后再跟一个:,后面就是原始方法名对应的混淆方法名,也是使用->分割。方法和变量都是有类型的。你是不是想到怎么解析了,没错,正则表达式,别急着写代码,在写代码之前我们先看看有没有造好的轮子可以用用,在github上搜一下proguard,没结果。。。再换个关键字,retrace,为什么是retrace呢,因为proguard自带了一个脚本叫retrace,可以从混淆后的异常信息还原为原始类的异常信息。结果出来了,在code中选择java,第一页的最后一条就是。这里我把这个仓库fork到自己的仓库中去了,见地址https://github.com/lizhangqu/retrace

当然不能完完全全的直接用,其实我们用得到的就三个类,一个是ClassMapping.java,一个是MethodMapping.java,还有一个是Retrace.java,至于如何改造,靠你自己了,源码都摆在你面前了你还不会改造?改造后的结果就是传入混淆后的全类名,返回原始的全类目,这样跟excludeClass进行对比就能正确处理了。

  • 没有被修改的类被却被打进了patch,为什么?

我们修改了一个复杂一点的类,准备打patch了,发现被打进patch的类怎样不是一个,还包含了一大堆其他的类,为什么呢?打修复包时利用正式包的mapping,修复bug,修改了原先的类,改变的类会改变,但有些类没有改变也会因为混淆的关系产生变化(混淆会剔除一些无用的方法,打修复包时那些无用的方法可能会加上),这就造成了有些类没有修改,但也会出现在修复包中。当然这种情况出现的概率还是挺大的,但是出现的类的数量就不一定了,有多了一个的,也有多了一坨的。。。。怎样解决。。。无解,多就多了呗。。。最多也就是patch包大小变大了。只能尽量避免这种情况的发生,比如打修复包的时候不要修改原有的缩进,一不小心手贱重新进行格式化,可能原来的代码没有格式化,你这么一格式化,整个类都发生了变化,包括这个类的内部类,这样patch的类的数量就会爆增。所以打patch的时候应该尽可能的减少代码的改动。

坑二、Application直接引用的类无法打Patch

  • 为什么会出现这种现象?

    出现这种现象的原因是Application类我们没有引用hack.apk,为什么不引用呢,因为在加载Application类之前我们还没加载hack.apk,引用了就会报找不到类的异常,于是这个类不能打,并且在加载hack.apk前用的类都不能引用hack.apk。于是就导致了Application类被打上了那个标记进行了校验。然后Application直接引用的类就无法打patch了,一打patch就会报那个异常Class ref in pre-verified class resolved to unexpected implementation

  • 如何解决这个问题?

    直接引用的类不能打patch,但是间接引用的可以打呀,把直接引用的类改成间接引用就ok了,怎么做呢?新建一个中间类,比如PatchUtil,里面有一个init方法,入参是Application,把原来在Application中的逻辑全都转移到PatchUtil中去,然后Application引用PatchUtil类进行调用,最终将一大推直接引用的类变成了间接引用,同时PatchUtil变成了直接引用的类,于是原来一大坨不能打patch的类变成了一个类不能打patch,还是值得的。

坑三、字节码注入的坑

  • 注入失败的原因是什么(混淆和私有构造函数)?

    如果代码不混淆,字节码注入是没问题的,所以这个原因还是混淆导致的,混淆之后,很多类没有了< init >,或者< init > 变成了 < clinit >,为什么会这样呢,我估计是剔除了无用方法导致的。还有一个特殊的情况就是私有构造函数,比如单例的情况下就存在只有一个私有构造函数,私有构造函数字节码中没有 < init >,甚至更绝,也没有 < clinit >,这种情况是百分百注入不进去的,而Nuwa的逻辑是判断name是不是等于< init >并且在构造函数的末尾。但是实际测试情况是绝大多数的类混淆之后字节码中都没有< init >或者< clinit >。

  • 如何去解决注入失败的问题?

    能不能插一个成员变量呢,实际测试结果是不能。。。具体原因我也不清楚。那么没有构造函数就给它插一个构造函数,但是却又不能显示的插一个构造函数,因为这种情况也可能是有问题的,比如原来就有一个私有构造函数,你再插一个公有构造函数,肯定是有问题的,于是就演变成了给它插一段静态初始化的代码就可以了,在这段代码中直接引用Hack.class。就像这样子

static{
    System.out.println(com.package.Hack.class);
}

至于这段代码怎么插。。。我表示用asm插我真的不会插,所以我把Nuwa插字节码的那段代码从使用asm插字节码替换成了用javassist插,至于怎么插,见后文。

  • 如何修改注入的字节码使其找不到类也不会报错

Nuwa原来的字节码注入是在构造函数中注入一段这样的代码

System.out.println(Hack.class);

这段代码有什么问题呢,仔细用脑子想一下,万一有些类在加载Hack.class之前就使用了,并且我们一不小心给他注入了这段代码,那么程序运行就会立马crash,于是,我们想能不能不让这段代码执行呢,答案是可能的,通过一个if语句,让它永远进不去这个if语句就可以了,下面是一种方式,当然你完全可以使用其他类似的代码

if(Boolean.FALSE.booleanValue()){
    System.out.println(Hack.class)
}

这样这段代码就永远不会被执行,即使提取使用了某个不应该使用的类,程序也不会crash,最多是控制台输出一条log,说这个引用的类找不到。而实际测试结果是,即使报了这个log,也还是能打patch的。

坑四、不支持gradle 1.5以上

  • 如何解决?

    • Hook方式解决

      hook的方式和1.2.3是一样的,只不过hook 1.5的gradle比1.2.3的处理要简单许多,具体实现可以见这个实现 AndHotFix hook的task的名字叫transfromClassesWidthDexForRelease或者transfromClassesWidthDexForDebug,在这个task之前执行我们的注入操作就可以了。

    • 使用transform api解决

      除了hook,还可以使用gradle1.5的新的api来解决,也就是transform接口,具体实现可以参考我前面的一篇文章 Android 热修复使用Gradle Plugin1.5改造Nuwa插件,这种方式有一个缺点,我们处理的类是没有被混淆前的类,处理完后打patch的时候需要在代码中根据配置文件以及mapping文件进行一次代码级别的混淆操作,当然这个操作也是全自动的,用代码来进行混淆即可,缺点是一个类的内部类都会被打进patch。所以也不是特别合适,反倒hook的方式更加灵活。

坑五、patch包没有进行签名校验

  • 如何防止patch包被非法篡改?

    patch包在app端的校验是必须的,因此校验的前提是对patch进行签名,如何签名呢?可以参考携程的打包脚本 https://github.com/CtripMobile/DynamicAPK,里面有对apk进行sign和zipalign的脚本,拿来稍微修改一下就可以使用了。下面是我修改后的脚本

public static signedApk(Logger logger, def variant, File apkFile) {
    if (!apkFile.exists())
        return;

    def signingConfigs = variant.getSigningConfig()
    if (signingConfigs == null) {
        logger.error "no need to sign"
        return;
    }

    def args = [JavaEnvUtils.getJdkExecutable(‘jarsigner‘),
                ‘-verbose‘,
                ‘-sigalg‘, ‘MD5withRSA‘,
                ‘-digestalg‘, ‘SHA1‘,
                ‘-keystore‘, signingConfigs.storeFile,
                ‘-keypass‘, signingConfigs.keyPassword,
                ‘-storepass‘, signingConfigs.storePassword,
                apkFile.absolutePath,
                signingConfigs.keyAlias]

    def proc = args.execute()
}

public static zipalign(Project project, File apkFile) {
    if (apkFile.exists()) {
        def sdkDir
        Properties properties = new Properties()
        File localProps = project.rootProject.file("local.properties")
        if (localProps.exists()) {
            properties.load(localProps.newDataInputStream())
            sdkDir = properties.getProperty("sdk.dir")
        } else {
            sdkDir = System.getenv("ANDROID_HOME")
        }
        if (sdkDir) {
            def cmdExt = Os.isFamily(Os.FAMILY_WINDOWS) ? ‘.exe‘ : ‘‘
            File dest = new File("${apkFile.absolutePath}.zipalign");
            def argv = []
            argv << ‘-f‘    //overwrite existing outfile.zip
            // argv << ‘-z‘    //recompress using Zopfli
            argv << ‘-v‘    //verbose output
            argv << ‘4‘     //alignment in bytes, e.g. ‘4‘ provides 32-bit alignment
            argv << apkFile.absolutePath

            argv << dest.absolutePath  //output

            project.exec {
                commandLine "${sdkDir}/build-tools/${project.android.buildToolsVersion}/zipalign${cmdExt}"
                args argv
            }

            if (apkFile.exists()) {
                apkFile.delete()
            }
            dest.renameTo(apkFile)
        } else {
            throw new InvalidUserDataException(‘$ANDROID_HOME is not defined‘)
        }
    }
}

然后客户端需要做的就是根据这个patch的前面和当前app的签名进行校验即可。

坑六、ASM字节码注入的维护成本高

这个不能算是Nuwa的坑,只不过Nuwa使用了ASM来进行注入字节码,ASM的可读性实在是太差,对于不懂字节码的人来说有一定的难度,所以必须提高代码的可读性,降低维护成本。

  • 如何降低维护成本

替换asm为javassist,相对asm来说,javassist在性能上可能差一点,但是在可读性上,那绝对是对开发人员友好的,因为写的就是java代码。下面我们来演示一下注入之前说的那段代码

if(Boolean.FALSE.booleanValue()){
    System.out.println(Hack.class)
}
ClassPool classPool = ClassPool.getDefault();
//这里动态生成Hack类,插入到classpatch中,因为javassist生成字节码需要依赖这个类,这里采用动态生成
CtClass hackClass = classPool.makeClass("com.lizhangqu.hack.Hack")
byte[] hackBytes = hackClass.toBytecode()
hackClass.defrost()
classPool.insertClassPath(new ByteArrayClassPath("com.weidian.hack.Hack", hackBytes))

Nuwa原来注入字节码的函数原型是这样的

private static byte[] referHackWhenInit(InputStream inputStream) {
}

入参是InputStream,返回值是字节码的byte数组,我们不改变函数原型,编写这个注入函数

private
    static byte[] referHackByJavassistWhenInit(ClassPool classPool, InputStream inputStream) {
        CtClass clazz = classPool.makeClass(inputStream)
        CtConstructor ctConstructor = clazz.makeClassInitializer()
        ctConstructor.insertAfter("if(Boolean.FALSE.booleanValue()){System.out.println(com.weidian.hack.Hack.class);}")
        def bytes = clazz.toBytecode()
        clazz.defrost()
        return bytes
    }

入参多了一个ClassPool参数,这个参数就是前面的那个ClassPool,里面包含了Hack这个类。这里面的关键是makeClassInitializer函数,这个函数的作用就是生成一段静态初始化的代码,如果不存在的话会新建一个,存在的话就返回,然后我们在这个最后面插入一段字节码,即

if(Boolean.FALSE.booleanValue()){System.out.println(com.lizhangqu.hack.Hack.class);}

插入完成后转换成字节数组,记得调用defrost方法进行解冻,否则会有异常。最终生产的代码就是这样的。

static{
    if(Boolean.FALSE.booleanValue(){
        System.out.println(com.lizhangqu.hack.Hack.class);
    }
}

坑七、Android各版本的兼容性如何

  • 在Android5.0与6.0上兼容性表现得如何?

    实际情况下,我测了三个系统版本,即4.4,5.0,6.0,实际测试结果怎么样呢,三个系统版本打patch都是没有问题的,唯一需要特殊处理的系统可能是6.0,为什么是6.0呢,因为6.0多了一个运行时权限申请。

  • Android 6.0 动态权限申请的坑

为什么这是个坑呢,因为测试的时候我是把patch放到sdcard根目录进行测试的,这种情况下,对应6.0的系统来说,读写sdcard除了需要在manifest文件中进行声明之外,还需要动态申请权限,因为对于用户来说,读写sdcard属于危险权限,需要用户主动授权,所以6.0的系统,如果你的patch在sdcard,你可能需要加入类似这样的申请权限的代码

int permission = ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE);
if (permission != PackageManager.PERMISSION_GRANTED) {
    Log.e("TAG", "未授权");
    ActivityCompat.requestPermissions(this,
            new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
            100);
}

之后只要用户授权了,就能正常的打patch了。

以上就是最近我遇到的一些坑的简述以及简单的给出了解决思路,如果你遇到了其他坑,欢迎留言。

时间: 03-30

聊聊Android 热修复Nuwa有哪些坑的相关文章

Android 热修复Nuwa的原理及Gradle插件源码解析

现在,热修复的具体实现方案开源的也有很多,原理也大同小异,本篇文章以Nuwa为例,深入剖析. Nuwa的github地址 https://github.com/jasonross/Nuwa 以及用于hotpatch生成的gradle插件地址 https://github.com/jasonross/NuwaGradle 而Nuwa的具体实现是根据QQ空间的热修复方案来实现的.安卓App热补丁动态修复技术介绍.在阅读本篇文章之前,请先阅读该文章. 从QQ空间终端开发团队的文章中可以总结出要进行热更

Android 热修复方案分析

绝大部分的APP项目其实都需要一个动态化方案,来应对线上紧急bug修复发新版本的高成本.之前有利用加壳,分拆两个dex结合DexClassLoader实现了一套全量更新的热更方案.实现原理在Android 基于Proxy/Delegate 实现bug热修复这篇博客中有分解.因为这套方案是在Java端实现,并且是全量更新所以兼容性较好,成功率较高.但是在线上跑了几个月之后就碰到了瓶颈,因为随着业务的增长分拆过之后的dex文件方法数也超过65535个,更换拆包方案的话维护成本太高.同时由于没有做差异

Android 热修复使用Gradle Plugin1.5改造Nuwa插件

随着谷歌的Gradle插件版本号的不断升级,Gradle插件如今最新的已经到了2.1.0-beta1,相应的依赖为com.android.tools.build:gradle:2.0.0-beta6,而Nuwa当时出来的时候,Gradle插件还仅仅是1.2.3版本号,相应的依赖为com.android.tools.build:gradle:1.2.3,当时的Nuwa是依据有无preDex这个Task进行hook做不同的逻辑处理,而随着Gradle插件版本号的不断增加,谷歌增加了一个新的接口能够用

Android热修复技术专题:来自微信、淘宝、支付宝、QQ空间的热修复方案

最近好多人都讨论关于热更新的话题,所以查询了一些资料看看 当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App.测试.向各个应用市场和渠道换包.提示用户升级.用户下载.覆盖安装.有时候仅仅是为了修改了一行代码,也要付出巨大的成本进行换包和重新发布. 这时候就提出一个问题:有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装?答案当然是有的,那就是最近涌现出来得热补丁方案,主要包括淘宝的Dexpo

Android热修复:Andfix和Hotfix,两种方案的比较与实现

Andfix和hotfix是两种android热修复框架. android的热修复技术我看的最早的应该是QQ空间团队的解决方案,后来真正需要了,才仔细调查,现在的方案中,阿里有两种Dexposed和Andfix框架,由于前一种不支持5.0以上android系统,所以阿里系的方案我们就看Andfix就好.Hotfix框架算是对上文提到的QQ空间团队理论实现.本文旨在写实现方案,捎带原理. Andfix 引入 框架官网:https://github.com/alibaba/AndFix 介绍是用英文

Android热修复框架汇总整理(Hotfix)

??Android平台出现了一些优秀的热更新方案,主要可以分为两类:一类是基于multidex的热更新框架,包括Nuwa.Tinker等:另一类就是native hook方案,如阿里开源的Andfix和Dexposed. 基于native hook的方案 ??需要针对dalvik虚拟机和art虚拟机做适配,需要考虑指令集的兼容问题,需要native代码支持,兼容性上会有一定的影响: 基于Multidex的方案 ??需要反射更改DexElements,改变Dex的加载顺序,这使得patch需要在下

Android热修复原理普及

Android热修复原理普及 这段时间比较难闲,就抽空研究一下Android热修复的原理.自从Android热修复这项技术出现之后,随之而现的是多种热修复方案的出现.前两天又看到一篇文章分析了几种热修复方案的比较. 原文地址是:[Android热修复] 技术方案的选型与验证 看完这篇文章,有点汗颜.有这么多的热修复方案,并且他们之间的实现原理也不一样,各有优缺点. 然后在尼古拉斯_赵四的博客中看到几篇关于热修复的文章,对着这几篇文章撸了一番.大概的了解了热修复一种原理,其思路和QQ空间提出的安卓

Android热修复——Tinker微信解决方案

Android的热修复 前言: 随着时代的发展,由于公司的项目需要去求变化平凡计划总赶不上变化,H5的高灵活性,开发周期短,更新速度快H5以及一些混合开发越来越被看好,然而主要原因之一:这种混合开发的方式容错率大,更新和修复BUG快.不用发布版本就可以让用户不觉的情况下就更新对应的内容或者BUG,我们不能否认混合开发的快捷,正在此前提下热修复和热更新技术也得到了非常大的发展,不管热修复还是热更新,都是对app的内容或者逻辑变化做出像web页面更新一样的体验.而本文只对热修复进行探索,不对H5进行

Android热修复学习之旅——HotFix完全解析

在上一篇博客Android热修复学习之旅开篇--热修复概述中,简单介绍了各个热修复框架的原理,本篇博客我将详细分析QQ空间热修复方案. Android dex分包原理介绍 QQ空间热修复方案基于Android dex分包基础之上,简单概述android dex分包的原理就是:就是把多个dex文件塞入到app的classloader之中,但是android dex拆包方案中的类是没有重复的,如果classes.dex和classes1.dex中有重复的类,当classes.dex和classes1