Android 当无障碍遇到TextView富文本可能存在的一些锅

引言

在日常开发中我们常常需要部分内容以链接的形式呈现,以"隐私协议 "和"用户许可协议 "最为常见。如下图中的效果: 上面的样式可以利用TextView + SpannableString 来实现富文本的样式,不过这里主题不是它,在这里不再多做赘述,感兴趣的小伙伴可以自行去搜索TextView如何实现富文本的方案即可。

背景

在项目的APM后台里,有一个富文本相关的崩溃有2800+的频率,如下所示: 进入详情查看崩溃堆栈又找不到任何项目相关代码的堆栈,只有Android源码的堆栈,这让排查问题难度大大增加。 详细堆栈:

java 复制代码
FATAL EXCEPTION: main
	java.lang.IndexOutOfBoundsException: setSpan (-1 ... -1) starts before 0
		at android.text.SpannableStringBuilder.checkRange(SpannableStringBuilder.java:1338)
		at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:692)
		at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:684)
		at android.view.accessibility.AccessibilityNodeInfo.setText(AccessibilityNodeInfo.java:2645)
		at android.widget.TextView.onInitializeAccessibilityNodeInfoInternal(TextView.java:11729)
		at android.view.View.onInitializeAccessibilityNodeInfo(View.java:8355)
		at android.view.View.createAccessibilityNodeInfoInternal(View.java:8314)
		at android.view.View.createAccessibilityNodeInfo(View.java:8299)
		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:110)
		at android.os.Looper.loop(Looper.java:219)
		at android.app.ActivityThread.main(ActivityThread.java:8349)
		at java.lang.reflect.Method.invoke(Native Method)
		at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
		at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1055)

堆栈信息中可看到在富文本设置中setSpan(-1 ... -1) 引用了不合理的下标导致的,心想,解决这个问题还不是小菜一碟,在设置的时候先判断引用的下标是否合理不就成了吗,然后我就在项目里全局搜索一下有用到富文本的地方,在它们设置富文本效果前判断下标是否合理,如果不合理则不会使用这种富文本效果。

修复之后马上把代码合入主干,并跟随着版本带出。但是经过了一个版本过后,再去APM后台查看相关崩溃,发现崩溃率不降反而增加了,而且在最新版本也有复现,这有点难倒我了,明明已经修复的bug,为啥不起作用呢?难道我的代码没入到主干里吗,于是我又去看看主干最新的代码,发现我的改动是有包含在里面的。于是我决定好好研究一下崩溃的堆栈,最后发现有以下关键字眼:AccessibilityNodeInfoSpannableStringBuilder.setSpan

所以我猜测应该是由于某个无障碍服务和项目内的富文本字体产生了冲突导致的。

试验想法

有了上述的想法,立马就新建个demo试验一把,先搜索一下项目内有使用到富文本的地方,然后copy到demo中:

kotlin 复制代码
class SecondActivity : AppCompatActivity() {

    private lateinit var tvSpan: TextView

    private lateinit var btnClick: Button

    @SuppressLint("MissingInflatedId")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_second)

        tvSpan = findViewById(R.id.tv_span)
        btnClick = findViewById(R.id.btn_click)
        btnClick.setOnClickListener {
            setTextLink()
        }
    }


    private fun setTextLink() {
        val normalText = tvSpan.text.toString()
        val linkText = "《隐私协议》"
        val start = normalText.indexOf(linkText)
        val end = start + linkText.length
        val sp = SpannableString(normalText)
        sp.setSpan(MySpan(), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
        tvSpan.setHintTextColor(Color.TRANSPARENT)
        tvSpan.text = sp
        tvSpan.movementMethod = LinkMovementMethod.getInstance()
    }

    inner class MySpan : ClickableSpan() {
        override fun onClick(widget: View) {
            Toast.makeText(this@SecondActivity, "点击了隐私协议!", Toast.LENGTH_SHORT).show()
        }

        override fun updateDrawState(ds: TextPaint) {
            super.updateDrawState(ds)
            ds.color = getColor(R.color.theme_color_100)
            ds.isUnderlineText = false
        }

    }
}
xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:padding="10dp">

    <TextView
        android:id="@+id/tv_span"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="@color/black"
        android:clickable="false"
        android:focusable="false"
        android:textSize="16dp"
        android:text="这是一段测试的文字,里面包含有span设置的有颜色的文字,关键字是:《隐私协议》,请认真阅读!"/>

    <Button
        android:id="@+id/btn_click"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="开启TextView富文本"
        android:layout_marginTop="10dp"/>

</LinearLayout>

