22、多屏幕适配测试:不同尺寸测试、横竖屏测试、折叠屏测试

说实话,做Compose UI测试这么多年,我最怕听到的一句话就是:「在我手机上没问题啊」。嗯,这句话背后往往藏着一个血泪史——你的应用在测试机上跑得飞起,结果用户拿个折叠屏一展开,布局直接崩了。

多屏幕适配测试,说白了就是让你的UI在各种奇形怪状的屏幕上都能正常显示。我见过太多团队只盯着主流尺寸测,结果上线后被用户骂成筛子。今天我们就来聊聊怎么用Compose的测试框架,系统性地搞定这件事。

核心观点: 多屏幕测试不是「测不测」的问题,而是「怎么高效测」的问题。Compose的测试框架天然支持配置化测试,我们只需要把屏幕参数当成测试输入即可。

22.1 为什么多屏幕测试这么重要?

你想想看,现在的Android设备有多离谱?从2.8英寸的智能手表到12.4英寸的平板,中间还夹着各种折叠屏、卷轴屏。我去年接手过一个项目,用户反馈在三星Fold系列上,底部按钮被折叠铰链区域挡住了——这就是典型的没做折叠屏适配测试的后果。

多屏幕测试要覆盖的核心维度有三个:

  • 屏幕尺寸:小屏手机、大屏手机、平板
  • 屏幕方向:竖屏、横屏
  • 折叠状态:折叠态、展开态、桌面模式

每个维度组合起来,测试用例数量会爆炸。所以我们需要一套可复用的测试方案,而不是手动一个个测。

多屏幕适配测试知识体系 多屏幕适配测试 不同尺寸测试 小屏(360dp) / 中屏(411dp) / 大屏(600dp+) 平板适配(800dp+) 横竖屏测试 竖屏(portrait) / 横屏(landscape) 配置变更生命周期处理 折叠屏测试 折叠态 / 半折叠态 / 展开态 铰链区域 / 桌面模式 测试策略:参数化测试 + 配置注入 + 快照对比

22.2 不同尺寸测试:从360dp到800dp+

Compose测试中,我们可以通过setContent时传入自定义的Configuration来模拟不同屏幕尺寸。我个人习惯把常用的尺寸定义成常量,方便复用。

// 定义常用屏幕尺寸常量
object ScreenSizes {
    val SMALL_PHONE = Configuration().apply {
        screenWidthDp = 360
        screenHeightDp = 640
        smallestScreenWidthDp = 360
    }
    
    val MEDIUM_PHONE = Configuration().apply {
        screenWidthDp = 411
        screenHeightDp = 731
        smallestScreenWidthDp = 411
    }
    
    val LARGE_PHONE = Configuration().apply {
        screenWidthDp = 480
        screenHeightDp = 853
        smallestScreenWidthDp = 480
    }
    
    val TABLET = Configuration().apply {
        screenWidthDp = 800
        screenHeightDp = 1280
        smallestScreenWidthDp = 800
    }
}

有了这些配置,写测试就简单了。我通常会写一个辅助函数,把屏幕尺寸作为参数传进去:

@OptIn(ExperimentalTestApi::class)
fun <T> runScreenSizeTest(
    configuration: Configuration,
    testBlock: ComposeContentTestRule.() -> T
): T {
    return composeTestRule.run {
        // 设置自定义配置
        mainClock.autoAdvance = false
        setContent {
            CompositionLocalProvider(
                LocalConfiguration provides configuration
            ) {
                // 这里放你的Composable
                MyScreen()
            }
        }
        testBlock()
    }
}

// 使用示例
@Test
fun testLoginScreen_onSmallPhone() {
    runScreenSizeTest(ScreenSizes.SMALL_PHONE) {
        onNodeWithTag("login_button").assertIsDisplayed()
        onNodeWithTag("logo").assertIsDisplayed()
    }
}

@Test
fun testLoginScreen_onTablet() {
    runScreenSizeTest(ScreenSizes.TABLET) {
        // 平板模式下,布局应该是两列
        onNodeWithTag("side_panel").assertIsDisplayed()
        onNodeWithTag("main_content").assertIsDisplayed()
    }
}
小技巧: 如果测试用例很多,可以用参数化测试(ParameterizedTest)来批量跑不同尺寸。这样写一个测试方法,就能覆盖所有尺寸,省时省力。

22.3 横竖屏测试:方向变了,布局不能崩

横竖屏切换是Android的老大难问题。我记得有一次,一个列表页面在竖屏时显示正常,一切到横屏,列表项之间的间距全乱了——因为横屏下屏幕宽度变大,但代码里写死了Modifier.width(100.dp)

在Compose测试中,模拟横竖屏其实很简单,只需要修改Configurationorientation字段:

object ScreenOrientations {
    val PORTRAIT = Configuration().apply {
        orientation = Configuration.ORIENTATION_PORTRAIT
        screenWidthDp = 411
        screenHeightDp = 731
    }
    
    val LANDSCAPE = Configuration().apply {
        orientation = Configuration.ORIENTATION_LANDSCAPE
        screenWidthDp = 731  // 横屏时宽高互换
        screenHeightDp = 411
    }
}

@Test
fun testDashboard_inLandscape() {
    runScreenSizeTest(ScreenOrientations.LANDSCAPE) {
        // 横屏下,导航栏应该在左侧
        onNodeWithTag("navigation_rail").assertIsDisplayed()
        // 内容区域应该更宽
        onNodeWithTag("content_area").assertWidthIsAtLeast(500.dp)
    }
}

这里有个坑要注意:横屏测试时,screenWidthDpscreenHeightDp的值要互换。我曾经犯过这个错,测试了半天发现横屏和竖屏的布局一模一样——原来是我忘了改宽高。

避坑指南: 横竖屏切换会触发Composable的重新组合。如果你的Composable里有LaunchedEffectremember,要确保它们在配置变更后能正确重置。我建议在横竖屏测试中都加上「状态保持」的断言。

22.4 折叠屏测试:新形态,新挑战

折叠屏是这两年最让人头疼的。我去年给一个电商App做适配,在三星Galaxy Z Fold上测试时发现:折叠态下商品卡片显示正常,但展开后卡片之间的间距变得特别大,看起来稀稀拉拉的。

折叠屏测试需要关注几个特殊场景:

  • 折叠态:类似普通手机,屏幕宽度约360-400dp
  • 半折叠态:屏幕呈一定角度,常用于桌面模式
  • 展开态:屏幕完全展开,宽度约600-800dp
  • 铰链区域:屏幕中间的物理折痕,要避免关键内容被遮挡

在Compose测试中,我们可以通过WindowLayoutInfo来模拟折叠状态。不过说实话,这个API在测试中不太好用。我个人的做法是直接模拟屏幕尺寸变化,因为折叠屏展开后本质上就是屏幕变宽了。

object FoldableStates {
    val FOLDED = Configuration().apply {
        screenWidthDp = 360
        screenHeightDp = 780
        smallestScreenWidthDp = 360
    }
    
    val HALF_FOLDED = Configuration().apply {
        screenWidthDp = 600
        screenHeightDp = 780
        smallestScreenWidthDp = 600
    }
    
    val UNFOLDED = Configuration().apply {
        screenWidthDp = 800
        screenHeightDp = 780
        smallestScreenWidthDp = 800
    }
}

@Test
fun testProductGrid_onUnfoldedScreen() {
    runScreenSizeTest(FoldableStates.UNFOLDED) {
        // 展开态下,应该显示4列商品
        onNodeWithTag("product_grid").assertExists()
        onAllNodesWithTag("product_card").assertCountEquals(8) // 2行4列
    }
}

@Test
fun testProductGrid_onFoldedScreen() {
    runScreenSizeTest(FoldableStates.FOLDED) {
        // 折叠态下,应该显示2列商品
        onNodeWithTag("product_grid").assertExists()
        onAllNodesWithTag("product_card").assertCountEquals(4) // 2行2列
    }
}
关键点: 折叠屏测试的核心是「响应式布局」。你的Composable应该根据BoxWithConstraintsWindowSizeClass来动态调整布局,而不是写死列数或间距。测试要验证的就是这些动态调整是否正确。

22.5 实战:写一个通用的多屏幕测试工具

说了这么多,我们来写一个真正能用的测试工具类。我一般在项目里会这样封装:

// 多屏幕测试工具类
class MultiScreenTestHelper(
    private val composeTestRule: ComposeContentTestRule
) {
    // 所有要测试的屏幕配置
    private val allConfigs = listOf(
        ScreenSizes.SMALL_PHONE,
        ScreenSizes.MEDIUM_PHONE,
        ScreenSizes.LARGE_PHONE,
        ScreenSizes.TABLET,
        ScreenOrientations.PORTRAIT,
        ScreenOrientations.LANDSCAPE,
        FoldableStates.FOLDED,
        FoldableStates.UNFOLDED
    )
    
    // 批量运行测试
    fun runOnAllScreens(
        testName: String,
        testBlock: () -> Unit
    ) {
        allConfigs.forEach { config ->
            val screenLabel = getScreenLabel(config)
            println("Running '$testName' on $screenLabel")
            
            composeTestRule.run {
                setContent {
                    CompositionLocalProvider(
                        LocalConfiguration provides config
                    ) {
                        // 这里由调用方提供Composable
                    }
                }
                testBlock()
            }
        }
    }
    
    private fun getScreenLabel(config: Configuration): String {
        return when {
            config.screenWidthDp <= 360 -> "小屏手机"
            config.screenWidthDp in 361..480 -> "中屏手机"
            config.screenWidthDp in 481..600 -> "大屏手机"
            config.screenWidthDp > 600 -> "平板/折叠屏展开"
            else -> "未知尺寸"
        }
    }
}

// 使用示例
@Test
fun testHomeScreen_onAllScreens() {
    val helper = MultiScreenTestHelper(composeTestRule)
    helper.runOnAllScreens("首页布局测试") {
        onNodeWithTag("home_title").assertIsDisplayed()
        onNodeWithTag("nav_bar").assertIsDisplayed()
    }
}
我的经验: 这个工具类我用了快两年,最大的好处是「一次编写,到处运行」。每次新增一个屏幕尺寸,只需要在allConfigs列表里加一项,所有测试用例自动覆盖。不过要注意,测试执行时间会线性增长,建议在CI上只跑关键用例的完整覆盖,日常开发可以只跑主流尺寸。

22.6 快照测试:一图胜千言

除了功能测试,我还强烈推荐做快照测试(Snapshot Testing)。说白了就是把UI渲染成图片,然后和基准图片对比。这样布局有没有偏移、间距对不对,一眼就能看出来。

Compose官方提供了captureToImage方法,配合Roborazzi或Paparazzi等库,可以很方便地做快照测试:

@Test
fun testProfileScreen_snapshot() {
    composeTestRule.setContent {
        ProfileScreen()
    }
    
    // 捕获当前UI的图片
    composeTestRule.onRoot().captureToImage().let { bitmap ->
        // 和基准图片对比
        assertSnapshot(
            bitmap = bitmap,
            name = "profile_screen_default"
        )
    }
}

快照测试特别适合多屏幕场景。你可以为每个屏幕尺寸生成一张基准图,然后每次代码变更后自动对比。我见过一个团队用这种方式,把UI回归测试的时间从2小时缩短到了10分钟。

注意: 快照测试对字体渲染、系统主题很敏感。不同Android版本渲染出来的图片可能有细微差异。建议在CI上使用模拟器镜像来保证一致性,或者设置一个像素容差阈值。

22.7 总结:多屏幕测试的黄金法则

做了这么多年多屏幕测试,我总结了几条黄金法则,分享给你:

  1. 不要等上线再测:从第一个Composable开始,就带上多屏幕测试。越早发现问题,修复成本越低。
  2. 参数化是王道:用参数化测试覆盖所有屏幕配置,别手动一个个写测试方法。
  3. 快照测试做补充:功能测试保证「能用」,快照测试保证「好看」。
  4. 关注边界值:小屏的最小宽度、折叠屏的铰链区域、平板的横竖屏切换——这些边界最容易出问题。
  5. 自动化是唯一出路:手动测10个屏幕尺寸?别傻了,写个脚本让机器去跑。

嗯,多屏幕适配测试其实没那么可怕。只要你把测试框架搭好了,后面就是水到渠成的事。下次再有人说「在我手机上没问题」,你就可以理直气壮地说:「来,看看我们的自动化测试报告,所有屏幕都过了」。


公众号:蓝海资料掘金营,微信deep3321