如何做到四端統一橋接?微醫跨平臺橋接標準化方案瞭解一下

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"近幾年隨着 React Native、Flutter、Weex 等跨平臺框架的流行,使得程序員可以儘量關注於業務本身,而非平臺間的差異。但是不管哪一種方案,從移動端的角度看,都對底層橋接 API 有着共同的訴求。從 H5 到 React Native,再到 Weex 以及後面的 Flutter,原生進行了多輪的 API 重複建設,造成了缺少 API 接口的標準化定義,以及實現的統一管控的現狀。所以針對這一情況,我們將統一所有容器 Bridge API,包括接口的定義,以及其底層原生代碼。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"方案概況"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"舊方案流程介紹"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/9f\/9fc36e25b11a618b5f91f0d86c9f490f.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"以前需要橋接一個方法時,我們需要在 Web、RN、Weex、Flutter 各自寫一套註冊和實現,有時候往往因爲需求時間的不同,或者開發人員的不同,導致相同功能的 Bridge 定義和實現也不一樣,這不僅僅浪費了開發人員的時間精力,而且當 Web 需要換成 RN 或者 Weex 時,增加了替換的難度及風險。就算規劃的好一點,統一了 Bridge 的實現和接口,但那時還是需要開發人員對各端都做註冊實現,也是很浪費時間。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"新方案流程介紹"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/5e\/5e06c292a4d4c7cc5725a4847fe2f86c.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們在基礎容器層對各跨平臺容器的 Bridge 層做了適配,主要是模塊註冊、Bridge 解析、調用以及兼容方案,具體實現後文會講。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"WYBridge"}]},{"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":"首先我們先介紹下底層的 WYBridge 庫,它包括了 4 端統一的橋接及實現。就像前面說的,我們以前的 bridge 都是各端寫各自的,實現及接口定義都不統一,極不規範。而現在我們只需要在這個庫中增加一個 module,上層 4 端會自動註冊這個模塊,這樣各端的定義和實現都是用的 WYBridge 中的,實現了下層的統一。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"Android"}]},{"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":"Android 中的 WYBridge 的核心爲 BridgeModule,提供給上層的模塊都會繼承該接口,裏面提供了 4 端都會用到的方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"public interface BridgeModule {\n  \/\/模塊名稱\n  String getName();\n}\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":"同時我們新建了 BridgeMethod 註解,標記爲該模塊提供給上層的方法(類似於 RN 中的@ReactMethod)。提供的方法必須符合下面兩種模板中的一種:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"@BridgeMethod\npublic void xxxx(JSONObject data, BridgeJSCallBack callBack){}\n\n@BridgeMethod\npublic void xxxx(BridgeJSCallBack callBack){}\n\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面就是一個簡單的 BridgeModle 例子:"}]},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"public class XXTestModule extends BaseBridgeModule {\n\n @Override\n public String getName() {\n return \"xxtest\";\n }\n \n @BridgeMethod\n public void getData(JSONObject data, final BridgeJSCallBack callBack){\n \/\/do something\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":"各端通過解析這些模塊,將其註冊到自己的平臺中。至此我們通過WYBridge實現了底層的統一。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"iOS"}]},{"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":"iOS 中的 WYBridge 的核心爲宏定義文件,提供給上層的模塊通過 "},{"type":"codeinline","content":[{"type":"text","text":"XX_EXPORT_MODULE(module_name)"}]},{"type":"text","text":"宏將該類以 module 的方式暴露給 JS,然後使用 "},{"type":"codeinline","content":[{"type":"text","text":"XX_EXPORT_METHOD(js_name)"}]},{"type":"text","text":"將 Native 方法暴露給 JS。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"模塊註冊"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"define XX_EXPORT_MODULE(module_name) \\\n    XX_EXTERN void XXRegisterWebModule(Class); \\\n    XX_EXTERN void XXRegisterWeexModule(Class); \\\n    XX_EXTERN void RCTRegisterModule(Class); \\\n    XX_EXTERN void XXRegisterFlutterModule(Class); \\\n    + (void)load {\\\n        XXRegisterWebModule(self);\\\n        XXRegisterWeexModule(self);\\\n        RCTRegisterModule(self);\\\n        XXRegisterFlutterModule(self);\\\n    }\\\n    + (NSString *)moduleName { return @# module_name; }\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"注:load 裏面會註冊四個平臺的RegisterXXModule,若沒有對應平臺的SDK,可以通過判斷頭文件註冊聲明一個空的RegisterXXModule"}]}]},{"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":"如上代碼所示,XX_EXPORT_MODULE 宏背後是兩個靜態方法"},{"type":"codeinline","content":[{"type":"text","text":"+(NSString *)moduleName"}]},{"type":"text","text":" 和"},{"type":"codeinline","content":[{"type":"text","text":"+(NSString *)load。moduleName"}]},{"type":"text","text":" 方法簡單的返回了 Native 模塊的類名,load 方法是大家耳熟能詳的的,load 方法調用 RegisterXXModule 函數註冊了模塊,我這裏註冊了 4 端,RegisterXXModule 函數的實現是參考 RCTRegisterModule(該函數定義在 RCTBridge.m 中)"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"void RCTRegisterModule(Class);\nvoid RCTRegisterModule(Class moduleClass)\n{\n  static dispatch_once_t onceToken;\n  dispatch_once(&onceToken, ^{\n    RCTModuleClasses = [NSMutableArray new];\n    RCTModuleClassesSyncQueue = dispatch_queue_create(\"com.facebook.react.ModuleClassesSyncQueue\", DISPATCH_QUEUE_CONCURRENT);\n  });\n...\n  \/\/ Register module\n  dispatch_barrier_async(RCTModuleClassesSyncQueue, ^{\n    [RCTModuleClasses addObject:moduleClass];\n  });\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":"很簡單,RCTRegisterModule 函數只做了 3 件事: 1.創建一個全局的可變數組\/字典和一個隊列(在 Web\/Weex\/Fluuter 我定義的是一個字典) 2.檢查導出給 JS 模塊是否遵守了 RCTBridgeModule 協議(由於該檢查,需要在 WYBridge 寫一個 RCTBridgeModule 的空協議,爲了躲過檢查,其他端可不作檢查) 3.把要導出的類添加到全局的可變數組\/字段中進行記錄 在 APP 啓動後調用 load 方法時,所有需要暴露給 JS 的方法都已經被註冊到一個數組\/字典中。到此爲止,只是把需要導出給 JS 的類記錄下來."}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"方法註冊及實現"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"# define XX_EXPORT_METHOD(method) \\\nXX_EXPORT_METHOD_INTERNAL(@selector(method),xx_export_method_)\\\nRCT_REMAP_METHOD(, method)\n# define XX_EXPORT_METHOD_INTERNAL(method, token) \\\n + (NSString *)XX_CONCAT_WRAPPER(token, __LINE__) { \\\n return NSStringFromSelector(method); \\\n }\n# define RCT_REMAP_METHOD(js_name, method) \\\n _RCT_EXTERN_REMAP_METHOD(js_name, method, NO)\n# define _RCT_EXTERN_REMAP_METHOD(js_name, method, is_blocking_synchronous_method) \\\n+ (const RCTMethodInfo *)XX_CONCAT_WRAPPER(__rct_export__, XX_CONCAT_WRAPPER(js_name, XX_CONCAT_WRAPPER(__LINE__, __COUNTER__))) { \\\nstatic RCTMethodInfo config = {# js_name, # method, is_blocking_synchronous_method}; \\\n return &config; \\}\ntypedef struct RCTMethodInfo {\n const char *const jsName;\n const char *const objcName;\n const BOOL isSync;\n } RCTMethodInfo;"}]},{"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":"通過上面一系列的宏調用不難看出,XX_EXPORT_METHOD 做了 3 件事"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"numberedlist","attrs":{"start":null,"normalizeStart":1},"content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"定義一個對象方法,用於真正調用的方法實現"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"定義了一個靜態方法,該方法名爲 xx_export_method___LINE__,返回值是註冊方法名,用於 Web 和 Weex,會掃描所有導出的 nativemodule 中以 xx_export_method 的方法"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"定義一個靜態方法,該方法名的格式是 "},{"type":"codeinline","content":[{"type":"text","text":"+(const RCTMethodInfo *)__rct_export__+js_name+___LINE__+__COUNTER__"}]},{"type":"text","text":",用於 RN 這個方法包裝成了一個 RCTMethodInfo 對象,在運行時 RN 會掃描所有導出的 Native module 中以__rct_export__開頭的方法。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":4,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"以上只是說了 module 和 method 是如何導出的,這些模塊和方法的註冊將會在各自模塊中介紹。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"使用"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\/\/模塊註冊\nXX_EXPORT_MODULE(moduleName)\n\/\/方法註冊\nXX_EXPORT_METHOD(methodName:success:fail:)\n\/\/方法實現\n- (void)methodName:(NSDictionary *)data success:(XXBridgeResolveBlock)success\nfail:(XXBridgeRejectBlock)fail {\n     ...\n     success(resdic);\n }\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"注:由於RN裏會對XX_EXPORT_METHOD()中的參數做解析,然後取的方法名做跳轉,而Web\/WEEX中直接取的方法名,所以方法註冊的時候還沒有統一,可以優化。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"Web"}]},{"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":"之前不管是 Android 還是 iOS,都是由原生提供給 H5 一個統一的方法用於調用原生橋接模塊,H5 把需要調用的橋接方法名以及參數以 json 字符串的方式傳入。這些橋接在 webview 初始化的時候進行註冊,註冊時只包含方法名,沒有模塊名,native根據參數找到對應註冊的方法進行調用,整個流程圖如下:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/8c\/8ca9f8d919d456f9f82ce63bc9457f1a.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"前面說到 H5 只有方法名,但是 RN 和 Weex 都是以\"模塊名.方法名\"的方式進行調用,所以爲了和其他端統一,H5 調用原生橋接的方法名也需要加上模塊名,並且初始化時,需將 WYBridge 中的橋接進行註冊。新的流程圖如下:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/7f\/7ff9f965618131df4263a23a1be9ebab.png","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"Android"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面具體介紹一下 Android Web 橋接 WYBridge 的實現。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"註冊"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"整體仿照weex和RN的註冊。新增新的註冊方式,將BridgeModule傳入:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\/\/新的註冊方式\nboolean registerHandler(Class extends BridgeModule> moduleClass);\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"註冊的時候,我們會將 BridgeModule 轉化成一個管理類,該類提供了 3 個方法:module實例化、module方法解析和module方法的調用。調用模塊實例化方法,創建 BridgeModule 實例,將前面的管理類和 module 實例都以鍵值對的方式生成 Map 表。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"解析"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"前面管理類中module方法解析,實際上就是解析 module 中被@BridgeMethod 註解的方法, 獲取到方法的 Invoker,最後的方法調用就是在這個Invoker裏面實現的。"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"for (Method method : mClazz.getMethods()) {\n  for (Annotation anno : method.getDeclaredAnnotations()) {\n    if (anno instanceof BridgeMethod) {\n      String name = method.getName();\n      methodMap.put(name, new Invoker(method));\n      ...\n      break;\n    }\n  }\n}\n"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"調用"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"已知 H5 調用都是通過統一的方法,最終會到一個處理 JS 調用 Native 數據類。在這個處理類中,我們將 H5 傳入的數據進行解析。調用新的 bridge 方法時,我們和 H5 約定方法名傳入爲“模塊名.方法名”,所以通過解析,我們可以得到對應的模塊名和方法名。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這樣根據模塊名找到存在上面 Map 中該模塊的實例和其管理類,又在管理類中根據方法名可以獲取前面解析得到的該方法對應的Invoker。最後我們將傳入的參數和回調一同傳入Invoker中,調用裏面method的invoke方法,即 WYBridge 中方法的調用。"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"method.invoke(receiver, params);\n"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"兼容方案"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因爲原來 H5 都是用過方法名的方式調用原生,現改成“模塊名.方法名”,舊的調用模塊將不再適用。但是 H5 團隊繁多,沒有統一的調用基類,由前端一處一處的改動是不現實的,所以需要做到兼容老的版本。 目前 Android 這邊的處理方案是,對於舊的橋接方法,新建一個對應的處理類,裏面包含了如下 5 個接口:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\/\/舊的方法名\nString aliasName();\n\/\/新的模塊名\nString bridgeModuleName();\n\/\/新的方法名\nString bridgeMethodName();\n\/\/入參轉換\nJSONObject mapping(String data);\n\/\/回調參數轉換\nString backMapping(String data, int code, String msg);\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在 webView 初始化的時候也將這些處理註冊到一個 Map,以舊的方法名爲 Key。當 H5 還是調用老的方法時,通過這個類找到對應新的模塊名和方法名,從而進行調用,整個流程如下:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/b4\/b4133e4e9a51aa16051075b709f075cb.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"iOS"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面具體介紹一下 iOS Web 橋接 WYBridge 的實現。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"註冊"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"WYBridge 中,在 APP 啓動後調用 load 方法時,所有需要暴露給 JS 的方法以 class 的方式 都已經被註冊到字典保存着,循環執行註冊方法即可."}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"以前是維護一份以 methodName 爲 key,原生代碼實現的 block 爲 value 的字典,現是以 moduleName.methodName 爲 key, 通過 NSInvocation 封裝了方法調用對象、方法選擇器、參數、返回值等的 block 爲 value 的字典."}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"[self registerApi:newMethod block:^(XXHandlerModel *handlerModel) {\n \/\/ 1、通過傳入handlerModel獲取 方法名、參數\n \/\/ 2、通過 methodName 獲取 selector\n \/\/ 3、通過註冊的時候保存的對象,生成該方法簽名,設置調用對象,設置參數然後調用方法\n   }];"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"調用"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們和 H5 約定方法名傳入爲“模塊名.方法名”,我們可以得到對應的模塊名和方法名,通過獲取一個類的所有實例方法,將所有以“xx_export_method_”開頭的方法返回保存在字典中,返回的是調用用的方法名."}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"- (NSDictionary *)clazzMethodFactory {\n    ...\n    \/\/1、通過class_copyMethodList獲取所有方法\n    \/\/2、返回以xx_export_method_開頭方法字典\n\n}"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"兼容方案"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"iOS 這邊的處理方案同 Android,新建一個對應的處理類 XXBridgeFactoryMapping,處理新老方法名字、入參、回調映射。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"React Native"}]},{"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":"RN 中我們給每一個 Module 都封裝了一個 ts 文件,用來統一上層 RN 端調用的接口,業務代碼引用的時候直接用該方法就行。舉個例子,如 wytest 模塊,裏面包含了一個 getData 方法:"}]},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"\/\/WYNativeTest.ts\nimport {NativeModules} from 'react-Native';\n\nlet WYTestModule = NativeModules.wytest;\n\nexport var WYNativeTest = {\n    getData(): Promise {\n    return WYTestModule.getData().then((value: any) => {\n        return value;\n    })\n    }\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"而 Native 層,Android 和 iOS 都有自己的一套橋接庫,在應用初始化的時候進行註冊。以前這些橋接實現都是寫在各自庫裏面,現在需要接入 WYBridge 實現底層統一,各端的方案各有不同,下面具體講解。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"Android"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"舊的 RN 橋接先是繼承 ReactPackage 創建一個原生模塊包用來添加原生模塊,後通過繼承 ReactContextBaseJavaModule 創建原生模塊,複寫 getName 方法設置模塊名,對 public 方法添加@ReactMethod 註解表示 RN 端可調用該方法。然後在應用初始化時添加該原生模塊包,這樣在 RN 創建 ReactContext 時,會解析該包,從而解析裏面的原生模塊,創建 JavaScript Module 註冊表,方便 JS 層調用。整個流程如下:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/92\/92eb6128e7df1c6cec1ab793b8445a81.png","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"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":"由上圖可知整個流程對我們接入 WYBridge 最主要的是 JavaModuleWrapper,裏面解析了模塊的名稱以及@ReactMethod 註解的方法,所以我們需要修改裏面的解析用來支持 WYBridge,但是 JavaModuleWrapper 又是在 NativeModuleRegistry 裏面 new 的,而 NativeModuleRegistry 則是在創建 reactContext 中創建的,中間過程無法介入修改。幸好 CatalystInstanceImpl 提供了 extendNativeModules 方法,通過修改 NativeModuleRegistry 註冊 Modules。故新的流程圖如下:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/f7\/f7fae71adc8fa46ecd776bf05181ad9d.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"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":"下面具體介紹一下 Android RN 橋接 WYBridge 的實現。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"註冊"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"註冊由原來從 RN 初始化時註冊改爲初始化完成時註冊。"}]},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"reactInstanceManager.addReactInstanceEventListener(new ReactInstanceManager.ReactInstanceEventListener() {\n    @Override\n    public void onReactContextInitialized(ReactContext context) {\n        \/\/註冊 modules\n    }\n});\n\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"註冊時會解析RN的Package,Package就是WYBridge中所有module的集合。將 BridgeModule以moduleName爲key生成 Map 表。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"將生成的 Map 傳入到註冊類中,這個註冊類繼承NativeModuleRegistry。最後調用 CatalystInstanceImpl 中的 extendNativeModules 方法進行註冊。"}]},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"\/\/extendNativeModules 註冊 module\npublic void extendNativeModules(NativeModuleRegistry modules) {\n    ...\n    Collection javaModules = modules.getJavaModules(this);\n    ...\n    this.jniExtendNativeModules(javaModules, cxxModules);\n}\n\n"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"解析"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在extendNativeModules中可以看到調用了getJavaModules方法,在裏面將module轉化成RN的模塊解析類JavaModuleWrapper。我們重寫裏面的方法解析以及 invoke 方法,原來 RN 在 JavaModuleWrapper 裏解析@ReactMethod,現在我們則解析 BridgeModule 中的@BridgeMethod 註解。最後將註解的方法生成對應的MethodWrapper類,該類繼承NativeModule.NativeMethod。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"調用"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"RN 端調用原生,實際上調用的是 JavaModuleWrapper 的 invoke 方法。根據傳入的methodId,找到前面生成的對應MethodWrapper類。將參數以及回調傳入,調用method的invoke方法,即 WYBridge 中方法的調用。"}]},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"method.invoke(receiver, params);\n"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"兼容方案"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因爲 RN 項目不分團隊,且上層有封裝,即使改變底層實現,也可以統一上層的調用。所以並未做兼容方案,直接對原來代碼進行修改。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"現存問題"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因爲 RN 之前橋接是在初始化時註冊的,現在是在初始化完成時註冊,所以當顯示 RN 頁面時,可能存在該橋接還未註冊完成的時候。目前沒有特別好的方法避免,只能在顯示 RN 頁面時做了一些延遲。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"iOS"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"註冊"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"WYBridge 中,在 APP 啓動後調用 load 方法時,調用 RCTRegisterModule"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"解析調用"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"由於 WYBridge 註冊方式回調與其 SDK 規則一致,所以不用適配"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"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":"Weex 和 RN 相似,也是在初始化的時候註冊橋接。原先 Native 層同樣在 Android 和 iOS 都有自己的一套橋接庫,實現都是寫在各自庫裏面,現在需要接入 WYBridge 實現底層統一,各端的方案也各有不同,下面具體講解。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"Android"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"舊的 Weex 註冊橋接時,橋接類必須繼承 WXModule,方法的解析在 TypeModuleFactory 中實現。註冊時調用 registerModule 設置 moduleName 和對應的 Module,對 public 方法添加@JSMethod 註解表示 Weex 可調用該方法。整個註冊和調用流程如下:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/fd\/fdd9581cda0b28669b15d62a10ef0ad7.png","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"上圖可知方法的解析是通過 TypeModuleFactory,我們只要修改裏面的解析方法,就可以實現 Weex 識別 WYBridge 的方法。而 WXSDKEngine.registerModule 中可以傳入 ModuleFactory 進行註冊,而 TypeModuleFactory 就是繼承該類的,故我們可以重寫 ModuleFactory 將其傳入,進行註冊。下面是整個新的流程:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/30\/30293c5e6c83200b336f8e5236e2b890.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面具體介紹一下 Android Weex 橋接 WYBridge 的實現。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"註冊"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"註冊由原來調用 "},{"type":"codeinline","content":[{"type":"text","text":"registerModule(String moduleName, Class extends WXModule> moduleClass)"}]},{"type":"text","text":"改爲調用 "},{"type":"codeinline","content":[{"type":"text","text":"registerModule(String moduleName, ModuleFactory factory, boolean global)"}]},{"type":"text","text":",首先創建一個類繼承 ModuleFactory,將 Module 進行封裝傳入註冊。後面建立 JS 和 Native 的映射表之類就和原來的 Weex 一致。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"解析"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在這個新建的類中重寫裏面的解析過程。原來 Weex 在 TypeModuleFactory 裏解析@JSMethod,現在我們則解析 BridgeModule 中的@BridgeMethod 註解,然後返回改方法的對應的 Invoker。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"前面的Web過程就參考這裏weex的解析過程。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"調用"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Weex 端調用原生,就是找到對應的ModuleFactory,在該類中我們根據傳入的方法名找到前面生成的Invoker,在這裏處理對應方法參數以及回調,具體參考 Weex 中 NativeInvokeHelper。最後調用Invoker 的 invoke 方法,即 WYBridge 中方法的調用。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"兼容方案"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"由於 Weex 層沒有像 RN 那樣團隊單一,且上層有 JS 封裝統一文件,所以需要對老的調用方式進行兼容。 其原理和 Web 一樣,即在舊方法調用時有一個依賴關係,可對應新方法的 moduleName、methodName、參數的映射和回調參數的映射,具體的過程就不再過多的說明了,可參考 Web 的兼容過程。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"iOS"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"註冊"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"WYBridge 中,在 APP 啓動後調用 load 方法時,所有需要暴露給 JS 的方法以 Class 的方式 都已經被註冊到 WYBridgeGetModuleClassesDic 保存着,循環執行註冊方法即可。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"解析調用"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"解析過程同以前 Weex 調用一致"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"兼容"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其實原理和 Web 一樣,即在舊方法調用時有一個依賴關係,可對應新方法的 moduleName、methodName、參數的映射和回調參數的映射, Hook Weex 的 WXJSCoreBridge 的 registerCallNativeModule 中調用的模塊名和方法名 Hook Weex 的 WXBridgeMethod 的 invocationWithTarget:selector: 的方法 處理參數回調映射。 整體流程圖如下:"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/3a\/3aa45b9e53970d8d99a0c25b83c5752f.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"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}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"Flutter"}]},{"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 就比較簡單,我們新建了一個 wrapper_bridge 的插件,所有的調用都通過這個通道來橋接。在 Flutter 層,我們給每個 Module 都新建了一個 dart 文件,並對裏面的方法進行包裝,方法調用的名稱規定爲“module 名稱.方法名稱”,這樣業務調用時則會更加的清晰,舉個例子,如 wytest 模塊,裏面包含了一個 getData 方法:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"\/\/wytest.dart\nimport 'dart:async';\n\nimport 'package:flutter\/services.dart';\n\nclass WYTest {\n  static const MethodChannel _channel =\n  const MethodChannel('bridge');\n  \n  static const moduleName = 'wytest.';\n\n  static Future get getData async {\n    final Map data = await _channel.invokeMethod(moduleName+'getData');\n    return data;\n  }\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":"在業務代碼中則可以引入這個文件,調用裏面的方法:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"import 'package:bridge\/wytest.dart';\n\nWYTest.getData.then((value){\n  print(value.toString());\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":"Flutter 層代碼統一,對於 Native 層提供的 module 方法沒有註冊流程,只是在調用的時候,根據調用方法名稱進行區分,所以中間並不需要修改。Flutter 使用平臺通道和原生端進行通訊,下面是官網的一張圖:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/eb\/ebbd1c9b4c440880458720638ffa54c7.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"Android"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"已知 Flutter 調用原生方式是通過 MethodChannel 中 invokeMethod 方法名調用的,最終所有的方法都在原生的 onMethodCall 方法中做處理:"}]},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"@Override\npublic void onMethodCall(MethodCall call, final MethodChannel.Result result) {}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"MethodCall 中保存了調用的方法名稱以及參數,MethodChannel.Result 則是回調。既然我們已經規定了方法調用名稱格式爲“module 名.方法名”,所以我們可以通過解析名稱從而調用對應的 WYBridge 方法。 在初始化的時候,我們將 WYBridge 裏面的 module 遍歷生成 Map。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"然後在方法調用時,根據傳過來的方法名解析出 module 名稱和實際調用的方法名稱,通過 module 名稱找到對應的模塊。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"知道了方法名以及實際調用的 moudle,我們就可以 invoke 方法。接下來就和前面的那些一樣,傳入參數和回調,調用invoke方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"invoke 中我們看到,只要是 module 中有的方法都是可以被調用,這樣可能會存在安全漏洞,所以我們規定允許被調用的函數必須有@BridgeMethod 註解,故調用的時候需要進行解析,如下:"}]},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"\/\/獲取調用的方法\nMethod m = moduleClazz.getMethod(methodName, new Class[]{...});\nfor (Annotation anno : me.getDeclaredAnnotations()) {\n    \/\/判斷是@BridgeMethod 註解的才執行\n    if(anno instanceof BridgeMethod) {\n        m.invoke(...);\n    }\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這樣我們就實現了 Android Flutter 橋接 WYBridge 底層的統一。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"iOS"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"與其他三端一樣,在 APP 啓動後調用 load 方法時,所有需要暴露給 JS 的方法以 Class 的方式 都已經被註冊到 WYBridgeGetModuleClassesDic 保存着. 方法的調用在原生 Flutter 插件中的 handleMethodCall 方法中做處理"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"獲取模塊名.方法名通過解析找到對應的方法通過獲取一個類的所有實例方法,將所有以“xx_export_method_”開頭的方法返回保存在字典中,返回的是調用用的方法名.然後通過註冊的時候保存的對象,生成該方法簽名,設置調用對象,設置參數然後調用方法"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"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":"目前 WYBridge 已經在微醫和微醫生項目中替換了部分 Bridge 投入使用,使用過程中未發現明顯 bug,接下來我們將進一步將所有 Bridge 都替換完成,統一所有容器 Bridge API。 這次的方案涉及的都只是橋接方法的調用,對於屬性等橋接並未涉及。而且 Android 因爲 RN 和 Weex 都涉及到源碼,iOS 在 Weex 中也有涉及,所以在升級時這幾部分解析也需要跟着升級,並不是特別友好。 通過這次方案研究,進一步衍生開來,對於 RN、Weex、Flutter 一些原生組件橋接也可以得到統一,這是未來我們所要研究的,希望感興趣的小夥伴和我們一起交流分享。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"horizontalrule"},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"頭圖:Unsplash"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作者:殷利萍,黃麗麗"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"原文:https:\/\/mp.weixin.qq.com\/s\/jq_mavgLnt8wPNemTbpoag"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"原文:如何做到四端統一橋接?微醫跨平臺橋接標準化方案瞭解一下"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"來源:微醫大前端技術 - 微信公衆號 [ID:wed_fed]"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"轉載:著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。"}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章