Android记一次优化文字缩进控件的坑
前言
项目中个人负责的多个列表页用到类似微博及小红书如下图的这种超过缩进行数文末添加" ...全文" 展开的控件。在页面的优化同城中,通过systrace跟踪发现项目中该自定义控件有个方法会反复调用多次(具体以下详解) ,本以为这种控件应该是有比较成熟的解决方案,于是github一顿搜索,唯一一个星数上千的库就是ExpandableTextView,查看后实现原理是:2个控件不是span的形式添加到textview尾部,然后获取4行时textview需显示的高度,按钮点下时动画控制view的height属性;且并无文末添加“...全文”的功能,与需求不符。无奈只能自己动手,优化现有控件。
思路及原理
- 发现其实这个效果与 TextView 设置 android:maxLines 之后,再设置 android:ellipsize 为 end 很相似,只是 … 替换换成了 …展开 ,遗憾的是系统并没有提供直接替换 … 的API。
但是,在涉及到 android:ellipsize 属性处理的 TextView 的源码中可以看到使用了 StaticLayout 了一个可以帮助我们实现效果的工具类 StaticLayout,StaticLayout 是android中处理文字换行的一个工具类。
有BoringLayout、StaticLayout 和 DynamicLayout 三个工具类
- BoringLayout 是单行显示时使用的
- StaticLayout 是针对不可以变的文本(不同系统版本构造方式不大一样,)
- DynamicLayout 则是针对可编辑改变的文本,并且会更新自身。
具体这些类及方法本文不详细展开,有兴趣的同学可自行查看相关文档
于是得出最终方案
2:动态截取文字,加上“...全文”后刚好撑满缩进,然后将新的CharSequence设入textview即可。
细节及注意点
- 一定要先测量一次如果本身文字行数就不会超过锁进行数,则什么都不要再做任何处理,浪费性能,直接走textview的方法即可(需求上也是如此)
- 记录一份原数据,如果有些地方是显示的是"...展开",点击后的效果是直接展开显示全文的话
- 新文字设置进来时,对比下当前记录的原数据与设入的新数据是否一致,一致则不再做多余处理,算是性能的优化。还取决于控件实现方式,像原项目中控件的处理,是在onDraw方法(当然做了其它限制,保证每次更改文字只触发一次,不能每次ondraw都去触发,否则性能就废了)时才取拦截,因为onDraw方法已经在measure和layout方法之后,如果不做该操作当控件放在listview或recyclerview列表中,当notify或者滑动操作时,会造成先高度测量差异而抖动一下。
项目中多次调用的方法优化
有了以上思路后,根据systrac显示,多次调用耗时,跟踪项目中调用多次的方法,发现他是一个循环,一直去尝试截取原文中的不同长度的文字去与“...全文”拼成后刚好布满指定缩进行数的文字。
一开始看到此处一脸懵逼,为啥要一直循环遍历去尝试,而不是直接先通过StaticLayout.getLineEnd方法,直接获取缩进行数的末尾offset,然后截取原文字,再对这个截取后的文字,删减“...全文”的长度,最后再将这个删减后的文字拼接上"...全文",不就是我们想要的最终结果,不就可以了
大概如下
...
int lineEnd = getLayout().getLineEnd(mCollapsedLines - 1);
CharSequence suffix = "...全文";
int newEnd = lineEnd - suffix.length() - 1;
int end = newEnd > 0 ? newEnd : lineEnd;
CharSequence finalSequence = note.subSequence(0, end);
...
然而,实际结果令人啪啪打脸[捂脸哭],会有的还有间距,有的超过。因为漏了一个重要因素,就是同样length的文字,在绘制时所占用的宽度不一定一致。
各种搜索网上其它大佬的解决方案,也都是只能遍历一直去尝试,看截到多少能刚好铺满。
如
1:
TextPaint paint = getPaint();
int maxWidth = mCollapsedLines * (getMeasuredWidth() - getPaddingLeft() - getPaddingRight());
while (paint.measureText(note.substring(0, end) + suffix) > maxWidth)
end--;
note = note.substring(0, end);
而且这代码还有个问题就是忽律了各种span长度问题
2:ExpandableText-Example
//计算原文截取位置
int endPos = layout.getLineEnd(maxLines - 1);
if (originalText.length() <= endPos) {
mCloseSpannableStr = charSequenceToSpannable(originalText);
} else {
mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, endPos));
}
SpannableStringBuilder tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING);
if (mOpenSuffixSpan != null) {
tempText2.append(mOpenSuffixSpan);
}
//循环判断,收起内容添加展开后缀后的内容
Layout tempLayout = createStaticLayout(tempText2);
while (tempLayout.getLineCount() > maxLines) {
int lastSpace = mCloseSpannableStr.length() - 1;
if (lastSpace == -1) {
break;
}
if (originalText.length() <= lastSpace) {
mCloseSpannableStr = charSequenceToSpannable(originalText);
} else {
mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace));
}
tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING);
if (mOpenSuffixSpan != null) {
tempText2.append(mOpenSuffixSpan);
}
tempLayout = createStaticLayout(tempText2);
}
还有其它多个库,不一一链接,区别在于如何去测量,如果去逼近求出最终字符串而已。所以现在能优化的重点就在于,如何尽量地去减少遍历的次数。
上方库的方法比较简单,也与项目中用到的方法类似。即:通过StaticLayout.getLineEnd方法,直接获取缩进行数的末尾offset,然后截取原文字,直接拼接上"...全文"span,然后依次往前递减字符去逼近。
项目中的是直接全字段二分查找去逼近,以上开源库方法做为备用方案。经试验,在文字长度不是很长时,效率比备用方法高不少;当文字长度过长时,备用方法则优势明显。
其实还可以进一部优化,即二分查找法的起始位置不要全字串二分,从 截取后的文字,删减“...全文”的长度,开始到最后拼接上的 这个小范围去二分查找。
优化后打log方法及效果如下:
//优化前方式
CharSequence destStr = tailorText(text,false);
long newEnd = System.currentTimeMillis();
//优化后方式
CharSequence destStrNewMethod = tailorText(text,true);
long newEnd2 = System.currentTimeMillis();
Log.d(TAG, ("oldMethod--->"+(newEnd - startTime))+"|NewMethod="+(newEnd2-newEnd) + "ms");
image.png
这几毫秒的时间在一个布局中并无关紧要,但因为项目中是放在listview及recyclerview中使用,一次滑动及来回操作便会调用反复调用多次,积累起来便很可观。
尾言
- 关于源码,由于本次只是做一些优化思路,具体控件有很多旧的冗余代码,未做清理,故不附上该控件的全部源码