Android TextView性能与文本展示优化 前言 优

前言

关于TextView相关的文章,我们之前也实现过,在读本篇之前,可以先读读其他文章

TextView是相当复杂的UI组件,TextView不仅仅支持纯文本展示,而且还支持图片、SpannableString、文本输入、超链接等诸多功能,因此很多View本身也是直接继承自TextView的,如EditText、Button、Chronometer等。可见TextView功能非常强大,基本上是app中使用率最高的View组件。

企业微信20240126-211325@2x.png

不过 TextView 缺点也不少,主要问题点如下:

  • 跑马灯执行的条件过高,且部分属性有一定的重复问题
  • setText 容易触发requestLayout
  • 换行文本容易出现犬牙(很多小说类app自行绘制文本来解决此问题)

当然,以上是大多数情况中我们容易遇到的问题。

优化方法

上面我们列出了3个常见的问题,我们这边逐一来看。

跑马灯问题

TextView对跑马灯的要求比较高,必须是单行文本,而且必须设置MaxLines,而且不支持Lines设置,另外必须是focused或者是selected,这显然增加了一些成本,要知道如果父布局focused,那么子View是不可能focused,显然对TV设备不够有好。但是另外一个问题,View可以同时具备Focused和Selected状态,这显然增加了问题的难度,为此我们需要剥离focused状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
scss复制代码   private void startMarquee() {
// Do not ellipsize EditText
if (getKeyListener() != null) return;

if (compressText(getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight())) {
//宽度大于0,或者硬件加速
return;
}


if ((mMarquee == null || mMarquee.isStopped()) && (isFocused() || isSelected())
&& getLineCount() == 1 && canMarquee()) {

//获焦或者selected状态,由于focus相对于selected复杂,建议使用selected
//TextLayout行数必须为1

if (mMarqueeFadeMode == MARQUEE_FADE_SWITCH_SHOW_ELLIPSIS) {
mMarqueeFadeMode = MARQUEE_FADE_SWITCH_SHOW_FADE;
final Layout tmp = mLayout;
mLayout = mSavedMarqueeModeLayout;
mSavedMarqueeModeLayout = tmp;
setHorizontalFadingEdgeEnabled(true);
requestLayout();
invalidate();
}

if (mMarquee == null) mMarquee = new Marquee(this);
mMarquee.start(mMarqueeRepeatLimit);
}
}

那么,这里我们通过优化,使其仅在selected状态具备跑马灯,当然,如果你还想用selected状态实现其他用途,显然是无法使用了,不过系统中还有setEnable、setActivated状态供大家使用。

下面是跑马灯兼容逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
java复制代码public class MarqueeTextView extends AppCompatTextView {

private static final String TAG = "MarqueeTextView";
private boolean isMarqueeEnable = false;

public MarqueeTextView(Context context) {
this(context, null);
}

public MarqueeTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public MarqueeTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

/**
* TextView.canMarquee() == false 时是不会滚动的
* 一般原因是行数问题影响,导致宽度不合适,而android:lines是无效的
* focus 或者 selected状态才能跑马灯
*/

setMaxLines(1);
setSingleLine(true);

if (isMarqueeEnable) {
setMarqueeRepeatLimit(-1);
setEllipsize(TextUtils.TruncateAt.MARQUEE);
} else {
setMarqueeRepeatLimit(0);
setEllipsize(TextUtils.TruncateAt.END);
}
super.setSelected(isMarqueeEnable);
}

public void setMarqueeEnable(boolean enable) {
if (isMarqueeEnable != enable) {
isMarqueeEnable = enable;
if (enable) {
super.setSelected(true);
setMarqueeRepeatLimit(-1);
setEllipsize(TextUtils.TruncateAt.MARQUEE);
} else {
super.setSelected(false);
setMarqueeRepeatLimit(0);
setEllipsize(TextUtils.TruncateAt.END);
}
}
}

public boolean isMarqueeEnable() {
return isMarqueeEnable;
}


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (!isMarqueeEnable) {
return;
}
if (getLineCount() > 1) {
Log.e(TAG, "the marquee will not work if TextLineCount > 1");
}
if (getMarqueeRepeatLimit() <= 0) {
Log.e(TAG, "the marquee may not work if MarqueeRepeatLimit != -1");
}
}

@Override
public void setSelected(boolean selected) {
//复写此方法,禁止外部调用,保证只有内部调用
}


}

频繁触发requestLayout

TextView很容易触发requestLayout,除非长宽必须是固定大小的,不过固定大小可能遇到文本展示的不全的问题,另外Google也提供了PrecomputedText异步测量文本的方式去优化性能,但是requestLayout造成的性能问题实际上比PrecomputedText测量要高,另外PrecomputedText编码方式也不够方便。

那么有没有更好的方法去抑制requestLayout的频繁调用呢?

实际上单行文本的使用远超多行文本,即便是播放器时间进度也是单行文本,因此我们可以自行测量单行文本,比较前后的尺寸差异,选择性调用requestLayout。

方式很多,这里我们利用BoringLayout优化,当然在android 5.0之前的版本BoringLayout 兼容性并不好,因此这里还引入StaticLayout进行兜底。

优化setText

构建Layout,优先是BoringLayout,我们前面说过,android 5.0之前的BoringLayout的兼容性不好,有些语言会转为StaticLayout兼容,特别需要注意的是,android 4.4 之前的版本,使用StaticLayout 时mLineSpacingMult参数必须大于0,否则测量出的大小会不正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
java复制代码    protected Layout buildTextLayout(CharSequence text, int wantWidth) {
// fixed: Chinese word is not boring in android 4.4,在Android 4.4长度可能小于measureText测量的
float measureTextWidth = mTextPaint.measureText(text, 0, text.length());
BoringLayout.Metrics boring = BoringLayout.isBoring(text, mTextPaint,UNKNOWN_BORING);

float lineSpaceMult = mLineSpacingMult;
if (lineSpaceMult < 1F) {
lineSpaceMult = 1.0f;
}
if (boring != null) {
int outWidth = wantWidth != ANY_WIDTH ? wantWidth : (int) (Math.max(boring.width,measureTextWidth) + mLineSpacingMult + mLineSpacingAdd);
return BoringLayout.make(text, mTextPaint,
outWidth,
Layout.Alignment.ALIGN_NORMAL,
0,
mLineSpacingAdd,
boring,
mIncludeFontPadding);
}
//fix Android 4.4 mLineSpacingMult 必须大于0
float desiredWidthForStaticLayout = StaticLayout.getDesiredWidth(text, mTextPaint);
int desiredWidth = (int) (Math.max(desiredWidthForStaticLayout,measureTextWidth) + mLineSpacingMult + mLineSpacingAdd);
int outWidth = wantWidth != ANY_WIDTH ? wantWidth : desiredWidth;
StaticLayout staticLayout = new StaticLayout(text,
mTextPaint,
outWidth,
Layout.Alignment.ALIGN_NORMAL,
lineSpaceMult,
mLineSpacingAdd,
mIncludeFontPadding);
return staticLayout;
}

设置文本,通过setText减少requestLayout触发的机率,主要做了以下几件事

  • 判断文本是否相同,如果相同则无需调用requestLayout,而TextView受限于需要支持Spanned,显然这点很难做到。
  • 判断是否AttachedToWindow,如果没有,这里也不需要测量,在onMeasure中进行测量。
  • 判断文本是否已测量过,如果没有则立即测量
  • 构建TextLayout,比较文本宽高,相同时只调用invalidate,不同时需要调用requestLayout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
java复制代码    public void setText(final CharSequence text) {
CharSequence targetText = text == null ? "" : text;
if (mLayout != null && TextUtils.equals(targetText, this.mText)) {
return; //文本相同
}
this.mText = targetText;
if (!isAttachedToWindow()) {
mLayout = null;
mHintLayout = null;
return;
}
if (measureWidthMode == -1 || measureHeightMode == -1) {
//文本还未测量
mLayout = null;
mHintLayout = null;
requestLayout();
postInvalidate();
return;
}
int width = measureWidthMode == MeasureSpec.EXACTLY ? getMeasuredWidth() : ANY_WIDTH;
mHintLayout = buildTextLayout(text, width); //构建TextLayout,比较宽高

int desireWidth = mHintLayout.getWidth() + getPaddingLeft() + getPaddingRight();
int desireHeight = getTextLayoutHeight(mHintLayout)+ getPaddingTop() + getPaddingBottom();

if (desireWidth != getWidth() || measureHeightMode != MeasureSpec.EXACTLY && desireHeight != getHeight()) {
mLayout = null;
requestLayout();
} else {
mLayout = mHintLayout;
mHintLayout = null;
}
postInvalidate();
}

性能优化完整代码

下面是BoringTextView完整代码,通过自定义View实现了setText性能优化,当然你可能会想,这种写法缺少了一些TextView的属性,实际上这些属性是可以自行解析的,按照代码中的规则进行即可,特别要属性的大小顺序。

性能问题:

我们知道文本宽高的测量必然消耗性能,这是个显而易见的问题,不过,文本测量的性能损耗是要小于requestLayout造成的损耗,因为后者也会测量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
java复制代码public class BoringTextView extends View {

private static final int ANY_WIDTH = -1;
private static final String TAG = "BoringTextView";
private TextPaint mTextPaint;
private DisplayMetrics mDisplayMetrics;
private int mContentHeight = 0;
private int mContentWidth = 0;
private Layout mLayout;
private Layout mHintLayout;
private int mTextColor;
private ColorStateList mTextColorStateList;
private CharSequence mText = "";
private boolean mIncludeFontPadding = false;
private int measureWidthMode = -1;
private int measureHeightMode = -1;
// fixed: mSpacingMult in android 4.4 must be greater 0
private float mLineSpacingMult = 1.0f;
private float mLineSpacingAdd = 0.0f;

private boolean isLockMaxWidth = false; //是否锁定最大宽度
Rect rect = new Rect();
int maxMeasureWidth = 0;

public static final BoringLayout.Metrics UNKNOWN_BORING = new BoringLayout.Metrics();


public BoringTextView(Context context) {
this(context, null);
}

public BoringTextView(Context context, AttributeSet attrs) {
super(context, attrs);
initPaint(context, attrs, 0, 0);

}

public BoringTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initPaint(context, attrs, defStyleAttr, 0);
}

@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public BoringTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initPaint(context, attrs, defStyleAttr, defStyleRes);
}

private void initPaint(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
Resources resources = getResources();
mDisplayMetrics = resources.getDisplayMetrics();
mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setAntiAlias(true);
mTextPaint.setStyle(Paint.Style.FILL);
mTextPaint.setTextSize(sp2px(12));
mTextPaint.density = mDisplayMetrics.density;
mTextColorStateList = ColorStateList.valueOf(Color.GRAY);

if (attrs != null) {
int[] attrset = {
//注意顺序,从大到小,否则无法正常获取
android.R.attr.textSize,
android.R.attr.textColor,
android.R.attr.text,
android.R.attr.includeFontPadding
};
TypedArray attributes = context.obtainStyledAttributes(attrs, attrset, defStyleAttr, defStyleRes);
int length = attributes.getIndexCount();
for (int i = 0; i < length; i++) {
int attrIndex = attributes.getIndex(i);
int attrItem = attrset[attrIndex];
switch (attrItem) {
case android.R.attr.text:
CharSequence text = attributes.getText(attrIndex);
setText(text);
break;
case android.R.attr.textColor:
//涉及到ColorStateList ,暂不做支持动态切换
ColorStateList colorStateList = attributes.getColorStateList(attrIndex);
if (colorStateList != null) {
mTextColorStateList = colorStateList;
}
break;
case android.R.attr.textSize:
int dimensionPixelSize = attributes.getDimensionPixelSize(attrIndex, (int) sp2px(12));
mTextPaint.setTextSize(dimensionPixelSize);
break;
case android.R.attr.includeFontPadding:
mIncludeFontPadding = attributes.getBoolean(attrIndex, false);
break;

}
}
attributes.recycle();
}

setTextColor(mTextColorStateList);

}

public void setTypeface(Typeface tf, int style) {
if (style > 0) {
if (tf == null) {
tf = Typeface.defaultFromStyle(style);
} else {
tf = Typeface.create(tf, style);
}

setTypeface(tf);
// now compute what (if any) algorithmic styling is needed
int typefaceStyle = tf != null ? tf.getStyle() : 0;
int styleFlags = style & ~typefaceStyle;
mTextPaint.setFakeBoldText((styleFlags & Typeface.BOLD) != 0);
mTextPaint.setTextSkewX((styleFlags & Typeface.ITALIC) != 0 ? -0.25f : 0);
} else {
mTextPaint.setFakeBoldText(false);
mTextPaint.setTextSkewX(0);
setTypeface(tf);
}
}

