原創 zhanghao 百度App技術java
Android的屏幕碎片化嚴重,各類屏幕分辨率層出不窮,而在不一樣分辨率的屏幕上顯示出一致的效果,是百度App的研發團隊和視覺團隊共同追求的目標。android
在百度App的Android開發中,TextView的行間距屏幕適配問題在研發和視覺之間糾纏已久 數組
該圖爲熱議頁面的圖文模板在三款設備上的顯示效果。能夠看到TextView的行間距在三款設備下的一致性表現不盡如人意,而這已成爲平常UI開發以及視覺review過程當中的一大痛點,下降了你們的工做效率。下面將探索一種簡單優雅的TextView行間距適配方案。bash
先來分析下TextView在不一樣設備上行間距表現不一致的緣由。百度App的UI團隊使用Sketch工具來進行UI設計以及UI review,所以本文接下來字體尺寸的測量都藉助Sketch工具完成。ide
先看下面一個簡單的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);
}
} else {
extra = 0;
}
// 將當前行的座標信息存入mLines[]數組中
lines[off + START] = start;
lines[off + TOP] = v;
lines[off + DESCENT] = below + extra;
lines[off + EXTRA] = extra;
// 計算下一行的的top值
v += (below - above) + extra;
mLineCount++;
return v;
}
複製代碼
因爲篇幅緣由,省略了一些無關代碼。上面對關鍵代碼都給出了詳細的註釋,這裏就不過多解釋。經過第87行,可得出以下兩個公式:
爲了方便你們理解行高,我把每行文字的baseline和top這兩根線畫了出來,紅色的線是baseline基線,綠色的線是top線,相鄰兩條綠線之間的距離即爲行高,以下圖所示:
到這裏,基本可以解釋,在沒有設置lineSpacingExtra / lineSpacingMultiplier屬性的狀況下,Sketch工具量出的行間距緣由:咱們知道每行文字以baseline做爲基線來繪製,在ascent範圍內繪製基線以上的部分,在descent範圍內繪製基線如下部分。因爲漢字不會像英文那樣高低不一,是很是整齊的方塊字。而漢字在descent範圍內繪製基線如下部分時,並無佔滿descent全部空間,會空出一部分距離,在ascent範圍內繪製基線以上部分時,也是一樣的道理。因此,Sketch測量出來的行間距就是上一行漢字佔據的descent範圍後的剩餘空間加上下一行漢字佔據的ascent範圍後的剩餘空間。
通過上面分析,瞭解到TextView的自帶行間距是因爲繪製的漢字沒有佔滿descent和ascent的空間引發的,且該行間距在不一樣的字號以及分辨率下表現不一。若可以去除掉這部分行間距,就能達到適配目的。怎麼去除呢?咱們再看一下系統TextView和視覺對一行文字行高的定義:
只要可以修改TextView的默認行高,讓其和視覺定義的行高保持統一,就能去除掉這部分行間距。怎麼修改TextView的默認行高呢?其實TextView在設計的時候,提供了一個接口去修改TextView的行高。回到上面對TextView的源碼分析,第20行-第39行,將字體的度量信息存入fm變量中,而後經過LineHeightSpan接口將fm變量傳遞出去,咱們藉助這個LineHeightSpan就能夠修改TextView的行高。
最終適配方案以下:
public class ExcludeInnerLineSpaceSpan implements LineHeightSpan {
// TextView行高
private final int mHeight;
public ExcludeInnerPaddingSpan(int height) {
mHeight = height;
}
@Override
public void chooseHeight(CharSequence text, int start, int end,
int spanstartv, int lineHeight,
Paint.FontMetricsInt fm) {
// 原始行高
final int originHeight = fm.descent - fm.ascent;
if (originHeight <= 0) {
return;
}
// 計算比例值
final float ratio = mHeight * 1.0f / originHeight;
// 根據最新行高,修改descent
fm.descent = Math.round(fm.descent * ratio);
// 根據最新行高,修改ascent
fm.ascent = fm.descent - mHeight;
}
}
複製代碼
類ExcludeInnerLineSpaceSpan實現LineHeightSpan接口,這個類用於去除TextView的自帶行間距。第5行,構造函數,以最新的行高做爲參數傳入。第14行,計算出原始行高。第19行,計算出新行高和原始行高的比例值。第21行-第23行,根據比例值修改字體度量的ascent參數和descent參數。
接下來自定義個TextView出來,提供一個setCustomText()方法出來,供使用方調用。代碼以下:
public class ETextView extends TextView {
/**
* 排除每行文字間的padding
*
* @param text
*/
public void setCustomText(CharSequence text) {
if (text == null) {
return;
}
// 得到視覺定義的每行文字的行高
int lineHeight = (int) getTextSize();
SpannableStringBuilder ssb ;
if (text instanceof SpannableStringBuilder) {
ssb = (SpannableStringBuilder) text;
// 設置LineHeightSpan
ssb.setSpan(new ExcludeInnerLineSpaceSpan(lineHeight),
0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
} else {
ssb = new SpannableStringBuilder(text);
// 設置LineHeightSpan
ssb.setSpan(new ExcludeInnerLineSpaceSpan(lineHeight),
0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
// 調用系統setText()方法
setText(ssb);
}
}
複製代碼
ShowCase
該方案使用系統公開API,簡單,侵入性低。並已在百度App熱議頁面上線,適配效果先後對比,以下圖所示: