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_PARAGRAPH
isInvalidParagraph(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虽然在开发过程中在所难免,但更重要的是深入分析问题的根本原因,避免头痛医头、脚痛医脚的临时解决方案。只有彻底解决问题的源头,才能提升代码的健壮性和维护性。
通过测试和开发的良性配合,既能缩短排查问题的时间,也能提升整体代码质量,避免类似问题反复出现。