记一例 Android 无障碍服务(Accessibility)引发的崩溃
source link: https://yrom.net/blog/2019/12/03/crashed-on-accessibilitynodeInfo-settext-in-textview/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
来自线上用户的一个神奇崩溃,日志如下:
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 < 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
-
定义一个
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
内存泄漏的问题……
这里省略约一万字,有空另写文再叙。
这个垃圾代码害人不浅啊( ・ˍ・)
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK