前言
关于TextView相关的文章,我们之前也实现过,在读本篇之前,可以先读读其他文章
TextView是相当复杂的UI组件,TextView不仅仅支持纯文本展示,而且还支持图片、SpannableString、文本输入、超链接等诸多功能,因此很多View本身也是直接继承自TextView的,如EditText、Button、Chronometer等。可见TextView功能非常强大,基本上是app中使用率最高的View组件。
不过 TextView 缺点也不少,主要问题点如下:
- 跑马灯执行的条件过高,且部分属性有一定的重复问题
- setText 容易触发requestLayout
- 换行文本容易出现犬牙(很多小说类app自行绘制文本来解决此问题)
当然,以上是大多数情况中我们容易遇到的问题。
优化方法
上面我们列出了3个常见的问题,我们这边逐一来看。
跑马灯问题
TextView对跑马灯的要求比较高,必须是单行文本,而且必须设置MaxLines,而且不支持Lines设置,另外必须是focused或者是selected,这显然增加了一些成本,要知道如果父布局focused,那么子View是不可能focused,显然对TV设备不够有好。但是另外一个问题,View可以同时具备Focused和Selected状态,这显然增加了问题的难度,为此我们需要剥离focused状态。
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状态供大家使用。
下面是跑马灯兼容逻辑
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,否则测量出的大小会不正确。
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
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造成的损耗,因为后者也会测量。
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方法测量使用纳秒
- 计算平均耗时
测试代码如下
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实际上也提供了优化方法
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性能可以更好。
犬牙问题:
这种问题的解决方法网上能搜出很多,但是对中文支持最好的得参考下面文章 《关于TextView中换行后对齐问题》
其中实现原理是对TextView重写,但是缺点是对英文支持的不够好,不过关系不大,对英文分词即可快速实现。
其核心逻辑是:对最后一行的以外的其他文本行增加文字间距(word space),从而使得看起来犬牙的文本显的规整,但其本身并非是两边对齐。
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全角字符展示问题
我们来试一下效果,我们设置一段下面的文字
"Good Morning ! 😂来,今天是一个美妙的日子,我们一起走在大街上,唱着
我们喜欢的歌谣、跳着不是舞蹈的舞蹈,雀跃的步伐,在热闹非凡的社火表演中,
与所有人的脚步和声音揉合在了一起。孩子们的声音非常尖锐,只有😄,没有😢,
或许他们才是今天的主角";
效果预览,很遗憾,表情符没有展示出来
这个问题是评论区有同学提出来的,原则上说展示表情符不在本篇范畴,既然提出来了自然需要解决一下,提供一个思路,以后可以解决类似的问题。
UnicodeBlock
在Android中,为了识别各个国家的文字编码,在Character类中定义了很多类似的常量。通常意义上,我们判断字符编码的范围的也是可以的,但是字符编码范围的坏处就是需要查找范围,而且还存在不准确的情况。其实最简单的方法还是使用UnicodeBlock来判断
下面我们来打印一句话的范围
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和全角字符展示的问题,后续如有其他需求可以在评论区提出。
问题修复代码
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;
}
修复后的效果
完整代码
到这里我们实现了两端尽可能对齐,避免犬牙问题,同时解决了emoji展示问题
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,也有很多方式可以实现此类优化。文本对齐问题,实际上在一些协议页面和小说页面使用会获得很好的体验,这里我们就不再赘述了。