记一例 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
的代码告诉了我们原因:
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 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
-
定义一个
TestSpan
继承ClickableSpan
并实现NoCopySpan
:TestSpan.ktclass TestSpan: ClickableSpan(), NoCopySpan { override fun onClick(widget: View) { Log.d("Test", "on click $this") } }
-
把这个
TestSpan
塞到TextView
的 text 中:TestActivity.ktclass 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
内存泄漏的问题……
这里省略约一万字,有空另写文再叙。
这个垃圾代码害人不浅啊( ・ˍ・)