Lark新特性解读(三)

Posted on July 30, 2015
更新: Lark项目的新特性现已全面合并到Egret Engine 2.5+中,不再独立维护,Lark GUI(Swan)扩展库,对应Egret Engine 2.5+中的EUI扩展库。

这是这系列文章的第三篇,主要介绍Lark GUI库的新特性。Lark GUI的API在swan这个模块名下,开发者可以通过swan.*方式访问到它们。在前两篇介绍的Lark核心库中,我们通过各种优化措施,最终带来了性能的大幅提升和包体的显著降低。在Lark GUI中,我们也同样对这两部分做了全面的优化:清除冗余计算,精简嵌套层次,减少类继承深度,并让包体最终降到了原先版本的40%。Lark GUI目前的版本gzip后,包体仅有36k大小,对加载量已构不成压力。

我们从未停止过对GUI体系本身的改进,严格意义上说,Lark GUI已经是这套GUI体系的第三个版本。前两个版本分别完成了架构精简和平台迁移的任务。在写这个版本时,我们已经在HTML5平台上摸爬滚打了一年多,趟过无数的坑,是时候利用这些积累的经验,再对GUI体系做一次更全面的升级。因此我们没有停留在性能和包体上的优化上,而是更进一步:从底层架构上重点解决了之前设计不够灵活以及学习曲线陡峭的问题。为了交一份更加有诚意的答卷,我们跟大量开发者进行了沟通。总结了新手接触GUI体系时比较集中的几个痛点:

  1. 异步问题。失效验证机制带来的延迟计算优化,导致组件宽高无法立即得到,立即访问会返回0。

  2. 不统一的显示列表。两套显示列表使用不便,开发者需要额外判断addElement()和addChild()用在什么地方。

  3. 皮肤分离机制过于严格。皮肤分离机制在定义可复用的外观上更有优势。但对于只使用一次的外观定义不够方便。

新版本的GUI库在提高新手易用性上做了大刀阔斧的重构,以上痛点在Lark GUI中也都得到了彻底解决,在以下的新特性解读中,将会逐一覆盖到。

组件立即返回宽高

第一小节的篇幅可能稍长,因为我们在分析这个问题的过程中,得出了一条避免造成初学门槛的重要结论:子类不应该改变父类行为。(重要的事情加粗,就不说三遍了 :D)

先简单介绍失效验证机制:就是属性发生改变时,不立即应用改变,而是先标记下来,延迟一定时间片后再统一计算,这样可以有效避免重复的计算量,从而提升运行时性能。尤其在使用自适应流式布局时,失效验证能起到强力的性能保障作用。而组件的宽高是需要动态测量的,所以也需要用失效验证机制延迟计算,因此实例化组件后,立即访问宽高将会返回0.

之前我们会告诉开发者,有两种方式解决可以此问题:1.调用组件的validateNow()方法,提前验证失效的属性。2.使用callLater()延迟到失效验证结束再访问宽高。虽然能解决问题,但是仍然不断有新开发者吐槽这个问题。实际上原生显示对象DisplayObject的宽高也是异步计算的,但却从没人吐槽过,因为它是延迟到访问宽高时执行了立即计算。那么问题来了,为什么我们不能让GUI组件访问宽高时也立即validateNow()呢?主要是由于原生显示对象的宽高只由子项决定,在访问宽高时可以立即确定。而GUI组件由于加入了自动布局机制,组件的尺寸不仅跟子项有关,还跟父级容器有关。层层关联的话,跟整个显示列表都有可能有关系,所以只能在所有操作结束之后才能确定。

所以无解了?并不是,继续再跟开发者深入沟通后,我们发现真实的需求都是:给容器添加一个子项后,访问容器宽高能立即得到包含子项的值即可,其实并不关心父级自动布局造成的影响。一句话总结也就是:希望能把GUI对象当做普通显示对象来用。 我们再分析了其他的几条痛点,例如显示列表不统一的问题,抽象出来质上几乎本都是相同的问题:子类改变了父类的行为,但开发者都是基于已有知识体系上手的,所以调用一个API发现不可用的时候,就会造成极大的挫折感。这才是造成「难学」「不好懂」的根本原因。

基于这个结论,我们重构了所有子类改变父类行为的设计。确保每个GUI组件都能直接当成普通显示对象来用。开发者在初学时,可以基于已有知识体系,一点一点过渡到新的设计上,而不至于在调用一个熟悉的API时,遇到报错而受挫。现在访问GUI组件宽高时,也会跟原生显示对象的表现一致,立即能得到包含子项的宽高值。

统一的显示列表

之前版本的GUI库引入了addElement()系列方法,用于替代addChild()系列方法,在GUI范围内调用addChild()方法将会报一个错。这么做的主要原因是自适应流式布局是层层向上测量,层层向下布局的。如果GUI显示列表中间混入了一个普通显示对象,将会造成自动布局体系断层而在那一层失效。所以想要正常启用自适应流式布局,就应该让GUI组件添加在一起,中间没有断层。但是这其实应该算一种最佳实践,不应该在框架层级去强制限制,开发者还是会有混合添加普通显示对象的需求的。之前是提供了UIAsset组件作为普通显示对象的包装器加到GUI显示列表,但这还不够方便。

基于以上得出的结论,两套显示列表并屏蔽addChild()系列方法的设计,显然是违反了「子类不能改变父类行为」原则的。现在Lark GUI里只有addChild()系列方法,已经不存在addElement()方法。任意GUI组件和普通显示对象都可以互相混合添加。这里我们需要讨论的是两种添加情况的处理方式:

1.普通显示对象添加到GUI组件里。效果跟设置GUI组件的includeInLayout属性为false类似。布局类在计算布局时会主动忽略它,对它不测量也不布局,这样本身也比较符合预期。那么也就不在需要UIAsset包装器了,任何一个GUI组件只要有addChild()方法,都可以直接添加普通显示对象。

2.GUI组件添加到普通显示对象里。这个在之前的的GUI库里就是允许的。在这种情况下,GUI组件被当做一个普通显示对象来用,你对它设置的left,right等布局属性都无效,因为布局属性是需要父级容器对它布局的。而你的父级不是GUI组件。这个就是所谓的断层处。但是只会影响这一层,在组件内部再添加其他GUI组件,是可以正常布局的。

所以结论是,开发者一开始可以不使用自动布局功能,把所有GUI组件仅当做普通显示对象来操作。若需要开始用自动布局,可以再采用最佳实践的方式,将GUI组件都组织到一起。

EXML支持内部类

首先简单介绍皮肤分离的机制:皮肤分离机制就是将原本一个组件拆分成两个。一个逻辑组件只管代码控制,一个皮肤组件只负责外观。运行时将皮肤组件附加到逻辑组件上,变成一个完整组件。皮肤组件并不是显示对象,实际上更类似一个持有外观信息的数据对象。这样做的好处比较多,例如:方便代码解耦,方便复用外观,方便可视化编辑,等等。

而传统的UI方式,通常是只有一个组件,在组件上直接修改预设的外观属性。这个带来的问题是你必须在UI组件上声明非常多的 外观属性,例如文本颜色,背景色等,而且不管声明再多,通常也都还是会不够用,导致可自定义的部分比较有限。而皮肤分离的模式,逻辑组件上基本不声明任何外观属性。完全交给另一个皮肤组件去决定外观。这样可以完全自定义,扩展性上比较灵活。

之前的GUI库里必须将皮肤声明为一个独立的EXML文件,再引用它。对于需要复用的皮肤,这种方式比较理想。而对于只用一次的外观,则会比较不便,显得文件也特别多。现在Lark GUI里已经支持EXML的内部类定义方式,可以直接嵌套写在节点内。通常有两种节点支持作为内部类:Skin和ItemRenderer,如下图:

EXML

如图中的例子,这个Button的外观只有它自己使用,那么可以从节点内部可以直接开始描述一个Skin,而不需要另外声明一个ButtonSkin的exml文件。另外一个比较常用的是ItemRenderer,ItemRenderer通常都是直接跟List关联的,很少有复用的情况,现在也可以直接嵌入写在List节点内部。

在代码上看,内部类的作用起来可能只是少创建了一个文件而已。但是在工具层面,这将会是完全不同的操作体验。之前要定义外观前,我们总是得先创建一个皮肤文件,编辑完后回来引用这个皮肤文件。现在的流程可以简化为:拖拽一个按钮到界面上,双击直接进入编辑外观。如果你不需要复用它,那么就结束了。当你需要复用这个按钮皮肤时,再一键将内部皮肤转换为独立EXML文件,变成可复用的。

运行时解析EXML

XML的文件结构描述显示列表有着天然的优势。在之前的GUI库里,EXML文件是在命令行编译阶段被编译为了JS文件,然后作为标准代码加载运行的。我们反复优化了好多次编译结果,始终还是EXML文件本身才是最小的记录方式。在Lark GUI中,我们将EXML文件改为运行时解析,而不再提前编译。这样做的几个好处:

  1. 能够减少网络加载量。

  2. 减少中间转换过程,降低调试难度。也可以直接在编辑器编辑后拷贝EXML内容到代码中粘贴解析。

  3. 由于之前编译器只为TS开发者设计的,现在不再依赖命令行,JS开发者也能直接使用EXML文件。

不过不用担心性能问题,运行解析并不是每次实例化皮肤都解析一次,而是只有第一次解析,会将EXML编译为JS代码,然后使用eval()方法转换为标准的类定义。之后都直接调用类定义快速创建。

另外,之前EXML的模块名是根据所在文件夹路径生成的,现在由于EXML文件变成了运行时解析,有可能只有文本内容,并没有路径信息,因此包名也不再依赖文件路径。我们提供了另一种声明类名的方式:在EXML根节点上设置class属性,class属性的值会被解析并注册为全局类名。若不声明,这个EXML文件解析的类定义会被解析器作为一个临时变量返回。声明方式如下图:

Class

EXML描述非皮肤对象

之前版本的GUI库里,EXML的根节点被限制为Skin皮肤节点,只能用于描述皮肤,实际上还有较多显示列表初始化需求,是直接使用Group等不可定义皮肤组件作为根容器初始化的。这样的情况就只能使用代码方式手动编写显示列表初始化代码,而不能使用EXML这种更加简便的描述方式。现在这部分代码,在Lark GUI里也可以直接用EXML描述了。EXML的根节点不再必须是Skin,可以为任意组件。这个特性全面提升了EXML的适用范围,能够简化普通容器的显示列表创建过程。解析后的对象是一个继承自根节点的自定义类。定义了ID的节点,会在自定义类上以ID名声明一个成员变量持有该节点的引用。

动态数据绑定

数据绑定一直都是呼声相当高的一个便捷功能。现在Lark GUI里也已经对其提供了支持。注意前文「EXML支持内部类」一节中的配图,可以发现ItemRendeerer内的Label节点已经使用了数据绑定功能:

text="{data.label}"

它表示Label的text属性与ItemRenderer的data.label属性绑定。当列表的数据源改变时,ItemRenderer里的Label会自动刷新显示的文本内容。而不用手动写刷新的逻辑代码。在EXML中,开发者只需要简单地使用一对{expression}即可完成数据绑定,大括号内的expression表示根节点组件上的属性或当前EXML内定义的ID(实际上也是根节点上的属性)。注意在这个ItemRenderer的例子中,由于ItemRenderer是内部类,根节点就是ItemRenderer自身。

数据绑定功能相当于给静态的XML语法加入了部分动态刷新的功能,能够极大程度减少逻辑代码的编写量,在配合列表的ItemRenderer视图刷新尤其方便。之前我们通常要写一个ItemRenderer的逻辑类,覆盖dataChanged()方法,访问data属性,然后重新赋值刷新所有相关的视图组件。现在只需要简单地定义一个数据绑定标签,无需任何繁琐的过程。

另外值得一提的是,Lark GUI里提供的数据绑定功能是基于setter的方式,改变的时候才会触发一次,并不是定时刷新检查的。得益于JavaScript的动态语言特性,所有的Object对象都可以实现动态数据绑定,并不限定于Lark框架内的对象。

自动布局兼容旋转缩放

这个特性也相当有用,之前版本的GUI库里,如果组件设置了旋转或缩放,自动布局的时候,依然是按照未变换之前的矩形来计算的,会造成诸多不便。现在Lark GUI里对这部分也实现了完美兼容,不仅旋转缩放,对GUI组件使用Matrix进行的任意变换,都可以按照实际显示的矩形区域被正确测量和布局。

Lark GUI里的优化远不止以上这些,但是限于篇幅。剩下的都是与具体的组件相关的优化和重构,这里就不一一赘述了。希望这系列的文章能够让大家对Lark有一个初步的印象,感谢关注!