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

語言: CN / TW / HK

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

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

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

我用ReactNative官方demo做了些改動,機型iPhoneX,使用FlatList(RN的高性能list組件)快速滑動下幀率表現如下,快速滑動的時候最低幀率在52幀(PS:掘金不支持gif寬高編輯😓,只能先這麼大了)

j7t5t-mnfk5.gif

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

kvazq-hvucr.gif

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

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

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

對比下自定義二進制以及通用文件格式的優劣

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

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

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

制定文件格式

我們可以參考https://zhuanlan.zhihu.com/p/20693043 進行二進制文件格式設計

自定義前端用法:

``` //ShopBannerComponent



```

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

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

``` //讀取文件名長度 long length = [self readTextLengthWithLength:1]; //讀取指定長度然後轉換成string NSString *fileName = [self readStringWithLength:length]:

  • (long)readTextLengthWithLength:(int)length { unsigned char result = 0; if (self.location <= (int)(self.data.length - length)) { [self.data getBytes:&result range:NSMakeRange(self.location, length)]; self.location += length; } return (long)result; }

  • (NSString )readStringWithLength:(int)length { NSString result = nil; if (length > 0 && self.location <= (int)(self.data.length - length)) { NSData *data = [self.data subdataWithRange:NSMakeRange(self.location, length)]; result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; self.location += length; } return result; } ```

大致流程圖

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

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

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

