今天分享一下如何简单方便的实现代码插装~
修复第三方 bug
事情是这样的,大概在上个月,公司的 Android 项目使用了一个阿里云提供的功能(真就独一份)。因为开发测试机一直是 wifi 情况下使用,完全正常,再快上线前在使用流量的情况下会崩溃。
最后发现是阿里的这个 SDK 使用的网络判断方法还是旧版本的方式(使用了getActiveNetworkInfo
,该方法已经废弃),在targetSdkVersion
30 的时候回直接崩溃。询问了阿里客服,答复是下周修复,我们肯定是等不到了。(事实是现在也没修复)
如何修复是个问题
上线时间已经确定了,不可能等第三方修复了。只能自己想办法: 比如我们有这么段代码
public class Utils {
public static void evil() {
int a = 1 / 0;
}
}
我们项目在打包的时候经历了:.java -> .class -> dex -> apk
,假设我们在打包的时候这么做 .java -> .class -> 拿到 Utils.class,修正里面的方法 evil 方法 -> dex -> apk
。这个时机,其实构建过程中也给我们提供了,也就是传说的 Transform 阶段。(类似的 arouter、butterknife 都是同样的原理实现代码插装的)
如何修改 Utils.class 呢?可以看看鸿阳大神的 ASM 修改字节码,这样学就对了!
轻量级 aop 框架 lancet 出现
饿了么,很早的时候就开源了一个框架,叫lancet。
这个框架可以支持你,在不懂字节码的情况下,也能够完成对对应方法字节码的修改。
代入到我们刚才的思路:
.java -> .class -> lancet 拿到 Utils.class,修正里面的方法 evil 方法 -> dex -> apk
引入框架
在项目的根目录添加:
classpath 'me.ele:lancet-plugin:1.0.6'
在 module 的 build.gradle 添加依赖和 apply plugin:
apply plugin: 'me.ele.lancet'
dependencies {
implementation 'me.ele:lancet-base:1.0.6'
}
开始使用
然后,我们做一件事情,把 Tools 里面的 evil 方法:
public static void evil() {
int a = 1 / 0;
}
里面的这个代码给去掉,让它变成空方法。
我们编写代码:
public class ToolsLancet {
@TargetClass("com.aokdv.Utils")
@Insert("evil")
public static void evil() {
}
}
我们编写一个新的方法,保证其是个空方法,这样就完成让原有的 evil 中调用没有了。
其中:
- TargetClass 注解:标识你要修改的类名;
- Insert 注解:表示你要往 evil 这个方法里面注入下面的代码
- 下面的方法声明需要和原方法保持一致,如果有参数,参数也要保持一致(方法名、参数名不需要一致)
然后我们打包,看看背后发生了什么神奇的事情。
在打包完成后,我们反编译,看看 Utils.class
public class Utils {
//...
public static void evil() {
Utils._lancet.com_apkdv_UtilsLancet_evil();
}
private static void evil$___twin___() {
int a = 1 / 0;
}
private static class _lancet {
private _lancet() {
}
@TargetClass("com.apkdv.Utils")
@Insert("evil")
static void com_apkdv_UtilsLancet_evil() {
}
}
}
可以看到,原本的 evil 方法中的校验,被换成了一个生成的方法调用,而这个生成的方法和我们编写的非常类似,并且其为空方法。
而原来的 evil 逻辑,放在一个 evil$___twin___()
方法中,可惜这个方法没地方调用。
这样原有的 evil 逻辑就变成了一个空方法了。
我们可以大致梳理下原理:
lancet 会将我们注明需要修改的方法调用中转到一个临时方法中,这个临时方法你可以理解为和我们编写的方法逻辑基本保持一致。
然后将该方法的原逻辑也提取到一个新方法中,以备使用。 很多时候,可能原有逻辑只是个概率很低的问题,比如发送请求,只有在超时等情况才发生错误,你不能粗暴的把人家逻辑移除了,你可能更想加个 try-catch 然后给个提示什么的。
这个时候你可以这么改:
public class ToolsLancet {
@TargetClass("com.aokdv.Utils")
@Insert("evil")
public static void evil() {
try {
Origin.callVoid();
} catch (Exception e) {
e.printStackTrace();
}
}
}
我们再来看下反编译代码:
public class Tools {
public static void evil() {
Tools._lancet.com_apkdv_UtilsLancet_evil();
}
private static void evil$___twin___() {
int a = 1 / 0;
}
private static class _lancet {
@TargetClass("com.aokdv.Utils")
@Insert("evil")
static void com_apkdv_UtilsLancet_evil() {
try {
Tools.evil$___twin___();
} catch (Exception var1) {
var1.printStackTrace();
}
}
}
}
中转方法内部调用了原有方法,然后外层包了个 try-catch。相对于运行时反射相关的 hook 更加稳定,其实他就像你写的代码,只不过是直接改的 class。
hook 系统方法
某天群里摸鱼的时候,有人问以前项目的 log 全部用的是系统的,Log
现在快发版了,有办法全部关闭吗?,
这不就是同样的功能吗?开始编码:
@Proxy("d")
@TargetClass("android.util.Log")
public static int anyName(String tag, String msg) {
return LogUtils.d(tag, msg);
}
我们使用 @Proxy
代理了系统方法,这样我们就代理了 系统的 Log.d
方法
收工
其实字节码 hook 在 Android 开发过程中更为强大,比我们传统的找 Hook 点(单例,静态变量),然后反射的方式方便太多了,还有个最大的优势就是稳定。
当然 lancet hook 有个前提就是要明确知道方法调用,如果你想 hook 一个类的所有调用,那么写起来就有点费劲了,可能并不如动态代理那么方便。