Logs

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

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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
    1
    2
    3
    4
    5
    class TestSpan: ClickableSpan(), NoCopySpan {
    override fun onClick(widget: View) {
    Log.d("Test", "on click $this")
    }
    }
  2. 把这个 TestSpan 塞到 TextView 的 text 中:

    TestActivity.kt
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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 内存泄漏的问题……
Screenshot_1575356634.png
这里省略约一万字,有空另写文再叙。

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

References

EOF