Skip to content

无障碍与可用性 ​

无障碍不是「给少数人做的附加题」,而是界面对外暴露的信息结构。读屏用户听到的、大字号用户看到的、手指粗的用户点到的,和视力正常、系统字号默认的用户看到的,必须是同一套东西。差别只在暴露得对不对。

这一页给规则和数字,不给理念。骨架见 7 UI 设计和业务开发,用哪个组件、参数叫什么见 UI 组件库。组件库已经封好的语义和触控尺寸不要重排 —— 重复补一层只会读出「按钮 按钮」。

八节:语义 → contentDescription → 触控目标 → 遍历顺序 → 对比度 → 大字号 → 减弱动效 → TalkBack 实测,最后一节是上线自检清单。

一、Modifier.semantics:把界面翻译给读屏 ​

Compose 只会为它认识的东西自动生成语义:Text 的文本、clickable 的点击动作、组件库里按钮的 role。你自己拼出来的东西(自绘图形、非标准交互、Box 包一个图标)在语义树里是空的或者是一堆碎文本 —— 读屏会一个 Text 一个 Text 地念,念不到「这是一个能点的东西」。

1.1 能表达的四件事 ​

字段类型读出来是什么什么时候写
roleRole控件类型:「按钮」「复选框」「标签页」自定义可点 / 可选节点,且组件库没给
contentDescriptionString?节点名称,替代子节点的文本只有纯图标、无可见文字的控件(见第二节)
stateDescriptionString?状态后缀:「已选中」「已展开」状态只靠颜色 / 图形表达,肉眼看得出来、读屏听不出来
onClick(label = "…") { true }String / 返回 Boolean动作名义:「双击以 收藏」自定义 Box 上的点击,且默认动作名不够清楚
kotlin
// 自定义的「收藏」开关:肉眼靠实心 / 描边心形区分,读屏必须靠 role + stateDescription
Box(
    modifier = Modifier
        .sizeIn(minWidth = 48.dp, minHeight = 48.dp)   // 命中区,见第三节
        .clickable { onToggle() }                      // 提供点击动作
        .semantics {
            role = Role.Checkbox
            contentDescription = "收藏"                // 名称
            stateDescription = if (fav) "已选中" else "未选中"   // 状态
            onClick(label = "收藏") { onToggle(); true }
        },
    contentAlignment = Alignment.Center,
) {
    Icon(
        imageVector = if (fav) Phosphor.Fill.Heart else Phosphor.Bold.Heart,
        contentDescription = null,                     // 名字在外层给了,图标是装饰
    )
}

读屏顺序念出来是:「收藏,复选框,未选中」。三个字段各管一段,谁都别抢别人的活。

自己写 Modifier.clickable 时还有个更短的入口:clickable(onClickLabel = "收藏") { … },等价于上面的 onClick(label = …)。能用参数解决的别去 semantics { } 里再写一遍。

组件库已经做了的,别再做

  • ActionImageButton / FilledButton 等四个按钮:role、点击动作、禁用态由组件内部给。你只需要在纯图标场景补 contentDescription。
  • FavoriteIconButton:描述跟着收藏状态变(「收藏」/「取消收藏」),不用你补。
  • ListItem / PillTab / BottomNavBar 的项:文本与角色已封好,别在外面再套一层 semantics。
  • 需要区块标题能被读屏跳读时,才在你自己的 Text 上加 Modifier.semantics { heading() }。

1.2 合并与清除:mergeDescendants 和 clearAndSetSemantics ​

两个方向相反的操作,用错都是在制造噪音。

操作做什么什么时候用什么时候千万别用
Modifier.semantics(mergeDescendants = true) { … }把这个节点和所有后代合成一个语义节点,孩子的内容拼成一句念出来自己拼的一行「标签 + 值 + 单位」该被当成一个整体;自绘卡片节点里含有两个以上可点孩子 —— 合并后子按钮的点击动作会藏起来,用户点不到
Modifier.clearAndSetSemantics { … }丢掉自己和孩子所有语义,只留你写的这一条自绘的评分条 / 进度条 / 图表:整块当一个元素,念一句完整的话里面还有真有意义的交互元素时
kotlin
// ✅ 一行「标签 + 数值」念成一句,而不是两个独立节点
Row(
    modifier = Modifier.fillMaxWidth()
        .semantics(mergeDescendants = true) { }
        .padding(16.dp),
) { Text("剩余次数"); Text("3 次") }        // 读屏:「剩余次数 3 次」

