使用NoCopySpan后Android无障碍服务(Accessibility)引发的崩溃

Log日志如下:

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:
class TestSpan: ClickableSpan(), NoCopySpan {
    override fun onClick(widget: View) {
        Log.d("Test", "on click $this")
    }
}
  • 把这个 TestSpan 塞到 TextView 的 text 中:
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)
            }
        })
    }
}
  • 启用设备里的会读取文本信息的无障碍服务,比如 TalkBack,Accessibility Scanner,等等。
  • 编译,在设备上运行 TestActivity。
  • 触发无障碍服务。。TestActivity 立马崩溃了>﹏<

Solution

修复也很简单,将 AccessibilityNodeInfo.setText 代码中 ClickableSpan[] 数组的获取源从 text 改为 spannable 即可。

但是这是Android 系统的源码,应用层得想办法绕过该 bug ╮(╯_╰)╭

所以,只有一个解决办法:ClickableSpan 子类不要去实现 NoCopySpan。

.
.
.
.
.

那你可能会问了,为什么要让 ClickableSpan 实现 NoCopySpan ?

那还不是为了解决 ClickableSpan 被 AssistStructure 持有进而导致 Activitiy 内存泄漏的问题……
在这里插入图片描述

解决方案

TextView设置 android:importantForAccessibility=“no”
禁用view无障碍服务。

更新:部分机型设置不会生效。所以这也不是最好的解决方案
目前建议:删除在自定义 ClickableSpan 实现中实现 NoCopySpan 接口(但可能存在 ClickableSpan 内存泄漏的风险)

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Android 中,可以使用 AccessibilityService 来模拟用户的操作,包括返回操作。 以下是实现模拟返回操作的基本步骤: 1. 在 AndroidManifest.xml 文件中声明一个 AccessibilityService: ```xml <service android:name=".MyAccessibilityService" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> <meta-data android:name="android.accessibilityservice" android:resource="@xml/accessibility_service_config" /> </service> ``` 其中,MyAccessibilityService 是自定义的 AccessibilityService,android:resource="@xml/accessibility_service_config" 指定了服务的配置文件。 2. 创建服务的配置文件 accessibility_service_config.xml: ```xml <accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" android:accessibilityEventTypes="typeAllMask" android:accessibilityFlags="flagDefault|flagIncludeNotImportantViews" android:accessibilityFeedbackType="feedbackGeneric" android:notificationTimeout="100" android:canRetrieveWindowContent="true" android:settingsActivity=".SettingsActivity" /> ``` 其中,android:accessibilityEventTypes 指定服务能够处理的事件类型,android:settingsActivity 指定服务的设置界面。 3. 实现 MyAccessibilityService 类: ```java public class MyAccessibilityService extends AccessibilityService { @Override public void onAccessibilityEvent(AccessibilityEvent event) { // 处理事件 } @Override public void onInterrupt() { // 中断服务 } @Override protected boolean onKeyEvent(KeyEvent event) { if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { performGlobalAction(GLOBAL_ACTION_BACK); return true; } return super.onKeyEvent(event); } } ``` 其中,onKeyEvent 方法会在用户按下键盘时被调用,如果按下的是返回键,就调用 performGlobalAction(GLOBAL_ACTION_BACK) 方法模拟返回操作。 4. 在应用中启动服务: ```java Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS); startActivity(intent); ``` 以上就是实现模拟返回操作的基本步骤。需要注意的是,使用 AccessibilityService 来模拟用户操作需要用户授权,因此在启动服务时需要跳转到无障碍设置界面让用户手动开启服务

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值