记一例 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