轉發請註明來源: github.com/tuesda/blog…
問題描述:
在 App 中正常瀏覽一段時間後,某些文字行間距失效。問題一旦出現,新開啟的頁面也會有這個問題,必須通過殺掉程式才能恢復。
問題分析:
初步分析:
根據現象大膽猜測是觸發了特定條件導致某個靜態物件狀態改變,否則不會殺掉程式才能恢復。
具體分析:
要找行間距失效的原因,我選擇 TextView 的 draw() 方法作為切入點,因為這裡是將字元展示在螢幕上的最後一步,肯定是有問題的。看程式碼可知 Layout.draw() 方法負責字元繪製,進入 Layout 類具體繪製方法是 drawText()
public void drawText() {
...
for() { // 遍歷每一行
...
int ltop = previousLineBottom;
int lbottom = getLineTop(lineNum + 1);
previousLineBottom = lbottom;
int lbaseline = lbottom - getLineDescent(lineNum);
...
}
...
}
複製程式碼
由這塊程式碼可以發現行間距是包含在 getLineDescent(lineNum) 中的,換句話說行間距屬於上一行。這個結論也可以根據 TextView 的 getLineHeight() 方法得出:
public int getLineHeight() {
return FastMath.round(mTextPaint.getFontMetricsInt(null) * mSpacingMult + mSpacingAdd);
}
複製程式碼
所以行間距失效的 TextView 中,mLayout 的 getLineDescent() 方法肯定有問題。Layout 類是個抽象類,負責 TextView 的文字佈局, 實現類有 StaticLayout 和 DynamicLayout,具體使用哪個取決於 text 是否為 Spannable,如果是 Spannable 使用 DynamicLayout 否則是 StaticLayout。通過新增除錯程式碼發現出現問題的 TextView 都是用的 DynamicLayout 來佈局,所以繼續看看 DynamicLayout 的 getLineDescent() 是否正常。
DynamicLayout 中程式碼如下:
@Override
public int getLineDescent(int line) {
return mInts.getValue(line, DESCENT);
}
複製程式碼
可以看出 lineDescent 是存在 mInts 這個 int 陣列中,繼續找計算的程式碼如下:
int desc = reflowed.getLineDescent(i);
if (i == n - 1)
desc += botpad;
ints[DESCENT] = desc;
複製程式碼
上面程式碼最後一行將計算的 descent 數值存在 ints 這個 int 陣列中,可以看到 DynamicLayout 的 lineDescent 計算是委託給 reflowed 這個物件。這個 reflowed 是 StaticLayout 物件,繼續看它的 getLineDescent() 方法。
@Override
public int getLineDescent(int line) {
return mLines[mColumns * line + DESCENT];
}
複製程式碼
和 DynamicLayout 的 getLineDescent() 類似,lineDescent 值是存在一個 int 陣列中,繼續查詢計算 lineDescent 的程式碼
...
boolean lastLine = mEllipsized || (end == bufEnd);
if (firstLine) {
if (trackPad) {
mTopPadding = top - above;
}
if (includePad) {
above = top;
}
}
int extra;
if (lastLine) {
if (trackPad) {
mBottomPadding = bottom - below;
}
if (includePad) {
below = bottom;
}
}
if (needMultiply && !lastLine) {
double ex = (below - above) * (spacingmult - 1) + spacingadd;
if (ex >= 0) {
extra = (int)(ex + EXTRA_ROUNDING);
} else {
extra = -(int)(-ex + EXTRA_ROUNDING);
}
} else {
extra = 0;
}
lines[off + START] = start;
lines[off + TOP] = v;
lines[off + DESCENT] = below + extra;
...
複製程式碼
上面程式碼中的 extra 應該就是行間距的值,通過除錯發現問題出在 lastLine 上,即使不是最後一行 lastLine 也為 true。lastLine 的賦值如下:
boolean lastLine = mEllipsized || (end == bufEnd);
複製程式碼
end == bufEnd 不總為 true, 而 mEllipsized 總是 true。通過在 StaticLayout.java 檔案中查詢 "mEllipsized =" 發現賦值程式碼只有一處在 calculateEllipsis() 方法中,這個方法負責 TextView 省略號的顯示邏輯,當需要顯示時 mEllipsized 被設定為 true,但是 StaticLayout 中卻沒有將 mEllipsized 設定為 false 的地方。這樣的邏輯在單次佈局計算時是沒有問題的,但當 StaticLayout 物件被複用時就會出錯,因為 mEllipsized 沒有被恢復 false 的邏輯。再回到 DynamicLayout 類中可以看到用來計算 lineDescent 的 StaticLayout 物件正是複用的,程式碼如下:
// generate new layout for affected text
StaticLayout reflowed;
StaticLayout.Builder b;
synchronized (sLock) {
reflowed = sStaticLayout;
b = sBuilder;
sStaticLayout = null;
sBuilder = null;
}
if (reflowed == null) {
reflowed = new StaticLayout(null);
b = StaticLayout.Builder.obtain(text, where, where + after, getPaint(), getWidth());
}
複製程式碼
綜上所述,這個問題是 Android 官方程式碼 StaticLayout 的 bug。引入這個 bug 的版本是 sdk 26,在 sdk 27 的原始碼裡增加了將 mEllipsized 重置為 false 的程式碼,這也正說明了 sdk 26 裡的實現確實是個 bug。
找到問題後,規避這個 bug 很簡單,只要將 DynamicLayout 中的靜態變數 reflowed 設定為空,每次用到時候建立新的 StaticLayout 物件就可以了。程式碼如下:
@Nullable
private Pair<Integer, Reflect> layoutReflect;
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
Layout layout = getLayout();
if (layout != null) {
// Issue: 全域性性行間距失效
// Reason: Sdk 26 DynamicLayout 的 sStaticLayout 成員變數中的 mEllipsized 一旦設為 true 無法恢復
if (SdkChecker.eq(26) && layout instanceof DynamicLayout) {
nullRecycledStaticLayout((DynamicLayout) layout);
}
}
}
private void nullRecycledStaticLayout(DynamicLayout dl) {
try {
final int dlHash = dl.hashCode();
Reflect dlReflect;
if (layoutReflect != null && layoutReflect.first != null && layoutReflect.second != null
&& layoutReflect.first == dlHash) {
dlReflect = layoutReflect.second;
} else {
dlReflect = Reflect.on(dl);
layoutReflect = new Pair<>(dlHash, dlReflect);
}
dlReflect.set("sStaticLayout", null);
} catch (Exception e) {
JLog.e(e);
}
}
複製程式碼
這裡用到了反射來將 sStaticLayout 設定為空,這裡用到的反射類庫是 JOOR。因為用到反射,混淆檔案也要加上下面這行,防止被混淆。
-keep public class android.text.DynamicLayout { <fields>; }
複製程式碼