public void setTypeface(Typeface tf) {
if (mTextPaint.getTypeface() != tf) {
mTextPaint.setTypeface(tf);
if (mLayout != null) {
requestLayout();
postInvalidate();
}
}
}

public Typeface getTypeface() {
if (mTextPaint != null) {
return mTextPaint.getTypeface();
}
return null;
}


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int defaultWidth = MeasureSpec.getSize(widthMeasureSpec);
if (measureWidthMode != -1 && measureWidthMode != widthMode) {
mHintLayout = null;
}
int widthSize = defaultWidth;

if (widthMode != MeasureSpec.EXACTLY) {
if (mHintLayout == null) {
//在setText时已经计算过了,直接复用mHintLayout
mLayout = buildTextLayout(this.mText, ANY_WIDTH);
} else {
mLayout = mHintLayout;
}
int requestWidth = (getPaddingRight() + getPaddingLeft()) + (mLayout != null ? mLayout.getWidth() : 0);

if(widthMode == MeasureSpec.AT_MOST){
widthSize = Math.min(requestWidth,defaultWidth);
}else {
widthSize = requestWidth;
}
} else {
if (mHintLayout == null) {
int contentWidth = (widthSize - (getPaddingRight() + getPaddingLeft()));
mLayout = buildTextLayout(this.mText, contentWidth);
} else {
mLayout = mHintLayout;
}
}

int defaultHeight = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = 0;

if (heightMode == MeasureSpec.AT_MOST) {
heightSize = Math.min(getTextLayoutHeight(mLayout),defaultHeight);
} else if (heightMode == MeasureSpec.UNSPECIFIED) {
int desireHeight = getTextLayoutHeight(mLayout);
heightSize = (getPaddingTop() + getPaddingBottom()) + desireHeight;
}

if(isLockMaxWidth){
maxMeasureWidth = Math.max(maxMeasureWidth,widthSize);
widthSize = maxMeasureWidth;
}

if(rect.width() != widthSize || measureWidthMode == -1) {
measureWidthMode = widthMode;
}
if(rect.height() != heightSize || measureHeightMode == -1) {
measureHeightMode = heightMode;
}
rect.set(0,0,widthSize,heightSize);

setMeasuredDimension(widthSize, heightSize);
Log.i(TAG,"widthSize="+widthSize+", heightSize="+heightSize+",paddingTop="+getPaddingTop()+",paddingBottom="+getPaddingBottom());

mHintLayout = null;
}


@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
measureWidthMode = -1;
measureHeightMode = -1;
maxMeasureWidth = 0;
}

@Override
public void setLayoutParams(ViewGroup.LayoutParams params) {
measureWidthMode = -1;
measureHeightMode = -1;
maxMeasureWidth = 0;
super.setLayoutParams(params);
}


@Override
protected void onVisibilityChanged(View changedView, int visibility) {
super.onVisibilityChanged(changedView, visibility);
if(visibility == GONE){
maxMeasureWidth = 0;
measureWidthMode = -1;
measureHeightMode = -1;
}
}

private int getTextLayoutHeight(Layout layout) {
if(layout == null) {
return 0;
}
int desireHeight = 0;
desireHeight = layout.getHeight();
if(desireHeight <= 0){
int minTextLayoutLines = Math.min(layout.getLineCount(), 1);
desireHeight = Math.round(mTextPaint.getFontMetricsInt(null)* mLineSpacingMult + mLineSpacingAdd) * minTextLayoutLines;
}
return desireHeight;
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mContentHeight = (h - getPaddingTop() - getPaddingBottom());
mContentWidth = (w - getPaddingLeft() - getPaddingRight());
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float strokeWidth = mTextPaint.getStrokeWidth() * 2;
if (mContentWidth <= strokeWidth || mContentHeight <= strokeWidth) {
return;
}
int save = canvas.save();

if (mLayout != null) {
int verticalHeight = getPaddingTop() + getPaddingBottom() + getTextLayoutHeight(mLayout);
float offset = (getHeight() - verticalHeight) >> 1;
if(offset < 0){
offset = 0;
}
canvas.translate(getPaddingLeft(), getPaddingTop() + offset);
mLayout.draw(canvas);
}
canvas.restoreToCount(save);
}

