如何設計一套純Native動態化方案
為什麼會有純Native的動態化方案
業內很多的動態化方案都是通過JS虛擬機器來實現的,好處有很多,邏輯可以實現動態化,有現成的JavaScriptCore(iOS)或者V8(Android)來做動態化引擎,能夠覆蓋90%的場景訴求
但是對於核心頁面,比如首頁Feeds,小黃車,下單,商詳這類頁面,通過這類動態化方案就會存在穩定性和效能問題(畢竟JS作為解釋性語言以及單執行緒存在天然瓶頸,基於暫存器的指令集,導致記憶體消耗更多,非同步回撥也是主執行緒派發到工作執行緒處理後的訊息通知機制實現,再加上bridge底層也是通過呼叫Native的方法來實現,還有做JS和Native的型別轉換)
我用ReactNative官方demo做了些改動,機型iPhoneX,使用FlatList(RN的高效能list元件)快速滑動下幀率表現如下,快速滑動的時候最低幀率在52幀(PS:掘金不支援gif寬高編輯😓,只能先這麼大了)
做了一個類似的Native列表,滑動表現如下,最低幀率58幀
佈局是兩個label加一個imageView,同時cell根據資料來展示不同高度來模擬不定高的情況,屬於非常典型的UI結構比較簡單的場景。這次情況下Native和RN的效能差異也會比較明顯,所以在cell結構比較複雜的情況下差異肯定會更加明顯了
對比完業界通用方案後,作為ReactNative場景的補充,頁面有動態化需求,且對邏輯的動態性要求沒有那麼高,渲染效能好的Native動態化方案也就有業務價值了
高效能的Native動態化方案一般是通過約定好的二進位制檔案格式,使用定製的解碼器在app內將二進位制檔案轉換成原型樹,然後流水線生成檢視樹最終渲染出一個Native的View。
對比下自定義二進位制以及通用檔案格式的優劣
| 能力對比 | 通用檔案比如JSON、XML | 自定義二進位制檔案 | | ------------- | -------------- | ------------------------ | | 通用性 | 是 | 否 | | 檔案大小(以彈窗為例) | 17KB | 2KB | | 解析同一檔案iOS耗時比例 | 6 | 1 | | 安全性 | 差 | 比較好,不知道解析規則的情況下無法獲取對應內容 | | 需要額外開發環境 | 不用 | 需要前端搭建編寫環境、服務端,客戶端定製編解碼器 | | 拓展性 | 差 | 高 |
對比以上優劣點,大型APP在資源充足的情況下往往更關注效能、安全性以及後續擴充套件性方面。
接下來我會大致聊聊端上相關的開發思路。
制定檔案格式
我們可以參考http://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
//輸入寬高約束,得到計算後的寬高 - (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啦