标签:
原创地址:http://blog.csdn.net/sbsujjbcy/article/details/51028027
前面写了两篇关于Nuwa的文章
然后我说了Nuwa有坑,有人就问Nuwa到底有哪些坑,这篇文章对自己在Nuwa上走过的坑做一个总结,如果你遇到了其他坑,欢迎留言,我会统一加到文章中去。当然有些也不算是Nuwa的坑,算是ClassLoader这种方式进行热修复暴露出来的问题吧。
在不混淆的情况下,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的类怎样不是一个,还包含了一大堆其他的类,为什么呢?打修复包时利用正式包的mapping,修复bug,修改了原先的类,改变的类会改变,但有些类没有改变也会因为混淆的关系产生变化(混淆会剔除一些无用的方法,打修复包时那些无用的方法可能会加上),这就造成了有些类没有修改,但也会出现在修复包中。当然这种情况出现的概率还是挺大的,但是出现的类的数量就不一定了,有多了一个的,也有多了一坨的。。。。怎样解决。。。无解,多就多了呗。。。最多也就是patch包大小变大了。只能尽量避免这种情况的发生,比如打修复包的时候不要修改原有的缩进,一不小心手贱重新进行格式化,可能原来的代码没有格式化,你这么一格式化,整个类都发生了变化,包括这个类的内部类,这样patch的类的数量就会爆增。所以打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的。
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的签名进行校验即可。
这个不能算是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);
}
}
在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了。
以上就是最近我遇到的一些坑的简述以及简单的给出了解决思路,如果你遇到了其他坑,欢迎留言。
标签:
原文地址:http://blog.csdn.net/sbsujjbcy/article/details/51028027