public void setText(final CharSequence text) {
CharSequence targetText = text == null ? "" : text;
if (mLayout != null && TextUtils.equals(targetText, this.mText)) {
return;
}
this.mText = targetText;
if (!isAttachedToWindow()) {
mLayout = null;
mHintLayout = null;
return;
}
if (measureWidthMode == -1 || measureHeightMode == -1) {
mLayout = null;
mHintLayout = null;
requestLayout();
postInvalidate();
return;
}
int width = measureWidthMode == MeasureSpec.EXACTLY ? getMeasuredWidth() : ANY_WIDTH;
mHintLayout = buildTextLayout(text, width);

int desireWidth = mHintLayout.getWidth() + getPaddingLeft() + getPaddingRight();
int desireHeight = getTextLayoutHeight(mHintLayout)+ getPaddingTop() + getPaddingBottom();

if (desireWidth != getWidth() || measureHeightMode != MeasureSpec.EXACTLY && desireHeight != getHeight()) {
if(isLockMaxWidth && getWidth() > desireWidth){
mLayout = mHintLayout;
mHintLayout = null;
}else {
mLayout = null;
requestLayout();
}
} else {
mLayout = mHintLayout;
mHintLayout = null;
}
postInvalidate();
}

protected Layout buildTextLayout(CharSequence text, int wantWidth) {
// fixed: Chinese word is not boring in android 4.4,在Android 4.4长度可能小于measureText测量的
float measureTextWidth = mTextPaint.measureText(text, 0, text.length());
BoringLayout.Metrics boring = BoringLayout.isBoring(text, mTextPaint,UNKNOWN_BORING);


float lineSpaceMult = mLineSpacingMult;
if (lineSpaceMult < 1F) {
lineSpaceMult = 1.0f;
}
if (boring != null) {
int outWidth = wantWidth != ANY_WIDTH ? wantWidth : (int) (Math.max(boring.width,measureTextWidth) + mLineSpacingMult + mLineSpacingAdd);
return BoringLayout.make(text, mTextPaint,
outWidth,
Layout.Alignment.ALIGN_NORMAL,
0,
mLineSpacingAdd,
boring,
mIncludeFontPadding);
}
//fix Android 4.4 mLineSpacingMult 必须大于0
float desiredWidthForStaticLayout = StaticLayout.getDesiredWidth(text, mTextPaint);
int desiredWidth = (int) (Math.max(desiredWidthForStaticLayout,measureTextWidth) + mLineSpacingMult + mLineSpacingAdd);
int outWidth = wantWidth != ANY_WIDTH ? wantWidth : desiredWidth;
StaticLayout staticLayout = new StaticLayout(text,
mTextPaint,
outWidth,
Layout.Alignment.ALIGN_NORMAL,
lineSpaceMult,
mLineSpacingAdd,
mIncludeFontPadding);
return staticLayout;
}

public float sp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDisplayMetrics);
}

public void setIncludeFontPadding(boolean includePad) {
this.mIncludeFontPadding = includePad;
mHintLayout = null;
mLayout = null;
requestLayout();
postInvalidate();
}

public void setTextColor(int color) {
ColorStateList colorStateList = ColorStateList.valueOf(color);
setTextColor(colorStateList);
}

public void setTextColor(ColorStateList colorStateList) {
if (colorStateList == null) return;
final int[] drawableState = getDrawableState();
int forStateColor = colorStateList.getColorForState(drawableState, 0);
mTextColor = forStateColor;
mTextColorStateList = colorStateList;
mTextPaint.setColor(forStateColor);
postInvalidate();
}


Runnable requestLayoutTask = new Runnable() {
@Override
public void run() {
requestLayout();
}
};

public void postRequestLayout(){
removeCallbacks(requestLayoutTask);
post(requestLayoutTask);
}

@Override
protected void drawableStateChanged() {
super.drawableStateChanged();
if(mTextColorStateList!=null && mTextColorStateList.isStateful()) {
setTextColor(mTextColorStateList);
}
}


public int getCurrentTextColor() {
return mTextColor;
}

public void setTextSize(float textSize) {
mTextPaint.setTextSize(textSize);
}

public TextPaint getPaint() {
return mTextPaint;
}

public CharSequence getText() {
return mText;
}

@Override
public boolean isAttachedToWindow() {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT){
return super.isAttachedToWindow();
}
return getWindowToken() != null;
}

