You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用Espresso测试RecyclerView的SelectionTracker选择功能

问题背景

我需要通过Espresso选中配置了SelectionTracker的RecyclerView列表项,同时支持在测试中执行取消选中操作。
SelectionTracker初始化代码如下:

val selectionTracker = SelectionTracker.Builder(
    selectionID,
    recyclerView,
    stringAdapter.itemKeyProvider,
    createItemDetailsLookup(recyclerView),
    StorageStrategy.createStringStorage()
)

debug版本手动测试确认SelectionTracker本身功能正常,不想每次代码变更后重复手动回归测试。


已尝试的方案与问题

方案1:直接对列表项执行longClick

最初尝试通过Espresso调用longClick选中列表项,测试代码如下:

val toolbarId = withId(R.id.toolbar_activity_main)
val toolbar = onView(toolbarId)
toolbar.check(matches(isCompletelyDisplayed()))
toolbar.check(
    matches(
        withInvisibleIcon(R.id.action_delete_toolbar_activity_main),
    )
)
val recyclerViewWithId = withId(R.id.fragment_editor_recycler_view)
val recyclerView = onView(recyclerViewWithId)
recyclerView.check(matches(isDisplayed()))
val itemView = onView(
    allOf(
        withParent(recyclerViewWithId),
        withChild(
            allOf(
                withText("AA"),
                withId(R.id.view_holder_item_text_view)
            )
        ),
        withId(R.id.view_holder_item),
        )
)
itemView.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
itemView.perform(longClick())
toolbar.check(
    matches(
        withVisibleIcon(R.id.action_delete_toolbar_activity_main),
    )
)

配套自定义Matcher实现:

private fun withInvisibleIcon(@IdRes int: Int): Matcher<View> {
    return object : TypeSafeMatcher<View>() {

        override fun describeTo(description: Description) {
            description.appendText("with invisible icon id: $int")
        }

        public override fun matchesSafely(view: View): Boolean {
            if (view !is MaterialToolbar) {
                return false
            }
            val item = view.menu.findItem(int)
            return (item.itemId == int) and (!item.isVisible)
        }
    }
}

private fun withVisibleIcon(@IdRes int: Int): Matcher<View> {
    return object : TypeSafeMatcher<View>() {

        override fun describeTo(description: Description) {
            description.appendText("with visible icon id: $int")
        }

        public override fun matchesSafely(view: View): Boolean {
            if (view !is MaterialToolbar) {
                return false
            }
            val item = view.menu.findItem(int)
            return (item.itemId == int) and (item.isVisible)
        }
    }
}

测试断点暂停时手动长按列表项,id为action_delete_toolbar_activity_main的删除图标会正常显示,手动操作应用时该图标也能正常呼出,但执行上述测试代码会抛出断言失败异常:

androidx.test.espresso.base.DefaultFailureHandler$AssertionFailedWithCauseError: 'with visible icon id: 2131230778' doesn't match the selected view.
...

方案2:通过RecyclerViewActions执行长按

之后尝试使用RecyclerViewAction对指定位置项执行长按:

recyclerView.perform(RecyclerViewActions.actionOnItemAtPosition<StringAdapter.ViewHolder>(
        0,
        longClick()
    ))

仍然无法触发选中。

方案3:自定义超长时长长按

将上述两种方案的长按替换为自定义实现,长按时长设为默认longClick的6又2/3倍,自定义Tap代码:

enum class Tap : Tapper {
    LONGER_CLICK {
        override fun sendTap(
            uiController: UiController?,
            coordinates: FloatArray?,
            precision: FloatArray?,
            inputDevice: Int,
            buttonState: Int
        ): Tapper.Status {
            var downEvent = MotionEvents.sendDown(
                uiController,
                coordinates,
                precision,
                inputDevice,
                buttonState
            ).down
            try {
                val longPressTimeout =
                    (ViewConfiguration.getLongPressTimeout() * 10f).toLong()
                uiController!!.loopMainThreadForAtLeast(longPressTimeout)
                if (!MotionEvents.sendUp(uiController, downEvent)) {
                    MotionEvents.sendCancel(uiController, downEvent)
                    return Tapper.Status.FAILURE
                }
            } finally {
                downEvent!!.recycle()
                downEvent = null
            }
            return Tapper.Status.SUCCESS
        }

        @Deprecated("Deprecated in Java")
        override fun sendTap(
            uiController: UiController?,
            coordinates: FloatArray?,
            precision: FloatArray?
        ): Tapper.Status {
            return sendTap(
                uiController,
                coordinates,
                precision,
                InputDevice.SOURCE_UNKNOWN,
                MotionEvent.BUTTON_PRIMARY
            )
        }

    }
}

调用代码:

recyclerView.perform(
RecyclerViewActions.actionOnItemAtPosition<StringAdapter.ViewHolder>(
    0,
    GeneralClickAction(
        Tap.LONGER_CLICK,
        GeneralLocation.VISIBLE_CENTER,
        Press.FINGER,
        InputDevice.SOURCE_UNKNOWN,
        MotionEvent.BUTTON_PRIMARY
    )
)
)

调试SelectionTracker源码确认只要点击坐标正确就会触发选中,该方案依然无效,最初怀疑是框架Bug。

方案4:手动获取坐标直接注入点击事件

尝试直接通过Activity获取列表项屏幕坐标后注入长按事件:

val location = IntArray(2){0}
val cdl  = CountDownLatch(1)
scenario.onActivity {
    val recyclerView = it.findViewById<RecyclerView>(R.id.fragment_editor_recycler_view)
    val ite = recyclerView.getChildAt(0)
    ite.getLocationOnScreen(location)
    cdl.countDown()
}
cdl.await()
val recyclerViewWithId = withId(R.id.fragment_editor_recycler_view)
onView(recyclerViewWithId).check(matches(isDisplayed()))
onView(withId(R.id.activity_main)).perform(
    GeneralClickAction(
        Tap.LONG,
        { FloatArray(location.size){ i -> (location[i]+1).toFloat() } },
        Press.FINGER,
        InputDevice.SOURCE_UNKNOWN,
        MotionEvent.BUTTON_PRIMARY
    )
)

后续改为从根视图注入事件:

val cdl  = CountDownLatch(1)
scenario.onActivity {
    val recyclerView = it.findViewById<RecyclerView>(R.id.fragment_editor_recycler_view)
    val ite = recyclerView.getChildAt(0)
    ite.getLocationOnScreen(location)
    cdl.countDown()
}
cdl.await()
onView(isRoot()).perform(
    GeneralClickAction(
        Tap.LONG,
        { FloatArray(location.size){ i -> (location[i]+1).toFloat() } },
        Press.FINGER,
        InputDevice.SOURCE_UNKNOWN,
        MotionEvent.BUTTON_PRIMARY
    )
)

已经确认SelectionTracker正确绑定到Fragment中的RecyclerView,以上方案全部无法触发选中逻辑。


解决方案

这不是框架Bug,问题出在SelectionTracker的触摸事件监听逻辑和Espresso默认注入事件的源类型不匹配:SelectionTracker内部的OnItemTouchListener会校验输入事件的source类型,Espresso默认使用InputDevice.SOURCE_UNKNOWN注入事件,会被SelectionTracker直接过滤,不会进入选中判定逻辑。

正确的测试实现要点:

  • 自定义点击/长按Action时,将inputDevice参数设置为InputDevice.SOURCE_TOUCHSCREEN,不要使用SOURCE_UNKNOWN
  • 不需要自定义超长点击时长,使用系统默认的长按超时阈值即可,自定义长按时长只要保持和系统ViewConfiguration.getLongPressTimeout()*1.5的安全系数一致就足够
  • 选中后验证状态、执行取消选中的操作,直接对已选中的项执行普通点击即可,不需要再次长按

可直接复用的选中/取消选中Action实现:

fun selectItemInSelectionTracker(position: Int): ViewAction {
    return RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
        position,
        GeneralClickAction(
            Tap.LONG,
            GeneralLocation.VISIBLE_CENTER,
            Press.FINGER,
            InputDevice.SOURCE_TOUCHSCREEN,
            MotionEvent.BUTTON_PRIMARY
        )
    )
}

fun deselectItemInSelectionTracker(position: Int): ViewAction {
    return RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
        position,
        GeneralClickAction(
            Tap.SINGLE,
            GeneralLocation.VISIBLE_CENTER,
            Press.FINGER,
            InputDevice.SOURCE_TOUCHSCREEN,
            MotionEvent.BUTTON_PRIMARY
        )
    )
}

测试代码中直接调用即可:

// 选中第0项
onView(withId(R.id.fragment_editor_recycler_view)).perform(selectItemInSelectionTracker(0))
// 验证删除图标显示
onView(withId(R.id.toolbar_activity_main)).check(matches(withVisibleIcon(R.id.action_delete_toolbar_activity_main)))
// 取消选中第0项
onView(withId(R.id.fragment_editor_recycler_view)).perform(deselectItemInSelectionTracker(0))
// 验证删除图标隐藏
onView(withId(R.id.toolbar_activity_main)).check(matches(withInvisibleIcon(R.id.action_delete_toolbar_activity_main)))

注意:如果你的ItemDetailsLookup实现中对触摸事件做了额外的坐标校验,确保GeneralLocation.VISIBLE_CENTER取到的坐标落在列表项可点击的有效区域内即可,大部分场景下不需要额外调整坐标。


内容的提问来源于stack exchange,提问作者John Glen

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.01 22:09:39