记一次无障碍测试引发app崩溃问题的排查与解决

1. 背景

在APP发版前,我们一般都会先跑mtc的自动化测试框架,也就是让脚本模拟用户点击,跑通流程。这种方式能够显著提高测试效率,降低人力成本,同时也能提前发现潜在的问题,降低发版后的风险。然而,有一个crash频繁出现且没有规律,刚开始因为线上没有该case所以没关注,但随着crash次数变多,测试脚本跑不下去,阻塞了流程,这才引起了我们的的关注。

异常信息

该crash异常信息如下:

频率较高,导致测试脚本跑不下去,日志如下:

php 复制代码
crash:
Version: 
Long Msg: 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.replaceClickableSpan(AccessibilityNodeInfo.java:2911)
  at android.view.accessibility.AccessibilityNodeInfo.setText(AccessibilityNodeInfo.java:2875)
  at android.widget.TextView.onInitializeAccessibilityNodeInfoInternal(TextView.java:11913)
  at android.view.View.onInitializeAccessibilityNodeInfo(View.java:8771)
  at android.view.View.createAccessibilityNodeInfoInternal(View.java:8730)
  at android.view.View.createAccessibilityNodeInfo(View.java:8694)
  at android.view.AccessibilityInteractionController$AccessibilityNodePrefetcher.prefetchAccessibilityNodeInfos(AccessibilityInteractionController.java:1082)
  at android.view.AccessibilityInteractionController.findAccessibilityNodeInfoByAccessibilityIdUiThread(AccessibilityInteractionController.java:342)
  at android.view.AccessibilityInteractionController.access$400(AccessibilityInteractionController.java:75)
  at android.view.AccessibilityInteractionController$PrivateHandler.handleMessage(AccessibilityInteractionController.java:1460)
  at android.os.Handler.dispatchMessage(Handler.java:106)
  at android.os.Looper.loop(Looper.java:254)
  at android.app.ActivityThread.main(ActivityThread.java:8243)
  at java.lang.reflect.Method.invoke(Native Method)
  at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:612)
  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1006)
// crashend

堆栈分析

由堆栈信息

I.关键类可以看到,因为AccessibilityNodeInfo 在手机开启无障碍时执行,所以推测该crash发生在手机开启无障碍模式扫描textview抛出。

但是我们测试机并没有开启无障碍,按理AccessibilityNodeInfo是不会启动的。

经查阅资料发现,原来在跑场景直达时,针对 Android 应用的 UI 自动化测试场景,AccessibilityNodeInfo 会被应用在一些特定的自动化测试框架。虽然 AccessibilityNodeInfo 是主要用于无障碍服务(Accessibility Service)中的,但许多自动化测试工具,如 UI AutomatorEspresso、fastbot ,会通过类似的机制来获取 UI 元素的信息,并与它们进行交互,而我们测试脚本框架采用的是fastbot

这就解释了为什么未开启无障碍的手机会抛出 AccessibilityNodeInfo 的crash。

II.replaceClickableSpan() 该函数是只有当该TextView设置了ClickableSpan的时候调用的,而项目中TextView是通过setText()调用setSpan()SpannableString来设置ClickableSpan

因此项目中问题代码形式如下:

sql 复制代码
SpannableString.setSpan(ClickableSpan clickableSpan, int start, int end, int flags);
TextView.setText(SpannableString);

初步方案

对于这种AccessibilityNodeInfo的crash,可以通过避免TextView被扫描来规避此异常,只需在设置ClickableSpanTextView里设置以下属性

ini 复制代码
textview.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS

这样避免测试脚本调用TextViewAccessibilityNodeInfo,从而在源头上规避该crash

但这只是治标不治本,最终原因还是需要开发人员排查

2.排查历程

排查

首先根据堆栈信息定位安卓源码中的问题代码

对报错信息"starts before"进行全局搜索,找到如下代码:

arduino 复制代码
private void checkRange(final String operation, int start, int end) {
    //省略无关代码

    if (start < 0 || end < 0) {
        throw new IndexOutOfBoundsException(operation + " " +
                region(start, end) + " starts before 0" );
    }
}