typedef NS_ENUM(uint32_t, MDNRenderStage) { MDNRenderStageParse, // 解析模板 MDNRenderStageBind, //綁定數據 MDNRenderStageCalculateFrame, // 計算佈局 MDNRenderStageFlatten, // 扁平化 MDNRenderStageDiff, // diff MDNRenderStageRender, // 渲染 }; int from = MDNRenderStageParse MDNRootView *olderView; for (; from <= to; from++){ switch(from){ case MDNRenderStageParse :{ //將二進制轉化成原型樹 component = [_binaryParser parseData:binaryData]; } break; case MDNRenderStageBind:{ //將數據綁定到未處理的扁平樹上 [_dataBinder bindData:data component:component]; } break; case MDNRenderStageCalculateFrame { //計算frame [_layoutManager layout:component]; } break; case MDNRenderStageFlatten { //層級扁平,生成真正的扁平樹 [_layoutManager flatten:component]; } break; case MDNPipelineStageDiff{ //diff [_differ diff:component olderView:olderView]; } case MDNRenderStageRender { //生成渲染樹 [_renderManager render:component]; } break; } }

組件解析

將本地二進制文件轉化原始視圖樹,這個階段不會綁定動態數據,通過全局緩存一份, 後續以Copy的形式生成對應副本,可以有效的提高性能以及降低內存,然後在副本進行數據綁定以及生成扁平樹

  • 原型樹:直接通過二進制數據解析出來的樹,全局只有一個
  • 展開樹:通過原型樹clone後,將數據填充進去計算佈局後的樹
  • 扁平樹:經過層級拍扁後的樹,將沒有點擊事件以及無特殊UI效果的Node進行合併,目的是為了降低渲染樹生成真實view的視圖層級,減少View實例,避免了創建無用view 對象的資源消耗,CPU生成更少的bitmap,順帶降低了內存佔用,GPU 避免了多張 texture 合成和渲染的消耗,降低Vsync期間的耗時
  • 渲染樹:和扁平樹一一對應,扁平樹進行遞歸生成的原生View

SDK需要內置大量基礎UI組件,滿足日常開發需求,比如Text、Image、List、ScrollView、Switch,不同類型的Component佈局策略不一樣,同時也支持不同業務方擴展業務組件,通過繼承自基類比如MDNBaseComponent,重寫相關方法

``` //通過4byte大小的32位數來保存約束模式以及約束值,好處是省了兩個property的內存佔用(16byte),相比於通過msgSend,位運算性能更佳 //在複雜視圖中體現的內存優化更明顯 //前30位存值,後2位存約束模式 //缺點是可讀性比較差 typedef int32_t MDNMeasureConstraint;

//每個property對應全局唯一的id,並且用數值來進行比對,佔用空間更小,性能更佳 typedef int32_t MDNHashID;

define MDNMeasureConstraintShift 30 //32-2偏移距

define MDNMeasureConstraintMask (int32_t)(0x3 << MDNMeasureConstraintShift) //1100000....(-1073741824)

define MDNMeasureConstraintMaxValue (int32_t)(INT32_MAX & ~MDNMeasureConstraintMask) //001111111...

typedef NS_ENUM(int32_t, MDNMeasureConstraint) { MDNMeasureConstraintUnspecified = 0 << MDNMeasureConstraintShift, // 不受父容器約束、可以大於父容器 MDNMeasureConstraintMatchParent = 1 << MDNMeasureConstraintShift, // 受父容器約束,小於等於父容器 MDNMeasureConstraintExactly = 2 << MDNMeasureConstraintShift, // 固定值 };

typedef NS_ENUM(MDNHashID, MDNPropertyType) { MDN_height = 1111111, MDN_width = 22222, MDN_backgroundColor = -123456, ... }

@interface MDNBaseComponent : NSObject { //儘量減少不必要的property,降低包大小 @public MDNMeasureConstraint _widthConstrains; //寬度約束,用於根據父容器來算寬高 MDNMeasureConstraint _heightConstrains; int measuredWidth; //經過數據綁定後以及父容器約束後算出來的寬度 int meaturedHeight; int width; // 0且有動態表達式:根據數據動態計算, -1:代表match_content -2:代表match_parent >0:代表固定值 int height; MDNBaseComponent parent; //父組件 NSArray * childrens; //子組件數組 UIColor backgroundColor; //背景色 }

//輸入寬高約束,得到計算後的寬高 - (void)onMeasureSizeWithWidthConstraint:(MDNMeasureConstraint)widthConstrains heightConstrains:(MDNMeasureConstraint)heightConstrains; //輸入計算好的父類寬高,計算組件的具體位置 - (void)onLayout:(CGRect)rect; //創建Native的時機 - (void)onCreate; //渲染NativeView的時機 - (void)onRender:(UIView )view; //收到事件的回調,子類可攔截 - ((BOOL)onEvent:(MDNEvent )event; //設置對應屬性值 - (void)onSetPropertyWithID:(MDNPropertyType)property value:(id)value; ```

  • 字符串存儲區域存的是對應的常量、枚舉、事件、方法、表達式,比如代碼中寬度375 ,枚舉值cover,表達式@data{data.backgroudPic},這些值都會有對應的key,用於組件解析的時候進行綁定對應屬性

<ImageComponent width="375" height="20" resizeMode="cover" imageUrl="@data{data.backgroudPic}"/>

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

組件動態綁定

當ViewComponent需要進行動態綁定,將表達式進行遍歷掃描,以@customClick{@data{data.jumpUrl}}為例,在二進制文件中,會通過對應的key解析成事件表達式Node,然後@data{data.jumpUrl}在二進制文件中,解析成方法表達式Node,最後在方法表達式裏data.jumpUrl會進行以下操作,偽代碼如下

``` typedef NS_ENUM(NSInteger,CurrentTokenState) { CurrentTokenStateChar = 0, //英文字符 CurrentTokenStateNum, //數字 CurrentTokenStateDot, // . CurrentTokenStateOpenSquareBrackets, // [ CurrentTokenStateCloseSquareBrackets, // ] CurrentTokenStateDone, //處理完成 CurrentTokenStateError, //處理報錯 };

static int transitionArray[7][7]={ // c n . [ ] E D {1,0,1,1,0,1,1}, //char {0,1,0,0,1,1,0}, //num {1,0,0,0,0,0,0}, //. {0,1,0,0,0,0,0}, //[ {0,0,1,1,1,1,1}, //] {0,0,0,0,0,1,0}, //Error {0,0,0,0,0,0,0} //Done };

static int stateTransitionValid(CurrentTokenState form,CurrentTokenState to) { return transitionArray[form][to]; }

-(id)getData:(NSString )expr data:(NSDictionary )curData NSString *exprStr = @"data.jumpUrl";//表達式 int left = 0; CurrentTokenState cState; for (int i = 0; i < exprStr.length;cState != CurrentTokenStateError; i++) { switch (exprStr[i]) { case '.': //假如是.,就直接取對應的key,這類忽略了大量的邊界處理 { if(!stateTransitionValid(cState,CurrentTokenStateDot)){ cState = CurrentTokenStateError
} else { curData = [curData objectForKey:exprStr.subStr(left,i)]; cState = CurrentTokenStateDot; } } break; case '[':{ //假如是[,説明是取的數組,先取對應的值,再直接重新計算leftIdx curData = [curData objectForKey:exprStr.subStr(left,i)]; cState = CurrentTokenStateOpenSquareBrackets; left = i+1; } break; case ']': //假如是],説明數組取值邏輯結束,直接取對應索引值,需要注意邊界處理 curData = curData[exprStr.subStr(left,i).toInt]; cState = CurrentTokenStateCloseSquareBrackets; break; default: { //再分別判斷是字母還是數字 left++; } break; } id ret if(stateTransitionValid(cState,CurrentTokenStateDone){ ret = [curData objectForKey:exprStr.subStr(left,i)]; } } return ret } ```

tips:事件表達式就是比如點擊、組件出現、scroll這類表達式,方法表達式就是對數據進行處理的表達式,可以通過繼承相關類進行業務邏輯定製處理

我寫的偽代碼邏輯相對簡單,但是裏面有很多狀態切換的情況需要考慮,比如如何從上個掃描的字符串到當前掃描字符串的狀態切換是合法的

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

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

組件寬高計算&佈局

綁定好最終的屬性後,就可以計算組件以及子組件的寬高了,以最簡單的固定寬高的父容器為例,偽代碼如下:

//計算frame,其實會有三種情況 //- 組件以及子組件寬高固定 //- 組件寬高取決於子組件 //- 子組件寬高取決於父組件 + (void)calculteSize:(ViewComponent *)viewComponent maxWidth:(CGFloat)maxWidth maxHeight:(CGFloat)maxHeight{ for (int i = 0; i <viewComponent.childrens.count; i++) { (ViewComponent *)childComponent = viewComponent.childrens[i]; //讓子類根據父類寬高來計算寬高 childComponent.measuredWidth = [childComponent.class calculteWidth:childComponent maxWidth:viewComponent.width]; childComponent.measuredHeight = [childComponent.class calculteWidth:childComponent maxHeight:viewComponent.height]; } //當父容器需要根據子容器內容來決定尺寸,再重新根據子組件寬高計算自身寬高 if(viewComponent.matchContent){ [self recalculateWithChildren:viewComponent.children]; } //當容器需要和父容器保持一致 else if (viewComponent.matchParent){ self.measuredWidth = maxWidth; self.measuredHeight = maxHeight; } else { self.measuredWidth = self.width; self.measuredHeight = self.height; } }

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

+ (void)flattenComponent:(ViewComponent *)component parentX:(CGFloat)parentX parentY:(CGFloat)parentY rootComponent:(ViewComponent *)root { BOOL needFlatten = YES; //背景色、有事件、邊框顏色則不扁平化 if (component.backgroundColor || component.events.count || component.cornerRadius || component.borderWidth) { needFlatten = NO; } if (needFlatten) { //重新連接節點 component.parent.childrens = component.childrens; component.childrens = nil; //重新計算x,y,然後遞歸扁平樹 for (ViewComponent *childComponent in component.parent.childrens) { childComponent.x = component.x + childComponent.x; childComponent.y = component.y + childComponent.y; [self flattenComponent:childComponent parentX:childComponent.x parentY:childComponent.y rootComponent:root]; } } }

組件渲染

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

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

``` + (void)diffWithNewComponent:(ViewComponent )newComponent oldComponent:(ViewComponent )oldComponent { //當新節點沒有值,但是老節點有值,直接移除老節點的view,然後複用 if (!newComponent.childrens.count && oldComponent.childrens.count) { [oldComponent.view.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)] return; } // 需要一個IndexInfo保存Component對應ID //positonArray確認newComponent對應位置是否在oldComponent有值 NSMutableDictionary indexDic = [NSMutableDictionary dictionary]; NSMutableArray positonArray = [NSMutableArray array]; int idIndex = 0; for (ViewComponent *childComponent in newComponent.childrens) { indexDic[@(childComponent.componentId)] = @(idIndex++); } for (int i = 0; i <newComponent.childrens.count; i++) { [positonArray addObject:@(-1)]; }

for (int i = 0; i < oldComponent.childrens.count; i++) {
    ViewComponent *childComponent = oldComponent.childrens[i];
    NSNumber *idIndex = @(childComponent.componentId);
    NSNumber *newIndexNum = indexDic[idIndex];
    //説明新老不一致
    if (!newIndexNum) {
        [childComponent.view removeFromSuperView];
    }
    else{
        //更新索引
        positonArray[newIndexNum] = @(i);
    }
}

//找到沒有被oldComponent對應的位置,如果有,那麼是新增節點,後續去render,否則複用
for ( i = 0;; i < newComponent.childrens.count; ++i) {
    NSInteger oldIdx = [positonArray[i] integerValue];
    ViewComponent *newChild = newComponent.childrens[i];
    if (oldIdx == -1) {
        continue;
    }
    ViewComponent *oldChild = oldComponent.childrens[oldIdx];
    // 這裏直接把舊節點的view拷貝到新節點
    newChild.view = oldChild.view;
    if (newChild.children.count || oldChild.children.count) {
        [self diff:newChild oldWidget:oldChild];
    }
}

} ```

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

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

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