使用NoCopySpan后Android无障碍服务(Accessibility)引发的崩溃

Log日志如下:

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 的代码告诉了我们原因:

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:

首先,从原始的 text 中获取的 ClickableSpan 对象数组 spans。
其次,遍历获取每个 ClickableSpan 在原始 text 中的位置。
最后,替换掉 Spannable 对应位置的 ClickableSpan。
崩溃就发生最最后一步 spannable.setSpan(…)。程序执行到这里的时候, spanToReplaceStart和spanToReplaceEnd都是 -1,就是说对应的 ClickableSpan 在经过 SpannableStringBuilder 拷贝后不见了 !!

why ???

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

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

  • 定义一个 TestSpan 继承 ClickableSpan 并实现 NoCopySpan:
class TestSpan: ClickableSpan(), NoCopySpan {
    override fun onClick(widget: View) {
        Log.d("Test", "on click $this")
    }
}
  • 把这个 TestSpan 塞到 TextView 的 text 中:
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)
            }
        })
    }
}
  • 启用设备里的会读取文本信息的无障碍服务,比如 TalkBack,Accessibility Scanner,等等。
  • 编译,在设备上运行 TestActivity。
  • 触发无障碍服务。。TestActivity 立马崩溃了>﹏<

Solution

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

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

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

.
.
.
.
.

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

那还不是为了解决 ClickableSpan 被 AssistStructure 持有进而导致 Activitiy 内存泄漏的问题……
使用NoCopySpan后Android无障碍服务(Accessibility)引发的崩溃_第1张图片

解决方案

TextView设置 android:importantForAccessibility=“no”
禁用view无障碍服务。

更新:部分机型设置不会生效。所以这也不是最好的解决方案
目前建议:删除在自定义 ClickableSpan 实现中实现 NoCopySpan 接口(但可能存在 ClickableSpan 内存泄漏的风险)

你可能感兴趣的:(android)