看来只有当start < 0 || end < 0 时才会抛此异常,结合堆栈,继续看这个函数的调用处

arduino 复制代码
// SpannableStringBuilder
private void setSpan(boolean send, Object what, int start, int end, int flags,
        boolean enforceParagraph) {
    checkRange( "setSpan" , start, end);

    //省略无关代码
    
    // 扩充
    mSpans = GrowingArrayUtils.append(mSpans, mSpanCount, what);
    
    // 省略
}

startend也是外部传进来的,还是去看该函数的调用处

ini 复制代码
// AccessibilityNodeInfo
private CharSequence replaceClickableSpan(CharSequence text) {

    ClickableSpan[] clickableSpans =
       ((Spanned) text).getSpans(0, text.length(), ClickableSpan.class);
    Spannable spannable = new SpannableStringBuilder(text);
    if (clickableSpans.length == 0) {
       return text;
    }
    
    for (int i = 0; i < clickableSpans.length; i++) {
        ClickableSpan span = clickableSpans[i];
        
        // 省略
        
        int spanToReplaceStart = spannable.getSpanStart(span);
        int spanToReplaceEnd = spannable.getSpanEnd(span);
        
        // 省略
        
        spannable.setSpan(replacementSpan, spanToReplaceStart, spanToReplaceEnd,
                spanToReplaceFlags);
    }
    return spannable;
}

startend 都是通过SpannableStringBuildergetSpanStartgetSpanEnd获取,查看其中一个函数详情

csharp 复制代码
// SpannableStringBuilder
public int getSpanStart(Object what) {
    if (mIndexOfSpan == null) return -1;
    Integer i = mIndexOfSpan.get(what);
    return i == null ? -1 : resolveGap(mSpanStarts[i]);
}

从该函数可知,若想start = -1,则**mIndexOfSpan = null** ****或者 ******mIndexOfSpan**没有存对应的 what

我们两个条件一个个排查,先排查**mIndexOfSpan = null**的情况。

回到AccessibilityNodeInfo.replaceClickableSpan(),看看spannable是怎么来的

ini 复制代码
// AccessibilityNodeInfo
private CharSequence replaceClickableSpan(CharSequence text) {
    ClickableSpan[] clickableSpans =
            ((Spanned) text).getSpans(0, text.length(), ClickableSpan.class);
    Spannable spannable = new SpannableStringBuilder(text);
    if (clickableSpans.length == 0) {
        return text;
    }
    for (int i = 0; i < clickableSpans.length; i++) {
        ClickableSpan span = clickableSpans[i];
        
        // 省略
        
        int spanToReplaceStart = spannable.getSpanStart(span);
        int spanToReplaceEnd = spannable.getSpanEnd(span);
        
        // 省略
        
        spannable.setSpan(replacementSpan, spanToReplaceStart, spanToReplaceEnd,
                spanToReplaceFlags);
    }
    return spannable;
}

spannable 是新建的SpannableStringBuilder,需要点进看其构造函数。

初步排查,问题代码已定位AccessibilityNodeInfo.replaceClickableSpan()SpannableStringBuilder的构造函数

arduino 复制代码
// SpannableStringBuilder
public SpannableStringBuilder(CharSequence text) {
    this(text, 0, text.length());
}

public SpannableStringBuilder(CharSequence text, int start, int end) {
    // 省略

    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;
            }
            
            // 省略
            
            setSpan(false, spans[i], st, en, fl, false /*enforceParagraph*/ );
        }
        restoreInvariants();
    }
}

setSpan()没有**mIndexOfSpan**,所以我们先看restoreInvariants()

ini 复制代码
// SpannableStringBuilder
private void restoreInvariants() {

// 省略

 if (mIndexOfSpan == null) {
        mIndexOfSpan = new IdentityHashMap<Object, Integer>();
    }
    for (int i = mLowWaterMark; i < mSpanCount; i++) {
        Integer existing = mIndexOfSpan.get(mSpans[i]);
        if (existing == null || existing != i) {
            mIndexOfSpan.put(mSpans[i], i);
        }
    }
}

从这个函数可以排除 mIndexOfSpan = null 的情况,因为每次新建SpannableStringBuilder,都会初始化mIndexOfSpan

