滴滴DoKit For Flutter正式開源,功能及核心實現解讀

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"DoKit For Flutter"},{"type":"text","text":"是一個DoKit針對Flutter環境的產研工具包,內部集成了各種豐富的小工具,UI、網絡、內存、監控等等。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https:\/\/github.com\/didi\/DoraemonKit\/tree\/master\/Flutter","title":"","type":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"Github地址"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https:\/\/pub.dev\/packages\/dokit","title":"","type":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"Pub倉庫地址"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"http:\/\/xingyun.xiaojukeji.com\/docs\/dokit\/#\/flutterGuide","title":"","type":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"操作文檔"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/23\/237955a6254ac95a7e03f16af1538fcd.jpeg","alt":null,"title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Flutter是Google開源的跨端技術框架。憑藉其區別於RN\/Weex的自渲染模式,在社區裏引起了廣泛關注,不管是終端還是前端的小夥伴都趨之若鶩,大有一統大前端江湖的氣勢。而國內大廠如閒魚、字節、美團等,也都在其核心業務上完成了落地。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"早在兩年前,滴滴就有多個內部團隊開始在Flutter領域進行嘗試。但是在開發過程中,我們遇到了很多調試性問題,如日誌、幀率、抓包等。爲了解決這些開發測試過程中遇到的各類問題,DoKit團隊聯合滴滴代駕和貨運團隊,把平時工作過程中沉澱下來的效率工具進行業務剝離和脫敏,並最終打造出"},{"type":"text","marks":[{"type":"strong"}],"text":"DoKit For Flutter"},{"type":"text","text":",在服務內部業務的同時,也爲社區貢獻一份力量。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"那麼接下來就讓我來列舉一下"},{"type":"text","marks":[{"type":"strong"}],"text":"DoKit For Flutter"},{"type":"text","text":"的功能以及核心實現。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"工具詳解"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"基本信息"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"基本信息模塊會展示當前dart虛擬機進程、CPU、Flutter版本信息、當前App包名和dart工程構建版本信息。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/6c\/6ca0ae2cfe1b99c0fc36c09f4b261a2c.jpeg","alt":null,"title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"VM信息通過"},{"type":"link","attrs":{"href":"https:\/\/pub.dev\/packages\/vm_service","title":"","type":null},"content":[{"type":"text","text":"VMService"}]},{"type":"text","text":"獲取。Flutter版本實際上是通過Devtools服務注入的\"flutterVersion\"方法獲取到的,在flutter attach後,本地會起一個websocket服務,連接VMService並注入flutterVersion和其餘方法(HotReload、HotRestart等),通過VMService調用flutterVersion方法,會從本地flutter sdk目錄下解析version文件返回版本號。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"路由信息"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/04\/04da40a2cb4e15582d90cff7e16e16f4.jpeg","alt":null,"title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在Flutter中,每個頁面對應一個Route,通過Navigator管理Route。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Navigator內部會包含一個Overlay Widget,每個Route最終都轉化成一個_OverlayEntryWidget添加到Overlay上。這個地方可以把Overlay理解爲Android中的FrameLayout,內部子View上下疊加。每打開一個新的Route,都相當於往FrameLayout添加一個新的子View。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Navigator會存在嵌套的情況,即Route所創建的頁面本身也包含一個Navigator,比如App的根Widget是MaterialApp(自帶Navigator),Route頁面也用MaterialApp包裹,就會形成Navigator嵌套的情況。還是以FrameLayout來理解,這也就相當於嵌套的FrameLayout。 "}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"路由信息功能會打印出當前棧頂頁面所處的Route信息,如果存在Navigator嵌套的情況,也會向上遍歷打印出每層Navigator的信息。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"具體的實現方式是,先獲取當前根app根Element,可以使用WidgetsBinding.instance.renderViewElement作爲根Element,再通過遞歸調用element的visitChildElements方法,向下遍歷整棵樹找到最後一個RenderObejctElement,該RenderObejctElement即爲當前顯示的頁面上的元素。然後使用ModalRoute.of(element)方法即可獲取到當前頁面的路由信息。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"至於嵌套的路由信息,則可以通過找到的RenderObejctElement的findAncestorStateOfType方法,反向向上遞歸遍歷,獲得所處的Navigator的NavigatorState,再調用ModalRoute.of(navigatorState.context),如果返回不爲空則表示存在嵌套。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"方法通道"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/d5\/d52fb04fd8e9b7beb21137f89e734ff8.jpeg","alt":null,"title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Flutter的Method Channel調用最終都會經過ServiceBinding.instance._defaultBinaryMessenger這個對象,類型爲BinaryMessenger,由於這個對象是個私有對象,無法動態進行修改。不過查看ServiceBinding的源碼可以發現這個對象是通過ServiceBinding.createBinaryMessenger方法創建的,通過使用flutter的mixins,可以實現對該方法的重寫。 "}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們知道,ServiceBinding實際也是通過mixins在WidgetsFlutterBinding.ensureInitialized方法中一起被初始化的,所以只要在WidgetsFlutterBinding這個類額外mixin一個繼承於ServiceBinding並且重寫了createBinaryMessenger方法的類,就能實現對ServiceBinding中createBinaryMessenger的覆蓋,代碼如下:"}]},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"class DoKitWidgetsFlutterBinding extends WidgetsFlutterBinding\n with DoKitServicesBinding {\n static WidgetsBinding ensureInitialized() {\n if (WidgetsBinding.instance == null) DoKitWidgetsFlutterBinding();\n return WidgetsBinding.instance;\n }\n}\n\nmixin DoKitServicesBinding on BindingBase, ServicesBinding {\n @override\n BinaryMessenger createBinaryMessenger() {\n return DoKitBinaryMessenger(super.createBinaryMessenger());\n }\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下去把runApp的入口調用改成如下,就能實現BinaryMessenger的替換 static void _runWrapperApp(DoKitApp wrapper) { DoKitWidgetsFlutterBinding.ensureInitialized() ..scheduleAttachRootWidget(wrapper) ..scheduleWarmUpFrame(); } 至於Method Channel具體信息的捕獲,只要hook住BinaryMessenger.handlePlatformMessage和BinaryMessenger.send兩個方法就行了,具體可看DoKitBinaryMessenger這個類"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"控件檢查"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/aa\/aa9f926062c96989f90b03c016a309b5.jpeg","alt":null,"title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"和路由功能類似,通過從根element向下遍歷,在遍歷過程中記錄和選中的View有交集的所有RendereObjectElement,並且記錄用以標誌當前頁面的RendereObjectElement,獲取它的Route信息。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"遍歷完成後,遍歷記錄下來的RendereObjectElement,過濾掉Route信息和當前頁面不一致的,這些Element屬於被遮蓋住的頁面。然後通過比對RendereObjectElement和選中View的交叉區域面積佔RendereObjectElement面積的比例,佔比最大的爲當前選中的組件。 "}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在Debug模式下可以獲取選中組件在工程中的代碼位置,將WidgetInspectorService.instance.selection.current賦值爲選中element的renderObject,再調用WidgetInspectorService.instance.getSelectedSummaryWidget方法,會返回一個json字符串,解析這個字符串就能獲取源碼文件名、行列信息等。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"日誌查看"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/9e\/9e1a92a69a431f1c6fec3ba52d764cd9.jpeg","alt":null,"title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"日誌查看功能比較簡單,只要使用runZoned方法替代runApp,傳入zoneSpecification,就能爲日誌輸出設置一個代理函數,在這個代理函數內進行日誌捕獲,同時,還可以爲onError設置一個代理函數,在這裏將捕獲的異常也會傳入到日誌當中。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"幀率"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/a8\/a8025a57357a3ada5b883d0f0e76be16.jpeg","alt":null,"title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用WidgetsBinding.instance.addTimingsCallback可以統計幀率信息,在每幀渲染完成時會觸發回調,包含該幀渲染的信息。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"內存"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/08\/08d505a5bf9abdac881ef4603eb0cfbf.jpeg","alt":null,"title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"同VM信息,使用VMService可以獲取到內存詳細使用信息。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"網絡請求"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/39\/39c01bb0be70b7effb3ded646fa2b37e.jpeg","alt":null,"title":null,"style":null,"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Flutter自帶的網絡請求通過HttpClient類發送,只要hook住HttpClient的創建就可以hook整個網絡請求的過程。查看HttpClient的構造函數可以發現,如果存在HttpOverrides,就會使用HttpOverrids來創建HttpClient"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"factory HttpClient({SecurityContext? context}) {\n HttpOverrides? overrides = HttpOverrides.current;\n if (overrides == null) {\n return new _HttpClient(context);\n }\n return overrides.createHttpClient(context);\n}\n\/\/ 所以這裏重寫了一個HttpOverrids\nclass DoKitHttpOverrides extends HttpOverrides {\n final HttpOverrides origin;\n\n DoKitHttpOverrides(this.origin);\n\n @override\n HttpClient createHttpClient(SecurityContext context) {\n if (origin != null) {\n return DoKitHttpClient(origin.createHttpClient(context));\n }\n \/\/ 置空,防止遞歸調用,使得_HttpClient可以被初始化\n HttpOverrides.global = null;\n HttpClient client = DoKitHttpClient(new HttpClient(context: context));\n \/\/ 創建完成後繼續置回DoKitHttpOverrides\n HttpOverrides.global = this;\n return client;\n }\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"替換HttpOverrides"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"HttpOverrides origin = HttpOverrides.current;\nHttpOverrides.global = new DoKitHttpOverrides(origin);"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"hook住HttpClient方法後,對於請求和返回結果的hook過程就和Android中的HttpUrlConnection類似了,具體可以看DoKitHttpClient、DoKitHttpClientRequest、DoKitHttpClientResponse三個類。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"版本API兼容"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Flutter版本更新還是比較快的,每一個大版本更新都會帶來一些API的變更,目前DoKit的方案需要重寫一些framework層的類,在兼容多版本時就會有一些問題。以上面的BinaryMessager爲例,1.17版本只有四個方法,用來hook的DoKitBinaryMessager是這麼寫的"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"class DoKitBinaryMessenger extends BinaryMessenger {\n final MethodCodec codec = const StandardMethodCodec();\n final BinaryMessenger origin;\n\n DoKitBinaryMessenger(this.origin);\n\n @override\n Future handlePlatformMessage(String channel, ByteData data, callback) {\n ChannelInfo info = saveMessage(channel, data, false);\n PlatformMessageResponseCallback wrapper = (ByteData data) {\n resolveResult(info, data);\n callback(data);\n };\n return origin.handlePlatformMessage(channel, data, wrapper);\n }\n\n @override\n Future send(String channel, ByteData message) async {\n ChannelInfo info = saveMessage(channel, message, true);\n ByteData result = await origin.send(channel, message);\n resolveResult(info, result);\n return result;\n }\n\n @override\n void setMessageHandler(\n String channel, Future Function(ByteData message) handler) {\n origin.setMessageHandler(channel, handler);\n }\n\n @override\n void setMockMessageHandler(\n String channel, Future Function(ByteData message) handler) {\n origin.setMockMessageHandler(channel, handler);\n }\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"用來hook的wrapper類需要調用oring對象的同名方法。但在1.20版本BinaryMessager增加了兩個新方法checkMessageHandler和checkMockMessageHandler,如果使用1.17.5版本的flutter sdk去編譯,就無法調用origin.checkMessageHandler方法,因爲不存在;如果使用1.20.4版本的flutter sdk去編譯,編譯和發佈沒問題,但編出來的sdk在1.17.5的工程被引用後,也會因爲checkMessageHandler方法不存在導致編譯失敗。 "}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"針對這種多個Flutter版本API不同導致的兼容性問題,可以使用擴展方法extension關鍵字來解決。 建立一個_BinaryMessengerExt類如下:"}]},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"extension _BinaryMessengerExt on BinaryMessenger {\n bool checkMessageHandler(String channel, MessageHandler handler) {\n return this.checkMessageHandler(channel, handler);\n }\n\n bool checkMockMessageHandler(String channel, MessageHandler handler) {\n return this.checkMockMessageHandler(channel, handler);\n }\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在1.17.5版本,調用origin.checkMessageHandler會走到擴展方法的checkMessageHandler中,編譯能通過,由於這個方法在1.17.5中是絕對不會被調用到的,雖然會形成遞歸調用,但沒影響。而在1.20版本,BinaryMessenger本身實現了checkMessageHandler方法,所以調用checkMessageHandler方法會走到BinaryMessenger的checkMessageHandler方法中,也能正常使用。 通過extentsion,只要以最低兼容版本的類作爲基礎,在擴展類中定義新版本中新增的API,就能解決多版本API兼容的問題。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"總結"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"以上就是"},{"type":"text","marks":[{"type":"strong"}],"text":"DoKit For Flutter"},{"type":"text","text":"的現有功能以及工具的基本原理介紹。 我們知道當前它的功能還不是完善,後續我們會繼續不斷深入的挖掘業務中的痛點並持續輸出各種提高用戶效率的工具,努力讓"},{"type":"text","marks":[{"type":"strong"}],"text":"DoKit For Flutter"},{"type":"text","text":"變得更加優秀,符合大家的期望。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"DoKit一直追求給開發者提供最便捷和最直觀的開發體驗,同時我們也十分歡迎社區中能有更多的人蔘與到DoKit的建設中來並給我們提出寶貴的意見或PR。 DoKit的未來需要大家共同的努力。"}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章