UGUI最佳实践(4)-UI 控件优化

本章主要讨论特定类型UI控件的优化,虽然大多数UI控件在性能方面变化不大,但是有两个控件在项目后期经常会出现性能问题,这两个控件分别是 Text 和 ScrollRect。

UI Text

UGUI内置的 Text 组件可以方便的显示文字,然而,我并不知道它底层的很多行为,这些往往会成为性能的热点,必须记住的一点是,当我们向 UI 添加一个 Text 组件的时候,它会为每个字符创建一个四边形,这些四边形周围存在很多的空白空间,这取决于他的形状大小,这种方式很容易在放置文本的时候中断其他 UI 元素的批处理。

文本网格重构

文本网格重构是一个非常重要的问题,当 Text 组件发生变化时,就需要重新计算各个字符的四边形网格来显示实际的字符串,当Text组件或者父物体从 disable 转换到 enable状态时,即使字符串内容没有发生变化,也会重新进行计算。

这对于那些包含大量 Text 组件的 UI 是有性能问题的,最常见的情况就是排行榜和统计信息面板。显示/隐藏 UI 最常用的方法就是 Enable/Disbale 包含该 UI 的游戏对象,包含大量 Text 组件的 UI 经常会引起帧率的波动。

一个变通的解决方式,可以参考禁用 Canvas

动态字体和字体图集

当整个项目需要显示的字符集非常大,或者在运行之前是未知的,那么动态字体是一种很方便的选择。在运行时,Unity 会将 Text 组件中使用的字符打包到一个字符图集中。每一个加载的字体都会生成自己的字符图集,即使它与另一种字体类型相同,比如,使用 Arial 字体创建一个 blod 类型的 Text,和使用 Arial Bold 字体创建一个 normal 类型的字体,虽然他们最终的效果一样,但是使用的却是不同的字符图集,一个是 Arial、一个是 Arial Bold。

从性能方面考虑,最重要的是要了解,UGUI 为每种字体大小、字体样式、字符维护一个字形,比如:现在有两个 Text 组件,显示内容都是 ‘A’,那么:

  1. 如果两个Text 的字体大小(fontSize)相同,字符图集中只有一个‘A’字形。
  2. 如果两个 Text 的字体大小不一样(一个16,一个24),字符图集将会包含两份‘A’字形的拷贝(一个对应16号字体,一个对应24号字体)。
  3. 如果一个Text 的字体样式是 blod,而另一个是 normal,字符图集将会包含两份‘A’字形的拷贝(一个对应 bold 样式,一个对应 normal 样式)。

每当 Text 遇到字符图集中没有的字符时,都会对字符图集进行重新构建。如果字符图集能够容纳新的字形,就会将它添加到图集中,并将图集重新上传到图形设备,如果字符图集太小不能容纳新字形,系统将会尝试重新构建字符图集,这个过程主要分两个阶段:
第一步,使用相同尺寸重新构建图集,但是只将当前活动的 Text 组件的字形添加到图集中,如果这些字形能够成功添加到图集中,那么第二步就不需要执行了。
第二部,如果第一步执行失败了,说明相同尺寸的图集已经容纳不下需要显示的字符,只能采用更大尺寸的图集,新尺寸 = 当前尺寸 x 2;例如 512 x 512 增大后会变成 512 x 1024 大小。
由于上面的算法,图集尺寸只有在创建的时候才会增大,考虑到图集重构的资源消耗,需要将该过程的消耗降到最低,有两个可用的方法:

  1. 尽可能采用非动态字体以及采用预先配置好的字符集,对于字符范围约束比较好的情况,这通常能够很好工作,例如将可用的字符集限定为拉丁/ASCii。
  2. 如果需要极大量的字符集,比如整个 Unicode 字符集,此时必须使用动态字体,为了避免可预见的性能问题,在启动时使用一组合适的字符来填充字符图集,利用Font.RequestCharactersInTexture方法实现该功能。

需要注意的是,每个变化的 Text 组件都会触发字符图集的重构,所以当布局一个具有大量 Text 组件的 UI 的时候,最好是提前将所用到的字符收集起来,然后通过调用Font.RequestCharactersInTexture方法在开始的时候填充字符图集。这样可以保证字符图集只重构一次,而不用每次添加新字形的时候都进行重构。

另外需要注意的是,当字符图集重构的时候,不包含在当前活动的 Text 组件的任何字符,不会出现在新创建的字符图集中,即使它们当初是通过Font.RequestCharactersInTexture方法添加到图集中的,为了解决这个限制,监听 Font.textureRebuilt 事件(该事件在字符集重构的时候调用),并且通过查询 Font.charactorInfo 来确保所有期望的字符被填充到图集中。

在有些情况下字符集是确定的,并且字符的位置也相对固定,此时一个更好的办法是通过自定义组件展示 Sprite 来完成字符的显示,一个可能的例子是得分显示功能。对于得分显示,它所需要的字符集是确定的(0-9),可以将一个整数分解成单个数字,然后组合显示出来,这种方式在计算、显示、动画方面比是使用 Text 组件要更快。

