249

记一例 Android 无障碍服务(Accessibility)引发的崩溃

 4 years ago
source link: https://yrom.net/blog/2019/12/03/crashed-on-accessibilitynodeInfo-settext-in-textview/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

来自线上用户的一个神奇崩溃,日志如下:

java.lang.IndexOutOfBoundsException: setSpan (-1 ... -1) starts before 0
    at android.text.SpannableStringBuilder.checkRange(SpannableStringBuilder.java:1330)
    at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:684)
    at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:676)
    at android.view.accessibility.AccessibilityNodeInfo.setText(AccessibilityNodeInfo.java:2645)
    at android.widget.TextView.onInitializeAccessibilityNodeInfoInternal(TextView.java:11652)
    at android.view.View.onInitializeAccessibilityNodeInfo(View.java:8257)
    at android.view.View.createAccessibilityNodeInfoInternal(View.java:8216)
    at android.view.View.createAccessibilityNodeInfo(View.java:8201)
    at android.view.AccessibilityInteractionController$AccessibilityNodePrefetcher.prefetchDescendantsOfRealNode(AccessibilityInteractionController.java:1204)
    at android.view.AccessibilityInteractionController$AccessibilityNodePrefetcher.prefetchAccessibilityNodeInfos(AccessibilityInteractionController.java:1029)
    at android.view.AccessibilityInteractionController.findAccessibilityNodeInfoByAccessibilityIdUiThread(AccessibilityInteractionController.java:341)
    at android.view.AccessibilityInteractionController.access$400(AccessibilityInteractionController.java:75)
    at android.view.AccessibilityInteractionController$PrivateHandler.handleMessage(AccessibilityInteractionController.java:1393)
    at android.os.Handler.dispatchMessage(Handler.java:107)
    at android.os.Looper.loop(Looper.java:214)
    at android.app.ActivityThread.main(ActivityThread.java:7356)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)

当用户触发无障碍服务 (Accessibility) 时,会遍历可点击的 TextView。TextView 再去创建 AccessibilityNodeInfo 传递给无障碍服务,但 AccessibilityNodeInfo 在获取文本用于构造 SpannableStringBuilder 时却发生了异常—— java.lang.IndexOutOfBoundsException

Why?

下面这段摘抄自 AccessibilityNodeInfo.java 的代码告诉了我们原因:

AccessibilityNodeInfo.java
public void setText(CharSequence text) {
    enforceNotSealed();
    mOriginalText = text;
    // Replace any ClickableSpans in mText with placeholders
    if (text instanceof Spanned) {
        ClickableSpan[] spans =
                ((Spanned) text).getSpans(0, text.length(), ClickableSpan.class);
        if (spans.length > 0) {
            Spannable spannable = new SpannableStringBuilder(text);
            for (int i = 0; i < spans.length; i++) {
                ClickableSpan span = spans[i];
                if ((span instanceof AccessibilityClickableSpan)
                        || (span instanceof AccessibilityURLSpan)) {
                    // We've already done enough
                    break;
                }
                int spanToReplaceStart = spannable.getSpanStart(span);
                int spanToReplaceEnd = spannable.getSpanEnd(span);
                int spanToReplaceFlags = spannable.getSpanFlags(span);
                spannable.removeSpan(span);
                ClickableSpan replacementSpan = (span instanceof URLSpan)
                        ? new AccessibilityURLSpan((URLSpan) span)
                        : new AccessibilityClickableSpan(span.getId());
                spannable.setSpan(replacementSpan, spanToReplaceStart, spanToReplaceEnd,
                        spanToReplaceFlags);
            }
            mText = spannable;
            return;
        }
    }
    mText = (text == null) ? null : text.subSequence(0, text.length());
}

上述代码关键是在替换 text 中的 ClickableSpan 对象为 AccessibilityURLSpan 或者 AccessibilityClickableSpan

  1. 首先,从原始的 text 中获取的 ClickableSpan 对象数组 spans
  2. 其次,遍历获取每个 ClickableSpan 在原始 text 中的位置。
  3. 最后,替换掉 Spannable 对应位置的 ClickableSpan

崩溃就发生最最后一步 spannable.setSpan(...) 。程序执行到这里的时候, spanToReplaceStartspanToReplaceEnd 都是 -1 ,就是说对应的 ClickableSpan 在经过 SpannableStringBuilder 拷贝后不见了 !!

why ???

其实问题的关键在 new SpannableStringBuilder(text)

SpannableStringBuilder.java
public SpannableStringBuilder(CharSequence text, int start, int end) {
    
    // omitted...

    if (text instanceof Spanned) {
        Spanned sp = (Spanned) text;
        Object[] spans = sp.getSpans(start, end, Object.class);

        for (int i = 0; i < spans.length; i++) {
            if (spans[i] instanceof NoCopySpan) {
                continue;
            }

            int st = sp.getSpanStart(spans[i]) - start;
            int en = sp.getSpanEnd(spans[i]) - start;
            int fl = sp.getSpanFlags(spans[i]);

            if (st < 0)
                st = 0;
            if (st > end - start)
                st = end - start;

            if (en < 0)
                en = 0;
            if (en > end - start)
                en = end - start;

            setSpan(false, spans[i], st, en, fl, false/*enforceParagraph*/);
        }
    }
    // ...
}

从上面一段代码可以看出, SpannableStringBuilder 在拷贝 spans 时会跳过 NoCopySpan 的对象!!!

也就是, AccessibilityNodeInfo.setText 这个方法代码写的有bug,没有考虑 ClickableSpan 的对象也有可能是 NoCopySpan ,进而导致异常发生。

Step to reproduce

  1. 定义一个 TestSpan 继承 ClickableSpan 并实现 NoCopySpan :

    TestSpan.kt
    class TestSpan: ClickableSpan(), NoCopySpan {
        override fun onClick(widget: View) {
            Log.d("Test", "on click $this")
        }
    }
    
  2. 把这个 TestSpan 塞到 TextView 的 text 中:

    TestActivity.kt
    class TestActivity: Activity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(TextView(this).apply {
                text = SpannableString("test").apply {
                    setSpan(TestSpan(), 0, 1, SpannableString.SPAN_INCLUSIVE_INCLUSIVE)
                }
            })
        }
    }
    
  3. 启用设备里的会读取文本信息的无障碍服务,比如 TalkBack, Accessibility Scanner ,等等。

  4. 编译,在设备上运行 TestActivity

  5. 触发无障碍服务。。 TestActivity 立马崩溃了>﹏<

Solution

修复也很简单,将 AccessibilityNodeInfo.setText 代码中 ClickableSpan[] 数组的获取源从 text 改为 spannable 即可。

但是这是Android 系统的源码,应用层得想办法绕过该 bug ╮(╯_╰)╭

所以,只有一个解决办法: ClickableSpan 子类不要去实现 NoCopySpan

.

.

.

.

.

那你可能会问了,为什么要让 ClickableSpan 实现 NoCopySpan

那还不是为了解决 ClickableSpanAssistStructure 持有进而导致 Activitiy 内存泄漏的问题……

tmWvidV3X7zSOly.png

这里省略约一万字,有空另写文再叙。

这个垃圾代码害人不浅啊( ・ˍ・)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK