Weex Android 文字渲染优化

本文首发于WeexTeam/Article,描述了在Android端,如何利用文字的style和内容,对文字进行measure和draw。

背景

在做Weex Android适配工作的时候,发现当Text没有设置高度,需要Weex根据文字内容、样式,计算出宽高的时候,在小米手机上可能会出现文字截断现象。

例如,前端期望如下图所示的渲染效果:

正常的文字

然而在小米手机上的渲染效果却是下面这样,默认标题那一段最后一行的文本被截断了:

被截断的文字

原因

在Android系统中,View的渲染可以分为Measure,Layout,Draw三步,对于Measure这一步,Weex和原生Android略有不同:

  • 在Android系统中,默认渲染文字的方式是使用TextView及其子类,TextView的宽度或高度可以使用wrap_content,match_parent或指定的值。
  • 在Weex中,Weex View的宽度和高度是由CSS属性指定或者css-layout根据flex属性计算出来的,Layout的时候使用FrameLayout.LayoutParams进行布局,因此并不存在wrap_content,match_parent这两个概念。对于文字View,如果在CSS中没有指定widthheight,css-layout会要求文字节点(TextDom)根据文字内容和文字样式(如font-size,font-family,line-height等),使用android.text.Layout,计算出该节点的宽度和高度,然后使用Weex view的布局方式。

在Draw这一步的时候,无论是Weex还是原生Android,都是使用TextView.onDraw()进行绘制的。在绘制文字的时候,TextView会根据文字内容和文字样式生成一个android.text.Layout对象,并根据此对象把文字画出来。

Weex渲染Text的过程可以用下图表示:

Weex Text

由于DOM和View针对同一个TextDom,生成了两个android.text.Layout对象。而android.text.Layout是一个接口,在DOM层和View层上可能使用了不同的实现,即DOM和View生成的android.text.Layout可能不一样。换句话说,DOM层负责Measure,View层负责Draw,Measure与Draw的结果可能存在差异,这样就可能导致了文字截断现象。

例如,对于一段中英文混排的文字,DOM层可能把文字计算成4行,而由于换行规则(android.text.StaticLayout.nComputeLineBreaks())不同,View层可能把文字计算成3行,这样就出现了Measure和Draw的结果不一致,发生了文字截断现象。

解决方案

DOM层和View层使用同一个对象分别进行Measure和Draw,确保Measure和Draw的结果一致,即可解决此问题。这个方案需要解决两个问题:

  • 使用一种统一的方案表示多种文字样式(font-size,font-family等)。
  • 找到一种方法,可以根据文字样式和文字内容计算文字区块的高度和宽度。

对于第一个问题使用Span解决,第二个问题则使用Layout机制解决。

Span

Android中的Span类似于html的<span>标签,可以用来描述一段inline文本的样式。

Span可以大致分为如下三种类型:

  • CharacterStyle,能影响文本中的每一个Character显示效果的Span。CharacterStyle
  • ParagraphStyle,能影响文本一段或者一行显示效果的Span。ParagraphStyle
  • UpdateAppearance,能动态修改文本中每一个Character显示效果的Span。UpdateAppearance

很多关于文字的CSS style都可以映射到某种类型的Span上。例如,

  • font-size可以映射到android.text.style.AbsoluteSizeSpan
  • font-weightfont-style都可以映射到android.text.style.StyleSpan
  • color可以映射到android.text.style.ForegroundColorSpan
  • text-decoration可以映射到android.text.style.UnderlineSpanandroid.text.style.StrikethroughSpan
  • font-family可以映射到android.text.style.TypefaceSpan
  • text-align可以映射到android.text.style.AlignmentSpan
  • line-height可以映射到android.text.style.LineHeightSpan

Weex还支持text-overflowlines(某段文字最多显示几行)两个属性,这两个属性并没有原生的Span与之对应,需要自行实现。

text-overflowlines