// ✅ 自绘的星级评分:屏蔽内部所有碎片,只暴露一句。
//    链的顺序 sizeIn → clickable → clearAndSetSemantics,点击动作在外层,不会被清掉
Box(
    modifier = Modifier
        .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
        .clickable { onRate() }
        .clearAndSetSemantics {
            contentDescription = "评分 4.5,满分 5"
            role = Role.Button
        },
) { StarCanvas(score) }

合并判据只有一条

问自己:这一块在用户脑子里是不是「一个东西」? 是 → 合并;不是 → 不合并。一个含「标题 + 摘要 + 两个按钮」的卡片不是「一个东西」,卡片整体可点就给外层 clickable + onClickLabel,里面两个按钮保持各自独立可读。

clearAndSetSemantics 把它内层(写在它后面的)语义全部清掉。要保留点击动作就把 clickable 写在它外层;如果实测发现读屏不播报点击动作,就在 clearAndSetSemantics 里显式补 role 和 onClick(label = …) —— 别赌它自动保留。

状态变化要不要主动念

Alert / ErrorState / LoadingState 把结果画在屏幕上了,但读屏不会主动念(焦点没动)。发生在用户当前焦点之外的关键反馈(提交失败、保存成功)加 liveRegion = LiveRegionMode.Polite;只有错误才用 Assertive,它会打断用户正在听的内容。

二、contentDescription 的取舍 ​

这一节只有三条铁律,其余全是推论。

  1. 有可见文字的控件,不要再用 contentDescription 重复一遍。 FilledButton(text = "保存") 已经念出「保存」,再补一个 contentDescription = "保存" 只是把同一件事暴露两遍。
  2. 装饰性图标必须传 contentDescription = null。 不是省略,是显式传 null。图标在组件库里是 Icon(imageVector = …),ActionImageButton 的 contentDescription 参数默认就是 null —— 默认值就是给装饰性场景用的。
  3. 纯图标按钮必须有可读名称。 这是最常见的遗漏:图标看得懂,读屏只念「按钮」。
场景怎么写读出来
图标 + 文字(如「已验证 ✓」)图标传 null,文字就是名称「已验证」
标题栏上的搜索按钮ActionImageButton(icon = …, onAction = …, contentDescription = "搜索")「搜索,按钮」
卡片角落的装饰性插画contentDescription = null跳过,不念
列表项右侧的 CaretRightcontentDescription = null跳过(整行已经可点)
网络异常状态的插画交给组件库 ErrorState由状态页组件统一表达
kotlin
// ❌ 全部是噪音
ActionImageButton(icon = Phosphor.Gear, onAction = { openSettings() })   // 只念「按钮」,不知道是什么
ActionImageButton(icon = Phosphor.Gear, onAction = { openSettings() },
    contentDescription = "图标")                                          // 描述渲染方式,不是用途
FilledButton(text = "保存", onClick = { save() },                         // 文字已经是名字
    modifier = Modifier.semantics { contentDescription = "保存" })        // 又暴露一遍,多余

// ✅ 纯图标给动作名,装饰图标给 null
ActionImageButton(icon = Phosphor.Gear, onAction = { openSettings() }, contentDescription = "设置")
Row { Icon(Phosphor.Bold.SealCheck, contentDescription = null); Text("已验证") }

写什么算好名字 ​

坏名字为什么坏好名字
「图标」「图片」「矢量图」描述的是渲染方式,不是用途删掉,或换成动作名
「心形」「齿轮」描述形状「收藏」「设置」
「更多」歧义:哪个「更多」「更多操作」/「打开菜单」
「搜索按钮」角色会由读屏自己念,重复「搜索」
「点击这里」没有信息量说明点下去会发生什么

一条自检:把眼睛闭上,听这一条能不能知道「这是什么、点了会怎样」。 两个都能答上来就够了,不用写成长句。

重排组件库的语义 = 制造「按钮 按钮」

ActionImageButton 内部已经把图标暴露成按钮,参数 contentDescription 是唯一该给的入口。 自己再往外包一层 Box + semantics + clickable,语义树里就出现两个可点节点 —— 读屏念两遍,双击还会触发两套逻辑。 图标按钮就用 ActionImageButton,需要更大命中区用 modifier 参数撑,不要自己拼。

三、触摸目标:视觉尺寸 ≠ 命中尺寸 ​

两件事分开看:

  • 视觉尺寸:图标画多大、按钮看着多大 —— 属于设计,可以小。
  • 命中尺寸:手指按下去多大一块矩形算点中 —— 属于可用性,下限 48dp × 48dp(M3 与 WCAG 2.5.5 同一条线)。

做法是用 sizeIn 撑命中区,不放大视觉块:

kotlin
@Composable
fun TinyCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .sizeIn(minWidth = 48.dp, minHeight = 48.dp)   // ① 命中区先撑到 48dp
            .clip(AppShapes.pill)                          // ② 形状
            .clickable(onClick = onClick)                  // ③ 命中范围 = 上面的 48dp
            .padding(14.dp),                               // ④ 内容内缩,视觉图标只剩 20dp
        contentAlignment = Alignment.Center,
    ) {
        Icon(Phosphor.Bold.X, contentDescription = "关闭", modifier = Modifier.size(20.dp))
    }
}

sizeIn 必须排在 clickable 之前(外层)。反过来的话,clickable 拿到的还是 20dp,48dp 只是画在里面的空白。

你要的效果写法结果
视觉 20dp,命中 48dp.sizeIn(minWidth = 48.dp, minHeight = 48.dp).clickable { }.padding(14.dp)命中 48dp、水波纹铺满 48dp
视觉 20dp,命中 20dp.size(20.dp).clickable { }点不中,最常见的「我明明点了」
只让文字可点、周围留白不响应.padding(16.dp).clickable { }命中区缩到文字上,一般不想要
整行可点组件库 ListItem行高与命中区已封好

组件库给的触控尺寸是标定过的,别改小也不要为了「更保险」自己加一层:

组件触控区你要做什么
FilledButton / TonalButton 等(ButtonSize.LARGE)48dp什么都不做;别为了省空间随手降到 SMALL
ActionImageButton44dp(内部标定)什么都不做;它已经是标题栏里的标准图标按钮
ListItem / PillTab整行 / 整胶囊什么都不做
你自己写的 Box + clickable你自己负责sizeIn(minWidth = 48.dp, minHeight = 48.dp) 排在 clickable 前

别给组件库组件再套一层 clickable

FilledButton 外面再包一层 Box.clickable,命中区会变成两个矩形、水波纹画两遍,读屏也会念两遍。要更大就调 modifier,不是加一层。

四、遍历顺序:读屏按树走,不按屏幕走 ​

读屏默认顺序是语义树(组合 / 布局树的写入顺序),不是屏幕坐标。视觉顺序是绘制出来的结果,两者可以不一样 —— 这就是「焦点跳错」的全部原因。

三种典型错位:

现象成因修法
悬浮按钮 / 底部操作条最后才读到它在代码里写在 Box 的最后一个孩子(画在最上层,树里排最后)给它 traversalIndex = -1f,或把它挪进标题栏
同一行里的两个动作读反了视觉上靠右的那个在代码里写在前调代码顺序;调不动才用 traversalIndex
一整个区块被逐个念成 10 条碎片没有分组概念,读屏在整页里平铺遍历区块容器加 isTraversalGroup = true,块内再谈顺序
kotlin
// 悬浮的回顶按钮:视觉在最上层,树上在最后 —— 用 traversalIndex 提前
ScrollToTopButton(
    onClick = { scope.launch { listState.animateScrollToItem(0) } },
    modifier = Modifier.semantics { traversalIndex = -1f },
)

两个 API 的作用范围不一样,别记混:

API语义写法
traversalIndex: Float在同一个遍历组内部排序,小的先读Modifier.semantics { traversalIndex = 0f }
isTraversalGroup = Boolean把这个节点的孩子划成一个组,读屏先读完这一组再往下Modifier.semantics { isTraversalGroup = true }

顺序问题的顺序解法

先改结构(把元素放进它该在的位置),再改 traversalIndex。 traversalIndex 是补丁:它不改变绘制顺序,只改朗读顺序,用多了以后视觉和听觉两套顺序各自漂移,接手的人再也对不上。判据:如果视觉顺序本身是合理的,就别动 traversalIndex。

