Weex Android 文字渲染优化
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中没有指定width
和height
,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的过程可以用下图表示:
由于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。ParagraphStyle
,能影响文本一段或者一行显示效果的Span。UpdateAppearance
,能动态修改文本中每一个Character显示效果的Span。
很多关于文字的CSS style都可以映射到某种类型的Span
上。例如,
font-size
可以映射到android.text.style.AbsoluteSizeSpan
。font-weight
和font-style
都可以映射到android.text.style.StyleSpan
。color
可以映射到android.text.style.ForegroundColorSpan
。text-decoration
可以映射到android.text.style.UnderlineSpan
或android.text.style.StrikethroughSpan
。font-family
可以映射到android.text.style.TypefaceSpan
。text-align
可以映射到android.text.style.AlignmentSpan
。line-height
可以映射到android.text.style.LineHeightSpan
。
Weex还支持text-overflow
和lines
(某段文字最多显示几行)两个属性,这两个属性并没有原生的Span与之对应,需要自行实现。
text-overflow
和lines
lines
属性指定了文字区块最多可以显示几行,text-overflow
属性则说明了如果文字的行数超过了lines
要如何处理,这两个属性在逻辑上关联紧密,实现的时候需要一起处理。
如果只需要支持Android API 23及以上,上述两个属性已有原生实现,使用StaticLayout.Builder
即可。然而在Weex中,minSdkVersion
为14,因此无法使用StaticLayout.Builder
。
故Weex使用如下过程来支持text-overflow
和lines
属性:
- 检查当前行数是否超过lines,如果是,进入2,否则结束。
- 找到最后一行的第一个字符和最后一个字符,如果二者不是一个字符,进入3,否则结束。
- 将文字分为末行和非末行。进入4。
- 如果text-overflow是ellipse,用
\u2026
(HORIZONTAL ELLIPSIS)替换末行最后一个字符,否则什么都不做。进入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
可以被定义为Ascent
和Descent
之间的距离。当指定的line-height
大于原始的Ascent
和Descent
之间的距离时,需要扩大Ascent
和Descent
之间的距离,反之需要缩小Ascent
和Descent
之间的距离。
有如下约定:
- basline为x轴,向下为正,向上为负。
leading
=line-height
-(descent
-ascent
)half-leading
=leading
/2leading
和half-leading
可能为负数。
如果leading
不为0,需要根据half-leading
调整Ascent
和Descent
。此外,根据StaticLayout
的源代码,Ascent
和Descent
并不会作用于首行顶部和末行尾部,需要调整Top
和Bottom
以处理首行和末行。上述逻辑可以用如下伪代码表示:
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不一致的问题,避免了为小米手机编写额外适配逻辑的成本。
此外,首屏加载性能也得到了小幅度提升。
参考文章
- http://instagram-engineering.tumblr.com/post/114508858967/improving-comment-rendering-on-android
- http://flavienlaurent.com/blog/2014/01/31/spans/
- http://stackoverflow.com/questions/27631736/meaning-of-top-ascent-baseline-descent-bottom-and-leading-in-androids-font