https://developer.baidu.com/topic/show/290696
背景
Android的屏幕碎片化严重,各种屏幕分辨率层出不穷,而在不同分辨率的屏幕上显示出一致的效果,是百度App的研发团队和视觉团队共同追求的目标。
在百度App的Android开发中,TextView的行间距屏幕适配问题在研发和视觉之间纠缠已久。
该图为热议页面的图文模板在三款设备上的显示效果。可以看到TextView的行间距在三款设备下的一致性表现不尽如人意,而这已成为日常UI开发以及视觉review过程中的一大痛点,降低了大家的工作效率。
下面将探索一种简单优雅的的TextView行间距适配方案。
分析
先来分析下TextView在不同设备上行间距表现不一致的原因。百度App的UI团队使用Sketch工具来进行UI设计以及UI review,因此本文接下来字体尺寸的测量都借助Sketch工具完成。
先看下面一个简单的xml布局:
<TextView
android:id="@+id/title"
android:layout_width="match_parent" android:layout_height="wrap_content" android:text="虽然此视图的实际布局取决于其父视图和任何同级视图中的其他属性。虽然。。。" android:textSize="16dp"/>
将这段代码运行在不同分辨率的机型上,借助Sketch工具测量出各机型的行间距如下:
从图中可看出,同样的字号大小,在分辨率为720设备上,行间距测量结果为5px;在分辨率为1080设备上,行间距测量结果为9px。
接下来修改下字号,将textSize改成24dp,并且看一下Mate20的效果:
<TextView
android:id="@+id/title"
android:layout_width="match_parent" android:layout_height="wrap_content" android:text="虽然此视图的实际布局取决于其父视图和任何同级视图中的其他属性。虽然。。。" android:textSize="24dp"/>
在同一款设备(Mate 20)上,不同的字号,行间距的测量结果,如下图所示:
从图中可看到,在同样的设备上,不同的字号,行间距的测量结果也不一样。
具体表现为:字号越大,行间距越大。这就让人非常苦恼了,因为一旦字号发生了变化,行间距就受到影响,行间距必须得跟随字号重新调整,无形之中就增加了额外的工作量。
读到这大家可能会有疑问:XML布局中并没设置lineSpacingExtra / lineSpacingMultiplier属性,那么上面所测量的行间距是哪来的呢?
这是因为视觉对行间距的定义和Android系统对行间距的定义不一致导致的。视觉层面定义行间距非常简单:即使用Sketch工具在上下相邻的两行文字中输入大小相同的文字,同时画出文字的矩形框,矩形框的高度为文字的大小,比如在1080P,density=3的设计图中,文字大小为16dp,那么矩形框的高度就设为48px。上下两个矩形框的间距就为文字的行间距,这从上面的测量效果图也可看出。
也就是说,即使没有设置lineSpacingExtra / lineSpacingMultiplier属性,但从视觉的角度来讲,仍存在一定的行间距。
那么在没有设置lineSpacingExtra / lineSpacingMultiplier属性的情况下,视觉所测量出来的行间距是什么原因导致的?下面结合TextView源码详细分析下,首先看下图:
该图展示了一行文字的绘制所需要的关键坐标信息,图中的几根线表示字体的度量信息,在源码中与其相对应的类为FontMetrics.java,代码如下:
/** * Class that describes the various metrics for a font at a given text size. * Remember, Y values increase going down, so those values will be positive, * and values that measure distances going up will be negative. This class * is returned by getFontMetrics(). */
public static class FontMetrics { /** * The maximum distance above the baseline for the tallest glyph in * the font at a given text size. */ public float top; /** * The recommended distance above the baseline for singled spaced text. */ public float ascent; /** * The recommended distance below the baseline for singled spaced text. */ public float descent; /** * The maximum distance below the baseline for the lowest glyph in * the font at a given text size. */ public float bottom; /** * The recommended additional space to add between lines of text. */ public float leading; }
代码中对字体度量信息的每个字段含义的解释非常详细,大家看注释即可,就不再过多解释。TextView对每行文字坐标信息的计算细节是在StaticLayout.java类中的out()方法完成的,代码如下:
private int out(final CharSequence text, final int start, final int end, int above, int below, int top, int bottom, int v, final float spacingmult, final float spacingadd, final LineHeightSpan[] chooseHt, final int[] chooseHtv, final Paint.FontMetricsInt fm, final boolean hasTab, final int hyphenEdit, final boolean needMultiply, @NonNull final MeasuredParagraph measured, final int bufEnd, final boolean includePad, final boolean trackPad, final boolean addLastLineLineSpacing, final char[] chs, final int widthStart, final TextUtils.TruncateAt ellipsize, final float ellipsisWidth, final float textWidth, final TextPaint paint, final boolean moreChars) { final int j = mLineCount; // 偏移量,标识当前的行号 final int off = j * mColumns; final int want = off + mColumns + TOP; // 一维数组,保存了TextView各行文字的计算出来的坐标信息。 int[] lines = mLines; final int dir = measured.getParagraphDir(); // 将所有的字体的度量信息存入fm变量中,然后通过LineHeightSpan接口将fm变量传递出去. // 这就给外部提供了一个接口去修改字体的度量信息。 if (chooseHt != null) { fm.ascent = above; fm.descent = below; fm.top = top; fm.bottom = bottom; for (int i = 0; i < chooseHt.length; i++) { if (chooseHt[i] instanceof LineHeightSpan.WithDensity) { ((LineHeightSpan.WithDensity) chooseHt[i]) .chooseHeight(text, start, end, chooseHtv[i], v, fm, paint); } else { chooseHt[i].chooseHeight(text, start, end, chooseHtv[i], v, fm); } } // 获取修改后的字体度量属性 above = fm.ascent; below = fm.descent; top = fm.top; bottom = fm.bottom; } if (firstLine) { if (trackPad) { mTopPadding = top - above; } if (includePad) { // 如果当前行是TextView的第一行文字,above(ascent)值使用top替代。 above = top; } } int extra; if (lastLine) { if (trackPad) { mBottomPadding = bottom - below; } if (includePad) { // 如果当前行是TextView的最后一行文字,below(descent)值使用bottom替代。 below = bottom; } } if (needMultiply && (addLastLineLineSpacing || !lastLine)) { // 计算行间距 // spacingmult变量对应lineSpacingMultiplier属性配置的值 // spacingadd变量对应lineSpacingExtra属性配置的值。 double ex = (below - above) * (spacingmult - 1) + spacingadd; if (ex >= 0) { extra = (int)(ex + EXTRA_ROUNDING); } else { extra = -(int)(-ex + EXTRA_ROUNDING);