Weex SDK Android 源碼解析
Weex SDK Android 源碼解析
Weex 是一套構建高性能、可擴展的原生應用跨平台開發方案。一次編寫,多端運行。對於前端是個與客戶端融合的最方便的路徑,透過客戶端的模塊封裝可以快速的讓前端突破瀏覽器的限制,將客戶端能力發威到極限,因此除了業務層的深入外,對於 SDK 框架本身也必須更進一步的了解。
本篇主要是從分析代碼入手,探討一下Weex在安卓平台上是如何構建一套JS的運行框架。主要討論的範疇包括:線程模型、渲染流程、Component/Module的註冊流程以及 Weex 中 JNI 的調試技巧。
本文目錄結構如下:
------1. 整體架構------------1.1 線程模型----------------1.1.1 結構圖
----------------1.1.2 線程間通信----------------1.1.3 線程異常處理----------------1.1.4 線程清理----------------1.1.5 @JSMethod 的使用 ------------1.2 渲染模型----------------1.2.1 結構圖----------------1.2.2 Native 中布局方式----------------1.2.3 FlexBox 概念說明----------------1.2.4 CSSNode/WXDomObject 方法說明----------------1.2.5 ViewPort的使用
------------1.3 Component/Module 註冊流程----------------1.3.1 結構圖----------------1.3.2 Component 註冊及使用方式----------------1.3.3 Module 註冊及使用方式----------------1.3.4 DomObject 註冊及使用方式------2. jni 調試技巧整體架構
整個運行框架包括三大部分:JS Bridge、Render、Dom,這三大部分都包含在 WXSDKManager 中。WXBridgeManager、WXRenderManager、WXDomManager 都可以通過WXSDKManager 獲取。
- JS Bridge:主要用來和 JS Engine(V8)進行雙向通信,運行在JSBridge線程中。Weex 的初始化,Component、Module、DomObject的註冊與調用,JSBridge 線程管理最終都會由JS Bridge 的管理類 WXBridgeManager 完成。所有和 Dom 相關的操作都會通知到 Dom 線程,交由 WXDomModule 處理。
- Render:主要用來操作具體的Native View,包括管理Native View的各種操作(添加/刪除Component,構造Component Tree等)、Native View的布局等,運行在UI線程中。由 WXRenderManager 統一管理,具體操作由 WXRenderStatement 管理,每一個weex instance 一一對應一個 WXRenderStatement。WXRenderStatement 具體就是操作 WXComponent。
- Dom:主要用來操作Dom結構,包括生成對應的Dom Tree,添加/刪除Dom 節點(WXDomObject)等操作,運行在獨立的 Dom 線程中。由 WXDomManager 統一管理,具體操作由 WXDomStatement 管理,每一個weex instance 一一對應一個 WXDomStatement。WXDomStatement 具體就是操作 WXDomObject。所有的 Dom 操作(包括CSSLayout的計算)都在 Dom 線程中,完成後會通知UI線程處理對應的Native Component View。
線程模型
線程模型圖
在處理複雜邏輯的情況下,使用線程是必不可少的,使UI線程不會積壓太重的任務,導致界面卡頓。這時候又遇到另一個問題,線程是一次性的消耗品,使用完了線程就自動退出銷毀了,假如有比較多的耗時任務,不得不重新創建線程去執行該耗時任務,就會存在性能問題:多次創建和銷毀線程是很耗系統資源的。為了解這種問題,我們可以自己構建一個循環線程Looper Thread,當有耗時任務投放到該循環線程中時,線程執行耗時任務,執行完之後循環線程處於等待狀態,直到下一個新的耗時任務被投放進來。這樣一來就避免了多次創建Thread線程導致的性能問題了。Android SDK中其實已經有一個循環線程的框架-HandlerThread,Weex 中的線程使用的正是HandlerThread,如何使用HandlerThread可以參看官方文檔。
Weex中的線程:
- JSBridgeThread:用來java jni層和v8 engine之間進行通信,包括初始化js framework、callJS、callNative等。
- DomThread:用來進行Dom操作,包括Dom解析、設置Dom樣式、CSS Layout操作、生成Component Tree等操作。圖中可知 DomThread 中的操作都是v8 engine調用上來的,也就是說是js runtime生成dom的各種操作,一旦js bundle過大,會是一個瓶頸。
- UIThread:用來真正的視圖渲染,包括設置View Layout、設置View Padding、綁定數據、Add/Remove View等操作。
通信:
- 通信方式:三個線程之間的通信方式使用的都是正常的Android Handler通信機制,每個線程中的所有操作都是時序性的(也是Handle的機制決定),保證了操作Dom的時序性。
使用 runnable 方式
Message m = Message.obtain(mJSHandler, WXThread.secure(r));nm.obj = token;nm.sendToTarget();n
使用 handleMessage 方式
Message msg = Message.obtain();nWXDomTask task = new WXDomTask();n…nmsg.what = WXDomHandler.MsgType.WX_DOM_CREATE_BODY;nmsg.obj = task;nWXSDKManager.getInstance().getWXDomManager().sendMessage(msg);n
- 通信流程
UIThread 與 JSBridgeThread: JSBridgeThread 不會直接發送任務給 UIThread , UIThread 發送給 JSBridgeThread 的任務有初始化js framework、開始渲染頁面createInstance、發送event事件等。
- UIThread 與 DomThread: UIThread 會在銷毀instance的時候發送任務給 DomThread 進行清理,DomThread 發送任務給 UIThread 會分為兩步,這兩步會是一個task:
- 發送前會重新計算CSSLayout的耗時操作,這部分的操作是在DomThread中進行。
- 發送 runnable 到 UIThread,runnable執行的就是view的渲染流程,在UIThread中進行。
說明: 這一整個task是每隔16ms自動觸發,也是說一旦dom操作過多,就會拖累幀率。
JSBridgeThread 與 DomThread:DomThread不會直接發送任務給JSBridgeThread 。js runtime會通過jni發送指令到 java 層,這一部分在JSBridgeThread中,然後JSBridgeThread會發送任務給 DomThread 進行各種 Dom 操作。
線程異常
在Weex中線程處理有一個專門的類WXThread,裡面分裝了HandlerThread處理任務的兩種方式:SafeRunnable、SafeCallback,並且 try catch 了所有的異常,以保證在處理過程中異常crash。
線程清理
正常情況下頁面退出,是不是應該把所有的線程清理(quit)呢?weex中的做法是NO,想必是為了提高創建、銷毀線程消耗系統資源的效率。Weex中WXSDKManager 、WXBridgeManager是單例,WXRenderManager 、WXDomManager的獲取都是通過WXSDKManager,在WXSDKInstance destroy 的時候並不會銷毀單實例,因此在多次Weex頁面進出的時候線程是重用的。
@JSMethod
在WXComponent、WXModule中可以使用 @JSMethod 註解來提供Native方法給JS調用,這個註解有uiThread這個方法,默認值為true 參數說明: 1. 如果uiThread = true,則在UIThread中執行 2. 否則在JSBridgeThread中執行
總結
- 雖說使用了線程,其實都是線性的在執行,只不過把繁重的任務讓線程執行了,這也和js runtime中dom解析邏輯、順序有關。
- 看完了Weex中的線程模型,是不是還是很簡單的,沒有那麼複雜,有木有~
渲染
結構圖
以添加dom節點來說明整個渲染過程,步驟1可以換成其他dom操作,比如update、remove等操作。步驟2則是通用的。- createBody/addDom: WXDomStatement,從 dom tree 到 component tree 的映射
- WXDomObject.parse() 遞歸解析dom JSONObject,最終得到當前dom樹結構
實例化WXDomObject
設置viewport
解析dom JSONObject,得到type、ref、style、attr、event
{n "attr":{"spmId":"spma"},n "ref":"_root",n "style":{},n "type":"div"n}n
賦值 domObject.mDomContext = sdkInstance
- 若為 root 節點
- prepareRoot
- 若沒有設置style flexDirection 與 backgroundColor,則設置默認值 column、#ffffff
- 設置style defaultWidth、defaultHeight
- prepareRoot
普通節點:parent.add(domObject) 把當前解析得到的dom樹加到父節點,並且把當前節點和父節點置為 dirty。
- traverseTree 遍歷當前dom節點
- 註冊得到所有dom節點到 mRegistry 中,標記為 young
- 檢查root節點是否為fixed節點,把fixed的節點存到root dom object 內
- apply所有的 style 到 CSSNode
遞歸創建Component Tree
添加 createBody 或者 addDom 任務到 renderTask中
代碼如下:
private void addDomInternal(JSONObject dom,boolean isRoot, String parentRef, final int index){n ……n //only non-root has parent.n WXDomObject parent;n WXDomObject domObject = WXDomObject.parse(dom,instance);n ……n if (isRoot) {n WXDomObject.prepareRoot(domObject, WXViewUtils.getWebPxByWidth(WXViewUtils.getWeexHeight(mInstanceId),WXSDKManager.getInstanceViewPortWidth(mInstanceId)), WXViewUtils.getWebPxByWidth(WXViewUtils.getWeexWidth(mInstanceId),WXSDKManager.getInstanceViewPortWidth(mInstanceId)));n } else if ((parent = mRegistry.get(parentRef)) == null) {n instance.commitUTStab(IWXUserTrackAdapter.DOM_MODULE, errCode);n return;n } else {n //non-root and parent existn parent.add(domObject, index);n }n domObject.traverseTree( mAddDOMConsumer, ApplyStyleConsumer.getInstance());n //Create component in dom threadn WXComponent component = isRoot ?n mWXRenderManager.createBodyOnDomThread(mInstanceId, domObject) :n mWXRenderManager.createComponentOnDomThread(mInstanceId, domObject, parentRef, index);n ……n AddDomInfo addDomInfo = new AddDomInfo();n addDomInfo.component = component;n mAddDom.put(domObject.getRef(), addDomInfo);n IWXRenderTask task = isRoot ? new CreateBodyTask(component) : new AddDOMTask(component, parentRef, index);n mNormalTasks.add(task);n addAnimationForDomTree(domObject);n mDirty = true;n ……n }n
- WXDomObject.parse() 遞歸解析dom JSONObject,最終得到當前dom樹結構
- layout:WXDomStatement,由 batch 驅動(每隔16ms執行一批任務,開始渲染)
把所有fixed的節點移到 root 節點 child 內
CSSLayout 計算整個 dom 樹(calculateLayout),耗時操作,dom 樹即為mRegistry中註冊的所有節點
- 遍歷 mRegistry 中所有節點
設置 markLayoutSeen
applyUpdate:如果當前節點已經被消費,則post message到渲染的 UIThread 更新Component hostView 的 LayoutParams(更新上一幀已經被消費過的節點的LayoutParams)。
為什麼需要這個步驟?正常情況下在renderTask中對Component View setLayout就可以了,但是這要基於一個前提,那就是所有的 Dom 節點都已經被註冊到 mRegistry 中了,只有這樣最後 CSSLayout 計算出來的才是正確的。 由於 batch 驅動的不確定性(有可能分好幾幀),非常有可能在本次 Layout 過程中 Dom 數量是不完整的,導致 CSSLayout 計算的結果肯定是不完整的。因此需要每次batch 重新計算完整 dom 樹的時候把之前節點 Layout 再更新一遍,並且此時的節點必為 old(上一幀被標為old)
更新 mRegistry 中計算好的所有節點到Component Tree中的 Dom 節點,並且置為 old,下次 layout 時需要更新Component 的 LayoutParams。(如果只有一幀,就沒有也不需要下次更新的機會了)
- 執行renderTasks:由一系列 js bridge 傳過來的各種指令,包括Dom操作(createBody、addDom、updateStyle等)、createFinish、updateFinish事件等引起的執行dom對應的componet view操作,這些操作都會被添加到renderTasks中,並且這些任務由 batch 驅動。因為對應的是UI操作,都需在UIThread中執行。
- 示例:createBody、addDom
- createView 如果有parent則add到父View
- applyLayoutAndEvent:Component setLayout,更新Component hostView 的LayoutParams。
- bindData
- 示例:createBody、addDom
代碼如下:
//WXDomStatement.javanvoid layout(WXDomObject rootDom) {n ……n rebuildingFixedDomTree(rootDom);n rootDom.calculateLayout(mLayoutContext);n ……n rootDom.traverseTree(new ApplyUpdateConsumer());n updateDomObj();n parseAnimation();n int count = mNormalTasks.size();n for (int i = 0; i < count && !mDestroy; ++i) {n mWXRenderManager.runOnThread(mInstanceId, mNormalTasks.get(i));n }n mNormalTasks.clear();n mAddDom.clear();n animations.clear();n mDirty = false;n ……n }n
最終生成的root component 會被添加到 RenderContainer
布局方式:
最終進行布局都會進入函數 applyLayoutAndEvent,layout操作會有兩步:
- setLayout:設置當前view的寬、高,以及 margin 值
- width:getLayoutWidth() CSSLayout 計算得到的寬
- height:getLayoutHeight() CSSLayout 計算得到的高
- left:margin left,當前節點相對於父節點的X坐標 - parent的padding值(包括bording值)
- top:margin top,同 left 計算方式
- right:margin right,直接使用CSSLayout計算得到的值
- bottom:margin bottom,同right 計算方式
- setPadding:使用 CSSLayout 計算得到的 padding 值設置當前 view 的 padding 值
其實布局就是設置當前 View LayoutParams 的padding、margin值
FlexBox 概念說明
- Flex direction:FlexDirection 控制 children 的排布方向,並且這個屬性標實為主軸方向,有四個可選值:
- Column(默認):主軸方向從上到下排布,垂直的軸即為從左到右
- Row:主軸方向從左到右排布,垂直的軸即為從上到下
- ColumnReverse:和 Column 相反,在 RTL 布局下使用
- RowReverse:和 Row 相反,在 RTL 布局下使用
- Justify content:JustifyContent 控制在一個容器內主軸方向上 children 的排列方式,比如當 FlexDirection = Row 時,可以用這個屬性控制 children 水平居中。有五個可選值:
JustifyContent = FlexStart (默認)
JustifyContent = FlexEnd
JustifyContent = Center
JustifyContent = SpaceBetween
JustifyContent = SpaceAround
- Flex wrap:FlexWrap 控制容器內的 children 超出容器時的排布方式。有兩個可選值:
FlexWrap = Nowrap:
FlexWrap = Wrap:如果FlexDirection = Row 時則往下排放,如果FlexDirection = Column時則往右排放。
- Alignment:AlignItems 控制在一個容器內垂直軸上 children 的排列方式。和 JustifyContent 有點類似,但是方向上正好相反。有四個可選值:
- Stretch(默認):在垂直軸上拉升 children 的大小與容器匹配。
- FlexStart:排列在垂直軸上的開始位置
- FlexEnd:排列在垂直軸上的末尾位置
- Center:排列在垂直軸上的中間位置
- Flex:FlexGrow 控制在主軸上 children 在剩餘的空間如何被分布。
FlexGrow = 1
- Margin、Padding、Border:Margin 與 Padding 有點類似,但是又有比較大的區別。Margin 相對於父節點或者兄弟節點的邊距,而 Padding則是指父容器內 children 的邊距。Border 則和 Padding 概念基本一致,主要是用來區別 border 效果的大小。
MarginStart = 50
MarginEnd = 50
MarginAll = 50
PaddingAll = 50
BorderWidth = 50
WXDomObject/CSSNode 方法說明
- young:標實當前節點是否被消費過,並且只有在第一次註冊是標記為 young。消費過即此節點 CSSLayout 計算完成,並且更新到Component中的DomObject,此後此節點必為 old
- needUpdate:是否需要更新
- markHasNewLayout:每次 CSSLayout 計算完成,都會標實 LayoutState 為 LayoutState.HAS_NEW_LAYOUT
- markLayoutSeen:每次 CSSLayout 計算完成之後都要調用這個方法,用來標實當前節點的 CSSLayout 值可以被使用了。如何當前節點沒有調用 markLayoutSeen 被 dirty,會拋異常,說明之前的CSSLayout計算還沒被使用過。
- addChild:添加子節點,並且 置為 dirty(自己和父節點)
- getLayoutX/Y():相對於父節點的x、y坐標
- getPadding():當前節點l、t、r、b的padding距離
- getBorder():當前節點l、t、r、b的border距離
- getMargin():當前節點l、t、r、b的margin距離
ViewPort
之前框架支持的布局策略是 flexable 布局,在不同屏寬下保持相同 ViewPort(默認 750),整體縮放,可以解決大部分屏幕自適應問題。但是在某些情況下無法滿足,比如想讓某些元素在不同屏寬手機中都保持相同大小,畫一像素的線等。目前weex 支持可配置的響應式布局,自定義設置 ViewPort 值。
使用方式<script type="config">n {n "viewport": {n "width": "device-width"n }n }n</script>n
Component/Module 註冊流程
Component 和 Module 是 Weex 中主要的兩種與 js 交互的載體,支持與 js 雙向通信。也是 Weex 擴展的兩種方式,具體的擴展方式可參考這兩篇文章 Weex Android/iOS 擴展指南 ,這篇文章主要介紹 Weex Android Component Module 的註冊流程,以及懶載入以提高初始化效率。
註冊流程
類結構圖
註冊類型主要有:Component、Module、DomObject,註冊入口統一在 WXSDKEngine 中
Component註冊方式 入口都是從 WXSDKEngine 開始
//WXSDKEngine.javanpublic static boolean registerComponent(String type, Class<? extends WXComponent> clazz, boolean appendTree) throws WXException {n return registerComponent(clazz, appendTree,type);n }n
最終的註冊到 WXComponentRegistry.java
public static boolean registerComponent(final String type, final IFComponentHolder holder, final Map<String, Object> componentInfo) throws WXException {n……n WXBridgeManager.getInstance()n .post(new Runnable() {n @Overriden public void run() {n try {n Map<String, Object> registerInfo = componentInfo;n if (registerInfo == null){n registerInfo = new HashMap<>();n }n registerInfo.put("type",type);n registerInfo.put("methods",holder.getMethods());n registerNativeComponent(type, holder);n registerJSComponent(registerInfo);n sComponentInfos.add(registerInfo);n } catch (WXException e) {n WXLogUtils.e("register component error:", e);n }n }n });n return true;n }n
可以看到註冊分為兩部分,這兩部分都是在 bridge 線程中執行:
native 部分註冊:記錄component 的 type 與 holder 的 map 映射。holder 默認為 SimpleComponentHolder,主要用來:如果不為懶載入(lazyLoad),會提前解析好註解 @WXComponentProp 和 @JSMethod,否則會等到使用的時候才會去解析註解,可以提高 weex 的初始化效率。
js 部分註冊:把 component 的 type 、methods 信息註冊到 js runtime中,可以看到最終的註冊部分在 WXBridgeManager 中
private void invokeRegisterComponents(List<Map<String, Object>> components) {n……n WXJSObject[] args = {new WXJSObject(WXJSObject.JSON,n WXJsonUtils.fromObjectToJSONString(components))};n try {n mWXBridge.execJS("", null, METHOD_REGISTER_COMPONENTS, args);n } catch (Throwable e) {n ……n }n }n
使用方式 使用 WXComponentFactory 的 newInstance 方法生成 WXComponent。
public static WXComponent newInstance(WXSDKInstance instance, WXDomObject node, WXVContainer parent) {n……n IFComponentHolder holder = WXComponentRegistry.getComponent(node.getType());n……n try {n return holder.createInstance(instance, node, parent);n } catch (Exception e) {n WXLogUtils.e("WXComponentFactory Exception type:[" + node.getType() + "] ", e);n }n n return null;n }n
通過holder (默認為 SimpleComponentHolder )的 createInstance 方法生成 WXComponent 實例。
Module
Module 的註冊方式與使用方式和 Component 類似,入口從 WXSDKEngine 的方法 registerModule 開始。最終通過 WXModuleManager 註冊,也分為 native 與 js 兩部分註冊,註冊的類型會區分是否是全局,如果是全局的則會提前實例化好。
使用方式 通過 WXModuleManager 的 callModuleMethod 方法使用 WXModule,調用者都是從 WXBridgeManager 發起(js端發起)。
static Object callModuleMethod(final String instanceId, String moduleStr, String methodStr, JSONArray args) {n ModuleFactory factory = sModuleFactoryMap.get(moduleStr);n……n final WXModule wxModule = findModule(instanceId, moduleStr,factory);n if (wxModule == null) {n return null;n }n WXSDKInstance instance = WXSDKManager.getInstance().getSDKInstance(instanceId);n wxModule.mWXSDKInstance = instance;n final Invoker invoker = factory.getMethodInvoker(methodStr);n try {n return instancen .getNativeInvokeHelper()n .invoke(wxModule,invoker,args);n } catch (Exception e) {n WXLogUtils.e("callModuleMethod >>> invoke module:" + moduleStr + ", method:" + methodStr + " failed. ", e);n return null;n } finally {n……n }n }n
步驟:
- 實例化 WXModule,會先從全局的Module中找,未找到則通過 ModuleFactory 的 buildInstance 方法實例化。
- 調用 WXModule 的方法
DomObject
註冊 DomObject主要是為了自定義 WXDomObject 類,默認的 Component 對應的domObject 都為 WXDomObject。
- 註冊方式:從 WXSDKEngine 的方法 registerDomObject 開始,最終會從 WXDomRegistry 中註冊。
- 獲取方式:使用場景是在native端生成dom樹的時候
- 從 WXDomRegistry 中獲取 DomObject 的 class。
- 從 WXDomObjectFactory 的方法 newInstance 實例化WXDomObject
jni 調試技巧
Android Weex 中使用的 Javascript 引擎為 google 的 V8 引擎,V8 由 C++ 寫,在 C++ 與 Java 之間需要JNI進行橋接,普通的JNI的調試方法只能打log輸出,重新 ndk build so 動態庫文件,重新跑APK程序,比較繁瑣,不能直觀的動態debug。有一個好消息是最新的Android Studio支持了Android Native 調試,下面就來介紹下如何在Android Studio中動態調試、斷點Weex Native 代碼。
在 SDK Manager 中的 SDK Tools 安裝CMake、LLDB、NDK。
在 Weex SDK 項目中的 build.gradle 增加如下配置
android {n externalNativeBuild {n ndkBuild {n path ../../../weex_v8core/jni/Android.mk // v8core的具體路徑n }n }n}n
把 v8core 目錄下build出來的v8core/obj/local/armeabi/libweexv8.so 拷貝到weex sdk目錄libs下對應的文件夾中,這一步尤為關鍵,這個 so 文件是靜態文件,不是動態庫文件,裡面包含了所有符號,因此可以調試debug。
推薦閱讀:
※安卓手機的照片被誤刪能恢復嗎?
※Android 如何簡單集成 Emoji 鍵盤
※無需Root也能使用Xposed!
※實戰kotlin@android(三): 擴展變數與其它技巧