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 拷贝后不见了 !!
其实问题的关键在 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,进而导致异常发生。
class TestSpan: ClickableSpan(), NoCopySpan {
override fun onClick(widget: View) {
Log.d("Test", "on click $this")
}
}
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)
}
})
}
}
修复也很简单,将 AccessibilityNodeInfo.setText 代码中 ClickableSpan[] 数组的获取源从 text 改为 spannable 即可。
但是这是Android 系统的源码,应用层得想办法绕过该 bug ╮(╯_╰)╭
所以,只有一个解决办法:ClickableSpan 子类不要去实现 NoCopySpan。
.
.
.
.
.
那你可能会问了,为什么要让 ClickableSpan 实现 NoCopySpan ?
那还不是为了解决 ClickableSpan 被 AssistStructure 持有进而导致 Activitiy 内存泄漏的问题……
TextView设置 android:importantForAccessibility=“no”
禁用view无障碍服务。
更新:部分机型设置不会生效。所以这也不是最好的解决方案
目前建议:删除在自定义 ClickableSpan 实现中实现 NoCopySpan 接口(但可能存在 ClickableSpan 内存泄漏的风险)