lines属性指定了文字区块最多可以显示几行,text-overflow属性则说明了如果文字的行数超过了lines要如何处理,这两个属性在逻辑上关联紧密,实现的时候需要一起处理。

如果只需要支持Android API 23及以上,上述两个属性已有原生实现,使用StaticLayout.Builder即可。然而在Weex中,minSdkVersion为14,因此无法使用StaticLayout.Builder

故Weex使用如下过程来支持text-overflowlines属性:

  1. 检查当前行数是否超过lines,如果是,进入2,否则结束。
  2. 找到最后一行的第一个字符和最后一个字符,如果二者不是一个字符,进入3,否则结束。
  3. 将文字分为末行和非末行。进入4。
  4. 如果text-overflow是ellipse,用\u2026(HORIZONTAL ELLIPSIS)替换末行最后一个字符,否则什么都不做。进入5。
  5. 将非末行文字和末行文字拼接成一个新的字符串并重新layout。进入1。

在Weex中以上计算过程是一个递归的过程,当前行数小于等于lines时,则停止递归。伪代码如下:

function updateLines(text, ellipse, lines)
	layout = makeLayout(text)
    if lines > 0 && lines < layout.lineCount
    	//Let lastLineStart and lastLineEnd be the index of the character
  		lastLineStart = layout.lineStart(lines - 1)
  		lastLineEnd = layout.lineEnd(lines - 1)
  		if lastLineStart < lastLineEnd
    		mainText = text.slice(0, lastLineStart)
    		remainder = text.slice(lastLineStart, !ellipse ? lastLineEnd : lastLineEnd - 1)
    		text = main
    		text += remainder
    		//text is a string, and += is the string concatenation operation
    		if ellipse
      			text += '\u2026'
    		updateLines(text, ellipse, lines) #### `line-height` `android.text.style.LineHeightSpan`并不能直接指定`line-height`,只能通过设置`Top`,`Ascent`,`Descent`,`Bottom`几个属性,从而间接设置`line-height`。下图阐述了这几个参数的意义: ![Paint.FontMetricsInt](http://i.stack.imgur.com/LwZJF.png)

根据上图,line-height可以被定义为AscentDescent之间的距离。当指定的line-height大于原始的AscentDescent之间的距离时,需要扩大AscentDescent之间的距离,反之需要缩小AscentDescent之间的距离。

有如下约定:

  • basline为x轴,向下为正,向上为负。
  • leading=line-height-(descent-ascent)
  • half-leading=leading/2
  • leadinghalf-leading可能为负数。

如果leading不为0,需要根据half-leading调整AscentDescent。此外,根据StaticLayout的源代码,AscentDescent并不会作用于首行顶部和末行尾部,需要调整TopBottom以处理首行和末行。上述逻辑可以用如下伪代码表示:

halfLeading = (lineHeight-(descent-ascent))/2;
top -= halfLeading;
bottom += halfLeading;
ascent -= halfLeading;
descent += halfLeading; #### Build a span 在Android中,构建`String`可以使用`StringBuilder`,构建`Span`的时候,则可以使用`Spannable`接口的三个子类:
  • SpannedString适用于文字的内容和文字的span都不变化的场景。
  • SpannableString适用于文字的内容不变,但是文字的span可能变化的场景。
  • SpannableStringBuilder适用于文字和文字的span都可能变化的场景。

在Weex中,使用的是SpannableString,每次更新文字内容,会创建一个新的Span

Layout

使用Spannable接口后,得到的仅仅是一个文本流,并不包含文字区域的高度、宽度、首行、末行这些与Measure或Layout相关的内容,因此还需要使用android.text.Layout对文字进行Measure和Layout。使用android.text.Layout时,把Spannable与文字区块的宽度做为Layout的构造函数的参数,即可完成文字的Layout过程。android.text.Layout有以下三种实现方式:

  • BoringLayout单行文字,文字方向为LTR。
  • StaticLayout根据Spannable和指定宽度计算文字行数,文字方向由文字内容决定
  • DynamicLayout除了含有StaticLayout的功能外,还包含动态更新功能。当Spannable更新的时候,Layout.getLines()也会随之变化。在内部实现上,DynamicLayout有一个Watcher,这个Watcher观察着Spannable的变化。DynamicLayout一般与SpannableStringBuilder配合使用。

此外,可以使用Layout.draw(Canvas c)来把Layout对象画在指定的Canvas上。在DOM中生成Layout对象,计算出文字的宽度和高度后,把Layout对象传递给View,View调用Layout.draw(Canvas c)即可把文字画出来,这样就保证了Layout与Draw的一致性。

线程同步

在Weex中,DOM相关的操作运行在DOM线程中,View相关的操作运行在UI线程中,二者可能同时操作同一个Layout对象,这样就存在着线程同步问题。考虑到加锁对性能的影响,Weex没有使用锁,而是AtomicReference解决这个问题。

DOM线程内有两个android.text.Layout对象,一个是TextDom的私有成员变量,一个是AtomicReference中保存的引用。之后使用如下机制保证UI线程和DOM线程不会操作同一个android.text.Layout对象,避免了加锁带来的额外开销。

  • UI线程通过AtomicReference来读取Layout对象。
  • DOM线程在计算开始的时候,生成一个新的android.text.Layout对象。在计算过程中把计算的中间结果也保存到这个对象中。DOM线程计算结束后,把计算结果更新到AtomicReference中,同时清空私有成员变量android.text.Layout

即UI线程负责Read,DOM线程负责Write;Read与Write操作的不是同一个对象,在DOM线程完成工作后,会更新Read输出的对象。

性能

  • 优化前的方案使用Layout和Text对象进行渲染,一次渲染需要生成两个Layout对象;
  • 优化后的方案使用Layout和对象进行文字渲染,一次渲染只需要生成一个Layout对象; 因此,在优化后,可以预期性能会得到一定幅度的提升。

下面分两种场景测试文字性能。

一段长文本

被截断的文字

使用上图所示的一段长文本做为测试文本,针对优化前和优化后两种场景,在小米手机上得到首屏加载时间如下图所示:

次数 优化前首屏加载时间 优化后首屏加载时间
1 825 813
2 832 721
3 816 761
4 852 756
5 850 750
6 838 766
7 863 781
8 846 780
9 793 753
10 905 727
平均 842 760.8

根据以上数据,可以看到优化后,一段长文本的渲染性能得到一定的提升,上述数据的性能提升幅度为(842-760.8)/842=9.6%。

多段短文本

被截断的文字

使用上图所示的多段短文本,针对优化前和优化后两种场景,在小米手机上得到首屏加载时间如下图所示:

次数 优化前首屏加载时间 优化后首屏加载时间
1 987 987
2 1056 869
3 948 880
4 997 822
5 969 947
6 1036 967
7 939 900
8 869 878
9 826 949
10 931 832
平均 955.8 903.1

根据以上数据,可以看到优化后,多段短文本的渲染性能也得到了一定的提升,上述数据的性能提升幅度为(955.8-903.1)/955.8=5.5%。

结论

使用以上的优化策略,在小米手机上得到了如下的文字渲染效果,可以看到,文字截断的现象消失了。 小米上的正常文字

上述优化策略的流程如下图所述:

改进后文字渲染流程

这次改进使用Layout和Span机制,解决了Measure和Draw不一致的问题,避免了为小米手机编写额外适配逻辑的成本。

此外,首屏加载性能也得到了小幅度提升。

参考文章

  1. http://instagram-engineering.tumblr.com/post/114508858967/improving-comment-rendering-on-android
  2. http://flavienlaurent.com/blog/2014/01/31/spans/
  3. http://stackoverflow.com/questions/27631736/meaning-of-top-ascent-baseline-descent-bottom-and-leading-in-androids-font