记一例 Android 无障碍服务(Accessibility)引发的崩溃
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 的代码告诉了我们原因:
1 | public void setText(CharSequence text) { |
上述代码关键是在替换 text
中的 ClickableSpan
对象为 AccessibilityURLSpan
或者AccessibilityClickableSpan
:
- 首先,从原始的
text
中获取的ClickableSpan
对象数组spans
。 - 其次,遍历获取每个
ClickableSpan
在原始text
中的位置。 - 最后,替换掉
Spannable
对应位置的ClickableSpan
。
崩溃就发生最最后一步 spannable.setSpan(...)
。程序执行到这里的时候, spanToReplaceStart
和spanToReplaceEnd
都是 -1
,就是说对应的 ClickableSpan
在经过 SpannableStringBuilder
拷贝后不见了 !!
why ???
其实问题的关键在 new SpannableStringBuilder(text)
:
1 | public SpannableStringBuilder(CharSequence text, int start, int end) { |
从上面一段代码可以看出,SpannableStringBuilder
在拷贝 spans
时会跳过 NoCopySpan
的对象!!!
也就是,AccessibilityNodeInfo.setText
这个方法代码写的有bug,没有考虑 ClickableSpan
的对象也有可能是NoCopySpan
,进而导致异常发生。
Step to reproduce
定义一个
TestSpan
继承ClickableSpan
并实现NoCopySpan
:TestSpan.kt 1
2
3
4
5class TestSpan: ClickableSpan(), NoCopySpan {
override fun onClick(widget: View) {
Log.d("Test", "on click $this")
}
}把这个
TestSpan
塞到TextView
的 text 中:TestActivity.kt 1
2
3
4
5
6
7
8
9
10class 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
内存泄漏的问题……
这里省略约一万字,有空另写文再叙。
这个垃圾代码害人不浅啊( ・ˍ・)
References
- https://developer.android.com/guide/topics/ui/accessibility/testing
- https://android.googlesource.com/platform/frameworks/base/+/master/
EOF