一个具体判据:任何叠加在外层的东西(悬浮按钮、底部操作条、加载蒙层),都要问一句「它在树里的位置和它被看到的先后一致吗」。不一致就显式处理。加载蒙层这类不该被读的元素,直接不暴露语义(保持无文本、无点击的空容器即可)。

五、对比度:记住四个数字 ​

对象最低对比度依据
正文文字(< 24sp,或 < 18.66sp 粗体)4.5:1WCAG 1.4.3
大字(≥ 24sp,或 ≥ 18.66sp 粗体)3:1WCAG 1.4.3
图标 / 控件边界 / 焦点态(非文本)3:1WCAG 1.4.11
禁用态组件豁免WCAG 1.4.3 明确豁免 inactive 组件

几条落地规则:

  • 正文用 onSurface,次要文字用 onSurfaceVariant。 不要为了让次要文字「更轻」而换成 outline 或降透明度 —— 占位符、日期、说明文字都算正文,一律 4.5:1。想弱化层级靠字号和位置,不靠降低对比度。
  • 禁用态的两档灰是刻意的:组件库按钮禁用态是 onSurface@12% 容器 + onSurface@38% 文字。上一版用「主色降透明度」实测只有 1.85:1,看起来不是「禁用」而是「按钮坏了」。所以别把禁用态调亮 —— 用户会以为还能点。
  • 状态不能只靠颜色。 选中 / 未选中、成功 / 失败、在线 / 离线,都必须有第二个信号:形状、图标、文字或位置。组件库的 PillTab 就吃过这个亏 —— M3 原值(secondaryContainer)实测只有 1.23:1(深色 1.99:1),改成 primary 实心后是 6.47:1(浅)/ 7.76:1(深)。这是「状态必须一眼可辨」的正面例子,别按 M3 文档改回去。
  • 颜色只从 MaterialTheme.colorScheme 取。 硬编码 Color(0xFF...) 在深色模式下必然失配,浅色下算出来的对比度到深色下就没了;圆角同理走 AppShapes。
  • 分隔线(HorizontalDivider)是装饰性的,不适用 3:1;但输入框的边框、开关的两种状态属于「辨识控件」的非文本内容,必须 3:1。TextField 的 label 同时是无障碍的字段名,必须传(项目硬规范第 3 条)。

六、大字号 / 字体缩放:布局别崩 ​

系统字体缩放会按 fontScale(1.0 / 1.15 / 1.3 / 1.5 / 2.0)放大所有 sp。布局写死了 dp 高度的地方,就是文字被切的地方。

三条规则:

  1. 不要给含文字的容器写死高度。 用 heightIn(min = …) 或什么都不写,让内容撑。
  2. 不要用固定 dp 装文字。 宽度同理:给 weight(1f),不给 width(120.dp)。
  3. maxLines 必须配 overflow。 只写 maxLines = 1 不写 overflow,多余的字会被硬切在半个字形上;写了 overflow = TextOverflow.Ellipsis 才会给省略号。
kotlin
// ❌ fontScale = 1.5 就切字:高度写死,文字没有伸展空间
Row(Modifier.height(48.dp), verticalAlignment = Alignment.CenterVertically) {
    Text(title, maxLines = 1)                       // 只有 maxLines,没有 overflow
    Text(subtitle, Modifier.width(80.dp))           // 宽度写死,放大后换行撑爆
}

// ✅ 高度跟着内容走,宽度吃剩余空间,溢出给省略号
Row(
    modifier = Modifier
        .fillMaxWidth()
        .heightIn(min = 48.dp)                      // 下限,不封顶
        .padding(horizontal = 16.dp, vertical = 12.dp),
    verticalAlignment = Alignment.CenterVertically,
    horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
    Column(Modifier.weight(1f)) {
        Text(title, style = MaterialTheme.typography.titleMedium,   // 字阶走 token,跟随 fontScale
            maxLines = 2, overflow = TextOverflow.Ellipsis)
        Text(subtitle, style = MaterialTheme.typography.bodySmall,
            maxLines = 1, overflow = TextOverflow.Ellipsis)
    }
    ActionImageButton(icon = Phosphor.CaretRight, onAction = { onOpen() })
}

