码迷,mamicode.com
首页 > 移动开发 > 详细

聊聊Android 热修复Nuwa有哪些坑

时间:2016-03-31 23:38:04      阅读:773      评论:0      收藏:0      [点我收藏+]

标签:

原创地址: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了。

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

聊聊Android 热修复Nuwa有哪些坑

标签:

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

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!