那么唯一使start = -1 的情况是**mIndexOfSpan**没有存相应的值

mIndexOfSpan是通过mSpans存值的,而mSpanssetSpan()里获取值。setSpan()调用时机有两处,一是新建SpannableStringBuilder时初始化,另一处是SpannableStringBuilder对象调setSpan(),也就是堆栈报错代码。后者可以忽略,因为后者就是问题堆栈的一部分,且卡在checkRange直接抛异常,不会往下执行,所以重点看前者。

因此问题代码就在SpannableStringBuilder的构造函数里调用的setSpan()

这一处过滤掉NoCopySpan的嫌疑很大,一旦Span[i]被过滤,那么mIndexOfSpan 自然存不到对应的值。因为报错是发生在AccessibilityNodeInfo.replaceClickableSpan()里的,必然只有设置了ClickableSpan才会执行,所以我们只要找到同时继承NoCopySpanClickableSpan的类就好了。但笔者搜索整个项目,没看到满足这一条件的类,至此只剩下下一行的setSpan()最有嫌疑了。

严格来说,是setSpan从执行到mSpans存值这段区间的代码最可疑

arduino 复制代码
// SpannableStringBuilder
private void setSpan(boolean send, Object what, int start, int end, int flags,
        boolean enforceParagraph) {
        
    checkRange( "setSpan" , start, end);

    int flagsStart = (flags & START_MASK) >> START_SHIFT;

    if (isInvalidParagraph(start, flagsStart)) {
        if (!enforceParagraph) {
 return;
        }
        throw new RuntimeException( "PARAGRAPH span must start at paragraph boundary"
+ " (" + start + " follows " + charAt(start - 1) + ")" );
    }

    int flagsEnd = flags & END_MASK;
    if (isInvalidParagraph(end, flagsEnd)) {
        if (!enforceParagraph) {
 return;
        }
        throw new RuntimeException( "PARAGRAPH span must end at paragraph boundary"
+ " (" + end + " follows " + charAt(end - 1) + ")" );
    }

 if (flagsStart == POINT && flagsEnd == MARK && start == end) {
        if (send) {
            Log.e(TAG, "SPAN_EXCLUSIVE_EXCLUSIVE spans cannot have a zero length" );
        }
 return;
    }

    // 忽略

    if (mIndexOfSpan != null) {
        Integer index = mIndexOfSpan.get(what);
        if (index != null) {
            // 忽略
            return;
        }
    }

    mSpans = GrowingArrayUtils.append(mSpans, mSpanCount, what);
    
    // 忽略
}

private boolean isInvalidParagraph(int index, int flag) {
    return flag == PARAGRAPH && index != 0 && index != length() && charAt(index - 1) != '\n' ;
}

setSpan执行一直到mSpans被赋值,拦截条件很多,我们一个个排查。

  1. checkRange( "setSpan" , start, end)-----不可能拦截,因为如果此时抛异常,跟报错堆栈对不上(startend 不等于-1)
  2. isInvalidParagraph(start, flagsStart)------不可能,项目里没有使用Spanned.SPAN_PARAGRAPH
  3. isInvalidParagraph(end, flagsEnd)------同上
  4. flagsStart == POINT && flagsEnd == MARK && start == end ------有可能
  5. mIndexOfSpan != null--------不可能,因为此时SpannableStringBuilder正在初始化,此时mIndexOfSpan未被赋值

根据唯一的可能以及信息**SPAN_EXCLUSIVE_EXCLUSIVE spans cannot have a zero length,笔者查找项目调用setSpan()参数里是否有 SPAN_EXCLUSIVE_EXCLUSIVE** ****和 start = end 的代码。

问题代码

经过对项目排查,终于找到问题代码,类似如下:

