自己在做SpEditTool:一个支持表情,@mention,#话题#等功能的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分析器
先通过火焰图看看最耗时的调用栈是哪一条
图上可知ChangeWatcher.onSpanChanged()
->ChangeWatcher.reflow()
->DynamicLayout.reflow()
->StaticLayout.generate()
这条调用栈最为耗时
再看看调用顺序图
有一点疑问,我看DynamicLayout源码,每次reflow()应该只会调用一次StaticLayout.generate()而且都是在主线程,CallChat却显示了多次,而且调用次数没看出啥规律,不知道有没有大神可以帮我解下惑
其实通过上面两步基本已经定位到问题了,再在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.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,搜狗输入法也会出现,估计是百度输入法在出现这样的情况时就把删除按钮的触摸事件给中断了setText()
时需要被重新创建,而第二次删除时InputConnection可能还没创建好或者IInputConnectionWrapper没处于激活状态跟输入法死磕几天未果正愁着呢,突然想到谷歌在android 8.0发布的时候推出了一个Emoji表情库,Emoji出现在TextView中逃不出也用的是ImageSpan,想看看谷歌会不会也有从中间开始删除表情卡顿的feature,就去找了下这个库的demo,一跑发现demo中不管从末尾还是从中间删都不会卡。顿时燃起了解决这个问题的希望,看完代码才发现解决方案如此简单
之前定位到问题在于ChangeWatcher,但它是一个内部类,自己想的法子都是在外部怎么避免ChangeWatcher.onSpanChanged()
被调用,谷歌直接简单粗暴的用反射获取了ChangeWatcher的Class对象,在setSpan()
的时候发现如果是ChangeWatcher
就把它包装在新的WatcherWrapper中,所有的操作都通过WatcherWrapper中转,就可以随心所欲控制onSpanChanged了
DynamicLayout.ChangeWatcher
的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));
}
}
贴上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;
}
}
setEditableFactory(ImageEditableFactory.getInstance());
自己的demo一跑果然无论从哪个位置删都不会卡顿了
如果你觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。
希望读到这的您能转发分享和关注一下我,以后还会更新技术干货,谢谢您的支持!