如何設計一套純Native動態化方案

語言: CN / TW / HK

為什麼會有純Native的動態化方案

業內很多的動態化方案都是通過JS虛擬機器來實現的,好處有很多,邏輯可以實現動態化,有現成的JavaScriptCore(iOS)或者V8(Android)來做動態化引擎,能夠覆蓋90%的場景訴求

但是對於核心頁面,比如首頁Feeds,小黃車,下單,商詳這類頁面,通過這類動態化方案就會存在穩定性和效能問題(畢竟JS作為解釋性語言以及單執行緒存在天然瓶頸,基於暫存器的指令集,導致記憶體消耗更多,非同步回撥也是主執行緒派發到工作執行緒處理後的訊息通知機制實現,再加上bridge底層也是通過呼叫Native的方法來實現,還有做JS和Native的型別轉換)

我用ReactNative官方demo做了些改動,機型iPhoneX,使用FlatList(RN的高效能list元件)快速滑動下幀率表現如下,快速滑動的時候最低幀率在52幀

j7t5t-mnfk5.gif

做了一個類似的Native列表,滑動表現如下,最低幀率58幀

kvazq-hvucr.gif

佈局是兩個label加一個imageView,同時cell根據資料來展示不同高度來模擬不定高的情況,屬於非常典型的UI結構比較簡單的場景。這次情況下Native和RN的效能差異也會比較明顯,所以在cell結構比較複雜的情況下差異肯定會更加明顯了

對比完業界通用方案後,作為ReactNative場景的補充,頁面有動態化需求,且對邏輯的動態性要求沒有那麼高,渲染效能好的Native動態化方案也就有業務價值了

高效能的Native動態化方案一般是通過約定好的二進位制檔案格式,使用定製的解碼器在app內將二進位制檔案轉換成OriginTree,然後流水線生成檢視樹最終渲染出一個Native的View。

對比下自定義二進位制以及通用檔案格式的優劣

| 能力對比 | 通用檔案比如JSON、XML | 自定義二進位制檔案 | | ------------- | -------------- | ------------------------ | | 通用性 | 是 | 否 | | 檔案大小(以彈窗為例) | 17KB | 2KB | | 解析同一檔案iOS耗時比例 | 6 | 1 | | 安全性 | 差 | 比較好,不知道解析規則的情況下無法獲取對應內容 | | 需要額外開發環境 | 不用 | 需要前端搭建編寫環境、服務端,客戶端定製編解碼器 | | 拓展性 | 差 | 高 |

對比以上優劣點,大型APP在資源充足的情況下往往更關注效能、安全性以及後續擴充套件性方面。

接下來我會大致聊聊端上相關的開發思路。

制定檔案格式

我們可以參考http://zhuanlan.zhihu.com/p/20693043 進行二進位制檔案格式設計

客戶端可以利用JSON來描述UI:

//ShopBannerComponent { "componentName": "ViewComponent", "width": "375", "height": "70", "backgroundColor": "#fff", "onClick": "customClick(mdnGetData(data.jumpUrl))", "children": [ { "componentName": "ListComponent", "width": "100%", "height": "50", "listData": "mdnGetData(data.list)", "orientation": "horizontal", "children": [ { "componentName": "TextComponent", "width": "mdnGetSubData(item.width)", "height": "mdnGetSubData(item.height)", "maxLines": "1", "textSize": "15", "textColor": "#fff", "text": "mdnGetSubData(item.content)" } ] }, { "componentName": "ImageComponent", "width": "100%", "height": "20", "contentMode": "aspectFill", "imageUrl": "mdnGetData(data.backgroudPic)" }, { "componentName": "TextComponent", "width": "44", "height": "15", "maxLines": "1", "textSize": "15", "textColor": "#fff", "text": "mdnGetData(data.desc)" } ] }

經過和後端協商定製協議後,生成的二進位制檔案如下:

Header(固定大小區域)

  • 標誌符:也叫MagicNumber,判斷是否是指定檔案格式
  • MainVersion:用來判斷二進位制檔案編譯的版本號,和本地解碼器版本做對比,當二進位制版本號大於本地時,判斷檔案不可用,最大值1btye,也就是版本號不能大於127
  • SubVersion:當新增feature的時候需要升級,本地解碼器根據版本做邏輯判斷,最大值不能大於short的最大值32767

大的版本迭代比如1.0升級到2.0,規定必須是基於核心邏輯的升級,整個二進位制檔案結構可能會重新設計,這時候通過主版本號比對,假如版本號小於檔案版本號,那麼就直接不讀取,返回為空。小的迭代比如二進位制檔案內新增了某個小feature,在對應SDK內部邏輯新增一個版本判斷,大於指定版本就讀取對應區域,使用新的feature,老版本還是能夠正常使用基本功能,做到向上相容。

  • ExtraData:預留空間,用於後續擴充套件,可以包含檔案大小,checksum等內容,用來檢驗檔案是否被篡改

Body

FileNameLength用於讀取檔名長度,然後根據FileNameLength讀取具體檔名,比如FileNameLength為19,往後讀取19byte長度資料,UTF8Decode成對應檔名ShopBannerComponent

讀取流程

大致流程圖

參考Flutter的渲染管線機制,設定如下流程圖

整個渲染流程都是在一個流水線內執行,可以保證從任意節點開始到任意節點結束

日常運用場景比如:我們在TableView裡要儘快的返回Cell的高度,這時候流水線執行到MDNRenderStageCalculateFrame即可,同時會按照indexPath進行索引值Cache,後續需要返回cell的時候,取到對應indexPath的Component,後續再執行MDNRenderStageFlatten以及後面邏輯,保證每個component的各個節點只會執行一次,大致流程如下

流水線執行始終圍繞在Component,只不過每道工序都會讓Component更接近NativeView

就和汽車工廠裡一樣,最開始只有一個車架,後面通過按照引擎、零部件、噴漆等等工序最終組裝成我們可以駕駛的汽車

元件解析

將本地二進位制檔案轉化原始檢視樹,這個階段不會繫結動態資料,通過全域性快取一份, 後續以Copy的形式生成對應副本,可以有效的提高效能以及降低記憶體,然後在副本進行資料繫結以及生成IntermediateTree

  • OriginObjectTree:直接通過二進位制資料解析出來的樹,全域性只有一個,類似於Flutter的WidgetTree
  • IntermediateTree:通過OriginObjectTree克隆後,將資料填充進去計算佈局後,然後經過層級剪枝的樹,將沒有點選事件以及無特殊UI效果的Node進行合併,目的是為了降低渲染樹生成真實view的檢視層級,減少View例項,避免了建立無用view 物件的資源消耗,CPU生成更少的bitmap,順帶降低了記憶體佔用,GPU 避免了多張 texture 合成和渲染的消耗,降低Vsync期間的耗時
  • RenderTree:和IntermediateTree一一對應,遞迴生成原生View

和ReactNative類似,所有的元件都繼承自基類,基類提供一些生命週期方法讓子類重寫

@interface MDNBaseComponent : NSObject { //子類重寫測量方法 - (void)onMeasureSizeWidth:(MDNMeasureValue)widthValue height:(MDNMeasureValue)heightValue; //子類重寫佈局方法 - (void)onLayout:(CGRect)rect; //子類重寫渲染對應的NativeView方法 - (void)onRender:(UIView *)view; //子類重寫事件相關方法 - ((BOOL)onEvent:(MDNEvent *)event; //子類被載入的方法 - (void)componentDidLoad; //子類被解除安裝的方法 - (void)componentDidUnload;

