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 超时控制机制

超时控制是防止测试“死等”的核心保障。实现策略:

  1. 记录任务开始时间戳 — 在 onTaskStarted() 中赋值 taskStartTime。
  2. 轮询时比较时间差 — 在 isIdleNow() 中计算 System.currentTimeMillis() - taskStartTime。
  3. 超时后强制空闲 — 超过阈值后状态切换为 TIMEOUT,调用 notifyIdle() 唤醒 Espresso。
  4. 清理资源 — 测试用例通过 forceIdle() 或超时回调释放线程池、取消协程、关闭文件句柄。
注意:超时阈值不宜设置过大(推荐 3~10 秒)。超过 15 秒可能触发 Espresso 自身的超时策略,导致测试失败而不是超时空闲。建议在 @Test 方法开始时通过 IdlingRegistry.getInstance().register() 注册,测试结束时 unregister()。

4.6 完整状态机与 SVG 流程

下图展示自定义 IdlingResource 的状态机:任务从入队到超时或消费完成的完整流转,以及 Espresso 轮询 isIdleNow() 的触发时机。

自定义 IdlingResource 状态机与超时流程 自定义 IdlingResource 状态机 IDLE QUEUED RUNNING COMPLETED TIMEOUT IDLE (回迁) onTaskQueued() onTaskStarted() onTaskCompleted() 超时切断 consumeResult() 轮询时机: Espresso 通过 IdlingResource.isIdleNow() 每 10~50ms 轮询一次。 仅 IDLE 和 TIMEOUT 状态返回 true,其他状态保持阻塞。

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() 或重新创建实例
子线程直接修改 UICompose 报 CalledFromWrongThreadException通过 composeTestRule.runOnMainThread 更新可组合状态
超时时间设置过短正常任务被误判为 TIMEOUT观察实际执行时间,留出 2~3 倍余量
callback 未及时调用Espresso 唤醒延迟,测试变慢状态切换后立即调用 notifyIdle(),不要延迟
未解注册 IdlingResource测试泄漏,影响后续测试@After 中必须 unregister()
性能优化建议:
  • isIdleNow() 内避免对象分配,使用 long 基本类型存储时间戳。
  • 超时检测的 System.currentTimeMillis() 调用不超过两次,可缓存当前时间。
  • 若任务频繁创建/销毁,使用 CountingIdlingResource 包装自定义实现,减少状态切换开销。