Let your EditText delete facial expressions more efficiently than Wechat -- remember an android Performance Analysis Optimization Practice

Posted by jbx on Sat, 10 Aug 2019 12:22:10 +0200

Preface

I'm working on SpEditTool: an EditText control that supports facial expressions, @mention, # topic, etc. This project has a very strange problem.

  • When EditText enters too many expressions, deleting expressions from the middle will result in a very card situation, while deleting expressions from the end will not.

After comparing the facial expression input function of Wechat, we found that Wechat, a big eyebrow and big eye, also has such feature s. (The phenomenon that Wechat has can be a bug, fog.)

However, I always feel uncomfortable when I write something wrong. After a week of intermittent tossing and turning, I finally solved this problem. I feel very helpful in the whole process. I record it and share it with you.

Initial realization

    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 saves the start and end positions of the text corresponding to the expression and deletes them directly using Editable.delete()

Problem Location

Rough positioning

Firstly, make a rough positioning of the problem with Log and add the log to the places that you think might cause Carton. The main culprit of Carton is editable.delete(spData.start, spData.end);

Accurate positioning

Prepare to find the real culprit of Carton, but the code jumps and jumps to the two super-large classes of Spannable String Builder and TextView, where you don't know what card you are going to get stunned. Only the reliability performance detection tool can locate the problem first and then analyze it further.

Here we use Android Profiler, which comes with Android Studio 3.0. You can see the specific usage of Android Profiler. Android Studio 3.0 Android Profiler Analyser

FlameChart

First look at the flame chart to see which call stack is the most time-consuming

From the graph, we can see that the call stack of ChangeWatcher. onSpanChanged () - > ChangeWatcher. reflow () - > DynamicLayout. reflow () - > StaticLayout. generate () is the most time-consuming.

CallChart

Look at the call sequence diagram again

  • ChangeWatcher.onSpanChanged() is called many times, and DynamicLayout.reflow() is called many times.
  • StaticLayout.generate() is called many times in DynamicLayout.reflow().

There is a little doubt, I see the DynamicLayout source code, each reflow() should only call StaticLayout.generate() once and it is in the main thread, CallChat shows many times, and the number of calls did not see any rule, I do not know if God can help me solve the confusion.

BottomUp

In fact, through the above two steps, we have basically located the problem, and then confirm it in the Bottom Up table.

There's this code in StaticLayout.generate(), and it's a real hammer.

                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);
                }

problem analysis

TextView is a relatively complex piece of code that does not have a line-by-line analysis of direct conclusions.

  • ChangeWatcher implements the SpanWatcher interface, which is used to monitor changes in Span in TextView
  • When an expression is deleted from the middle, all the ImageSpan positions behind the deleted expression change. Each ImageSpan change triggers a call stack such as ChangeWatcher. onSpanChanged ()-> ChangeWatcher. reflow ()-> DynamicLayout. reflow ()-> StaticLayout. generate ().

That's why you have to delete it from the middle to get stuck, and it won't be deleted from the end.

Solve the problem

From the above conclusion, we can see that the key to remove the expression carton from the middle is how to make ChangeWatcher.onSpanChanged() not call many times.

Phase I Programme

As mentioned in the previous article, SpanWatcher inherits from NoCopySpan interface. NoCopySpan will not be copied when a new Spannable object is generated, while ChangeWatcher implements SpanWatcher, so it will not be copied. A bright solution comes out.

  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;
  }

  • Previously, it was deleted directly.
  • The new solution is to take out the text content first, copy it to the new Spannable String Builder, and delete the expression before setting it to the input box, because the new Spannable String Builder does not contain ChangeWatcher, so ChangeWatcher.onSpanChanged() will not be called many times.
  • Delete the expression and set the Spannable String Builder to EditText
  • Finally, set the cursor position

After completing this series of operations, demo ran and deletion became smoother. At that time, I was so happy that I could do a better function than Wechat.

Input Method Problem

But it's always handsome for only three seconds. It was not long before a new problem was discovered.

  • Baidu input method can only delete expressions one by one, but can not be deleted by a long click. (Sogou is OK.)

Just after the war, we have another Baidu input method to write a facial expression input function, which is the same as the boss in the game. Originally confident to find out the bug s of Baidu input method, but never contact the development work related to input method. Running the sample of google input method, we found that the official input method has the same problem, and struggled to turn over the source code several times, and finally came back in vain.

Although the problem of input method has not been solved, it is not completely fruitless.

            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/IInputConnection Wrapper: SendKeyEvent on inactive InputConnection will have such a log when deleted continuously. Sogou input method will also appear. It is estimated that Baidu input method will interrupt the touch event of deletion button when such a situation occurs.
  • The reason for the above log is that InputConnection needs to be recreated at setText(), and InputConnection may not have been created at the second deletion or IInputConnection Wrapper is not activated.

Full version solution

After a few days of frustration with the input method, it suddenly occurred to me that Google launched an Emoji expression library when android 8.0 was released. Emoji could not escape from TextView, but also used ImageSpan. I wanted to see if Google would also have the feature to delete the expression carton from the middle, so I went to the demo of this library and ran. It was found that no matter from the end or from the middle of the demo, it would not be carded. Suddenly, the hope of solving this problem arose. After reading the code, we found that the solution was so simple.

Previously, the problem was ChangeWatcher, but it was an internal Class. All I thought about was how to avoid changing Watcher. onSpanChanged () being invoked externally. Google got ChangeWatcher's lass object directly and roughly with reflection. When setSpan(), it found that if it was ChangeWatcher, it would wrap it in ChangeWatcher. In the new Watcher Wrapper, all operations are transferred through Watcher Wrapper, and onSpanChanged can be controlled at will.

Customize an Editable.Factory

  • Capturing Class Objects of DynamicLayout.ChangeWatcher with Reflection
  • Class object is passed in as the construction parameter of the new Spannable StringBuilder
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));
  }
}

Customize a Spannable StringBuilder

  • Define a WatcherWrapper to wrap ChangeWatcher, all previous calls to ChangeWatcher are done through WatcherWrapper
  • Here onSpanChanged handles ImageSpan specially and returns directly without calling ChangeWatcher.onSpanChanged
  • Relevant methods to cover Spannable StringBuilder
  • Special treatment of Span-related methods

Paste Watcher Wrapper code, custom Spannable StringBuilder code is not pasted, you can go to the project to find com. sunhapper. spedittool. view. Spannable Builder to see for yourself.

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;
    }
  }

Setting Editable Factory for EditText

setEditableFactory(ImageEditableFactory.getInstance());

No matter where you delete your demo, you won't get stuck.

summary

  • Performance analysis tools can help you quickly locate problems, and it's even more effective for android sdk, a code that's not easy to debug.
  • When solving problems, don't blindly stumble. Especially when you are not familiar with something, you may be wrong in your thinking.
  • For some private methods, reflection can be used to achieve a lot of coquettish operations.~

Complete code

Last

If you think the article is well written, give it a compliment? If you think it's worth improving, please leave me a message. We will inquire carefully and correct the shortcomings. Thank you.

I hope you can forward, share and pay attention to me, and update the technology dry goods in the future. Thank you for your support! ___________

Forwarding + Praise + Concern, First Time to Acquire the Latest Knowledge Points

Android architects have a long way to go. Let's work together.

The following wall cracks recommend reading!!!

Finally, I wish you all a happy life.~

Topics: Android Google emoji SDK