最近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