前言
自己在做SpEditTool:一个支持表情,@mention,#话题#等功能的EditText控件,这个项目的时候出现了一个很奇怪的问题
- EditText输入表情过多的时候,从中间开始删除表情,会出现非常卡的情况,而从最后开始删除则不会
对比微信的表情输入功能之后,发现微信这个浓眉大眼的也有这样的feature(微信都有的现象那能是bug嘛,大雾。。。)
不过自己写的东西有问题心里总归不爽,断断续续折腾一个礼拜终于把这个问题解决了,整个过程中自己感觉受益匪浅,记录下分享给大家
最初的实现
setOnKeyListener(new OnKeyListener() {
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_DEL && event.getAction() == KeyEvent.ACTION_DOWN) {
return onDeleteEvent();
}
return false;
}
});
private boolean onDeleteEvent() {
int selectionStart = getSelectionStart();
int selectionEnd = getSelectionEnd();
if (selectionEnd != selectionStart) {
return false;
}
SpData[] spDatas = getSpDatas();
for (SpData spData : spDatas) {
if (selectionStart == spData.end) {
Editable editable = getText();
editable.delete(spData.start, spData.end);
return true;
}
}
return false;
}
SpData
中保存了表情对应的文本的开始位置和结束位置,直接使用Editable.delete()
删除
问题定位
粗略定位
先打Log粗略定位下问题,把自己觉得可能会造成卡顿的地方都加了log,发现卡顿的罪魁祸首就是editable.delete(spData.start, spData.end);
这一行
精确定位
再准备顺藤摸瓜找到卡顿的真正元凶,但是代码跳着跳着就到SpannableStringBuilder
和TextView
这两个超大的类里去了,在哪卡的还不知道自己就绕晕了,只能靠性能检测工具先具体定位到问题再进一步分析了
这里用到了AndroidStudio3.0自带的Android Profiler
,具体的用法可以看AndroidStudio3.0 Android Profiler分析器
FlameChart
先通过火焰图看看最耗时的调用栈是哪一条
图上可知
ChangeWatcher.onSpanChanged()
->ChangeWatcher.reflow()
->DynamicLayout.reflow()
->StaticLayout.generate()
这条调用栈最为耗时
CallChart
再看看调用顺序图
- ChangeWatcher.onSpanChanged()被调用了多次,会多次调用DynamicLayout.reflow()
- DynamicLayout.reflow()中会调用多次StaticLayout.generate()
有一点疑问,我看DynamicLayout源码,每次reflow()应该只会调用一次StaticLayout.generate()而且都是在主线程,CallChat却显示了多次,而且调用次数没看出啥规律,不知道有没有大神可以帮我解下惑
BottomUp
其实通过上面两步基本已经定位到问题了,再在BottomUp的表格中确认一下
StaticLayout.generate()中有这样一段代码,这下实锤了
if (spanned == null) {
spanEnd = paraEnd;
int spanLen = spanEnd - spanStart;
measured.addStyleRun(paint, spanLen, fm);
} else {
spanEnd = spanned.nextSpanTransition(spanStart, paraEnd,
MetricAffectingSpan.class);
int spanLen = spanEnd - spanStart;
MetricAffectingSpan[] spans =
spanned.getSpans(spanStart, spanEnd, MetricAffectingSpan.class);
spans = TextUtils.removeEmptySpans(spans, spanned, MetricAffectingSpan.class);
measured.addStyleRun(paint, spans, spanLen, fm);
}
问题分析
TextView这块相关代码比较复杂就不一行行分析了直接说结论
- ChangeWatcher实现了SpanWatcher接口,它是用来监听TextView中Span发生变化的
- 当从中间删除一个表情,被删除表情后面的所有的ImageSpan位置都发生了变化,每个ImageSpan变化都会触发一次
ChangeWatcher.onSpanChanged()
->ChangeWatcher.reflow()
->DynamicLayout.reflow()
->StaticLayout.generate()
这样的调用栈
这就是为什么要从中间删除才会卡顿,从最后删不会的原因
解决问题
通过以上的结论可以知道,要解决从中间删除表情卡顿的关键在于如何让ChangeWatcher.onSpanChanged()
不多次调用
第一阶段方案
之前文章中提到过SpanWatcher
继承于NoCopySpan
接口,在产生一个新的Spannable对象时NoCopySpan
不会被复制,而ChangeWatcher
则实现了SpanWatcher
,所以它也不会被复制,灵光一闪一个解决方案出来了
private boolean onDeleteEvent() {
int selectionStart = getSelectionStart();
int selectionEnd = getSelectionEnd();
if (selectionEnd != selectionStart) {
return false;
}
SpData[] spDatas = getSpDatas();
for (int i = 0; i < spDatas.length; i++) {
SpData spData = spDatas[i];
if (selectionStart == spData.end) {
Editable editable = getText();
SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(editable);
spannableStringBuilder.delete(spData.start, spData.end);
GifTextUtil.setText(this, spannableStringBuilder);
setSelection(spData.start);
return true;
}
}
return false;
}
- 之前是直接删除
- 新的方案是先取出文本内容,复制给新的
SpannableStringBuilder
,在设置到输入框之前删除表情,因为此时新的SpannableStringBuilder中并不包含ChangeWatcher所以不会多次调用ChangeWatcher.onSpanChanged() - 删除表情后再将
SpannableStringBuilder
设置给EditText - 最后设置光标位置
完成这一系列操作之后demo一跑,删除果然变流畅了,当时心里那个高兴啊,竟然做个功能可以比微信实现的还好那么一点
输入法问题
然而总是帅不过三秒。没过一会就发现了新的问题。
- 百度输入法只能一个个删除表情,而不能长按一溜删下来(搜狗是可以的。。。)
刚战完微信又来个百度输入法,写个表情输入功能咋跟打游戏里的boss一样呢。本来自信满满要找出百度输入法的bug,但是从来没接触过输入法相关的开发工作,跑了跑google的输入法的sample还发现官方的输入法一样有问题,又挣扎了几下翻了翻源码,最终还是无功而返
虽然没解决输入法的问题,不过也不是完全没有收获
case DO_SEND_KEY_EVENT: {
InputConnection ic = getInputConnection();
if (ic == null || !isActive()) {
Log.w(TAG, "sendKeyEvent on inactive InputConnection");
return;
}
ic.sendKeyEvent((KeyEvent)msg.obj);
onUserAction();
return;
}
-
W/IInputConnectionWrapper: sendKeyEvent on inactive InputConnection
连续删除时会出现这样的log,搜狗输入法也会出现,估计是百度输入法在出现这样的情况时就把删除按钮的触摸事件给中断了 - 出现上面log的原因是因为InputConnection在
setText()
时需要被重新创建,而第二次删除时InputConnection可能还没创建好或者IInputConnectionWrapper没处于激活状态
完全版的解决方案
跟输入法死磕几天未果正愁着呢,突然想到谷歌在android 8.0发布的时候推出了一个Emoji表情库,Emoji出现在TextView中逃不出也用的是ImageSpan,想看看谷歌会不会也有从中间开始删除表情卡顿的feature,就去找了下这个库的demo,一跑发现demo中不管从末尾还是从中间删都不会卡。顿时燃起了解决这个问题的希望,看完代码才发现解决方案如此简单
之前定位到问题在于ChangeWatcher,但它是一个内部类,自己想的法子都是在外部怎么避免ChangeWatcher.onSpanChanged()
被调用,谷歌直接简单粗暴的用反射获取了ChangeWatcher的Class对象,在setSpan()
的时候发现如果是ChangeWatcher
就把它包装在新的WatcherWrapper中,所有的操作都通过WatcherWrapper中转,就可以随心所欲控制onSpanChanged了
自定义一个Editable.Factory
- 用反射获取了
DynamicLayout.ChangeWatcher
的Class对象 - 将Class对象作为新的
SpannableStringBuilder
的构造参数传入
final class ImageEditableFactory extends Factory {
private static final Object sInstanceLock = new Object();
@GuardedBy("sInstanceLock")
private static volatile Factory sInstance;
@Nullable
private static Class<?> sWatcherClass;
@SuppressLint({"PrivateApi"})
private ImageEditableFactory() {
try {
String className = "android.text.DynamicLayout$ChangeWatcher";
sWatcherClass = this.getClass().getClassLoader().loadClass(className);
} catch (Throwable var2) {
;
}
}
public static Factory getInstance() {
if (sInstance == null) {
Object var0 = sInstanceLock;
synchronized (sInstanceLock) {
if (sInstance == null) {
sInstance = new ImageEditableFactory();
}
}
}
return sInstance;
}
public Editable newEditable(@NonNull CharSequence source) {
return (Editable) (sWatcherClass != null ? SpannableBuilder.create(sWatcherClass, source)
: super.newEditable(source));
}
}
自定义一个SpannableStringBuilder
- 定义一个WatcherWrapper将ChangeWatcher包装起来,所有之前对ChangeWatcher的调用都通过WatcherWrapper完成
- 这里onSpanChanged就对ImageSpan特殊处理了,直接返回不调用ChangeWatcher.onSpanChanged
- 覆盖SpannableStringBuilder的相关方法
- 对和Span相关的方法特殊处理
贴上WatcherWrapper 的代码,自定义SpannableStringBuilder代码就不贴了,大家可以去项目里找com.sunhapper.spedittool.view.SpannableBuilder
自己看
private static class WatcherWrapper implements TextWatcher, SpanWatcher {
private final Object mObject;
private final AtomicInteger mBlockCalls = new AtomicInteger(0);
WatcherWrapper(Object object) {
this.mObject = object;
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
((TextWatcher) mObject).beforeTextChanged(s, start, count, after);
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
((TextWatcher) mObject).onTextChanged(s, start, before, count);
}
@Override
public void afterTextChanged(Editable s) {
((TextWatcher) mObject).afterTextChanged(s);
}
@Override
public void onSpanAdded(Spannable text, Object what, int start, int end) {
if (mBlockCalls.get() > 0 && isImageSpan(what)) {
return;
}
((SpanWatcher) mObject).onSpanAdded(text, what, start, end);
}
@Override
public void onSpanRemoved(Spannable text, Object what, int start, int end) {
if (mBlockCalls.get() > 0 && isImageSpan(what)) {
return;
}
((SpanWatcher) mObject).onSpanRemoved(text, what, start, end);
}
@Override
public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart,
int nend) {
if (mBlockCalls.get() > 0 && isImageSpan(what)) {
return;
}
((SpanWatcher) mObject).onSpanChanged(text, what, ostart, oend, nstart, nend);
}
final void blockCalls() {
mBlockCalls.incrementAndGet();
}
final void unblockCalls() {
mBlockCalls.decrementAndGet();
}
private boolean isImageSpan(final Object span) {
return span instanceof ImageSpan;
}
}
设置EditText的EditableFactory
setEditableFactory(ImageEditableFactory.getInstance());
自己的demo一跑果然无论从哪个位置删都不会卡顿了
总结
- 性能分析工具可以帮助自己快速定位问题,对于android sdk这种不太好调试的代码更是事半功倍
- 解决问题的时候不要一味死磕,特别对于自己不熟悉的东西,有可能思路本身就是错的
- 对于一些私有的方法,用反射可以实现很多风骚操作
相关文章
自定义EditText轻松实现群聊精确@提及功能(@mention) 微博话题等功能
一行代码让TextView中ImageSpan支持Gif(一)----轻松实现TextView中gif图文混排
一行代码让TextView中ImageSpan支持Gif(二)----实现及代码分析
一行代码让TextView中ImageSpan支持Gif(三)----利用android-gif-drawable和Glide实现TextView中gif的图文混排
一行代码让TextView中ImageSpan支持Gif(四)----drawable复用,减少内存消耗,支持RecylerView/ListView等场景
完整代码https://github.com/sunhapper/SpEditTool
欢迎star,提PR、issue