public void setLockMaxWidth(boolean lockMaxWidth) {
isLockMaxWidth = lockMaxWidth;
maxMeasureWidth = 0;
postRequestLayout();
postInvalidate();
}
}

下面,我们进行一下性能测试,之前测过但是没往博客里添加,不过评论区有同学要求,这个要求并不过分,写性能优化方面的还得实事求是才能解决问题。

第一类性能测试

我们这里设计一下测试方案:

  • 定时展示时间,测试20次
  • 每500ms刷新一次
  • 测量时直接测量子类的方法
  • View方法测量使用纳秒
  • 计算平均耗时

企业微信20240325-074550@2x.png

测试代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码Runnable r = new Runnable() {
int count = 0;
@Override
public void run() {
String format = sdf.format(System.currentTimeMillis());
textView.setText(format);
if(count > 20){
textView.printPerformance();
return;
}
textView.postDelayed(this,500);
count++;
}
};
textView.post(r);

测试结果

优化前(使用TextView):
onMeasure=223249.75ns onDraw=65753.3ns setText=588362.94ns

优化后(使用BoringTextView):
onMeasure=50216.9ns onDraw=25258.2ns setText=170792.8ns

我们看到,对于频繁刷新的情况,BoringTextView性能明显好于TextView,比如我们播放器的进度刷新场景(一般是500-800ms刷新一次),这种收益很明显。

当然,上面是单个View,如果从整体View树中测量,那么嵌套越深,理论上BoringTextView性能收益会越明显。

第二类性能测试

不过,你可能会疑问,如果测试间隔扩大至1秒以上,性能会怎么样,实际上BoringTextView的原理是如下

  • 相同文本不刷新
  • 文本长度一样不调用requestLayout
  • 减少Spannable造成的性能问题

显然,上面的优化没有包含长短变化的考虑,但是如果文本长短变化频繁的场景,那么onmeasure也会多次调用,另外,在实际开发中,还有一些系统字体也会引发长短变化问题,BoringTextView实际上也提供了优化方法

1
2
3
4
5
6
java复制代码public void setLockMaxWidth(boolean lockMaxWidth) {
isLockMaxWidth = lockMaxWidth;
maxMeasureWidth = 0;
postRequestLayout();
postInvalidate();
}

我们设置isLockMaxWidth = true进行测试

测试结果

优化前:onMeasure=299225.2 onDraw=88330.8 setText=635284.4

优化后:onMeasure=95652.8 onDraw=56671.3 setText=408131.2

以上就是测试结果,显然,自定义TextView比原生的TextView性能可以更好。

犬牙问题:

企业微信20240128-085747@2x.png

这种问题的解决方法网上能搜出很多,但是对中文支持最好的得参考下面文章
关于TextView中换行后对齐问题

其中实现原理是对TextView重写,但是缺点是对英文支持的不够好,不过关系不大,对英文分词即可快速实现。

其核心逻辑是:对最后一行的以外的其他文本行增加文字间距(word space),从而使得看起来犬牙的文本显的规整,但其本身并非是两边对齐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
java复制代码    private void drawScaledText(Canvas canvas, int lineStart, String line,
float lineWidth) {
float x = 0;
if (isFirstLineOfParagraph(lineStart, line)) {
String blanks = " ";
canvas.drawText(blanks, x, mLineY, getPaint());
float bw = StaticLayout.getDesiredWidth(blanks, getPaint());
x += bw;

line = line.substring(3);
}

int gapCount = line.length() - 1;
int i = 0;
if (line.length() > 2 && line.charAt(0) == 12288
&& line.charAt(1) == 12288) {
String substring = line.substring(0, 2);
float cw = StaticLayout.getDesiredWidth(substring, getPaint());
canvas.drawText(substring, x, mLineY, getPaint());
x += cw;
i += 2;
}

float d = (mViewWidth - lineWidth) / gapCount;
for (; i < line.length(); i++) {
String c = String.valueOf(line.charAt(i));
float cw = StaticLayout.getDesiredWidth(c, getPaint());
canvas.drawText(c, x, mLineY, getPaint());
x += cw + d;
}
}

emoji全角字符展示问题

我们来试一下效果,我们设置一段下面的文字

