让你的EditText删除表情比微信更高效--记一次android性能分析优化实战

前言

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

精确定位

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

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

FlameChart

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

flame_chart.png

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

CallChart

再看看调用顺序图

call_chart.png
  • ChangeWatcher.onSpanChanged()被调用了多次,会多次调用DynamicLayout.reflow()
  • DynamicLayout.reflow()中会调用多次StaticLayout.generate()

有一点疑问,我看DynamicLayout源码,每次reflow()应该只会调用一次StaticLayout.generate()而且都是在主线程,CallChat却显示了多次,而且调用次数没看出啥规律,不知道有没有大神可以帮我解下惑

BottomUp

其实通过上面两步基本已经定位到问题了,再在BottomUp的表格中确认一下

bottom_up.png

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,423评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,147评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,019评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,443评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,535评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,798评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,941评论 3 407
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,704评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,152评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,494评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,629评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,295评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,901评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,742评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,978评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,333评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,499评论 2 348

推荐阅读更多精彩内容