Android开发程序员Android技术知识

让你的EditText删除表情比微信更高效--记一次androi

2019-08-10  本文已影响49人  06fd4cf1f427

前言

自己在做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);这一行

精确定位

再准备顺藤摸瓜找到卡顿的真正元凶,但是代码跳着跳着就到SpannableStringBuilderTextView这两个超大的类里去了,在哪卡的还不知道自己就绕晕了,只能靠性能检测工具先具体定位到问题再进一步分析了

这里用到了AndroidStudio3.0自带的Android Profiler,具体的用法可以看AndroidStudio3.0 Android Profiler分析器

FlameChart

先通过火焰图看看最耗时的调用栈是哪一条

图上可知ChangeWatcher.onSpanChanged()->ChangeWatcher.reflow()->DynamicLayout.reflow()->StaticLayout.generate()这条调用栈最为耗时

CallChart

再看看调用顺序图

有一点疑问,我看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.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;
  }

完成这一系列操作之后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;
            }

完全版的解决方案

跟输入法死磕几天未果正愁着呢,突然想到谷歌在android 8.0发布的时候推出了一个Emoji表情库,Emoji出现在TextView中逃不出也用的是ImageSpan,想看看谷歌会不会也有从中间开始删除表情卡顿的feature,就去找了下这个库的demo,一跑发现demo中不管从末尾还是从中间删都不会卡。顿时燃起了解决这个问题的希望,看完代码才发现解决方案如此简单

之前定位到问题在于ChangeWatcher,但它是一个内部类,自己想的法子都是在外部怎么避免ChangeWatcher.onSpanChanged()被调用,谷歌直接简单粗暴的用反射获取了ChangeWatcher的Class对象,在setSpan()的时候发现如果是ChangeWatcher就把它包装在新的WatcherWrapper中,所有的操作都通过WatcherWrapper中转,就可以随心所欲控制onSpanChanged了

自定义一个Editable.Factory

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 的代码,自定义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架构师之路很漫长,一起共勉吧!

以下墙裂推荐阅读!!!

最后祝大家生活愉快~

上一篇下一篇

猜你喜欢

热点阅读