1
2
3
4
java复制代码"Good Morning ! 😂来,今天是一个美妙的日子,我们一起走在大街上,唱着
我们喜欢的歌谣、跳着不是舞蹈的舞蹈,雀跃的步伐,在热闹非凡的社火表演中,
与所有人的脚步和声音揉合在了一起。孩子们的声音非常尖锐,只有😄,没有😢,
或许他们才是今天的主角";

效果预览,很遗憾,表情符没有展示出来

企业微信20240216-105022@2x.png

这个问题是评论区有同学提出来的,原则上说展示表情符不在本篇范畴,既然提出来了自然需要解决一下,提供一个思路,以后可以解决类似的问题。

UnicodeBlock

在Android中,为了识别各个国家的文字编码,在Character类中定义了很多类似的常量。通常意义上,我们判断字符编码的范围的也是可以的,但是字符编码范围的坏处就是需要查找范围,而且还存在不准确的情况。其实最简单的方法还是使用UnicodeBlock来判断

下面我们来打印一句话的范围

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码G - BASIC_LATIN
o - BASIC_LATIN
o - BASIC_LATIN
d - BASIC_LATIN
- BASIC_LATIN
M - BASIC_LATIN
o - BASIC_LATIN
r - BASIC_LATIN
n - BASIC_LATIN
i - BASIC_LATIN
n - BASIC_LATIN
g - BASIC_LATIN
- BASIC_LATIN
! - BASIC_LATIN
- BASIC_LATIN
? - HIGH_SURROGATES
? - LOW_SURROGATES
来 - CJK_UNIFIED_IDEOGRAPHS
, - HALFWIDTH_AND_FULLWIDTH_FORMS
今 - CJK_UNIFIED_IDEOGRAPHS
天 - CJK_UNIFIED_IDEOGRAPHS
是 - CJK_UNIFIED_IDEOGRAPHS
一 - CJK_UNIFIED_IDEOGRAPHS
个 - CJK_UNIFIED_IDEOGRAPHS
美 - CJK_UNIFIED_IDEOGRAPHS
妙 - CJK_UNIFIED_IDEOGRAPHS
的 - CJK_UNIFIED_IDEOGRAPHS
日 - CJK_UNIFIED_IDEOGRAPHS
子 - CJK_UNIFIED_IDEOGRAPHS

显然,我们发现emoji占2个char code 位置,分别是: HIGH_SURROGATES和LOW_SURROGATES,实际上UnicodeBlock内置了EMOJIICONS,但是这里并没有命中,说明这里的解析规则无法匹配。不过,这也不是无法修复,我们完全可以在Canvas绘制的时候,将2个字符当作一个字符绘制或者按行绘制,就可以解决此问题。

按行绘制

我们知道,Paint#setWordSpacing和Paint#setLetterSpacing可以调整文字间距。

canvas按行绘制是可以解决此问题的,但是我们要处理对齐必然要增大文字间的空格,遗憾的是paint#setWordSpacing只有高版本支持。另一个问题,如果使用paint#setLetterSpacing,会导致英文字符不够紧凑,更严重的是单位是EM,如果按16px = 1EM 转换,会发现兼容性存在一些问题。因此这种方案是不可行的。

两个字符合并为一个

这个是可行的,但是可能存在不周全的问题,目前来说,我们使用这种方法解决了emoji和全角字符展示的问题,后续如有其他需求可以在评论区提出。

问题修复代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码int width = getWidth();
int lineLength = line.length();
float d = (width - lineWidth) / gapCount;
for (; i < lineLength; i++) {
char c = line.charAt(i);
Character.UnicodeBlock block = Character.UnicodeBlock.of(c);
int start = i;
int end = i + 1; //普通字符

if (block == Character.UnicodeBlock.HIGH_SURROGATES && i < (lineLength - 1)) {
if(Character.UnicodeBlock.LOW_SURROGATES == Character.UnicodeBlock.of(line.charAt(i+1))){
end = i + 2;
i = i + 1; //全角字符
}
}

CharSequence charSequence = line.subSequence(start, end);
float cw = StaticLayout.getDesiredWidth(charSequence,paint);
canvas.drawText(charSequence.toString(), x, mLineY, paint);
x += cw + d;
}

修复后的效果

企业微信20240216-105040@2x.png

完整代码

