外观
无障碍与可用性
无障碍不是「给少数人做的附加题」,而是界面对外暴露的信息结构。读屏用户听到的、大字号用户看到的、手指粗的用户点到的,和视力正常、系统字号默认的用户看到的,必须是同一套东西。差别只在暴露得对不对。
这一页给规则和数字,不给理念。骨架见 7 UI 设计和业务开发,用哪个组件、参数叫什么见 UI 组件库。组件库已经封好的语义和触控尺寸不要重排 —— 重复补一层只会读出「按钮 按钮」。
八节:语义 → contentDescription → 触控目标 → 遍历顺序 → 对比度 → 大字号 → 减弱动效 → TalkBack 实测,最后一节是上线自检清单。
一、Modifier.semantics:把界面翻译给读屏
Compose 只会为它认识的东西自动生成语义:Text 的文本、clickable 的点击动作、组件库里按钮的 role。你自己拼出来的东西(自绘图形、非标准交互、Box 包一个图标)在语义树里是空的或者是一堆碎文本 —— 读屏会一个 Text 一个 Text 地念,念不到「这是一个能点的东西」。
1.1 能表达的四件事
| 字段 | 类型 | 读出来是什么 | 什么时候写 |
|---|---|---|---|
role | Role | 控件类型:「按钮」「复选框」「标签页」 | 自定义可点 / 可选节点,且组件库没给 |
contentDescription | String? | 节点名称,替代子节点的文本 | 只有纯图标、无可见文字的控件(见第二节) |
stateDescription | String? | 状态后缀:「已选中」「已展开」 | 状态只靠颜色 / 图形表达,肉眼看得出来、读屏听不出来 |
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 的取舍
这一节只有三条铁律,其余全是推论。
- 有可见文字的控件,不要再用
contentDescription重复一遍。FilledButton(text = "保存")已经念出「保存」,再补一个contentDescription = "保存"只是把同一件事暴露两遍。 - 装饰性图标必须传
contentDescription = null。 不是省略,是显式传null。图标在组件库里是Icon(imageVector = …),ActionImageButton的contentDescription参数默认就是null—— 默认值就是给装饰性场景用的。 - 纯图标按钮必须有可读名称。 这是最常见的遗漏:图标看得懂,读屏只念「按钮」。
| 场景 | 怎么写 | 读出来 |
|---|---|---|
| 图标 + 文字(如「已验证 ✓」) | 图标传 null,文字就是名称 | 「已验证」 |
| 标题栏上的搜索按钮 | ActionImageButton(icon = …, onAction = …, contentDescription = "搜索") | 「搜索,按钮」 |
| 卡片角落的装饰性插画 | contentDescription = null | 跳过,不念 |
列表项右侧的 CaretRight | contentDescription = 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 |
ActionImageButton | 44dp(内部标定) | 什么都不做;它已经是标题栏里的标准图标按钮 |
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:1 | WCAG 1.4.3 |
| 大字(≥ 24sp,或 ≥ 18.66sp 粗体) | 3:1 | WCAG 1.4.3 |
| 图标 / 控件边界 / 焦点态(非文本) | 3:1 | WCAG 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 高度的地方,就是文字被切的地方。
三条规则:
- 不要给含文字的容器写死高度。 用
heightIn(min = …)或什么都不写,让内容撑。 - 不要用固定 dp 装文字。 宽度同理:给
weight(1f),不给width(120.dp)。 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 实测:开、听、认坏味道
写在代码里的语义对不对,只有真机能证明。
怎么开
- 设置 → 无障碍 → TalkBack → 开启(首次会问是否保留快捷方式)。
- 之后用无障碍快捷方式临时切换:设置 → 无障碍 → 快捷方式,勾选后长按两个音量键开关。开发期反复验的时候比进设置快。
- 命令行等价写法(仅调试机):
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 参数 |
| 只念「按钮」,没有名字 | 纯图标按钮漏了 contentDescription | ActionImageButton(…, 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。