先把结论放在前面:无障碍与国际化在工程上是一件事的两面——它们都是"你的默认假设只在母语、单手、正常视力、静态内容这四个前提下成立"的检验器。 无障碍检验的是能力的可及性(视觉、听觉、行动、认知),国际化检验的是内容的可迁移性(语言、区域、书写方向、格式)。把这两件事当成"合规检查项"来做会失败,当成"默认假设的边界声明"来做才成立。
这题面试里的分水岭很清楚:能背出 48dp 与 4.5:1 的人很多,能说出"为什么这两条是硬指标、哪些场景可以放宽、怎么用工具验证"的人很少。
无障碍四条硬指标与它们的边界
| 指标 | 阈值 | 适用 | 边界与例外 |
|---|---|---|---|
| 触摸目标尺寸 | ≥48dp × 48dp | 所有可点击元素 | 密集列表可降级,但需保证相邻目标间距 ≥8dp |
| 文字对比度 | 正文 ≥4.5:1,大字 ≥3:1 | 文字与背景 | 大字定义是 18sp 或 14sp 加粗 |
| 无障碍标签 | 可交互元素须有 contentDescription |
图标按钮、图形 | 装饰性图形须显式标为装饰(importantForAccessibility = no) |
| 焦点顺序 | 与视觉顺序一致 | 复杂表单、自绘控件 | 自绘控件需手写 AccessibilityNodeInfo 与遍历顺序 |
这四条要能说出"为什么"和"怎么验证":
触摸目标 48dp 不是美学要求,而是运动障碍用户的点击精度与系统手指误差的平衡点;对比度 4.5:1 来自 WCAG AA 对正文可读性的定义;焦点顺序错乱则会让读屏用户"跳着走",表单填错率极高。验证手段是三个:Android Studio 的 Layout Inspector 开启无障碍检查、Accessibility Scanner(Google Play 服务提供)、Espresso 的 AccessibilityChecks(可进 CI)。
// 自定义 View 提供无障碍语义与可点击区域扩展
class IconToggleView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : androidx.appcompat.widget.AppCompatImageView(context, attrs, defStyle) {
init {
// 让 TalkBack 朗读出状态而不只是"按钮"
ViewCompat.setAccessibilityDelegate(this, object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
super.onInitializeAccessibilityNodeInfo(host, info)
info.className = "android.widget.Switch"
info.isCheckable = true
info.isChecked = isSelected
// 扩大可点击区域而不改变视觉尺寸
info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK)
ViewCompat.setAccessibilityDelegate(host, this)
}
})
}
// 视觉上 16dp 的图标,靠 padding 把热区撑到 48dp
override fun onTouchEvent(event: MotionEvent): Boolean {
val slop = resources.displayMetrics.density * 24 // 额外 24dp 触达余量
return if (event.x < -slop || event.x > width + slop ||
event.y < -slop || event.y > height + slop) false
else super.onTouchEvent(event)
}
}
这段代码的价值在两点:① 用 AccessibilityNodeInfoCompat 表达语义(是开关还是按钮、当前状态),而不只是给个 contentDescription;② 视觉尺寸与触摸热区解耦,是列表里小图标的标准做法。
TalkBack 体验的三个隐性维度
朗读顺序。 TalkBack 按视图树的绘制顺序朗读。用 RelativeLayout 或按坐标定位把视觉顺序与 XML 顺序弄反,用户听到的顺序就是错的。 规范是视觉顺序与 XML 顺序保持一致,或者在自定义 ViewGroup 里覆写 getChildAt / dispatchDraw 保持两者一致。
焦点不丢失。 列表刷新、Dialog 关闭、屏幕旋转后,焦点应该回到合理位置。规范是列表重提交时保存与恢复焦点位置(Compose 里用 rememberLazyListState 与 LaunchedEffect 配合),否则读屏用户"每次滑动都被打回开头"。
触摸探索(Explore by Touch)。 用户在屏幕任意位置双击读取该处内容。自定义 View 绘制了内容但没提供节点时,触摸探索会"读不到东西"——这是自绘图表、Canvas 界面最常见的无障碍缺口。规范是自绘内容必须补 accessibility node provider,为每个可交互区域提供 bounds 与描述。
国际化:四类本地化工作
| 类别 | 处理内容 | 典型错误 |
|---|---|---|
| 文本 | 提取到 strings.xml、占位符、复数 |
拼接字符串导致语序错乱 |
| 布局 | RTL、文本膨胀、按语言断行 | 用 left/right 而非 start/end |
| 格式 | 日期、数字、货币、时区、姓名 | 用固定格式串 |
| 资源 | 字体、drawable、布局回退 | 只做英中双语,德/俄/阿语直接崩 |
字符串拼接是最典型的坑。 <string name="welcome">欢迎,%1$s</string> 是正确做法,因为它把语序交给各语言的翻译,而不是让代码去拼。阿拉伯语、日语、韩语的语序与中文差异极大,拼接式翻译在这些语言下会直接读不通。
<!-- 复数用 plurals,各语言规则不同 -->
<plurals name="unread_count">
<item quantity="one">%d 条未读</item>
<item quantity="other">%d 条未读</item>
</plurals>
<!-- RTL:方向相关的属性一律用 start/end -->
<Button
android:layout_marginStart="16dp"
android:layout_alignParentStart="true"
android:textDirection="locale" />
RTL 的实际改动量常被低估:除了 start/end,还涉及 drawableStart/End(而不是 left/right)、layoutDirection、supportsRtl="true"、以及自己绘制的 Canvas 逻辑(canvas.drawText 需要自己按方向偏移)。能主动提"自绘代码是最容易漏 RTL 的地方",说明真做过本地化。
文本膨胀是另一条暗线:德语与俄语文案通常是中文的 2~3 倍长度,固定高度的按钮与标签会截断。规范是布局不写死高度用 wrap_content,按钮宽度用 minWidth + wrap_content,并用最长语言做一次回归。
最常见的坑是
第一层坑:图标按钮缺 contentDescription,读屏用户完全不可用。
更隐蔽的变体是contentDescription 写成了视觉描述而不是功能描述——写成"星星图标"对读屏用户毫无信息量,应写成"收藏"。另一变体是装饰性图标也标了描述,导致 TalkBack 朗读出一堆无意义的"箭头""背景"。规范是功能性元素必须有功能描述,装饰性元素显式标记为装饰。
第二层坑:字符串硬编码在代码里,国际化时漏掉。
// 反模式:语言切换后这三处仍是中文
button.text = "提交"
toast("操作成功")
contentDescription = "返回"
// 规范:统一走资源
button.setText(R.string.action_submit)
Toast.makeText(ctx, R.string.toast_ok, Toast.LENGTH_SHORT).show()
规范是把"代码里不得出现中文字面量"做成 lint 卡点(MissingTranslation、HardcodedText 两条内置规则已能拦住大部分,团队可再补自定义规则拦住 Kotlin 里的字面量)。
第三层坑:只做了英语与中文,字体与图片在RTL 语言下全崩。
典型现场:阿语环境下界面布局镜像了,但图标方向没镜像(返回箭头该反过来);自定义字体不含阿拉伯字形,文字回退到系统字体且行高错乱;数字与日期仍是美式格式。规范是把 RTL 与长文本纳入发版前的必测项,用 values-ar/values-ru/values-de 三套做视觉回归。
这题在面试里怎么问、怎么答
问:无障碍有哪些硬性要求?你怎么验证?
答:四条硬指标——触摸目标 ≥48dp、正文对比度 ≥4.5:1(大字 ≥3:1)、可交互元素有无障碍标签、焦点顺序与视觉顺序一致。验证靠三层:开发期用 Layout Inspector 的无障碍检查与 TalkBack 实机走查、测试期用 Accessibility Scanner、CI 期用 Espresso 的 AccessibilityChecks 接入自动化流水线。能补一句"自绘控件需要自己提供 accessibility node provider",说明处理过真实缺口。
问:为什么是 48dp?能不能更小?
答:48dp 是综合了手指接触精度与运动障碍用户的操作容差得出的经验值,不是数学推导结果。密集列表(如标签筛选条)可以视觉上更小,但必须保证相邻目标之间的间距,避免"点不准"从尺寸问题变成间距问题;扩展热区的标准做法是 TouchDelegate 或在自定义 View 里扩大命中判定而不是放大视觉。
问:国际化要注意什么?举几个容易错的地方。
答:四类。① 文本要提取到资源、避免拼接(语序交给翻译)、复数用 plurals。② 布局用 start/end 而非 left/right、支持 RTL、自绘代码要自己处理方向。③ 格式日期数字货币时区走 Locale 与 DateFormat,不要用固定格式串。④ 资源字体要含目标字形、图片与 drawable 方向。再加一条最容易被忘的:长文本膨胀——德语俄语文案长度是中文的两三倍,固定高度会截断。
问:contentDescription 应该怎么写?
答:写功能与状态,不写视觉。比如收藏按钮写"收藏,当前已收藏"而不是"星星图标"。装饰性图形(背景、分隔线、纯装饰插画)标为 importantForAccessibility = no,否则 TalkBack 会朗读一堆无意义内容。再补一句:同一屏里多个同类控件的描述要能区分("第 3 条未读消息,张三"),否则读屏用户无法定位。
问:Compose 侧无障碍和 View 侧有什么不同?
答:机制上从"给 View 设属性"变成"在语义树里声明"。Compose 用 Modifier.semantics { contentDescription = "..." ; role = Role.Button } 合并语义,clearAndSetSemantics 用于显式覆盖子节点的语义(避免父子语义重复朗读),semantics(mergeDescendants = true) 用于把整块内容合成一个可聚焦节点。差别在于 View 侧语义是分散在各控件属性上的、容易漏;Compose 侧集中在语义修饰符上,但更依赖"是否挂在了正确的节点上"—— 挂错位置或漏挂修饰符同样会导致读不到。
落到项目里怎么做
一条能写进规范的红线:所有新增的可点击元素必须满足 48dp 触达与功能型 contentDescription,两条在 code review 时逐项检查;代码中不得出现中文字面量。 这一条能拦住无障碍与国际化里最高频的两类问题。
配套实践三条:① 把 AccessibilityChecks 接进 CI,让无障碍回归变成自动化失败而不是靠人工点;② 建立三套基准语言(英/德/阿)的视觉回归截图集,覆盖长文本与 RTL 两个方向;③ 无障碍走查由非开发同学做一次盲测(不看代码、只用 TalkBack 完成核心流程),这是最快暴露焦点顺序与标签问题的办法。
给正在准备面试的你
这题的答法要落到"无障碍与国际化都是对默认假设的边界检验"这条主线。推荐这条线:先给四条硬指标与各自的验证手段 → 讲 TalkBack 的三个隐性维度(朗读顺序、焦点不丢、触摸探索)→ 讲国际化的四类工作(文本/布局/格式/资源)+ 长文本膨胀 → 补三个坑 → 收在"review 卡点 + CI 自动化 + 盲测"。能被追问"为什么是 48dp"并答出"精度与容差的经验值,密集场景靠间距补",说明理解到位;能主动补出"自绘代码最容易漏 RTL"或"拼接式翻译在日语下会读不通",说明真做过本地化而不是背资料。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:WebView-性能优化:预加载与白屏治理
下一篇预告:组件与-UI-阶段通关地图:从生命周期到-Compose
有任何问题欢迎在评论区留言交流。