回退字体和内存占用

首先解释一下什么是“回退字体”,当 Text 组件要显示一个字符的时候,首先从 Text 组件指定的字体中查找字符对应的字形,但是如果找不到怎么办?Unity采用了一个回退机制来解决这个问题,当我们导入字体的时候,导入设置中有一个“Font Names”属性,该属性相当于指定了一个备胎名字(回退字体的名称),如果在字体找不到相应的字符,会根据备胎名字,在项目中和用户机器上查找回退字体,最后在回退字体中查找相应字符。有两种情况会触发回调机制:

  1. Text 组件找不到指定的字体。这种情况是在导入设置时没有勾选 Incl. Font Data 选项(如果勾选该选项,在发布项目的时候,字体会自动的打包进项目中),而且用户机器上也没有安装该字体。
  2. Text 组件指定的字体不包含所请求的字形。比如在 Arial 字体中只包含了 Ascii 字符集,而不包括 Unicode 字符。

一个回退字体例子,首先导入两种字体,Arial和方正舒体,在导入设置中,将Arial字体的“Font Names”设置为“FZShuTi”,然后创建一个 Text 组件,在 Text 组件中输入一些汉字,此时字体采用系统默认的 Arial,汉字显示也是采用其默认的回退字体;现在将 Text 字体设置为导入的 Arial,此时的汉字显示采用回退字体。

为了支持这种回退行为,Unity 必须将 “Font Names”中列出的字体加载到内存中,如果回调字体的字符集非常大,那么回调字体的内存占用将会非常大,这在使用中文字符时非常常见。

Best Fit与性能

通常来说,永远不应该使用 Text 组件的 Best Fit 选项。
Best Fit 选项会自动的调节字体大小到 Text 组件边框能够容纳的最大尺寸,改尺寸受指定的最大/最小约束。上面提过,对于不同字体大小的字符,Unity都会向字符图集添加新的字形,所以启用 Best Fit 时,不同尺寸的字形将会非常快的把字符图集填满。

在Unity2017.3中,Best Fit 的字体大小检测并不是最优的,它会为尺寸测试过程中的每种尺寸都生成字形并添加到字符图集中,这增加了后期生成图集所需的时间。同样会造成字符图集的迅速填满,这会引起字符集重构,造成旧字形(当前没有Text 组件在显示的字符)被剔除出字符集。即使后续修复了这个问题,启用 Best Fit 仍然比较慢。

TextMeshPro Text组件

TextMeshPro(TMP)是UGUI 内置 Text/TextMesh 组件的替代方案,TMP 采用有向距离场(SDF)作为其主要的渲染管线,使得文本在任何尺寸和分辨率下都能清楚的呈现,使用一系列自定义Shader来完成 SDF 渲染,TMP能够通过改变材质的属性来动态的改变文本的展示效果,包括 Outline、Shadow。

在Unity2018.1之后,TMP作为一个资源包包含在项目当中。可以通过 Package Manager进行管理。

TMP的文本网格重构

与内置的 Text 组件相似,改变文本的展示效果将会触发Canvas.BuildBatch、Canvas.SendWillRendererCanvas函数被调用,尽可能少的修改 TextMeshProUGUI.text 属性,如果需要频繁的修改 TextMeshProUGUI.text 属性,那么应该为 TextMeshProUGUI 创建单独Canvas 组件,来提高 Canvas重构效率。

当需要在三维场景显示文本的时候,建议使用 TextMeshPro 来替代 TextMeshProUGUI,因为Canvas 的worldSpace 渲染效率比较低,直接使用 TextMeshPro 效率更高,并且不会引起 Canvas 的重构。

字体和内存占用

TMP没有提供动态字体功能,因此只能依赖于回退字体,理解回退字体如何加载和使用对于内存优化至关重要。
TMP采用递归的方式来查找字符,查找策略如下:

  1. 如果在TMP 的 Font Asset 中没有找到字符,TMP将会遍历Font Asset 的 Fallback Font Assets列表,从列表中第一个 Font Asset 开始查找,如果没有找到,继续递归它的 Fallback Font Assets。
  2. 如果仍然没有找到字符,接着在 TMP 的 spriteAsset 进行递归查找。
  3. 如果仍然没有找到字符,接着从 TMP_Settings.fallbackFontAssets 中进行递归查找。
  4. 如果仍然没有找到字符,从 TMP_Settings.defaultFontAsset 进行递归查找。
  5. 如果仍然没有找到字符,从 TMP_Settings.defaultSpriteAsset 进行递归查找。
  6. 最终如果还是没有找到,TMP 就会采用 TMP_Settings.missingGlyphCharacter 替换字符显示。(通常是一个方框)

当场景或者项目引用 TMP Font Assets 时,它们就会被加载,主要包括 Text 组件的引用、TMP_Setting的引用、自身回退的引用,当第一个包含TMP Text 组件的场景被加载时,TMP_Setting中引用的 FontAsset 和 SpriteAsset 将会递归的加载。

此外,如果一个 TMP Text 组件引用的 FontAsset 在 TMP_Setting 加载时没有被加载,那么在该组件第一次被激活时,将会被递归加载。当项目中包含许多 FontAsset 时,必须时刻谨记这一过程,特别是可用内存比较成问题时。

由于以上原因,使用 TMP 进行项目本土化是一个需要解决的问题,把所有语言的字体资源都通过 TMP_Setting 进行加载对内存压力非常大。如果必须进行本地化,建议只有在需要时才指定字体资源以及回退资源(当场景加载的时候),或者使用AssetBundle 进行模块化加载。

当程序启动时,在初始化程序中验证用户的区域,然后为配置每个 Font Asset 的回退字体:

  1. 为基础的 TMP Font Assets 创建 AssetBundle。(例如只包含拉丁字符)
  2. 每种语言的 Font Assets 分别创建各自的 AssetBundle。
  3. 在初始化步骤中首先加载基础的 AssetBundle。
  4. 然后根据用户的区域,加载对应的 AssetBundle。
  5. 设置步骤3中加载的的所有基础字体,将回退字体指定为步骤4加载的字体。

如果不使用默认的 Sprite Asset 中的表情图片,可以将它的引用从 TMP_Setting 中移除,能够节省一点内存资源。

TMP 的 Best Fit 与性能

再次强调,由于TMP不支持动态字体功能,所以 UGUI 部分描述的 Best Fit 问题是不存在的,唯一需要考虑的是,当使用 Best Fit (TMP中是Auto Size)时,TMP采用二分查找来查找合适的字体大小,最好只使用 AutoSize 来查找最合适的字体大小,一旦字体大小确定下来以后,关掉AutoSize 选项,然后手动设置字体大小,这样能够提升一定的性能,并且能够避免一组 Text 的字体大小不一样。

Scroll View 组件

在填充率问题之后,Scroll View 组件是引发性能问题的第二大根源。Scroll View 通常需要在 Content 区域内显示很多的元素,Scroll View 有两种使用方式:

  1. 直接将所有的元素填充进来。
  2. 采用对象池,通过设置元素的位置,实现元素重复利用。

这两种方式各有优劣,首先第一种当元素数量增多的时候,会增加实例化时间以及ScrollView 重构的时间,如果元素的数量非常少,比如只有少量的 Text 组件,那么采用这种方式是比较简单方便的;第二种方式需要花大量代码去实现,后面会提供两种实现方式,对于任何复杂的滚动 UI,通常都需要采用对象池来解决性能问题。

对于所有的使用方式,都可以通过向ScrollView添加 RectMask2D 组件来提升性能,该组件能够保证在 ViewPort 范围之外的元素不被添加到绘制列表中。(该列表中的元素在Canvas重构时,必须进行网格生成、排序、分析)

简单Scroll View 元素对象池实现

为了使 Layout 系统能正确计算 ScrollView 内容大小以及 ScollBar 能够正常工作,可以使用 LayoutElement 作为占位符,为每个要生成的元素生成一个 LayoutElement。

然后创建一个对象池来管理 UI元素,当 LayoutElement 占位符进入到ScrollView 的显示区时,从对象池中获取UI元素并将它放到占位符下面。

这种方式会减少批处理元素数量,因为批处理的消耗是随着 Canvas Renderer 的数量增长,而非随着 RectTransform的数量。

存在的问题

一般,当UI元素的父物体发生变化,或者兄弟顺序发生变化时,UI元素以及他的所有子物体都会被标记为 dirty,然后强制它们的Canvas 进行重构。

造成这一问题的原因是Unity 没有将父物体变化回调和兄弟顺序变化回调分开处理,两者都是触发 OnTransformParentChanged 回调,而在UGUI的Graphic源码中,OnTransformParentChanged 方法调用了 SetAllDirty 函数,对于被标记为dirty 的Graphic,系统会在下一帧显示的时候,对它的Layout 和 顶点进行重构。

一种可能的办法是通过给每个UI元素的跟物体添加一个 Canvas来限制重构范围,但是这样会增加draw call 数量,进一步说,如果每个元素都包含多个Graphic组件、Layout组件,那么重构它们的花销将会明显降低帧率,尤其是在低配置设备上。

基于位置的 ScrollView 对象池

为了避免上述的问题,可以通过移动RectTransform 的位置来实现元素的重用,这样能够减少不必要的重构,明显提升性能。
为了实现这种方式,最好的办法是自定义 ScrollView 的子类,或者自定义个 LayoutGroup 组件,后一种实现方式要稍微简单一点,只需要从 LayoutGroup 继承一个子类就可以。

自定义 LayoutGroup 组件可以分析需要显示的数据,来检查有多少个必须显示的元素,然后调整 Content 的 RectTransform 组件,通过订阅 ScrollView.onValueChange 事件来调整元素的位置。