配套注意:

  • 字号只走 MaterialTheme.typography.*,不要自己写 fontSize = 14.sp —— 写死 .sp 值等于绕开设计基线,缩放行为也跟 token 不一致。
  • maxLines = 1 是取舍,不是默认。 标题、错误提示、按钮文字这类「读不到就等于没有」的内容,允许两行;只有列表项的次要信息才压一行。
  • 一行里并排多个元素时,把弹性留给文字:weight(1f) 的 Text + 固定尺寸的图标 / 按钮,是最稳的组合(见布局与 Modifier 3.1)。
  • 组件的尺寸档位(ButtonSize / PillTabSize)已经标定好,不要因为「怕字大了装不下」就降档;装不下说明文案该缩短,或者该换成整行布局。
  • 单行不换行的胶囊(PillTab)在超大字号下容易挤:控制标签长度(两个汉字最稳),别把长分类名塞进去。

验证方式(三条一起过):

text
设置 → 显示 → 字体大小,拉到最大,走一遍主流程
设置 → 显示 → 显示大小(DPI),再拉大一次
深浅色各来一遍:对比度是在具体配色下测的,不是抽象测的

命令行快速切档:adb shell settings put system font_scale 1.5(改完需要重进页面)。

七、减弱动效:系统说别动就别动 ​

系统「移除动画」(开发者选项 / 部分机型的无障碍设置)会把动画时长缩放置零。Compose 的 rememberInfiniteTransition 不认这个开关 —— 它会照旧每帧跑下去。持续动画必须自己判。

读取方式(Android 全局设置):

kotlin
import android.provider.Settings
import androidx.compose.ui.platform.LocalContext

@Composable
fun rememberReducedMotion(): Boolean {
    val context = LocalContext.current
    return remember(context) {
        Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
    }
}

7.1 持续动画:不跑,但要留静态呈现 ​

kotlin
val reduced = rememberReducedMotion()

if (reduced) {
    // 静态态:保留「这里有东西 / 正在加载」的视觉信息,只是不动
    Box(Modifier.padding(16.dp).alpha(0.7f).clip(AppShapes.cardSmall).background(bg))
} else {
    val transition = rememberInfiniteTransition(label = "pulse")
    val alpha by transition.animateFloat(
        initialValue = 0.4f, targetValue = 1f,
        animationSpec = infiniteRepeatable(tween(1200), RepeatMode.Reverse),
        label = "pulseAlpha",
    )
    Box(Modifier.padding(16.dp).alpha(alpha).clip(AppShapes.cardSmall).background(bg))
}

三个要点:

  • rememberInfiniteTransition 不要无条件调用。 把它包在 if (!reduced) 里,或者至少不把结果用在绘制上。
  • 静态态必须还在。 骨架屏、呼吸提示、打字光标这类「动起来才看得出存在」的东西,关掉动画后要给一个固定形态,不能整块消失。
  • 加载中的表达优先用组件库 LoadingIndicator / LoadingState,别自己拿无限动画拼一套 —— 组件库覆盖了标准加载态,你自己造一套只会多一处需要适配减弱动效的地方。

7.2 一次性动画:时长归零,不用删 ​

进出场、展开收起这类一次性动画,把 spec 换掉就行,视觉变化本身保留:

kotlin
val reduced = rememberReducedMotion()
val spec = if (reduced) snap<Dp>() else tween(durationMillis = 220)
val offset by animateDpAsState(targetValue = if (shown) 0.dp else 12.dp, animationSpec = spec)

AnimatedVisibility(
    visible = expanded,
    enter = if (reduced) EnterTransition.None else fadeIn() + expandVertically(),
    exit = if (reduced) ExitTransition.None else fadeOut(),
) { Content() }

判据:动画只是让变化更好懂,删掉动画变化本身还在。 如果某个状态变化只能靠动画看出来(比如必须看到「飞过去」才知道东西去哪了),那是信息设计的问题,不是动画的问题,见动效。

八、TalkBack 实测:开、听、认坏味道 ​

写在代码里的语义对不对,只有真机能证明。

怎么开

  1. 设置 → 无障碍 → TalkBack → 开启(首次会问是否保留快捷方式)。
  2. 之后用无障碍快捷方式临时切换:设置 → 无障碍 → 快捷方式,勾选后长按两个音量键开关。开发期反复验的时候比进设置快。
  3. 命令行等价写法(仅调试机):
bash
adb shell settings put secure enabled_accessibility_services \
  com.google.android.marvin.talkback/com.google.android.marvin.talkback.TalkBackService
