滴滴DoKit-Android核心原理揭秘之AOP字节码实现

4,460 阅读9分钟

前言

最近DoKit V3.3.1版本已经发布了,新版本增加了很多重磅的功能,同时也在库的名字上对Androidx和Android support进行了区分。

具体的更新信息参考:DoKit Android版本信息

感兴趣的小伙伴们赶快通过Android参考文档去升级体验吧。

技术背景

业务代码零侵入一直是DoKit秉持的底线。 DoKit作为一款终端一站式研发解决方案。我们在不断的给社区用户提供各种各样优秀工具来帮助用户提升研发效率,于此同时我们也要尽可能保证用户的线上代码交付质量。庆幸的是,从DoKit推出到现在我们累计收获了10000+的用户,至今还没有收到过一起用户反馈的由于集成DoKit而引发的线上bug。那我们是如何做到在业务代码零侵入的情况下给用户提供各种强大的工具的呢?其实这背后离不开AOP的功劳。

DoKit AOP原理

(以下图片来自于我在滴滴集团内部的DoKit专题分享)

AOP方案选型

AOP方案选型

在社区中针对Android的主流的AOP实现方案主要有以下两个:AspectJ和AS插件+ASM。其实DoKit在早期的版本中用的就是AspectJ的方案,但是随着DoKit的社区越来越健壮、社区用户也越来越多,渐渐的就开始有很多人反馈AspectJ会和他们项目中的AspectJ由于版本不一致造成冲突,从而导致编译失败。DoKit团队一直很重视社区用户的使用体验,所以针对这一问题,我们经过了大量的调研和社区验证,最终决定将整个AOP技术方案替换为AS Plugin+ASM。 在经过几个版本的验证以后,我们发现ASM在项目集成过程中的冲突相比AspectJ明显减少,这也坚定了我们后续大力优化该套方案的信心。ASM是比较偏底层的方案,它是直接作用在JVM字节码上的。所以我们在使用ASM方案的时候需要克服以下两个难点:

1.你要对JVM的字节码有一定的了解(感兴趣的小伙伴可以通过asm.ow2.io了解更多信息)。

2.为了寻找最优的Hook点,我们需要了解主流第三方的库原理。

AOP原理

AOP原理

在确定好技术选型以后我们来看下ASM的相关原理。其实通过上图我们已经能够大概了解其大致的原理。AS Gradle的编译会将我们的java class文件、jar包以及resource资源文件打包最为最原始的数据输出给第一个Transform,第一个transform处理完的产物再输出给第二个transform,以此类推形成完整的链路。而ASM就是作用于图中的第一个红色TransformA。它会拿到一开始的原始数据以后会进行一定的分析。并且按照JVM字节码的格式针对类、变量、方法等类型调用相关的回调方法。在相应的回调方法中我们可以对相关的字节码指令进行操作。比如新增、删除等等。中间的图片就是它具体的运行时序图。最后两者结合编译就会产生新的JVM class 文件。

AOP落地场景

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) {
        //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

数据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(终端)

数据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 {
            //可能存在用户没有引入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 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.htmldokit_js_vconsole_hook.html。 然后我们在通过jsBridge将js的请求信息告知终端,终端拿到请求以后再通过okhttp去代理转发,于是整条链路又回到了终端数据mock的流程。

最终H5助手的效果图如下:

业务价值

业务价值

到此数据Mock的整条链路在Android上的实现都已经分析完了。这一块由于篇幅的原因没有深入到每一个技术点去讲,只是简单的阐述了一下AOP方案,欢迎感兴趣的小伙伴和我进行深入的交流。

总结

DoKit一直追求给开发者提供最便捷和最直观的开发体验,同时我们也十分欢迎社区中能有更多的人参与到DoKit的建设中来并给我们提出宝贵的意见或PR。

DoKit的未来需要大家共同的努力。

最后,厚脸皮的拉一波star。来都来了,点个star再走呗。DoKit