Espresso测试后台任务触发Compose刷新方法 - 第4节
4.1 为什么需要自定义 IdlingResource
Espresso 内置的 CountingIdlingResource 适用于简单的计数等待场景,但真实项目中的后台任务往往涉及复杂状态流转:任务队列、线程池执行、结果回调、超时重置等。此时需要一个可感知任务全生命周期的自定义 IdlingResource,而非仅仅依赖计数器归零。
| 场景 | 内置 IdlingResource 局限 | 自定义方案优势 |
|---|---|---|
| 任务队列中存在多个阶段 | 仅计数递增/递减,无法区分阶段 | 通过状态枚举精确控制每个阶段是否空闲 |
| IO 线程的异步回调 | 回调时间窗口不确定,容易误判空闲 | 绑定回调状态标记,回调完成前始终繁忙 |
| 任务超时需要强放弃 | 无超时机制,可能永久阻塞测试 | 集成超时定时器,超时后强制标记为空闲 |
| 跨 Compose 重组边界 | 不感知 Compose 重组状态 | 组合 IdlingResource 与 Compose 副作用监听 |
4.2 核心接口与设计原理
自定义 IdlingResource 需要实现 IdlingResource 接口的三个方法:
getName()— 返回唯一标识,用于调试和注册。isIdleNow()— Espresso 轮询此方法判断是否空闲。若返回 false,测试线程阻塞。registerIdleTransitionCallback()— 资源从繁忙变为空闲时,通过 callback 通知 Espresso 立即重新检查。
设计要点:
isIdleNow() 必须线程安全且快速返回,不应在此方法内执行阻塞操作或长时间计算。Espresso 在主线程轮询此方法,耗时过长会直接导致 ANR。
4.3 实现流程与代码骨架
4.3.1 定义状态枚举与任务状态变量
public enum TaskState {
IDLE, // 无任务
QUEUED, // 已入队
RUNNING, // 执行中
COMPLETED, // 执行完成,等待消费结果
TIMEOUT // 超时放弃
}
private final AtomicReference<TaskState> currentState =
new AtomicReference<>(TaskState.IDLE);
4.3.2 实现 IdlingResource 接口
public class CustomTaskIdlingResource implements IdlingResource {
private final String name;
private final AtomicReference<TaskState> state;
private final long timeoutMillis;
private volatile ResourceCallback callback;
private volatile long taskStartTime;
public CustomTaskIdlingResource(String name, long timeoutMs) {
this.name = name;
this.state = new AtomicReference<>(TaskState.IDLE);
this.timeoutMillis = timeoutMs;
}
@Override
public String getName() {
return name;
}
@Override
public boolean isIdleNow() {
TaskState s = state.get();
// 超时检测:RUNNING 状态持续超过阈值则强制空闲
if (s == TaskState.RUNNING && timeoutMillis > 0) {
long elapsed = System.currentTimeMillis() - taskStartTime;
if (elapsed > timeoutMillis) {
state.set(TaskState.TIMEOUT);
notifyIdle();
return true;
}
}
// 只有 IDLE 和 TIMEOUT 算作空闲
return s == TaskState.IDLE || s == TaskState.TIMEOUT;
}
@Override
public void registerIdleTransitionCallback(ResourceCallback callback) {
this.callback = callback;
}
private void notifyIdle() {
ResourceCallback cb = callback;
if (cb != null) {
cb.onTransitionToIdle();
}
}
// ---- 对外控制方法 ----
public void onTaskQueued() {
state.set(TaskState.QUEUED);
}
public void onTaskStarted() {
state.set(TaskState.RUNNING);
taskStartTime = System.currentTimeMillis();
}
public void onTaskCompleted() {
// 状态先切到 COMPLETED,等待消费
state.set(TaskState.COMPLETED);
}
public void consumeResult() {
state.set(TaskState.IDLE);
notifyIdle();
}
public void forceIdle() {
state.set(TaskState.TIMEOUT);
notifyIdle();
}
}
4.4 安全线程处理
Espresso 在主线程轮询 isIdleNow(),而后台任务在子线程修改状态。多个线程同时访问 currentState 和 callback 必须保证可见性与原子性:
- 状态变量:使用
AtomicReference<>或volatile+synchronized。 - callback 引用:使用
volatile保证主线程和子线程之间的可见性。 - 超时时间戳:使用
volatile long确保子线程写入后主线程能立即读到最新值。
常见错误:状态字段不加 volatile 或 Atomic 包装,导致
isIdleNow() 读到过期的状态值,测试永远等待或提前释放。务必使用 AtomicReference 或 AtomicInteger 管理可变更状态。
// 错误示例:不加 volatile 的普通 int
private int state; // ❌ 主线程可能永远看不到子线程的修改
// 正确示例
private final AtomicInteger state = new AtomicInteger(0);
4.5 超时控制机制
超时控制是防止测试“死等”的核心保障。实现策略:
- 记录任务开始时间戳 — 在
onTaskStarted()中赋值taskStartTime。 - 轮询时比较时间差 — 在
isIdleNow()中计算System.currentTimeMillis() - taskStartTime。 - 超时后强制空闲 — 超过阈值后状态切换为
TIMEOUT,调用notifyIdle()唤醒 Espresso。 - 清理资源 — 测试用例通过
forceIdle()或超时回调释放线程池、取消协程、关闭文件句柄。
注意:超时阈值不宜设置过大(推荐 3~10 秒)。超过 15 秒可能触发 Espresso 自身的超时策略,导致测试失败而不是超时空闲。建议在
@Test 方法开始时通过 IdlingRegistry.getInstance().register() 注册,测试结束时 unregister()。
4.6 完整状态机与 SVG 流程
下图展示自定义 IdlingResource 的状态机:任务从入队到超时或消费完成的完整流转,以及 Espresso 轮询 isIdleNow() 的触发时机。
4.7 在 Compose 测试中使用自定义 IdlingResource
将自定义 IdlingResource 集成到 Compose 测试的 @Before / @After 生命周期中:
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
private lateinit var taskIdlingResource: CustomTaskIdlingResource
@Before
fun setUp() {
taskIdlingResource = CustomTaskIdlingResource("task_idler", 5000L)
IdlingRegistry.getInstance().register(taskIdlingResource)
}
@After
fun tearDown() {
IdlingRegistry.getInstance().unregister(taskIdlingResource)
}
@Test
fun testBackgroundTaskUpdatesComposeUI() {
// 模拟后台任务触发
taskIdlingResource.onTaskStarted()
// 在子线程执行异步操作,完成后回调
executor.execute {
// 模拟耗时操作
Thread.sleep(2000)
// 更新 Compose State(通过主线程)
composeTestRule.runOnMainThread {
viewModel.updateData("new data")
}
taskIdlingResource.onTaskCompleted()
taskIdlingResource.consumeResult()
}
// Espresso 将自动等待 taskIdlingResource 变为空闲
composeTestRule.onNodeWithText("new data").assertIsDisplayed()
}
4.8 常见错误与稳定性优化
| 错误模式 | 现象 | 解决方案 |
|---|---|---|
| 未正确重置状态 | 多次测试共享 IdlingResource,后测始终繁忙 | 在 @After 中调用 forceIdle() 或重新创建实例 |
| 子线程直接修改 UI | Compose 报 CalledFromWrongThreadException | 通过 composeTestRule.runOnMainThread 更新可组合状态 |
| 超时时间设置过短 | 正常任务被误判为 TIMEOUT | 观察实际执行时间,留出 2~3 倍余量 |
| callback 未及时调用 | Espresso 唤醒延迟,测试变慢 | 状态切换后立即调用 notifyIdle(),不要延迟 |
| 未解注册 IdlingResource | 测试泄漏,影响后续测试 | @After 中必须 unregister() |
性能优化建议:
isIdleNow()内避免对象分配,使用 long 基本类型存储时间戳。- 超时检测的
System.currentTimeMillis()调用不超过两次,可缓存当前时间。 - 若任务频繁创建/销毁,使用
CountingIdlingResource包装自定义实现,减少状态切换开销。