24、无障碍与国际化:ContentDescription、语义属性、RTL布局、多语言支持

说实话,很多开发者在做App时,都会把“无障碍”和“国际化”放到最后才考虑。我以前也这么干过,总觉得“先把功能跑通再说”。直到有一次,我负责的一个海外项目上线后,用户反馈说“阿拉伯语用户根本没法用”,我才意识到——这两个东西,其实应该从一开始就融入代码里。

这一章,我们就来聊聊Jetpack Compose里怎么做无障碍和国际化。说白了,就是让你的App能被更多人用,包括视障用户、左撇子、以及不同语言文化背景的人。

一、无障碍:让App“看得见”

无障碍(Accessibility)的核心,是让屏幕阅读器(比如TalkBack)能理解你的UI。你想想看,如果一张图片没有文字描述,视障用户听到的就是“未标记的图片”,这体验能好吗?

1. ContentDescription:给元素一个“名字”

在Compose里,给可点击元素添加contentDescription是最基本的操作。我个人的习惯是,所有ImageIconButton都必须加描述。

// 基础用法
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枚举里有很多预设角色,比如ButtonCheckboxSwitch。尽量复用这些角色,不要自己造轮子。

二、国际化:让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提供了DateUtilsNumberFormat,可以自动根据当前语言环境格式化。

// 日期格式化
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里使用startend代替leftright

// 错误写法:硬编码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) {
    // 镜像处理
}

三、知识体系与核心逻辑

下面这张图,是我自己梳理的无障碍与国际化知识体系。你可以把它当作一个检查清单,开发时对照着看。

无障碍与国际化知识体系 无障碍 (Accessibility) 国际化 (i18n) ContentDescription 语义属性 自定义语义 角色(Role) 多语言字符串 日期/数字格式化 RTL布局 start/end替代left/right 核心原则:让所有用户都能顺畅使用你的App

四、实战:一个支持无障碍和国际化的设置页面

说了这么多,不如直接看代码。下面是一个简单的设置页面,包含了语言切换、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),需要手动处理
    }
}
避坑指南: 我曾经在RTL布局下,用Row包裹了两个Text,一个左对齐一个右对齐。结果在阿拉伯语下,左对齐变成了右对齐,右对齐变成了左对齐,整个布局乱套了。后来我改用Arrangement.SpaceBetween,配合Modifier.weight,才解决了问题。

五、测试:别等上线才发现问题

无障碍和国际化,光靠代码写对了还不够。你得真机测试。我个人的习惯是:

  • 无障碍测试: 打开TalkBack,闭着眼睛操作一遍App。如果某个按钮读不出来,或者焦点顺序不对,立刻改。
  • 国际化测试: 把系统语言切换到阿拉伯语、日语、德语,看看布局有没有溢出、文字有没有被截断、日期格式对不对。
  • RTL测试: 在开发者选项里,强制开启RTL布局模式。这样不用切换语言,就能看到镜像效果。

嗯,这里要注意:有些第三方库可能不支持RTL。比如一些老旧的图片加载库,或者自定义的弹窗。遇到这种情况,要么换库,要么自己写兼容代码。

好了,这一章的内容就到这里。无障碍和国际化,说白了就是“尊重用户”。你想想看,一个视障用户能通过TalkBack顺畅地使用你的App,一个阿拉伯语用户能看到镜像正确的布局,这种成就感,比单纯实现一个功能要强得多。

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