ini 复制代码
ss.setSpan(clickableSpan, 0, 0, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

这段代码正常下运行是不会抛异常的,只有手机开启无障碍或跑自动化UI框架时会抛。

验证

为了验证排查推论是否正确,简单新建一个Demo,里面包含了上述异常代码,开启无障碍后立马崩溃了,堆栈一致。

至此,测试环境大量崩溃的原因找到

3.解决方案

解决方案有两种:

I.在问题textview设置相关属性

在问题textview中加入如下属性,避免无障碍时该textviewAccessibilityNodeInfo访问,从而不执行报错堆栈的代码。

ini 复制代码
textview.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS

importantForAccessibility 是 Android 中 View 的一个属性,用于控制该视图在辅助功能(如屏幕阅读器)中的重要性。该属性可以设置为以下几种值:

  1. View.IMPORTANT_FOR_ACCESSIBILITY_AUTO

    1. 默认值。
    2. 系统会根据上下文自动确定视图是否对无障碍服务重要。
    3. 通常,如果视图可以接收焦点或提供有意义的无障碍信息,则视图会被视为重要的。
  2. View.IMPORTANT_FOR_ACCESSIBILITY_YES

    1. 明确标记此视图对无障碍服务重要。
    2. 无障碍服务(如屏幕阅读器)会将此视图纳入视图树,并与用户交互。
  3. View.IMPORTANT_FOR_ACCESSIBILITY_NO

    1. 标记此视图对无障碍服务不重要。
    2. 无障碍服务会忽略此视图,但仍会考虑其子视图。
  4. View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS

    1. 标记此视图及其所有子视图对无障碍服务都不重要。
    2. 无障碍服务会忽略此视图及其子树中的所有内容。
  5. View.IMPORTANT_FOR_ACCESSIBILITY_YES_HIDE_DESCENDANTS (API 30 及以上)

    1. 表示此视图本身对无障碍服务重要,但其所有子视图都被忽略。
    2. 用于限制无障碍服务的焦点范围,仅处理当前视图本身。

II.检查代码参数设置是否规范正确

避免项目出现在setSpan参数设置 **start = endSpanned. *SPAN_EXCLUSIVE_EXCLUSIVE***的代码。

此外,如果项目中出现同时继承NoCopySpanClickableSpan的类,也会出现此错误堆栈,需要额外注意下

项目采用

基于上述两种方案,我们项目采取了第一种方案。修复方案上线后,有关setSpan的crash不再出现,测试环境跑自动化场景直达不再被阻塞

4.思考

该问题的代码封装在一个工具类中,该工具类在项目中被大量调用,导致在测试环境运行场景直达脚本时频繁触发崩溃。这一现象引发了笔者的思考。

对于测试人员而言,在编写测试脚本时,可以加入更完善的日志捕获机制,以记录崩溃时的关键信息,例如:最近几次操作的UI状态(包括文字内容、控件层级结构、View信息等),并结合崩溃页面的截图或详细日志,为开发人员提供足够的排查线索。

对于开发人员而言,出现Bug虽然在开发过程中在所难免,但更重要的是深入分析问题的根本原因,避免头痛医头、脚痛医脚的临时解决方案。只有彻底解决问题的源头,才能提升代码的健壮性和维护性。

通过测试和开发的良性配合,既能缩短排查问题的时间,也能提升整体代码质量,避免类似问题反复出现。

相关推荐
雾里看山13 分钟前
【MySQL】 表的约束(上)
android·mysql·adb
我不当帕鲁谁当帕鲁29 分钟前
arcgis for js实现平移立体效果
前端·javascript·arcgis
录大大i1 小时前
HTML之CSS定位、浮动、盒子模型
前端·css·html
P7进阶路1 小时前
Ajax:重塑Web交互体验的人性化探索
前端·javascript·ajax
q567315232 小时前
无法在Django 1.6中导入自定义应用
android·开发语言·数据库·django·sqlite
bin91532 小时前
DeepSeek 助力 Vue 开发:打造丝滑的步骤条
前端·javascript·vue.js
a3158238062 小时前
Android设置个性化按钮按键的快捷启动应用
android·开发语言·framework·源码·android13
zengyuhan5032 小时前
当Rust邂逅DLL:Tauri桌面开发的硬核调用指南
前端·rust·libra
ZeZeZe2 小时前
数据结构之栈与队列
前端·javascript
Henry_He2 小时前
SystemUI通知在阿拉伯语下布局方向RTL下appName显示异常
android