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 Automator 和 Espresso、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被扫描来规避此异常,只需在设置ClickableSpan的TextView里设置以下属性
ini
textview.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
这样避免测试脚本调用TextView的AccessibilityNodeInfo,从而在源头上规避该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);
// 省略
}
start与end也是外部传进来的,还是去看该函数的调用处
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;
}
start 与 end 都是通过SpannableStringBuilder的getSpanStart或getSpanEnd获取,查看其中一个函数详情
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存值的,而mSpans在setSpan()里获取值。setSpan()调用时机有两处,一是新建SpannableStringBuilder时初始化,另一处是SpannableStringBuilder对象调setSpan(),也就是堆栈报错代码。后者可以忽略,因为后者就是问题堆栈的一部分,且卡在checkRange直接抛异常,不会往下执行,所以重点看前者。
因此问题代码就在SpannableStringBuilder的构造函数里调用的setSpan()
这一处过滤掉NoCopySpan的嫌疑很大,一旦Span[i]被过滤,那么mIndexOfSpan 自然存不到对应的值。因为报错是发生在AccessibilityNodeInfo.replaceClickableSpan()里的,必然只有设置了ClickableSpan才会执行,所以我们只要找到同时继承NoCopySpan和ClickableSpan的类就好了。但笔者搜索整个项目,没看到满足这一条件的类,至此只剩下下一行的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被赋值,拦截条件很多,我们一个个排查。
checkRange("setSpan", start, end)-----不可能拦截,因为如果此时抛异常,跟报错堆栈对不上(start、end不等于-1)isInvalidParagraph(start, flagsStart)------不可能,项目里没有使用Spanned.SPAN_PARAGRAPHisInvalidParagraph(end, flagsEnd)------同上flagsStart ==POINT&& flagsEnd ==MARK&& start == end------有可能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中加入如下属性,避免无障碍时该textview被AccessibilityNodeInfo访问,从而不执行报错堆栈的代码。
ini
textview.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
importantForAccessibility 是 Android 中 View 的一个属性,用于控制该视图在辅助功能(如屏幕阅读器)中的重要性。该属性可以设置为以下几种值:
-
View.IMPORTANT_FOR_ACCESSIBILITY_AUTO- 默认值。
- 系统会根据上下文自动确定视图是否对无障碍服务重要。
- 通常,如果视图可以接收焦点或提供有意义的无障碍信息,则视图会被视为重要的。
-
View.IMPORTANT_FOR_ACCESSIBILITY_YES- 明确标记此视图对无障碍服务重要。
- 无障碍服务(如屏幕阅读器)会将此视图纳入视图树,并与用户交互。
-
View.IMPORTANT_FOR_ACCESSIBILITY_NO- 标记此视图对无障碍服务不重要。
- 无障碍服务会忽略此视图,但仍会考虑其子视图。
-
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS- 标记此视图及其所有子视图对无障碍服务都不重要。
- 无障碍服务会忽略此视图及其子树中的所有内容。
-
View.IMPORTANT_FOR_ACCESSIBILITY_YES_HIDE_DESCENDANTS(API 30 及以上)- 表示此视图本身对无障碍服务重要,但其所有子视图都被忽略。
- 用于限制无障碍服务的焦点范围,仅处理当前视图本身。
II.检查代码参数设置是否规范正确
避免项目出现在setSpan参数设置 **start = end且Spanned. *SPAN_EXCLUSIVE_EXCLUSIVE***的代码。
此外,如果项目中出现同时继承NoCopySpan和ClickableSpan的类,也会出现此错误堆栈,需要额外注意下
项目采用
基于上述两种方案,我们项目采取了第一种方案。修复方案上线后,有关setSpan的crash不再出现,测试环境跑自动化场景直达不再被阻塞
4.思考
该问题的代码封装在一个工具类中,该工具类在项目中被大量调用,导致在测试环境运行场景直达脚本时频繁触发崩溃。这一现象引发了笔者的思考。
对于测试人员而言,在编写测试脚本时,可以加入更完善的日志捕获机制,以记录崩溃时的关键信息,例如:最近几次操作的UI状态(包括文字内容、控件层级结构、View信息等),并结合崩溃页面的截图或详细日志,为开发人员提供足够的排查线索。
对于开发人员而言,出现Bug虽然在开发过程中在所难免,但更重要的是深入分析问题的根本原因,避免头痛医头、脚痛医脚的临时解决方案。只有彻底解决问题的源头,才能提升代码的健壮性和维护性。
通过测试和开发的良性配合,既能缩短排查问题的时间,也能提升整体代码质量,避免类似问题反复出现。