adb shell settings put secure accessibility_enabled 1
# 关掉:把 enabled_accessibility_services 置为 null

怎么听

手势作用
单指右滑 / 左滑下一个 / 上一个元素(这就是遍历顺序)
单指双击激活当前元素(单击只是聚焦)
单指上滑再右滑 / 下滑再右滑「动作」菜单 / 按段落跳读
单指按住拖动手指划到哪念到哪,快速定位元素

要找的东西:每个可点元素都念出「名称 + 角色」;每个纯图标按钮有名字;状态变化(选中 / 禁用 / 加载完)能被听到;顺序和视觉一致;一条主流程(进页面 → 操作 → 出结果)全程不用看屏幕能走完。

常见坏味道对照表

听到根因修法
「按钮 按钮」外层自建 clickable 与内层组件各暴露一次语义删掉外层包裹,用组件库组件的 contentDescription 参数
只念「按钮」,没有名字纯图标按钮漏了 contentDescriptionActionImageButton(…, contentDescription = "搜索")
念出「图标」「图片」「矢量」装饰性 Icon 漏传 null,或名字写成了形状描述装饰 → null;有意义 → 换成动作名
读出的名字和可见文字重复有可见文字的控件又补了一遍 contentDescription文字就是名字,删掉多余的描述
一长串文字被逐个念成碎片该合并的一行没有 mergeDescendants行容器加 modifier = Modifier.semantics(mergeDescendants = true) { }
悬浮按钮最后才读到叠加层在树的末尾traversalIndex = -1f,或移进标题栏
听不出选中 / 禁用状态只画了颜色、没进语义stateDescription,PillTab 这类用组件库自带的选中态
念出「12」(不知道是什么)角标数字独立暴露成节点把它并在主节点名称里(「收藏」+「12 条」由组件库 count 统一表达即可)
打开页面就被念一通该静默的蒙层 / 装饰暴露了文本装饰传 null、蒙层不暴露内容
弹窗打开后焦点还在背后的页面上弹窗没有接管焦点用组件库 Dialog / BottomSheet / AlertDialog,别自己用 Box 叠一层

验收粒度

至少跑这三条路径,每条都用读屏走一遍:首屏加载 → 列表滚动 → 详情 / 操作;打开弹窗 / 底部面板 → 选一项 → 关闭;触发一次错误 → 读到错误 → 重试成功。 再叠加两个环境:字体缩放 2.0 + 深色模式。四条组合能覆盖绝大多数「上线后才发现」的问题。

九、上线前无障碍自检清单 ​

  • [ ] 每个纯图标按钮都有可读名称(ActionImageButton 的 contentDescription 传了动作名,不是「图标」「按钮」)
  • [ ] 装饰性图标显式传 contentDescription = null;有可见文字的控件没有再补一遍描述
  • [ ] 每个自定义 Box + clickable 都有 sizeIn(minWidth = 48.dp, minHeight = 48.dp),且排在 clickable 之前
  • [ ] 没有给组件库组件再套一层 clickable 或 semantics(读屏不会念两遍「按钮」)
  • [ ] 状态(选中 / 禁用 / 加载 / 错误)不靠颜色单独表达,且有 stateDescription 或组件库自带状态
  • [ ] 所有颜色走 MaterialTheme.colorScheme、圆角走 AppShapes;正文对比度 ≥ 4.5:1,次要文字 ≥ 4.5:1,图标 / 边界 ≥ 3:1
  • [ ] 含文字的容器没有写死高度,文字块用 weight(1f);每个 maxLines 都配了 overflow
  • [ ] 字体缩放 2.0 + 深色模式下走一遍主流程,没有截断、重叠、超出屏幕的文字
  • [ ] 持续动画(rememberInfiniteTransition)在「移除动画」开启后不跑,且保留静态呈现
  • [ ] TalkBack 下三条路径全程可用、遍历顺序与视觉一致、弹窗能接管焦点

顺手的语义测试(可选,用 createComposeRule):onNodeWithContentDescription("设置").assertIsDisplayed()、onNodeWithText("保存").assertHasClickAction()、onNodeWithText("保存").assertHeightIsAtLeast(48.dp) —— 把这三条断言挂在关键组件上,比每次手点快。完整写法见测试。状态与副作用的坑见状态与副作用,命中区顺序规则见布局与 Modifier。