原文链接:http://pingguohe.net/2017/12/07/Tangram-2.html
Tangram 2.0 库
Android
- Tangram-Android
- Virtualview-Android
iOS
- Tangram-iOS
- Virtualview-iOS
背景
技术背景
一直以来,无线应用都在不断寻求动态化页面的解决方案,在阿里巴巴集团内,除了风风火火地 Weex 项目外,各个团队都有大大小小的解决方案。我们猫客一直持续基于 Tangram 方案来解决页面动态化的问题,然而在面对持续升级的业务需求时,原有的开发模式也慢慢变得无法胜任,本年度以来,我们 Tangram 体系在各个层面都进行了大跨度的技术升级(可参考文章天猫APP改版之首页架构&开发模式全面升级),本文再详细介绍一下页面内组件体系升级方案。
老组件体系的问题
在原有的 Tangram 体系里,主要解决了页面内布局结构的动态化能力,通过 json 数据描述可以组合出常用的页面结构。然而页面内具体的坑位样式,我们称之为业务组件,是采用常规的 native 代码开发的,除非内置了足够多的逻辑,否则组件的样式调整或者新组件的开发都要发布版本,无法满足业务节奏;当然我们也尝试过使用 Weex 开发业务组件贴到页面上,但是在体验和性能上还是有较大的缺陷。
所以总结起来,就是两点问题:
- 业务组件无法动态更新;
- 现有的动态组件方案较重,影响性能和体验;
解决之道
对于上述问题,解决思路其实是比较通用的,要动态更新界面视图,就需要用界面模板描述视图,模板与数据分离。将动态下发的模板和数据在端上绑定渲染。要提升性能,也有三大着力点——减少视图层级与个数,结构尽量扁平化;异步布局渲染流程,解放主线程计算量;回收与复用组件,减少内存开销。
新的组件体系就是在模板化描述视图,动态更新视图,减少视图层级几个方面做文章,至于组件的回收复用,则是在页面级别统一完成;而异步布局渲染流程,则是后续的优化方向。
新的组件方案称之为 VirtualView,简称 VV,也称为2.0组件,它的设计遵循以下几个思路:
- 以了一种虚拟化开发基础组件的技术,使用方只要按照指定协议实现一个基础组件的尺寸计算、绘制逻辑、布局逻辑,即能实现在宿主容器的 canvas 里实现直接绘制 UI 内容的,让最终渲染出来的视图结构呈现扁平化,提升组件渲染性能。同时为了解决虚拟化 View 带来的原生 View 的能力损失的问题,它支持加载和渲染原生基础组件,两者组合产生合力,既能减少开销,又能满足特殊场景下的业务需求。
- 内置实现了一系列基础组件,可以让使用方直接上手尝试;而搭建业务组件的方式采用 XML 模板来编写,配套 XML 模板更新 sdk,这使得业务组件动态更新成为了可能。XML 模板里还支持写数据绑定的表达式,在样式动态化、数据动态化的场景下能非常方便地实现业务需求。
- XML 模板里涉及到的基础节点、属性、字符串资源等都被提前编译成二进制资源,客户端加载通过加载编译后的模板数据来创建视图。
设计方案
整体架构
先从整体上预览一下整个方案的大体结构:
自下往上,自左往右的顺序介绍各个模块:
- 基础模板加载器负责加载编译后的模板数据,比如从文件加载、从二进制数组加载、从网络加载,将编译后的二进制模板数据加载到内存里,通过组件加载器、字符串资源加载器、表达式资源加载器等提取出其中的资源。
- 框架还内置了基础组件,包括原子的基础组件如文本、图片、线条,还包括布局类型的基础组件,比如线性布局、帧布局、网格布局等;每一种类型的基础组件提供了原生 Native 版本的实现和虚拟化的实现,用户也可以自定义自己的基础组件注册到框架内部,组件构造器通过加载好的组件数据,来构造出整个业务组件树,并添加到宿主容器里,对于虚拟组件,会在渲染阶段绘制到宿主容器的 canvas 上,而原生组件会作为子 View 添加到宿主容器里。
- 框架内部也提供了基础的表达式能力,主要分两种,一种是简单的数据绑定表达式,一种是简单的逻辑表达式;前者用于在模板里写表达式绑定数据到基础组件的属性上,而后者提供了一种轻量级的逻辑运算能力,可以访问基础组件的属性并更新,实现一些联动效果。
- 事件管理,本方案聚焦于界面的动态化创建,但对业务逻辑的处理主要还是靠原生的代码实现,因此处理组件的一些常用交互事件,比如组件的点击、长按、触摸、曝光事件等。事件管理模块负责将外部的各个类型的事件处理模块注册进来,当组件发生特定的事件时,找到对应类型的处理模块来调用处理。
- 宿主容器管理负责对虚拟组件的宿主容器进行构建和回收复用的管理。当原有的组件滑出屏幕后,可以回收到统一的池子里,以便后续复用。
- 组件管理负责对基础组件进行构建和回收复用管理。当原有的组件滑出屏幕后,除了宿主容器可以回收复用,内部的基础组件对象也可以回收到统一的池子里。如果组件的池子是空的,则在需要的时候构造新的组件。
- 扩展模块管理则用于注册外部功能扩展模块,当内置的基础能力无法满足业务场景的时候,通过扩展模块注册特定的功能模块,然后编写自定义基础组件来实现特定功能。
- 模板存储、模板校验、模板更新、模板注册则分别负责模板数据的存储、安全性校验、版本校验、与更新检查与新模板下载、注册模板数据到框架,整体协同来完成业务组件的动态更新,它并不与整个渲染组件的核心框架耦合,可以作为独立模块存在。
- 配套的工具和服务主要包括模板编写工具、模板编译工具、模板更新服务.模板编写工具用于 XML 的模板的编辑,并调用编译模块编译模板,模板里涉及到的组件资源、字符串资源、表达式资源会分别用对应的模块处理。编译后端模板数据可以上传到模板更新服务里,客户端调用相应的接口检查是否有更新。
运行流程
有了上述基础,当我们要开发新的业务组件的时候,除了有新增 Native 逻辑的需求场景(比如新增视频功能),大部分需求都可以告别原生代码的编写,转而编写组件模板。
- 先编写业务组件的模板。
- 通过工具将模板数据编译成二进制数据。
- 客户端加载二进制数据可以有两种路径,一是直接打包到客户端里,写代码加载,另一种是发布到模板管理后台,客户端在线更新到模板数据。
- 不论哪种方式加载二进制数据,客户端接下来的工作是解析二进制数据里,比如校验版本号,合法性,读取头信息等等。
- 等要真正创建组件的时候,根据组件名称找到二进制数据,从中解析并创建出真正的组件模型数据。
- 从模板里创建在组件往往不含有业务数据,因为业务数据是动态性的,用户需要获取到业务数据绑定到组件上,组件的属性里可以写表达式来指定使用哪一个数据字段。
值得注意的是,在上述架构及流程里,描述了一个完整的实践经验,但对于本方案来说,核心点在于提供了对组件从编写到展示流程的实现,其周边的配套设施,并没有内置在框架里,包括客户端上的模板管理、更新、注册模块,以及后端的模板发布服务,因为这些模块往往涉及业务逻辑,且与各个应用的基础设施相关,内置在框架里反而限制了使用方的接入。这里提供一些可供参考的经验:
- 模板管理后台要能对模板的进行发布、更新,并且按照客户端版本、平台、组件版本、生效优先级等几个维度来管理模板;
- 模板文件可以存放到 CDN 上供客户端下载,管理平台只是对比下发远信息;下载文件要做足够的校验;
- 客户端要内置一份打底的模板数据,这样不至于因为模板不存在而出现空窗;
- 客户端可提供一个统一的模板管理模块,面向全应用提供服务,在合适的时候请求管理平台检查有没有更新,比如启动、用户刷新、推送指令的到达,并且负责下载、文件校验、通知页面刷新等功能;页面刷新可以做优先级区分,比如高优先级的模板更新主动去刷新下页面,而低优先级的可以等二次进入页面或者刷新页面的时候生效;
几个核心设计
组件的基础模型
对于组件,我们做了如下定义,每一个基础的原子组件或者容器组件都会有以下属性,自定义的基础组件应当继承自基础定义并做扩展。
名称 | 类型 | 默认值 | 描述 |
---|---|---|---|
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配合使用,设置了这三个属性时,在计算组件尺寸时具有更高的优先级。当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:水平方向居中,可用或 组合描述 |
方案内内置了一系列基础组件,完整的组件列表如下:
- 虚拟文本组件
- 原生文本组件
- 虚拟图片组件
- 原生图片组件
- 虚拟线条组件
- 原生线条组件
- 虚拟进度条组件
- 虚拟图形组件
- 原生翻页布局容器组件
- 原生滚动布局容器组件
- 虚拟帧布局容器组件
- 虚拟比例布局容器组件
- 虚拟网格布局容器组件
- 原生网格布局容器组件
- 虚拟线性布局容器组件
- 原生线性布局容器组件
虚拟组件
上文提到虚拟化开发的组件的技术,简称虚拟组件。很多做性能优化的方案、建议都会提到采用 Canvas 直接绘制的方式来减少 View 的个数,虚拟将这个开发流程做了抽象与规范,可以让开发人员像定义原生组件一样定义虚拟组件。
具体来讲,基础组件需要遵循一个接口的规范,这个口定义了渲染过程中需要的三个流程:计算尺寸阶段、布局阶段、绘制阶段;定义这个三个阶段是为了更好的与系统平台特别是 Android 平台对接,因为在 Android 原生平台下也会有这个三个阶段,在 iOS 平台下则也需要按照本方案里要求的规范去处理。计算尺寸阶段定义要触发一次尺寸计算,需要对其包含的子组件进行计算调用;布局阶段定义了要触发一次布局,将子元素按照计算好的位置尺寸排布,也要对包含的子组件进行布局调用;绘制阶段定义要进行视图绘制,当然也要对起包含的子组件进行绘制的调用;对于虚拟组件,就在这些接口里实现相关逻辑,而对于原生组件,在这些接口实现里调用原生组件的对应逻辑。
不论是虚拟化组件还是原生组件,都采用上述相同的模型来定义,再加上相同的尺寸计算接口、布局接口、绘制接口,这样对于宿主容器来说,包装在内部的组件就不分虚拟化还是原生,一视同仁,暴露给外面的接口也是一样的,只要将宿主容器像普通的 View 一样添加到的视图界面上,就可以在后续的渲染过程中显示出来。如果虚拟组件使用的越多,View 的个数就越少,对于系统来说层级越扁平。以下图示例的组件来说,最终呈现的 View 只有宿主容器和两个图片组件,如果将图片也用虚拟化的方式实现,最终 View 只有一个宿主容器,而界面仍然保持不变。
二进制文件的格式
通过 XML 编写的业务组件,并不直接在客户端里运行使用,而是先进行一次二进制序列化操作,原始的 XML 模板文件保存成文件的时候,就是以纯文本的形式存在,会包含很多冗余信息,比如空格、换行、还有重复出现的字符串等,文件体积比较大,以xml解析器去解析的时候,也会需要大量字符串操作,效率和性能不能达到最优。而将它编译成二进制格式,会避免这些问题,比如文件重复出现的字符串只保留一份,通过字符串索引去引用它,所有的组件类型也都会被转换成一个数字索引,在客户端内通过数字索引反过来找到对应的类实例化。这样文件格式会非常紧凑,体积更小。整个设计也借鉴了 Android 系统编译模板文件的思路。它的具体格式说明如下:
按照图中从左往右、从上往下的顺序分别说明每个段的作用:
- 开始5个字节固定为 ALIVV;相当于我们的文件格式的一个标记。
- 版本号分三个,分别为主版本号,次版本号和修订版本号,均为 2 个字节;在无重大重构更新时,前两位一般不变,第三位用于组件的业务级别变更升级;
- 组件区的起始位置和长度,均为 4 个字节;表示这份文件里组件区数据从第几个字节开始,它总共有多少个字节,这样解析这份数据的时候能直接将文件指针定位到特定位置来读取数据。
- 字符串区的起始位置和长度,均为 4 个字节;表示这份文件里字符串数据从第几个字节开始,它总共有多少个字节。
- 表达式区的起始位置和长度,均为 4 个字节;表示这份文件里字符串数据从第几个字节开始,它总共有多少个字节。
- 数据区的起始位置和长度,均为 4 个字节;表示这份文件里附加数据从第几个字节开始,它总共有多少个字节。目前这一区块是作为一种保留区,实际还未使用到。
- 当前文件所属页编码,2 个字节,唯一标识一个页(保留使用)
- 当前文件依赖页的个数为 2 个字节,后面为依赖页的 Id,依赖页个数大于 0 表示该页用到了其他页的资源或者代码,在该页加载之前需要确保依赖页必须已经加载;(保留使用)
- 组件区开始,前 4 个字节表示文件里业务组件个数,目前一个 XML 模板编译成一个二进制文件,故其值固定为 1。每个业务组件前 2 个字节表示业务组件名称字符串的长度,后面为指定长度的字符串字节数据;紧接着是 2 个字节的编译后组件二进制流长度,后面为二进制代码;
- 字符串区开始,前4个字节表示字符串个数,在我们的框架里,会内置一些系统级别的字符串资源,比如上文5.2开端表格里提到的那些属性名,这些字符串不用序列化到二进制文件里,而模板文件里出现的非系统字符串才会作为资源序列化到二进制文件。每个字符串资源前 4 个字节字符串索引 Id 即它的 hashCode,后面 2 个自己为字符串的长度,再后面为对应的字符串;
- 逻辑表达式代码表。前 4 个字节表示逻辑表达式资源个数,每个表达式资源前4个自己表示表达式的索引,它是表达式原始字符串的hashCode,后面两个2 个字节表示表达式的长度,后面为对应的表达式内容,它是表达式按照关键字切割后的字符串结构;
- 扩展数据段是保留为第三方扩展使用;
绑定数据的表达式
开发业务组件的时候,基础属性或者样式往往不能在模板里直接写死,而是需要从数据里获取,所以引入了用户数据绑定的表达式,语法和实现上目前比较简单,参考了很多同类的设计,尽可能符合开发人员的直觉。
- 访问数据属性的表达式
语法上以 ${ 开头,以 } 结束。对于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 }
考虑到篇幅限制,不能将上述架构和流程中的每一细节完全展开,详情可以参考苹果核这里的文档。
效果
与 Tangram 及 TAC 结合
VirtualView 方案是 Tangram 的极大补充,可以解决80%场景下的动态化需求,而 Tangram 依赖的数据则通过 TAC 提供解决,三者结合可以形成一个闭环,让一个开发从端到端地解决整块业务的开发。
组件动态下发
以双十一期间为例,90%的双十一业务组件都是动态下发的,且随时可根据业务节奏调整。
展望
尽管在功能流程上已经逐步稳定,能承载起日常及大促的需求变更,我们的方案还是有很多不足之处的,比如我们期望更高的运行效率、更加扁平化的UI结构、更加方便的开发体验,对此也做了更进一步的规划建设:
功能 | 计划 |
---|---|
提供更加完善的文档和教程、Demo,内外版本同步,建立以 github 为中心的迭代开发机制 | 17年12月 |
组件创建、布局计算、数据绑定机制优化,提升性能 | 18年1月 |
重构模板编译工具,提升编译开发体验 | 18年1月 |
提供预览服务,提升开发效率 | 18年3月 |
提供配套的后端数据服务与基础设施,即 TAC 平台开放 | 18年3月 |
附录
Tangram 2.0 主要更新说明
- 组件模型的概念升级,从原来的『卡片』+『组件』升级成『布局』+『组件』,即原来的『卡片』认为是一种具有布局能力的组件,具备嵌套另一组件的能力;
- 页面结构优化,页面下可以直接挂载组件,不需要嵌套一层布局;
- 组件类型的语义化,从原来的 1、2、3、4...等数字枚举类型定义,升级成字符串类型的定义,兼容解析原有的数字枚举定义;
- 更好的嵌套布局实现,流式布局在模型描述上支持多层次的嵌套,并优化了 Android 端上的实现方式;
- margin 去重的实现,同一层级的容器组件或原子组件直接,支持外边距 margin 的去重,使得动态数据下控制间距更方便;
- 支持 zIndex,无论是容器组件还是原子组件,支持在其样式上配置 zIndex,zIndex 值越大,绘制层次越高;
- 升级组件开发方式,引入动态化组件开发技术,提升组件动态性,实现组件样式的高效渲染与动态更新;
其他相关的 Tangram 库
Android
- vlayout
- UltraViewPager
iOS
- LazyScrollView
工具
- virtualview_tools