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

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

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(...)
。程序执行到这里的时候, spanToReplaceStart
spanToReplaceEnd
都是 -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  end - start)
                st = end - start;

            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

那还不是为了解决 ClickableSpan
AssistStructure
持有进而导致 Activitiy
内存泄漏的问题……
这里省略约一万字,有空另写文再叙。

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