如何設計一套純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在資源充足的情況下往往更關注性能、安全性以及後續擴展性方面。

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

制定文件格式

我們可以參考https://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 & 佈局引擎