最近DoKit V3.3.1版本已经发布了,新版本增长了不少重磅的功能,同时也在库的名字上对Androidx和Android support进行了区分。html
具体的更新信息参考:DoKit Android版本信息java
感兴趣的小伙伴们赶快经过Android参考文档去升级体验吧。android
业务代码零侵入一直是DoKit秉持的底线。 DoKit做为一款终端一站式研发解决方案。咱们在不断的给社区用户提供各类各样优秀工具来帮助用户提高研发效率,于此同时咱们也要尽量保证用户的线上代码交付质量。庆幸的是,从DoKit推出到如今咱们累计收获了10000+的用户,至今尚未收到过一块儿用户反馈的因为集成DoKit而引起的线上bug。那咱们是如何作到在业务代码零侵入的状况下给用户提供各类强大的工具的呢?其实这背后离不开AOP的功劳。git
(如下图片来自于我在滴滴集团内部的DoKit专题分享)github
在社区中针对Android的主流的AOP实现方案主要有如下两个:AspectJ和AS插件+ASM。其实DoKit在早期的版本中用的就是AspectJ的方案,可是随着DoKit的社区愈来愈健壮、社区用户也愈来愈多,渐渐的就开始有不少人反馈AspectJ会和他们项目中的AspectJ因为版本不一致形成冲突,从而致使编译失败。DoKit团队一直很重视社区用户的使用体验,因此针对这一问题,咱们通过了大量的调研和社区验证,最终决定将整个AOP技术方案替换为AS Plugin+ASM。 在通过几个版本的验证之后,咱们发现ASM在项目集成过程当中的冲突相比AspectJ明显减小,这也坚决了咱们后续大力优化该套方案的信心。ASM是比较偏底层的方案,它是直接做用在JVM字节码上的。因此咱们在使用ASM方案的时候须要克服如下两个难点:web
1.你要对JVM的字节码有必定的了解(感兴趣的小伙伴能够经过asm.ow2.io了解更多信息)。编程
2.为了寻找最优的Hook点,咱们须要了解主流第三方的库原理。小程序
在肯定好技术选型之后咱们来看下ASM的相关原理。其实经过上图咱们已经可以大概了解其大体的原理。AS Gradle的编译会将咱们的java class文件、jar包以及resource资源文件打包最为最原始的数据输出给第一个Transform,第一个transform处理完的产物再输出给第二个transform,以此类推造成完整的链路。而ASM就是做用于图中的第一个红色TransformA。它会拿到一开始的原始数据之后会进行必定的分析。而且按照JVM字节码的格式针对类、变量、方法等类型调用相关的回调方法。在相应的回调方法中咱们能够对相关的字节码指令进行操做。好比新增、删除等等。中间的图片就是它具体的运行时序图。最后二者结合编译就会产生新的JVM class 文件。api
站在巨人的肩膀上可以帮助咱们更快更好的实现相关功能。秉持着不重复造轮子的理念,咱们在进行普遍的技术选型之后,决定使用滴滴的Booster做为DoKit插件的底层实现。Booster为咱们屏蔽了各个Gradle版本之间的API差别,功能很是强大,强烈建议感兴趣的的小伙伴们了解一下。markdown
为了更加便于理解,我这里举一个具体的例子。从图中的例子咱们可以发现,通过DoKit AOP插件编译之后就至关于咱们替用户主动写了一部分代码。经过这种代理的编程模式,咱们就能发在运行时拿到用户的对象,并达到修改对象属性的目的。
如图所示,到目前为止AOP在DoKit中的大部分功能中都获得了落地。
下面咱们来具体看一下在这些落地场景中,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) {
//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
}
复制代码
因为字节码指令有点长,我这边只选取一部分的代码。首先咱们经过全限定名在编译的过程当中找到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对象
//这是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");
}
}
复制代码
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());
//经过反射强制改变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是如何来实现的。
一、统一网络请求
咱们都知道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))
}
}
复制代码
经过以上的这些操做咱们基本上就实现网络框架的统一。
二、插入拦截器
咱们都知道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 {
//可能存在用户没有引入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)
说完了数据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的几个核心方法的原型,具体参考:dokit_js_hook.html和dokit_js_vconsole_hook.html。 而后咱们在经过jsBridge将js的请求信息告知终端,终端拿到请求之后再经过okhttp去代理转发,因而整条链路又回到了终端数据mock的流程。
最终H5助手的效果图以下:
业务价值
到此数据Mock的整条链路在Android上的实现都已经分析完了。这一块因为篇幅的缘由没有深刻到每个技术点去讲,只是简单的阐述了一下AOP方案,欢迎感兴趣的小伙伴和我进行深刻的交流。
DoKit一直追求给开发者提供最便捷和最直观的开发体验,同时咱们也十分欢迎社区中能有更多的人参与到DoKit的建设中来并给咱们提出宝贵的意见或PR。
DoKit的将来须要你们共同的努力。
最后,厚脸皮的拉一波star。来都来了,点个star再走呗。DoKit