上述代码中,我们模拟"隐私协议"的富文本样式,然后再开启手机的某个无障碍功能模拟线上崩溃的发生。
实测中发现,好像开启无障碍功能 + 富文本并不会导致崩溃啊,难道是我的猜想有问题吗?我又在项目里全局搜索一下富文本使用的地方,发现有些地方跟我demo中的使用方式不一样,他们继承了ClickableSpan 同时还实现了NoCopySpan的接口,然后我也试着在demo中加上这个接口的实现:

kotlin 复制代码
inner class MySpan : ClickableSpan(), NoCopySpan {
    override fun onClick(widget: View) {
        Toast.makeText(this@SecondActivity, "点击了隐私协议!", Toast.LENGTH_SHORT).show()
    }

    override fun updateDrawState(ds: TextPaint) {
        super.updateDrawState(ds)
        ds.color = getColor(R.color.theme_color_100)
        ds.isUnderlineText = false
    }

}

然后再次运行demo查看效果:

发现真的崩溃了,由此可以得出结论:实现了NoCopySpan的接口方式的富文本使用方式 + 无障碍会引发崩溃。

得到结论后立马就同步到项目里,搜索所有使用NoCopySpan接口的地方,然后将它们删除。随着最新版本的发布后查看APM后台,发现崩溃率真的下去了,而且新发生的崩溃都是原来的旧版本,新版本没有再复现。

源码分析

解决完问题后,我们查看一下源码,为什么会导致这个问题的出现,我们知道,发生崩溃后有下面的堆栈信息,我们就一层一层看起。

java 复制代码
android.text.SpannableStringBuilder.checkRange(SpannableStringBuilder.java:1338)
		at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:692)
		at android.text.SpannableStringBuilder.setSpan(SpannableStringBuilder.java:684)
		at android.view.accessibility.AccessibilityNodeInfo.setText(AccessibilityNodeInfo.java:2645) 

AccessibilityNodeInfo.java

我们看上述代码中位置1和位置2的地方,位置1是获取富文本的起始位置和终止位置,因为堆栈信息里面报的错是起始位置或者终止位置为-1,猜测这里的的位置应该是被替换了;位置2的是设置富文本的区域,这里没啥好分析的,我们重点看看位置1的起始位置和终止位置是如何得到的。

SpannableStringBuilder.java

通过代码可以看出,起始和终止位置都是通过 mSpanStartsmSpanEnds 这两个数组得到的,所以我们要看看这两个数组究竟是在哪里初始化和填入数据的。
通过代码发现,上述的两个数组是在构造方法里面初始化的,如图中位置1所示;位置3 调用setSpan 方法后会给这两个数组填入数据,但是我们仔细观察会发现位置2的一个判断,官方把是 NoCopySpan类型的span直接就给过滤了,也就是说,没有在数组填入相关的数据,那当然也在取起始和终止位置的时候返回 -1了,到这里,我们终于弄清楚了这个bug的来龙去脉了。

为什么项目里的要实现NoCopySpan接口

由于继承 ClickableSpan 后,通过 LeakCanary 检测出有内存泄漏,所以才会实现 NoCopySpan 以避免内存泄漏的风险。导致内存泄漏的原因:
stackoverflow.com/questions/2... 但是实现该接口会有崩溃的风险,所以我们只能另取其他方案来解决 ClickableSpan 内存泄漏的问题。 # 解决Android中使用ClickableSpan导致的内存泄漏

总结

以上就是我在排查和修复Android下无障碍 + NoCopySpan会引发崩溃的心路历程,希望能够帮到有需要的小伙伴,有什么说的不对的望各位大佬指出。

相关推荐
ac-er88881 小时前
Yii框架中的队列:如何实现异步操作
android·开发语言·php
流氓也是种气质 _Cookie3 小时前
uniapp 在线更新应用
android·uniapp
zhangphil5 小时前
Android ValueAnimator ImageView animate() rotation,Kotlin
android·kotlin
徊忆羽菲6 小时前
CentOS7使用源码安装PHP8教程整理
android
编程、小哥哥7 小时前
python操作mysql
android·python
Couvrir洪荒猛兽7 小时前
Android实训十 数据存储和访问
android
五味香10 小时前
Java学习,List 元素替换
android·java·开发语言·python·学习·golang·kotlin
十二测试录10 小时前
【自动化测试】—— Appium使用保姆教程
android·经验分享·测试工具·程序人生·adb·appium·自动化
Couvrir洪荒猛兽12 小时前
Android实训九 数据存储和访问
android
aloneboyooo12 小时前
Android Studio安装配置
android·ide·android studio