24、无障碍与国际化:ContentDescription、语义属性、RTL布局、多语言支持
说实话,很多开发者在做App时,都会把“无障碍”和“国际化”放到最后才考虑。我以前也这么干过,总觉得“先把功能跑通再说”。直到有一次,我负责的一个海外项目上线后,用户反馈说“阿拉伯语用户根本没法用”,我才意识到——这两个东西,其实应该从一开始就融入代码里。
这一章,我们就来聊聊Jetpack Compose里怎么做无障碍和国际化。说白了,就是让你的App能被更多人用,包括视障用户、左撇子、以及不同语言文化背景的人。
一、无障碍:让App“看得见”
无障碍(Accessibility)的核心,是让屏幕阅读器(比如TalkBack)能理解你的UI。你想想看,如果一张图片没有文字描述,视障用户听到的就是“未标记的图片”,这体验能好吗?
1. ContentDescription:给元素一个“名字”
在Compose里,给可点击元素添加contentDescription是最基本的操作。我个人的习惯是,所有Image、Icon、Button都必须加描述。
// 基础用法
Icon(
imageVector = Icons.Default.Favorite,
contentDescription = "收藏按钮",
modifier = Modifier.clickable { /* 收藏逻辑 */ }
)
// 如果图片是装饰性的,可以传null
Image(
painter = painterResource(id = R.drawable.divider_line),
contentDescription = null // 屏幕阅读器会跳过
)
contentDescription设为null。否则屏幕阅读器会读出一堆无意义的内容,干扰用户。
2. 语义属性:让布局“有结构”
光有描述还不够。你想想看,一个复杂的列表,如果屏幕阅读器只能逐行读,用户很难理解整体结构。这时候就需要语义属性(Semantics)来帮忙。
Compose提供了Modifier.semantics,可以给组件添加额外的语义信息。比如,告诉屏幕阅读器“这是一个标题”、“这是一个列表项”、“这个按钮的状态是选中”。
// 标记为标题
Text(
text = "设置",
modifier = Modifier.semantics { heading() }
)
// 标记为选中状态
ToggleButton(
checked = isChecked,
onCheckedChange = { isChecked = it },
modifier = Modifier.semantics {
stateDescription = if (isChecked) "已开启" else "已关闭"
}
) {
Text("Wi-Fi")
}
stateDescription,结果TalkBack读出来的数值是“50.0”,用户根本不知道这是百分比还是绝对值。后来我加上了stateDescription = "亮度 $progress% ",问题就解决了。
3. 自定义语义:处理复杂组件
有些组件是自定义的,比如一个可拖动的滑块,或者一个手势操作的画板。这时候,你需要手动告诉屏幕阅读器“这个组件支持什么操作”。
// 自定义滑块,添加语义
Slider(
value = currentValue,
onValueChange = { currentValue = it },
modifier = Modifier.semantics {
// 告诉屏幕阅读器这是一个可调节的控件
role = Role.Slider
// 设置最小值和最大值
setProgressBarRange(0f..100f)
// 当前值
value = currentValue
}
)
嗯,这里要注意:Role枚举里有很多预设角色,比如Button、Checkbox、Switch。尽量复用这些角色,不要自己造轮子。
二、国际化:让App“说人话”
国际化(Internationalization,简称i18n)不只是翻译字符串那么简单。它还包括日期格式、数字格式、货币符号、甚至布局方向。我见过不少App,中文界面很漂亮,一切换到阿拉伯语就乱成一团。
1. 多语言字符串:用资源文件管理
在Android里,多语言字符串放在res/values/strings.xml里。不同语言对应不同的文件夹,比如values-zh(中文)、values-ja(日语)。
// res/values/strings.xml(默认英语)
<resources>
<string name="app_name">My App</string>
<string name="welcome_message">Welcome to My App</string>
</resources>
// res/values-zh/strings.xml(中文)
<resources>
<string name="app_name">我的应用</string>
<string name="welcome_message">欢迎使用我的应用</string>
</resources>
在Compose里,通过stringResource()引用:
Text(text = stringResource(id = R.string.welcome_message))
strings.xml里给每个字符串加一个translatable="false"的注释,标记哪些字符串不需要翻译(比如品牌名、技术术语)。这样翻译人员就不会误改了。
2. 格式化:日期、数字、货币
不同地区的日期格式差异很大。美国是“MM/dd/yyyy”,中国是“yyyy年MM月dd日”,日本是“yyyy年MM月dd日(平成xx年)”。直接用SimpleDateFormat硬编码?别傻了。
Android提供了DateUtils和NumberFormat,可以自动根据当前语言环境格式化。
// 日期格式化
val date = Calendar.getInstance().time
val formattedDate = DateUtils.formatDateTime(
context,
date.time,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR
)
// 数字格式化(比如显示百分比)
val percentage = 0.85
val formattedPercentage = NumberFormat.getPercentInstance().format(percentage)
// 中文环境下输出 "85%",英文环境下输出 "85%"
在Compose里,你可以用remember来缓存这些格式化器,避免重复创建。
3. RTL布局:镜像你的UI
阿拉伯语、希伯来语是从右向左书写的。如果你的App只支持从左到右(LTR),那切换到阿拉伯语时,整个布局会像照镜子一样反着来。
Compose对RTL的支持很好。你不需要手动调整每个组件的边距。只需要在Modifier里使用start和end代替left和right。
// 错误写法:硬编码left/right
Text(
text = "用户名",
modifier = Modifier.padding(start = 16.dp) // 用start代替left
)
// 正确写法:使用start/end
Row(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "标签",
modifier = Modifier.padding(end = 8.dp) // 用end代替right
)
TextField(value = "", onValueChange = {})
}
Canvas或者DrawScope,记得检查LayoutDirection。我曾经在一个自定义的进度条里,直接画了从左到右的渐变,结果RTL模式下渐变方向反了,看起来特别奇怪。
检查当前布局方向:
val layoutDirection = LocalLayoutDirection.current
if (layoutDirection == LayoutDirection.Rtl) {
// 镜像处理
}
三、知识体系与核心逻辑
下面这张图,是我自己梳理的无障碍与国际化知识体系。你可以把它当作一个检查清单,开发时对照着看。
四、实战:一个支持无障碍和国际化的设置页面
说了这么多,不如直接看代码。下面是一个简单的设置页面,包含了语言切换、RTL适配、以及无障碍语义。
@Composable
fun SettingsScreen() {
val configuration = LocalConfiguration.current
val isRtl = configuration.layoutDirection == LayoutDirection.Rtl
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.semantics { contentDescription = "设置页面" }
) {
// 标题
Text(
text = stringResource(id = R.string.settings_title),
modifier = Modifier.semantics { heading() },
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(16.dp))
// 语言选择
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { /* 打开语言选择 */ }
.semantics {
role = Role.Button
contentDescription = "选择语言,当前为中文"
}
.padding(vertical = 12.dp)
) {
Text(
text = stringResource(id = R.string.language_label),
modifier = Modifier.weight(1f)
)
Text(
text = "中文",
modifier = Modifier.padding(start = 8.dp)
)
}
// 亮度滑块(带无障碍)
var brightness by remember { mutableStateOf(0.5f) }
Text(text = stringResource(id = R.string.brightness_label))
Slider(
value = brightness,
onValueChange = { brightness = it },
modifier = Modifier.semantics {
role = Role.Slider
stateDescription = "亮度 ${(brightness * 100).toInt()}%"
setProgressBarRange(0f..1f)
}
)
// 注意:RTL下,Row的排列顺序会自动镜像
// 但如果你用了绝对定位(比如Canvas),需要手动处理
}
}
Row包裹了两个Text,一个左对齐一个右对齐。结果在阿拉伯语下,左对齐变成了右对齐,右对齐变成了左对齐,整个布局乱套了。后来我改用Arrangement.SpaceBetween,配合Modifier.weight,才解决了问题。
五、测试:别等上线才发现问题
无障碍和国际化,光靠代码写对了还不够。你得真机测试。我个人的习惯是:
- 无障碍测试: 打开TalkBack,闭着眼睛操作一遍App。如果某个按钮读不出来,或者焦点顺序不对,立刻改。
- 国际化测试: 把系统语言切换到阿拉伯语、日语、德语,看看布局有没有溢出、文字有没有被截断、日期格式对不对。
- RTL测试: 在开发者选项里,强制开启RTL布局模式。这样不用切换语言,就能看到镜像效果。
嗯,这里要注意:有些第三方库可能不支持RTL。比如一些老旧的图片加载库,或者自定义的弹窗。遇到这种情况,要么换库,要么自己写兼容代码。
好了,这一章的内容就到这里。无障碍和国际化,说白了就是“尊重用户”。你想想看,一个视障用户能通过TalkBack顺畅地使用你的App,一个阿拉伯语用户能看到镜像正确的布局,这种成就感,比单纯实现一个功能要强得多。
公众号:蓝海资料掘金营,微信deep3321