滴滴DoKit Android核心原理揭秘之AOP字节码实现
最近 DoKit V3.3.1 版本已经发布了,新版本增加了很多重磅的功能,同时也在库的名字上对 Androidx 和 Android support 进行了区分。
具体的更新信息参考 DoKit Android 版本信息:
https://github.com/didi/DoraemonKit/blob/master/Doc/android-ReleaseNotes.md
感兴趣的小伙伴们赶快通过 Android 参考文档去升级体验吧。
http://xingyun.xiaojukeji.com/docs/dokit/#/androidGuide
业务代码零侵入一直是 DoKit 秉持的底线。DoKit 作为一款终端一站式研发解决方案。我们在不断的给社区用户提供各种各样优秀工具来帮助用户提升研发效率,于此同时我们也要尽可能保证用户的线上代码交付质量。庆幸的是,从 DoKit 推出到现在我们累计收获了 10000+ 的用户,至今还没有收到过一起用户反馈的由于集成 DoKit 而引发的线上 bug。那我们是如何做到在业务代码零侵入的情况下给用户提供各种强大的工具的呢?其实这背后离不开 AOP 的功劳。
(以下图片来自于我在滴滴集团内部的 DoKit 专题分享)
在社区中针对 Android 的主流的 AOP 实现方案主要有以下两个:AspectJ 和 AS 插件 +ASM。其实 DoKit 在早期的版本中用的就是 AspectJ 的方案,但是随着 DoKit 的社区越来越健壮、社区用户也越来越多,渐渐的就开始有很多人反馈 AspectJ 会和他们项目中的 AspectJ 由于版本不一致造成冲突,从而导致编译失败。DoKit 团队一直很重视社区用户的使用体验,所以针对这一问题,我们经过了大量的调研和社区验证,最终决定将整个 AOP 技术方案替换为 AS Plugin+ASM。在经过几个版本的验证以后,我们发现 ASM 在项目集成过程中的冲突相比 AspectJ 明显减少,这也坚定了我们后续大力优化该套方案的信心。ASM 是比较偏底层的方案,它是直接作用在 JVM 字节码上的。所以我们在使用 ASM 方案的时候需要克服以下两个难点:
你要对 JVM 的字节码有一定的了解 (感兴趣的小伙伴可以通过 https://asm.ow2.io 了解更多信息)。
为了寻找最优的 Hook 点,我们需要了解主流第三方的库原理。
在确定好技术选型以后我们来看下 ASM 的相关原理。其实通过上图我们已经能够大概了解其大致的原理。AS Gradle 的编译会将我们的 java class 文件、jar 包以及 resource 资源文件打包最为最原始的数据输出给第一个 Transform,第一个 transform 处理完的产物再输出给第二个 transform,以此类推形成完整的链路。而 ASM 就是作用于图中的第一个红色 TransformA。它会拿到一开始的原始数据以后会进行一定的分析。并且按照 JVM 字节码的格式针对类、变量、方法等类型调用相关的回调方法。在相应的回调方法中我们可以对相关的字节码指令进行操作。比如新增、删除等等。中间的图片就是它具体的运行时序图。最后两者结合编译就会产生新的 JVM class 文件。
站在巨人的肩膀上能够帮助我们更快更好的实现相关功能。秉持着不重复造轮子的理念,我们在进行广泛的技术选型以后,决定使用滴滴的 Booster 作为 DoKit 插件的底层实现。Booster 为我们屏蔽了各个 Gradle 版本之间的 API 差异,功能非常强大,强烈建议感兴趣的的小伙伴们了解一下。
为了更加便于理解,我这里举一个具体的例子。从图中的例子我们能够发现,经过 DoKit AOP 插件编译以后就相当于我们替用户主动写了一部分代码。通过这种代理的编程模式,我们就能发在运行时拿到用户的对象,并达到修改对象属性的目的。
如图所示,到目前为止 AOP 在 DoKit 中的大部分功能中都得到了落地。
下面我们来具体看一下在这些落地场景中,DoKit 是如何用比较优雅的方式来进行字节码操作的。
(DoKit 所有的字节码操作只针对 Debug 包生效,所以不用担心会污染线上代码)
(由于篇幅的原因,我只选取了社区中比较关心的几个功能进行一下分析,其实字节码操作的原理都差不多,我们需要的是创意以及大量的三方源码阅读,这样才能找到最优雅的插桩点)
大图检测其实社区中已经有一篇分析得很详细的文章了,我这里就不具体分析了,大家参考一下:《通过 ASM 实现大图监控》
https://juejin.im/post/6844904136266219534
函数耗时可以参考我以前写过的一篇文章:《滴滴 DoKit Android 核心原理揭秘之函数耗时》
https://juejin.im/post/6844904154624688136
DoKit 中针对每一项插件功能在编译期都设置了一个开关功能,防止某些字节码操作在特定场景下会造成编译失败以及运行时 bug,同时也是为了更友好的提醒用户该项功能的状态,我们会在运行时判断用户在编译期的开关状态。那么问题来了,DoKit 是如何拿到 gradle.properties 或者 build.gradle 里的配置信息的呢,其实这背后也是字节码的功劳。下面我们来具体看一下它的实现逻辑。
public class DokitPluginConfig {
/**
* 注入插件配置 动态注入到 DoraemonKitReal#pluginConfig 方法中
*/
public static void inject(Map config) {
//LogHelper.i(TAG, "map====>" + config);
SWITCH_DOKIT_PLUGIN = (boolean) config.get("dokitPluginSwitch");
SWITCH_METHOD = (boolean) config.get("methodSwitch");
SWITCH_BIG_IMG = (boolean) config.get("bigImgSwitch");
SWITCH_NETWORK = (boolean) config.get("networkSwitch");
SWITCH_GPS = (boolean) config.get("gpsSwitch");
VALUE_METHOD_STRATEGY = (int) config.get("methodStrategy");
}
}
那么我们只要编译期动态的往 pluginConfig 的方法中插入 DokitPluginConfig.inject(map) 就可以了,这个 map 里存储的就是我们吸血编译期配置信息。下面我们来看一下自己吗操作的相关代码 CommTransformer:
if (className == "com.didichuxing.doraemonkit.DoraemonKitReal") {
// 插件配置
klass.methods?.find {
it.name == "pluginConfig"
}.let { methodNode ->
"${context.projectDir.lastPath()}->insert map to the DoraemonKitReal pluginConfig succeed".println()
methodNode?.instructions?.insert(createPluginConfigInsnList())
}
}
/**
* 创建 pluginConfig 代码指令
*/
private fun createPluginConfigInsnList(): InsnList {
//val insnList = InsnList()
return with(InsnList()) {
//new HashMap
add(TypeInsnNode(NEW, "java/util/HashMap"))
add(InsnNode(DUP))
add(MethodInsnNode(INVOKESPECIAL, "java/util/HashMap", "<init>", "()V", false))
// 保存变量
add(VarInsnNode(ASTORE, 0))
// 获取第一个变量
add(VarInsnNode(ALOAD, 0))
add(LdcInsnNode("dokitPluginSwitch"))
add(InsnNode(if (DoKitExtUtil.dokitPluginSwitchOpen()) ICONST_1 else ICONST_0))
add(
MethodInsnNode(
INVOKESTATIC,
"java/lang/Boolean",
"valueOf",
"(Z)Ljava/lang/Boolean;",
false
)
)
add(
MethodInsnNode(
INVOKEINTERFACE,
"java/util/Map",
"put",
"(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;",
true
)
)
add(InsnNode(POP))
.........
// 将 HashMap 注入到 DokitPluginConfig 中
add(VarInsnNode(ALOAD, 0))
add(
MethodInsnNode(
INVOKESTATIC,
"com/didichuxing/doraemonkit/aop/DokitPluginConfig",
"inject",
"(Ljava/util/Map;)V",
false
)
)
this
}
//return insnList
}
private final void pluginConfig() {
HashMap hashMap = new HashMap();
hashMap.put("dokitPluginSwitch", true);
hashMap.put("gpsSwitch", true);
hashMap.put("networkSwitch", true);
hashMap.put("bigImgSwitch", true);
hashMap.put("methodSwitch", true);
hashMap.put("methodStrategy", 0);
DokitPluginConfig.inject(hashMap);
}
大家感兴趣的话可以通过我们的 github 上的 demo,看下编译前后的 pluginConfig 方法里的差别。
滴滴作为一家出行行业的独角兽企业,我们 DoKit 需要协助开发和测试模拟各种位置信息。所以这也是我们在集团内部被广泛使用的一款工具。下面我们来看一下具体的实现。
目前市面上主要有高德、腾讯、百度再加上 Android 自带的几款地图 SDK。目前 DoKit 已经全部兼容。
系统自带
其中系统自带的经纬度我们是通过 hook LocationService 的方式来实现的,具体的代码参考:
https://github.com/didi/DoraemonKit/blob/master/Android/java/doraemonkit/src/main/java/com/didichuxing/doraemonkit/kit/gpsmock/LocationHooker.java
由于这一块不涉及到字节码操作,我就不具体分析了。
三方地图
由于我们不知道用户的项目中具体集成的是哪个地图 SDK,所以我们通过 compileOnly 的方式引入。ext 文件参考如下 config.gradle :
// 高德地图定位
compileOnly rootProject.ext.dependencies["amap_location"]
// 腾讯地图定位
compileOnly rootProject.ext.dependencies["tencent_location"]
// 百度地图定位
compileOnly files('libs/BaiduLBS_Android.jar')
private var mapLocationListener = AMapLocationListener { aMapLocation ->
val errorCode = aMapLocation.errorCode
val errorInfo = aMapLocation.errorInfo
Log.i(
TAG,
"高德定位 ===lat==>" + aMapLocation.latitude + " lng==>" + aMapLocation.longitude + " errorCode===>" + errorCode + " errorInfo===>" + errorInfo
)
}
mLocationClient!!.setLocationListener(mapLocationListener)
// 这是 AMapLocationClient 编译后的反编译代码
public void setLocationListener(AMapLocationListener aMapLocationListener) {
AMapLocationListenerProxy aMapLocationListenerProxy = new AMapLocationListenerProxy(aMapLocationListener);
try {
if (this.f110b != null) {
this.f110b.mo19841a((AMapLocationListener) aMapLocationListenerProxy);
}
} catch (Throwable th) {
CoreUtil.m1617a(th, "AMClt", "sLocL");
}
}
public class AMapLocationListenerProxy implements AMapLocationListener {
AMapLocationListener aMapLocationListener;
public AMapLocationListenerProxy(AMapLocationListener aMapLocationListener) {
this.aMapLocationListener = aMapLocationListener;
}
@Override
public void onLocationChanged(AMapLocation mapLocation) {
if (GpsMockManager.getInstance().isMocking()) {
try {
mapLocation.setLatitude(GpsMockManager.getInstance().getLatitude());
mapLocation.setLongitude(GpsMockManager.getInstance().getLongitude());
// 通过反射强制改变 p 的值 原因: 看 mapLocation.setErrorCode
ReflectUtils.reflect(mapLocation).field("p", 0);
mapLocation.setErrorInfo("success");
} catch (Exception e) {
e.printStackTrace();
}
}
if (aMapLocationListener != null) {
aMapLocationListener.onLocationChanged(mapLocation);
}
}
}
// 插入高德地图相关字节码
if (className == "com.amap.api.location.AMapLocationClient") {
klass.methods?.find {
it.name == "setLocationListener"
}.let {
methodNode ->
methodNode?.instructions?.insert(createAmapLocationInsnList())
}
}
// 插入字节码
private fun createAmapLocationInsnList(): InsnList {
return with(InsnList()) {
// 在 AMapLocationClient 的 setLocationListener 方法之中插入自定义代理回调类
add(TypeInsnNode(NEW, "com/didichuxing/doraemonkit/aop/AMapLocationListenerProxy"))
add(InsnNode(DUP))
// 访问第一个参数
add(VarInsnNode(ALOAD, 1))
add(MethodInsnNode(
INVOKESPECIAL,
"com/didichuxing/doraemonkit/aop/AMapLocationListenerProxy",
"<init>",
"(Lcom/amap/api/location/AMapLocationListener;)V",
false
)
)
// 对第一个参数进行重新赋值
add(VarInsnNode(ASTORE, 1))
this
}
我们会去遍历所有的 class 资源文件,然后通过全限定名找到指定的 setLocationListener 方法,然后我们通过 ASM 提供的 inset 方法在 setLocationListener 方法开始的的地方去操作和插入我们内置的代码,从而达到用户无感知的目的。
数据 Mock 作为 DoKit 的重磅功能,我们现在基本上已经实现了全平台 (Android、iOS、H5 js 以及小程序) 的覆盖同时该项功能也是在社区中引起广泛讨论以及评价非常高的功能。所以我们可以重点分析一下。
传统解决方案
首先我们来看一下在平时的开发过程中,假如不使用 DoKit 的数据 Mock 方案我们是如何来进行数据 Mock 的。我们开发和测试经常会使用抓包工具来查看和修改网络返回的数据。首先我们来看一下现有的抓包方案都存在哪些问题:
1)无法支持多人协同操作同一个接口
2)无法针对同一接口返回不同的场景数据。
3)抓包操作起来非常繁琐,需要和手机保证在同一个局域网,还要修改 ip 和端口号。
针对这些问题,DoKit 提出了打造面向全平台的数据 Mock 方案。
为了实现这个目标我经过一定程度的调研,我总结了一下要实现这个目标我们要解决的难点。
1) 统一 Android 端繁多的网路框架。
2) 保证业务代码零侵入。
3) 为了拦截到 H5 中 Ajax 的请求我们必须还要 hook Webview。
接下来我们来具体看一下 DoKit 在 Andoid 端上是如何来解决这些问题的。(整个链路还是有点长的,请大家耐心往下看。)
数据 Mock(终端)
这是 DoKit 数据 Mock 终端方案在编译期和运行时的一个简单流程图。由于今天主要的侧重点是 AOP 字节码,所以我们就来看一下 DoKit 是如何来实现的。
1、统一网络请求
我们都知道 Android 终端封装的三方网络框架有很多,但是仔细分析其实最底层基本上都是基于 HttpClient(Google 放弃维护不考虑兼容)、HttpUrlConnection、Okhttp(使用最多)。所以我们只要统一 HttpUrlConnection 和 OkHttp 两套框架就可以了。经过调研,OkHttp 官方提供了一个将 HttpUrlConnection 转化为 OkHttp 请求的解决方案:
https://gist.github.com/swankjesse/dd91c0a8854e1559b00f5fc9c7bfae70
if (protocol.equalsIgnoreCase("http")) {
return new ObsoleteUrlFactory.OkHttpURLConnection(url, OkhttpClientUtil.INSTANCE.getOkhttpClient());
}
if (protocol.equalsIgnoreCase("https")) {
return new ObsoleteUrlFactory.OkHttpsURLConnection(url, OkhttpClientUtil.INSTANCE.getOkhttpClient());
}
val url = URL(path)
// 打开连接
val urlConnection = url.openConnection() as HttpURLConnection
// 得到输入流
val `is` = urlConnection.inputStream
val url = URL(path)
// 打开连接
val urlConnection = HttpUrlConnectionProxyUtil.proxy(url.openConnection()) as HttpURLConnection
// 得到输入流
val `is` = urlConnection.inputStream
private val SHADOW_URL = "com/didichuxing/doraemonkit/aop/urlconnection/HttpUrlConnectionProxyUtil"
private val DESC = "(Ljava/net/URLConnection;)Ljava/net/URLConnection;"
klass.methods.forEach { method ->
method.instructions?.iterator()?.asIterable()?.filterIsInstance(MethodInsnNode::class.java)?.filter {
it.opcode == INVOKEVIRTUAL &&
it.owner == "java/net/URL" &&
it.name == "openConnection" &&
it.desc == "()Ljava/net/URLConnection;"
}?.forEach {
method.instructions.insert(it, MethodInsnNode(INVOKESTATIC, SHADOW_URL, "proxy", DESC, false))
}
}
通过以上的这些操作我们基本上就实现网络框架的统一。
2、插入拦截器
public static final class Builder {
Dispatcher dispatcher;
@Nullable Proxy proxy;
List<Protocol> protocols;
List<ConnectionSpec> connectionSpecs;
// 通用拦截器列表
final List<Interceptor> interceptors = new ArrayList<>();
// 网络拦截器列表
final List<Interceptor> networkInterceptors = new ArrayList<>();
EventListener.Factory eventListenerFactory;
ProxySelector proxySelector;
}
那么我们就需要在 OkHttpClient#Build 构造方法的最后在往拦截器列表的头部加入我们自己的内置拦截器。代码如下:
if (className == "okhttp3.OkHttpClient$Builder") {
// 空参数的构造方法
klass.methods?.find {
it.name == "<init>" && it.desc == "()V"
}.let { zeroConsMethodNode ->
zeroConsMethodNode?
.instructions?
.getMethodExitInsnNodes()?
.forEach {
zeroConsMethodNode
.instructions
.insertBefore(it,createOkHttpZeroConsInsnList())
}
}
// 一个参数的构造方法
klass.methods?.find {
it.name == "<init>" && it.desc == "(Lokhttp3/OkHttpClient;)V"
}.let { oneConsMethodNode ->
oneConsMethodNode?
.instructions?
.getMethodExitInsnNodes()?
.forEach {
oneConsMethodNode
.instructions
.insertBefore(it,createOkHttpOneConsInsnList())
}
}
}
public Builder() {
this.interceptors = new ArrayList();
this.networkInterceptors = new ArrayList();
this.dispatcher = new Dispatcher();
......
this.pingInterval = 0;
// 编译期插入的代码
this.interceptors.addAll(OkHttpHook.globalInterceptors);
this.networkInterceptors.addAll(OkHttpHook.globalNetworkInterceptors);
}
Builder(OkHttpClient okHttpClient) {
this.interceptors = new ArrayList();
this.networkInterceptors = new ArrayList();
this.dispatcher = okHttpClient.dispatcher;
......
// 编译期插入的代码
OkHttpHook.performOkhttpOneParamBuilderInit(this, okHttpClient);
}
public static void installInterceptor() {
if (IS_INSTALL) {
return;
}
try {
// 可能存在用户没有引入 okhttp 的情况
globalInterceptors.add(new MockInterceptor());
globalInterceptors.add(new LargePictureInterceptor());
globalInterceptors.add(new DoraemonInterceptor());
globalNetworkInterceptors.add(new DoraemonWeakNetworkInterceptor());
IS_INSTALL = true;
} catch (Exception e) {
e.printStackTrace();
}
}
至此终端的网络拦截功能已经完成。此项功能同时也是抓包、数据 Mock、弱网模拟、大图检测等功能的基础。感兴趣的小伙伴可以通过源码更加深入的了解下。
数据 Mock(js)
mWebView = findViewById<WebView>(R.id.normal_web_view)
initWebView(mWebView)
mWebView.loadUrl(url)
mWebView = findViewById<WebView>(R.id.normal_web_view)
initWebView(mWebView)
WebViewHook.inject(mWebView);
mWebView.loadUrl(url)
klass.methods.forEach { method ->
method.instructions?.iterator()?
.asIterable()?
.filterIsInstance(MethodInsnNode::class.java)?
.filter {
it.opcode == INVOKEVIRTUAL &&
it.name == "loadUrl" &&
it.desc == "(Ljava/lang/String;)V" &&
isWebViewOwnerNameMatched(it.owner)
}?.forEach {
method.instructions.insertBefore(
it,
createWebViewInsnList())
}
}
/**
* 创建 webView 函数指令集
* 参考:https://www.jianshu.com/p/7d623f441bed
*/
private fun createWebViewInsnList(): InsnList {
return with(InsnList()) {
// 复制栈顶的 2 个指令 指令集变为 比如 aload 2 aload0 aload 2 aload0
add(InsnNode(DUP2))
// 抛出最上面的指令 指令集变为 aload 2 aload0 aload 2 其中 aload 2 即为我们所需要的对象
add(InsnNode(POP))
add(
MethodInsnNode(
INVOKESTATIC,
"com/didichuxing/doraemonkit/aop/WebViewHook",
"inject",
"(Ljava/lang/Object;)V",
false
)
)
this
}
}
注意 DUP2 和 POP 指令的配合使用,注释里已经写了原因。这是这一块的难点。可以看到字节码指令非常强大,大家如果对字节码有深入的了解的话,真的可以为所欲为。
mWebView = findViewById<WebView>(R.id.normal_web_view)
initWebView(mWebView)
String var3 = this.url;
WebViewHook.inject(mWebView);
mWebView.loadUrl(url)
多了一行 url 的赋值代码,但是这基本上不影响我们的功能,我们也不需要在意。
private static void injectNormal(WebView webView) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!(WebViewCompat.getWebViewClient(webView) instanceof DokitWebViewClient)) {
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setAllowUniversalAccessFromFileURLs(true);
webView.addJavascriptInterface(new DokitJSI(), "dokitJsi");
webView.setWebViewClient(new DokitWebViewClient(WebViewCompat.getWebViewClient(webView), settings.getUserAgentString()));
}
}
}
一开始我们已经说过了 shouldInterceptRequest 方法的入参无法拿到 post 的 body 信息。所以这里又遇到问题,经过一番调研,我们其实在该方法中是可以拿到原始的 html 数据流的,那么我们只需要在 Webview 开始渲染之前,在原始的 html 数据中插入我们自己的一段 js 脚本,脚本中根据 js 的原型链原理,我们会去指定 XmlHttpRequest 和 Fetch 的几个核心方法的原型,具体参考:dokit_js_hook.html 和 dokit_js_vconsole_hook.html。然后我们在通过 jsBridge 将 js 的请求信息告知终端,终端拿到请求以后再通过 okhttp 去代理转发,于是整条链路又回到了终端数据 mock 的流程。
最终 H5 助手的效果图如下:
业务价值
到此数据 Mock 的整条链路在 Android 上的实现都已经分析完了。这一块由于篇幅的原因没有深入到每一个技术点去讲,只是简单的阐述了一下 AOP 方案,欢迎感兴趣的小伙伴和我进行深入的交流。
DoKit 一直追求给开发者提供最便捷和最直观的开发体验, 同时我们也十分欢迎社区中能有更多的人参与到 DoKit 的建设中来并给我们提出宝贵的意见或 PR。
DoKit 的未来需要大家共同的努力。
最后,厚脸皮的拉一波 star。来都来了,点个 star 再走呗。
DoKit:
https://github.com/didi/DoraemonKit