前言
最近 DoKit V3.3.1 版本已经发布了,新版本增加了很多重磅的功能,同时也在库的名字上对 Androidx 和 Android support 进行了区分。
具体的更新信息参考:DoKit Android版本信息
感兴趣的小伙伴们赶快通过Android参考文档去升级体验吧。
技术背景
业务代码零侵入一直是 DoKit 秉持的底线。
DoKit 作为一款终端一站式研发解决方案。我们在不断的给社区用户提供各种各样优秀工具来帮助用户提升研发效率,于此同时我们也要尽可能保证用户的线上代码交付质量。庆幸的是,从 DoKit 推出到现在我们累计收获了 10000+的用户,至今还没有收到过一起用户反馈的由于集成 DoKit 而引发的线上 bug。那我们是如何做到在业务代码零侵入的情况下给用户提供各种强大的工具的呢?其实这背后离不开 AOP 的功劳。
DoKit AOP 原理
(以下图片来自于我在滴滴集团内部的 DoKit 专题分享)
AOP 方案选型
在社区中针对 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点,我们需要了解主流第三方的库原理。
AOP 原理
在确定好技术选型以后我们来看下 ASM 的相关原理。其实通过上图我们已经能够大概了解其大致的原理。AS Gradle 的编译会将我们的 java class 文件、jar 包以及 resource 资源文件打包最为最原始的数据输出给第一个 Transform,第一个 transform 处理完的产物再输出给第二个 transform,以此类推形成完整的链路。而 ASM 就是作用于图中的第一个红色 TransformA。它会拿到一开始的原始数据以后会进行一定的分析。并且按照 JVM 字节码的格式针对类、变量、方法等类型调用相关的回调方法。在相应的回调方法中我们可以对相关的字节码指令进行操作。比如新增、删除等等。中间的图片就是它具体的运行时序图。最后两者结合编译就会产生新的 JVM class 文件。
AOP 落地场景
站在巨人的肩膀上能够帮助我们更快更好的实现相关功能。秉持着不重复造轮子的理念,我们在进行广泛的技术选型以后,决定使用滴滴的Booster作为 DoKit 插件的底层实现。Booster 为我们屏蔽了各个 Gradle 版本之间的 API 差异,功能非常强大,强烈建议感兴趣的的小伙伴们了解一下。
为了更加便于理解,我这里举一个具体的例子。从图中的例子我们能够发现,经过 DoKit AOP 插件编译以后就相当于我们替用户主动写了一部分代码。通过这种代理的编程模式,我们就能发在运行时拿到用户的对象,并达到修改对象属性的目的。
如图所示,到目前为止 AOP 在 DoKit 中的大部分功能中都得到了落地。
DoKit AOP 场景落地
下面我们来具体看一下在这些落地场景中,DoKit 是如何用比较优雅的方式来进行字节码操作的。
(DoKit 所有的字节码操作只针对 Debug 包生效,所以不用担心会污染线上代码)
(由于篇幅的原因,我只选取了社区中比较关心的几个功能进行一下分析,其实字节码操作的原理都差不多,我们需要的是创意以及大量的三方源码阅读,这样才能找到最优雅的插桩点)
大图检测
大图检测其实社区中已经有一篇分析得很详细的文章了,我这里就不具体分析了,大家参考一下:通过ASM实现大图监控
函数耗时
函数耗时可以参考我以前写过的一篇文章:滴滴DoKit Android核心原理揭秘之函数耗时
功能开关配置
DoKit 中针对每一项插件功能在编译期都设置了一个开关功能,防止某些字节码操作在特定场景下会造成编译失败以及运行时 bug,同时也是为了更友好的提醒用户该项功能的状态,我们会在运行时判断用户在编译期的开关状态。那么问题来了,DoKit 是如何拿到 gradle.properties 或者 build.gradle 里的配置信息的呢,其实这背后也是字节码的功劳。下面我们来具体看一下它的实现逻辑。
DoraemonKitReal 内置了一个空的 pluginConfig 方法,用来做字节码插装。然后定义了一个 DokitPluginConfig 类用来存储和读取相关配置信息。
public class DokitPluginConfig {
* 注入插件配置 动态注入到DoraemonKitReal#pluginConfig方法中
*/
public static void inject(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 {
return with(InsnList()) {
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))
.........
add(VarInsnNode(ALOAD, 0))
add(
MethodInsnNode(
INVOKESTATIC,
"com/didichuxing/doraemonkit/aop/DokitPluginConfig",
"inject",
"(Ljava/util/Map;)V",
false
)
)
this
}
}
复制代码
由于字节码指令有点长,我这边只选取一部分的代码。首先我们通过全限定名在编译的过程中找到 class 中找到需要操作的方法。然后在通过 ASM API 动态的去插入相关代码。通过以上的操作最后生成的代码如下:
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 的方式来实现的,具体的代码参考:LocationHooker。由于这一块不涉及到字节码操作,我就不具体分析了
三方地图
由于我们不知道用户的项目中具体集成的是哪个地图 SDK,所以我们通过 compileOnly 的方式引入(ext 文件参考如下:config.gradle):
compileOnly rootProject.ext.dependencies["amap_location"]
compileOnly rootProject.ext.dependencies["tencent_location"]
compileOnly files('libs/BaiduLBS_Android.jar')
复制代码
这样能够避免引入用户不需要的地图 SDK,减少编译冲突。
由于百度、腾讯、高德地图的 SDK 调用 API 都是差不多的,下面我就以高德为例进行分析。
首先我们通过 demo 来看一下高德是如何返回经纬度的:
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)
复制代码
如果我们能够把代码变成如下的方式其实就可以拿到用户的 AMapLocationListener 对象
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");
}
}
复制代码
DoKit 内置 AMapLocationListener 代理对象
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());
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()) {
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
数据 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 请求的解决方案:ObsoleteUrlFactory。
所以我们可以通过以下代码将 HttpUrlConnection 转化为 okhttp 的请求。
if (protocol.equalsIgnoreCase("http")) {
return new ObsoleteUrlFactory.OkHttpURLConnection(url, OkhttpClientUtil.INSTANCE.getOkhttpClient());
}
if (protocol.equalsIgnoreCase("https")) {
return new ObsoleteUrlFactory.OkHttpsURLConnection(url, OkhttpClientUtil.INSTANCE.getOkhttpClient());
}
复制代码
找到了 HttpUrlConnection 转化为 OkHttp 的方案以后,接下来就是想办法拿到这个 HttpUrlConnection 对象。
val url = URL(path)
val urlConnection = url.openConnection() as HttpURLConnection
val `is` = urlConnection.inputStream
复制代码
以上的代码是 HttpUrlConnection 的标准 api,urlConnection 对象是通过 url.openConnection()创建而来的。所以我们需要在编译期间把以上的代码改成下面的代码就可以了。
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、插入拦截器
我们都知道 OkHttp 的核心就是其拦截器,所以我们只需要在项目启动的时候把我们自己的内置拦截器查插入到拦截器列表的头部这样就能对项目中的所有网络请求进行拦截了。通过仔细的源码阅读,我们发现 Okhttp 拦截器列表的初始化是在 OkHttpClient#Build 的中进行初始化的。
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 构造方法的最后在往拦截器列表的头部加入我们自己的内置拦截器。代码如下CommTransformer:
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);
}
复制代码
DoKit SDK 中内置了 4 个拦截器OkHttpHook
public static void installInterceptor() {
if (IS_INSTALL) {
return;
}
try {
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)
说完了数据 mock 在终端上的实现,下面我们来看下 H5 中的 js 请求我们要如何才能拦截到。
如图所示,要想拦截到 js 的请求有个技术前提那就是 WebViewClient#shouldInterceptRequest(大家可以去了解一下该方法的作用)。按照惯例,我们还是得先 hook WebView(通过 Webview 可以拿到 WebViewClient)。比如下面的代码:
mWebView = findViewById<WebView>(R.id.normal_web_view)
initWebView(mWebView)
mWebView.loadUrl(url)
复制代码
我们要加载 h5,那么就必须要调用 loadUrl。所以我们需要在 loadUrl 之前对 webView 进行一些操作。比如这样:
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 的赋值代码,但是这基本上不影响我们的功能,我们也不需要在意。
最后我们拿到 Webview 对象以后我们就能注入自己的 WebviewClient。WebViewHook
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 的几个核心方法的原型,具体参考:dokitjshook.html和dokitjsvconsole_hook.html。
然后我们在通过 jsBridge 将 js 的请求信息告知终端,终端拿到请求以后再通过 okhttp 去代理转发,于是整条链路又回到了终端数据 mock 的流程。
最终 H5 助手的效果图如下:
业务价值
到此数据 Mock 的整条链路在 Android 上的实现都已经分析完了。这一块由于篇幅的原因没有深入到每一个技术点去讲,只是简单的阐述了一下 AOP 方案,欢迎感兴趣的小伙伴和我进行深入的交流。
总结
DoKit 一直追求给开发者提供最便捷和最直观的开发体验,同时我们也十分欢迎社区中能有更多的人参与到 DoKit 的建设中来并给我们提出宝贵的意见或 PR。
DoKit 的未来需要大家共同的努力。
https://github.com/didi/DoraemonKit
评论