Tangram 2.0 庫
Android
iOS
背景
技術背景
一直以來,無線應用都在不斷尋求動態(tài)化頁面的解決方案,在阿里巴巴集團內,除了風風火火地 Weex 項目外,各個團隊都有大大小小的解決方案。我們貓客一直持續(xù)基于 Tangram 方案來解決頁面動態(tài)化的問題,然而在面對持續(xù)升級的業(yè)務需求時,原有的開發(fā)模式也慢慢變得無法勝任,本年度以來,我們 Tangram 體系在各個層面都進行了大跨度的技術升級(可參考文章天貓APP改版之首頁架構&開發(fā)模式全面升級),本文再詳細介紹一下頁面內組件體系升級方案。
老組件體系的問題
在原有的 Tangram 體系里,主要解決了頁面內布局結構的動態(tài)化能力,通過 json 數據描述可以組合出常用的頁面結構。然而頁面內具體的坑位樣式,我們稱之為業(yè)務組件,是采用常規(guī)的 native 代碼開發(fā)的,除非內置了足夠多的邏輯,否則組件的樣式調整或者新組件的開發(fā)都要發(fā)布版本,無法滿足業(yè)務節(jié)奏;當然我們也嘗試過使用 Weex 開發(fā)業(yè)務組件貼到頁面上,但是在體驗和性能上還是有較大的缺陷。
所以總結起來,就是兩點問題:
- 業(yè)務組件無法動態(tài)更新;
- 現有的動態(tài)組件方案較重,影響性能和體驗;
解決之道
對于上述問題,解決思路其實是比較通用的,要動態(tài)更新界面視圖,就需要用界面模板描述視圖,模板與數據分離。將動態(tài)下發(fā)的模板和數據在端上綁定渲染。要提升性能,也有三大著力點——減少視圖層級與個數,結構盡量扁平化;異步布局渲染流程,解放主線程計算量;回收與復用組件,減少內存開銷。
新的組件體系就是在模板化描述視圖,動態(tài)更新視圖,減少視圖層級幾個方面做文章,至于組件的回收復用,則是在頁面級別統(tǒng)一完成;而異步布局渲染流程,則是后續(xù)的優(yōu)化方向。
新的組件方案稱之為 VirtualView,簡稱 VV,也稱為2.0組件,它的設計遵循以下幾個思路:
- 以了一種虛擬化開發(fā)基礎組件的技術,使用方只要按照指定協議實現一個基礎組件的尺寸計算、繪制邏輯、布局邏輯,即能實現在宿主容器的 canvas 里實現直接繪制 UI 內容的,讓最終渲染出來的視圖結構呈現扁平化,提升組件渲染性能。同時為了解決虛擬化 View 帶來的原生 View 的能力損失的問題,它支持加載和渲染原生基礎組件,兩者組合產生合力,既能減少開銷,又能滿足特殊場景下的業(yè)務需求。
- 內置實現了一系列基礎組件,可以讓使用方直接上手嘗試;而搭建業(yè)務組件的方式采用 XML 模板來編寫,配套 XML 模板更新 sdk,這使得業(yè)務組件動態(tài)更新成為了可能。XML 模板里還支持寫數據綁定的表達式,在樣式動態(tài)化、數據動態(tài)化的場景下能非常方便地實現業(yè)務需求。
- XML 模板里涉及到的基礎節(jié)點、屬性、字符串資源等都被提前編譯成二進制資源,客戶端加載通過加載編譯后的模板數據來創(chuàng)建視圖。
設計方案
整體架構
先從整體上預覽一下整個方案的大體結構:
自下往上,自左往右的順序介紹各個模塊:
- 基礎模板加載器負責加載編譯后的模板數據,比如從文件加載、從二進制數組加載、從網絡加載,將編譯后的二進制模板數據加載到內存里,通過組件加載器、字符串資源加載器、表達式資源加載器等提取出其中的資源。
- 框架還內置了基礎組件,包括原子的基礎組件如文本、圖片、線條,還包括布局類型的基礎組件,比如線性布局、幀布局、網格布局等;每一種類型的基礎組件提供了原生 Native 版本的實現和虛擬化的實現,用戶也可以自定義自己的基礎組件注冊到框架內部,組件構造器通過加載好的組件數據,來構造出整個業(yè)務組件樹,并添加到宿主容器里,對于虛擬組件,會在渲染階段繪制到宿主容器的 canvas 上,而原生組件會作為子 View 添加到宿主容器里。
- 框架內部也提供了基礎的表達式能力,主要分兩種,一種是簡單的數據綁定表達式,一種是簡單的邏輯表達式;前者用于在模板里寫表達式綁定數據到基礎組件的屬性上,而后者提供了一種輕量級的邏輯運算能力,可以訪問基礎組件的屬性并更新,實現一些聯動效果。
- 事件管理,本方案聚焦于界面的動態(tài)化創(chuàng)建,但對業(yè)務邏輯的處理主要還是靠原生的代碼實現,因此處理組件的一些常用交互事件,比如組件的點擊、長按、觸摸、曝光事件等。事件管理模塊負責將外部的各個類型的事件處理模塊注冊進來,當組件發(fā)生特定的事件時,找到對應類型的處理模塊來調用處理。
- 宿主容器管理負責對虛擬組件的宿主容器進行構建和回收復用的管理。當原有的組件滑出屏幕后,可以回收到統(tǒng)一的池子里,以便后續(xù)復用。
- 組件管理負責對基礎組件進行構建和回收復用管理。當原有的組件滑出屏幕后,除了宿主容器可以回收復用,內部的基礎組件對象也可以回收到統(tǒng)一的池子里。如果組件的池子是空的,則在需要的時候構造新的組件。
- 擴展模塊管理則用于注冊外部功能擴展模塊,當內置的基礎能力無法滿足業(yè)務場景的時候,通過擴展模塊注冊特定的功能模塊,然后編寫自定義基礎組件來實現特定功能。
- 模板存儲、模板校驗、模板更新、模板注冊則分別負責模板數據的存儲、安全性校驗、版本校驗、與更新檢查與新模板下載、注冊模板數據到框架,整體協同來完成業(yè)務組件的動態(tài)更新,它并不與整個渲染組件的核心框架耦合,可以作為獨立模塊存在。
- 配套的工具和服務主要包括模板編寫工具、模板編譯工具、模板更新服務.模板編寫工具用于 XML 的模板的編輯,并調用編譯模塊編譯模板,模板里涉及到的組件資源、字符串資源、表達式資源會分別用對應的模塊處理。編譯后端模板數據可以上傳到模板更新服務里,客戶端調用相應的接口檢查是否有更新。
運行流程
有了上述基礎,當我們要開發(fā)新的業(yè)務組件的時候,除了有新增 Native 邏輯的需求場景(比如新增視頻功能),大部分需求都可以告別原生代碼的編寫,轉而編寫組件模板。
- 先編寫業(yè)務組件的模板。
- 通過工具將模板數據編譯成二進制數據。
- 客戶端加載二進制數據可以有兩種路徑,一是直接打包到客戶端里,寫代碼加載,另一種是發(fā)布到模板管理后臺,客戶端在線更新到模板數據。
- 不論哪種方式加載二進制數據,客戶端接下來的工作是解析二進制數據里,比如校驗版本號,合法性,讀取頭信息等等。
- 等要真正創(chuàng)建組件的時候,根據組件名稱找到二進制數據,從中解析并創(chuàng)建出真正的組件模型數據。
- 從模板里創(chuàng)建在組件往往不含有業(yè)務數據,因為業(yè)務數據是動態(tài)性的,用戶需要獲取到業(yè)務數據綁定到組件上,組件的屬性里可以寫表達式來指定使用哪一個數據字段。
值得注意的是,在上述架構及流程里,描述了一個完整的實踐經驗,但對于本方案來說,核心點在于提供了對組件從編寫到展示流程的實現,其周邊的配套設施,并沒有內置在框架里,包括客戶端上的模板管理、更新、注冊模塊,以及后端的模板發(fā)布服務,因為這些模塊往往涉及業(yè)務邏輯,且與各個應用的基礎設施相關,內置在框架里反而限制了使用方的接入。這里提供一些可供參考的經驗:
- 模板管理后臺要能對模板的進行發(fā)布、更新,并且按照客戶端版本、平臺、組件版本、生效優(yōu)先級等幾個維度來管理模板;
- 模板文件可以存放到 CDN 上供客戶端下載,管理平臺只是對比下發(fā)遠信息;下載文件要做足夠的校驗;
- 客戶端要內置一份打底的模板數據,這樣不至于因為模板不存在而出現空窗;
- 客戶端可提供一個統(tǒng)一的模板管理模塊,面向全應用提供服務,在合適的時候請求管理平臺檢查有沒有更新,比如啟動、用戶刷新、推送指令的到達,并且負責下載、文件校驗、通知頁面刷新等功能;頁面刷新可以做優(yōu)先級區(qū)分,比如高優(yōu)先級的模板更新主動去刷新下頁面,而低優(yōu)先級的可以等二次進入頁面或者刷新頁面的時候生效;
幾個核心設計
組件的基礎模型
對于組件,我們做了如下定義,每一個基礎的原子組件或者容器組件都會有以下屬性,自定義的基礎組件應當繼承自基礎定義并做擴展。
| 名稱 | 類型 | 默認值 | 描述 |
|---|---|---|---|
| id | int | 0 | 組件id |
| layoutWidth | int/float/enum(match_parent/wrap_content) | 0 | 組件的布局寬度,與Android里的概念類似,寫絕對值的時候表示絕對寬高,match_parent表示盡可能撐滿父容器提供的寬高,wrap_content表示根據自身內容的寬高來布局 |
| layoutHeight | int/float/enum(match_parent/wrap_content) | 0 | 組件的布局寬度,與Android里的概念類似,寫絕對值的時候表示絕對寬高,match_parent表示盡可能撐滿父容器提供的寬高,wrap_content表示根據自身內容的寬高來布局 |
| layoutGravity | enum(left/right/top/bottom/v_center/h_center) | left|top | 描述組件在容器中的對齊方式,left:靠左,right:靠右,top:靠上,bottom:靠底,v_center:垂直方向居中,h_center:水平方向居中,可用或組合描述 |
| autoDimX | int/float | 1 | 組件寬高比計算的橫向值 |
| autoDimY | int/float | 1 | 組件寬高比計算的豎向值 |
| autoDimDirection | enum(X/Y/NONE) | NONE | 組件在布局中的基準方向,用于計算組件的寬高比,與autoDimX、autoDimY配合使用,設置了這三個屬性時,在計算組件尺寸時具有更高的優(yōu)先級。當autoDimDirection=X時,組件的寬度由layoutWidth和父容器決策決定,但高度 = width * (autoDimY / autoDimX),當autoDimDirection=Y時,組件的高度由layoutHeight和父容器決策決定,但寬度 = height * (autoDimX / autoDimY) |
| minWidth | int/float | 0 | 最小寬度 |
| minHeight | int/float | 0 | 最小高度 |
| paddingLeft | int/float | 0 | 左內邊距 |
| paddingRight | int/float | 0 | 右內邊距 |
| paddingTop | int/float | 0 | 上內邊距 |
| paddingBottom | int/float | 0 | 下內邊距 |
| layoutMarginLeft | int/float | 0 | 左外邊距 |
| layoutMarginRight | int/float | 0 | 右外邊距 |
| layoutMarginTop | int/float | 0 | 上外邊距 |
| layoutMarginBottom | int/float | 0 | 下外邊距 |
| background | int | 0 | 背景色 |
| backgroundImage | string | null | 背景圖地址 |
| borderWidth | int | 0 | 邊框寬度 |
| borderColor | int | 0 | 邊框顏色 |
| visibility | enum(visible/invisible/gone) | visible | 可見性,與Android里的概念類似,visible:可見,invisible:不可見,但占位,gone:不可見也不占位 |
| gravity | enum(left/right/top/bottom/v_center/h_center) | left|top | 描述內容的對齊,比如文字在文本組件里的位置、原子組件在容器里的位置,left:靠左,right:靠右,top:靠上,bottom:靠底,v_center:垂直方向居中,h_center:水平方向居中,可用或組合描述 |
方案內內置了一系列基礎組件,完整的組件列表如下:
- 虛擬文本組件
- 原生文本組件
- 虛擬圖片組件
- 原生圖片組件
- 虛擬線條組件
- 原生線條組件
- 虛擬進度條組件
- 虛擬圖形組件
- 原生翻頁布局容器組件
- 原生滾動布局容器組件
- 虛擬幀布局容器組件
- 虛擬比例布局容器組件
- 虛擬網格布局容器組件
- 原生網格布局容器組件
- 虛擬線性布局容器組件
- 原生線性布局容器組件
虛擬組件
上文提到虛擬化開發(fā)的組件的技術,簡稱虛擬組件。很多做性能優(yōu)化的方案、建議都會提到采用 Canvas 直接繪制的方式來減少 View 的個數,虛擬將這個開發(fā)流程做了抽象與規(guī)范,可以讓開發(fā)人員像定義原生組件一樣定義虛擬組件。
具體來講,基礎組件需要遵循一個接口的規(guī)范,這個口定義了渲染過程中需要的三個流程:計算尺寸階段、布局階段、繪制階段;定義這個三個階段是為了更好的與系統(tǒng)平臺特別是 Android 平臺對接,因為在 Android 原生平臺下也會有這個三個階段,在 iOS 平臺下則也需要按照本方案里要求的規(guī)范去處理。計算尺寸階段定義要觸發(fā)一次尺寸計算,需要對其包含的子組件進行計算調用;布局階段定義了要觸發(fā)一次布局,將子元素按照計算好的位置尺寸排布,也要對包含的子組件進行布局調用;繪制階段定義要進行視圖繪制,當然也要對起包含的子組件進行繪制的調用;對于虛擬組件,就在這些接口里實現相關邏輯,而對于原生組件,在這些接口實現里調用原生組件的對應邏輯。
不論是虛擬化組件還是原生組件,都采用上述相同的模型來定義,再加上相同的尺寸計算接口、布局接口、繪制接口,這樣對于宿主容器來說,包裝在內部的組件就不分虛擬化還是原生,一視同仁,暴露給外面的接口也是一樣的,只要將宿主容器像普通的 View 一樣添加到的視圖界面上,就可以在后續(xù)的渲染過程中顯示出來。如果虛擬組件使用的越多,View 的個數就越少,對于系統(tǒng)來說層級越扁平。以下圖示例的組件來說,最終呈現的 View 只有宿主容器和兩個圖片組件,如果將圖片也用虛擬化的方式實現,最終 View 只有一個宿主容器,而界面仍然保持不變。
二進制文件的格式
通過 XML 編寫的業(yè)務組件,并不直接在客戶端里運行使用,而是先進行一次二進制序列化操作,原始的 XML 模板文件保存成文件的時候,就是以純文本的形式存在,會包含很多冗余信息,比如空格、換行、還有重復出現的字符串等,文件體積比較大,以xml解析器去解析的時候,也會需要大量字符串操作,效率和性能不能達到最優(yōu)。而將它編譯成二進制格式,會避免這些問題,比如文件重復出現的字符串只保留一份,通過字符串索引去引用它,所有的組件類型也都會被轉換成一個數字索引,在客戶端內通過數字索引反過來找到對應的類實例化。這樣文件格式會非常緊湊,體積更小。整個設計也借鑒了 Android 系統(tǒng)編譯模板文件的思路。它的具體格式說明如下:
按照圖中從左往右、從上往下的順序分別說明每個段的作用:
- 開始5個字節(jié)固定為 ALIVV;相當于我們的文件格式的一個標記。
- 版本號分三個,分別為主版本號,次版本號和修訂版本號,均為 2 個字節(jié);在無重大重構更新時,前兩位一般不變,第三位用于組件的業(yè)務級別變更升級;
- 組件區(qū)的起始位置和長度,均為 4 個字節(jié);表示這份文件里組件區(qū)數據從第幾個字節(jié)開始,它總共有多少個字節(jié),這樣解析這份數據的時候能直接將文件指針定位到特定位置來讀取數據。
- 字符串區(qū)的起始位置和長度,均為 4 個字節(jié);表示這份文件里字符串數據從第幾個字節(jié)開始,它總共有多少個字節(jié)。
- 表達式區(qū)的起始位置和長度,均為 4 個字節(jié);表示這份文件里字符串數據從第幾個字節(jié)開始,它總共有多少個字節(jié)。
- 數據區(qū)的起始位置和長度,均為 4 個字節(jié);表示這份文件里附加數據從第幾個字節(jié)開始,它總共有多少個字節(jié)。目前這一區(qū)塊是作為一種保留區(qū),實際還未使用到。
- 當前文件所屬頁編碼,2 個字節(jié),唯一標識一個頁(保留使用)
- 當前文件依賴頁的個數為 2 個字節(jié),后面為依賴頁的 Id,依賴頁個數大于 0 表示該頁用到了其他頁的資源或者代碼,在該頁加載之前需要確保依賴頁必須已經加載;(保留使用)
- 組件區(qū)開始,前 4 個字節(jié)表示文件里業(yè)務組件個數,目前一個 XML 模板編譯成一個二進制文件,故其值固定為 1。每個業(yè)務組件前 2 個字節(jié)表示業(yè)務組件名稱字符串的長度,后面為指定長度的字符串字節(jié)數據;緊接著是 2 個字節(jié)的編譯后組件二進制流長度,后面為二進制代碼;
- 字符串區(qū)開始,前4個字節(jié)表示字符串個數,在我們的框架里,會內置一些系統(tǒng)級別的字符串資源,比如上文5.2開端表格里提到的那些屬性名,這些字符串不用序列化到二進制文件里,而模板文件里出現的非系統(tǒng)字符串才會作為資源序列化到二進制文件。每個字符串資源前 4 個字節(jié)字符串索引 Id 即它的 hashCode,后面 2 個自己為字符串的長度,再后面為對應的字符串;
- 邏輯表達式代碼表。前 4 個字節(jié)表示邏輯表達式資源個數,每個表達式資源前4個自己表示表達式的索引,它是表達式原始字符串的hashCode,后面兩個2 個字節(jié)表示表達式的長度,后面為對應的表達式內容,它是表達式按照關鍵字切割后的字符串結構;
- 擴展數據段是保留為第三方擴展使用;
綁定數據的表達式
開發(fā)業(yè)務組件的時候,基礎屬性或者樣式往往不能在模板里直接寫死,而是需要從數據里獲取,所以引入了用戶數據綁定的表達式,語法和實現上目前比較簡單,參考了很多同類的設計,盡可能符合開發(fā)人員的直覺。
- 訪問數據屬性的表達式
語法上以 ${ 開頭,以 } 結束。對于Map,通過 . 操作符進行訪問,對于 Array 或者 List 通過 [] 操作符進行訪問。
比如:
${benefitImgUrl}
${data[0].benefitImgUrl}
- 條件表達式
用來給那些需要根據數據中某個字段來設置值的屬性,語法上以 @{ 開頭,以 } 結束,中間部分為表達式的具體內容。
條件表達式 ? 結果表達式[1] : 結果表達式[2]
當條件表達式成立的時候,使用結果表達式[1],否則使用結果表達式[2]。
其中:
條件表達式支持布爾類型、字符串類型、JSONObject、JSONArray。
以下場景均為 false:
- 布爾類型值為 false
- 字符串為 null 或者 "" 或者 "null"
- 字符串 "false" 或者 "FALSE"
- JSONObject 為空或 JSONObject.NULL
- JSONArray 長度為 0
- 字段不存在
比如:
@{${logoUrl} ? visible : invisible }
考慮到篇幅限制,不能將上述架構和流程中的每一細節(jié)完全展開,詳情可以參考蘋果核這里的文檔。
效果
與 Tangram 及 TAC 結合
VirtualView 方案是 Tangram 的極大補充,可以解決80%場景下的動態(tài)化需求,而 Tangram 依賴的數據則通過 TAC 提供解決,三者結合可以形成一個閉環(huán),讓一個開發(fā)從端到端地解決整塊業(yè)務的開發(fā)。
組件動態(tài)下發(fā)
以雙十一期間為例,90%的雙十一業(yè)務組件都是動態(tài)下發(fā)的,且隨時可根據業(yè)務節(jié)奏調整。
展望
盡管在功能流程上已經逐步穩(wěn)定,能承載起日常及大促的需求變更,我們的方案還是有很多不足之處的,比如我們期望更高的運行效率、更加扁平化的UI結構、更加方便的開發(fā)體驗,對此也做了更進一步的規(guī)劃建設:
| 功能 | 計劃 |
|---|---|
| 提供更加完善的文檔和教程、Demo,內外版本同步,建立以 github 為中心的迭代開發(fā)機制 | 17年12月 |
| 組件創(chuàng)建、布局計算、數據綁定機制優(yōu)化,提升性能 | 18年1月 |
| 重構模板編譯工具,提升編譯開發(fā)體驗 | 18年1月 |
| 提供預覽服務,提升開發(fā)效率 | 18年3月 |
| 提供配套的后端數據服務與基礎設施,即 TAC 平臺開放 | 18年3月 |
附錄
Tangram 2.0 主要更新說明
- 組件模型的概念升級,從原來的『卡片』+『組件』升級成『布局』+『組件』,即原來的『卡片』認為是一種具有布局能力的組件,具備嵌套另一組件的能力;
- 頁面結構優(yōu)化,頁面下可以直接掛載組件,不需要嵌套一層布局;
- 組件類型的語義化,從原來的 1、2、3、4...等數字枚舉類型定義,升級成字符串類型的定義,兼容解析原有的數字枚舉定義;
- 更好的嵌套布局實現,流式布局在模型描述上支持多層次的嵌套,并優(yōu)化了 Android 端上的實現方式;
- margin 去重的實現,同一層級的容器組件或原子組件直接,支持外邊距 margin 的去重,使得動態(tài)數據下控制間距更方便;
- 支持 zIndex,無論是容器組件還是原子組件,支持在其樣式上配置 zIndex,zIndex 值越大,繪制層次越高;
- 升級組件開發(fā)方式,引入動態(tài)化組件開發(fā)技術,提升組件動態(tài)性,實現組件樣式的高效渲染與動態(tài)更新;