  • 字串儲存區域存的是對應的常量、列舉、事件、方法、表示式,比如程式碼中寬度375 ,列舉值aspectFill,表示式mdnGetData(data.backgroudPic),這些值都會有對應的key,用於元件解析的時候進行繫結對應屬性

{ "componentName": "ImageComponent", "width": "100%", "height": "20", "contentMode": "aspectFill", "imageUrl": "mdnGetData(data.backgroudPic)" }

  • 表示式區域儲存的是全部用到的表示式欄位,每個表示式都有對應的key,與component的屬性進行關聯,因為表示式可以互相巢狀,因此我們可以考慮設定成樹型結構。startToken以及endToken代表表示式的開始和結束,通過遍歷將表示式exprNode入棧,同時將入棧的exprNode新增到之前棧頂的exprNodechildren,形成一個單節點樹,方便表示式組合使用
  • 元件區域是按照DSL程式碼順序,從上往下遍歷,因為Component也是可以互相巢狀,也是樹形結構,通過startToken以及endToken代表一個component的開始和結束,客戶端層面也是按照區域順序讀取,遇到startToken建立一個component,期間會繫結屬性、事件、方法,以及動態表示式,然後入棧,遇到endToken出棧,同時設定棧頂的Component為父元件,最終得到一個ComponentOriginTree

元件動態繫結

當ViewComponent需要進行動態繫結,將表示式進行遍歷掃描,以customClick(mdnGetData(data.jumpUrl))為例,在二進位制檔案中,會通過對應的key解析成事件表示式Node,然後mdnGetData(data.jumpUrl)在二進位制檔案中,解析成方法表示式Node,最後在方法表示式裡data.jumpUrl會進行以下操作,大致流程如下:

這個解析流程參考了SQL的解析原理

注意:合法判斷裡面有很多狀態切換的情況需要考慮,比如如何從上個掃描的字串到當前掃描字串的狀態切換是合法的

  • 前一個是a-z,A-Z相關的字母,那麼後面的掃描結果也只能是a-z,A-Z、[、.,假如掃描到了],就是非法的
  • 前一個是[,那麼後面的掃描只能是0-9
  • 前一個是0-9,後面則只能是0-9、]

由於一個元件內肯定有大量的表示式邏輯,進行上千乃至上萬次遍歷是很正常的情況,這種狀態判斷積累的效能損耗也是很大的,因此這種狀態判斷邏輯最好是通過矩陣來做from到to的處理,達到優化效能的效果,經測試,隨機狀態執行一萬次,執行時間縮短了20%

元件寬高計算&佈局

繫結好最終的屬性後,就可以計算元件以及子元件的寬高了,以最簡單的固定寬高的父容器為例,父容器遍歷子檢視傳遞自身的約束條件,比如父容器的最大寬高,子容器根據父容器的約束來計算自身的size,然後根據DFS演算法進行約束遞迴最終確定各個子檢視的佈局

拿圖一的佈局來做示範

計算完所有Component的佈局後,就需要將無用的層級Component進行剪枝,避免渲染樹層級過高,優化複雜檢視結構的效能

元件渲染

當我們拿到完整的扁平樹後,就可以遞迴生成對應Native的View了,渲染前我們需要進行diff,儘可能減少UIView的建立和銷燬,有助於提升效能,尤其是在低端機且檢視結構複雜的元件上,複用能降低大量的渲染時間

同時因為安卓iOS對View的操作必須在主執行緒,因此假如提前建立View,並對資料或者佈局進行修改,會觸發很多無用transcation提交,因此將資料以及frame算好後,最後只設置一次能保證效能最優

diff演算法可以參考flutter的diff,通過O(n)遍歷,決定每個子節點是否能被複用

diff完畢後,就是將Component對應的frame,以及事件繫結到對應的view上,比如

ViewComponent對應MDNView ListComponent對應MDNCollectionView ImageComponent對應MDNImageView TextComponent對應MDNLabelView

最後我們就得到了一個純端上邏輯支援點選手勢的動態化View啦\

參考文件:

ParseSQLToken

FlutterInside

動態介面:DSL & 佈局引擎