到这里我们实现了两端尽可能对齐,避免犬牙问题,同时解决了emoji展示问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
java复制代码public class TextAlignTextView extends AppCompatTextView {

private float mLineY;

public TextAlignTextView (Context context, AttributeSet attrs) {
super(context, attrs);
}

public TextAlignTextView(Context context) {
super(context);
}

@Override
protected void onLayout(boolean changed, int left, int top, int right,
int bottom) {
super.onLayout(changed, left, top, right, bottom);
}

@Override
protected void onDraw(Canvas canvas) {
TextPaint paint = getPaint();
paint.setColor(getCurrentTextColor());
paint.drawableState = getDrawableState();
CharSequence text = getText();
mLineY = getTextSize();
Layout layout = getLayout();

// layout.getLayout()在4.4.3出现NullPointerException
if (layout == null) {
return;
}

Paint.FontMetrics fm = paint.getFontMetrics();

int textHeight = (int) (Math.ceil(fm.descent - fm.ascent));
textHeight = (int) (textHeight * layout.getSpacingMultiplier() + layout
.getSpacingAdd());
//解决了最后一行文字间距过大的问题
for (int i = 0; i < layout.getLineCount(); i++) {
int lineStart = layout.getLineStart(i);
int lineEnd = layout.getLineEnd(i);
float width = StaticLayout.getDesiredWidth(text, lineStart,
lineEnd, getPaint());
CharSequence line = text.subSequence(lineStart, lineEnd);
String lineText = line.toString();
if(i < layout.getLineCount() - 1) {
if (needScale(line)) {
drawScaledText(canvas,line, lineStart, width);
} else {
canvas.drawText(lineText, 0, mLineY, paint);
}
} else {
canvas.drawText(lineText, 0, mLineY, paint);
}
mLineY += textHeight;
}
}

private void drawScaledText(Canvas canvas, CharSequence line,int lineStart,
float lineWidth) {
float x = 0;
TextPaint paint = getPaint();
String lineText = line.toString();
if (isFirstLineOfParagraph(lineStart, lineText)) {
String blanks = " ";
canvas.drawText(blanks, x, mLineY, paint);
float bw = StaticLayout.getDesiredWidth(blanks, paint);
x += bw;

line = line.subSequence(3,line.length());
}

int gapCount = line.length() - 1;
int i = 0;
if (line.length() > 2 && line.charAt(0) == 12288
&& line.charAt(1) == 12288) {
CharSequence substring = line.subSequence(0, 2);
float cw = StaticLayout.getDesiredWidth(substring, paint);
canvas.drawText(substring.toString(), x, mLineY, paint);
x += cw;
i += 2;
}

int width = getWidth();
int lineLength = line.length();
float d = (width - lineWidth) / gapCount;
for (; i < lineLength; i++) {
char c = line.charAt(i);
Character.UnicodeBlock block = Character.UnicodeBlock.of(c);
int start = i;
int end = i + 1; //普通字符

if (block == Character.UnicodeBlock.HIGH_SURROGATES && i < (lineLength - 1)) {
if(Character.UnicodeBlock.LOW_SURROGATES == Character.UnicodeBlock.of(line.charAt(i+1))){
end = i + 2;
i = i + 1; //全角字符
}
}

CharSequence charSequence = line.subSequence(start, end);
float cw = StaticLayout.getDesiredWidth(charSequence,paint);
canvas.drawText(charSequence.toString(), x, mLineY, paint);
x += cw + d;
}
}

private boolean isFirstLineOfParagraph(int lineStart, String line) {
return line.length() > 3 && line.charAt(0) == ' '
&& line.charAt(1) == ' ';
}

private boolean needScale(CharSequence line) {
if (line == null || line.length() == 0) {
return false;
}
return line.charAt(line.length() - 1) != '\n';
}

}

总结

到这里本篇就结束了,TextView作为Android中最复杂的View组件之一,其中有很多方法的调用也是非公开的,另外其中的Editor也是没有公开的,这显然是造成TextView存在性能问题的原因之一。

本篇这里的优化基本都有线上使用,在播放器页面中,用到了BoringTextView和跑马灯效果,有效降低了焦点问题和requestLayout频繁的问题,当然文本的展示并不一定非得用BoringLayout和StaticLayout,也有很多方式可以实现此类优化。文本对齐问题,实际上在一些协议页面和小说页面使用会获得很好的体验,这里我们就不再赘述了。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%