背景
我们的老年关怀应用需要支持简体中文、繁体中文和英文三种语言。字符串资源的国际化是常规操作——在 values/、values-zh-rTW/、values-en/ 下分别放对应的 strings.xml 就行。但当涉及到图片资源和动态拼接的设备名时,多语言适配就会出现一些不那么明显的陷阱。
本文分享了两个实际遇到的案例:引导图片的多语言切换,以及TTS语音播报中设备名的本地化问题。
案例一:引导图片的多语言适配
问题
心电检测引导页有两张说明图片 dialog_4 和 dialog_5,原本只有简体中文版本。产品要求增加繁体(dialog_4_tw/dialog_5_tw)和英文(dialog_4_en/dialog_5_en)版本,根据当前语言展示对应图片。
新增的图片资源已放到 mipmap-xxhdpi 目录,但XML布局里硬编码了简体图片:
<ImageView
android:id="@+id/iv_check_hint"
android:src="@mipmap/dialog_4" />
<ImageView
android:id="@+id/iv_check_hint2"
android:src="@mipmap/dialog_5" />
思路误区:能不能用资源限定符?
Android 支持通过资源限定符自动匹配不同语言版本的资源,比如:
mipmap-zh-rTW/dialog_4.webp // 繁体
mipmap-en/dialog_4.webp // 英文
mipmap/dialog_4.webp // 默认(简体)
系统会根据当前Locale自动选择对应目录下的资源,XML里写 @mipmap/dialog_4 就行。
但这条路走不通,原因有两个:
-
文件格式不一致:简体版是
.webp,繁体和英文版是.png,资源限定符目录要求同密度下文件名一致 -
另一个入口也需要同一套图片:引导弹窗
HealthPulseDeviceGuideDialog的代码里也引用了这些图片,两个入口需要保持一致
解决方案:代码中动态选择
在Fragment和Dialog中根据 LanguageSP().language 动态选择图片资源:
// Fragment中
private fun setupGuideImages() {
val (hint1Res, hint2Res) = when (LanguageSP().language) {
"zh_TW" -> R.mipmap.dialog_4_tw to R.mipmap.dialog_5_tw
"en" -> R.mipmap.dialog_4_en to R.mipmap.dialog_5_en
else -> R.mipmap.dialog_4 to R.mipmap.dialog_5
}
viewBinding.ivCheckHint.setImageResource(hint1Res)
viewBinding.ivCheckHint2.setImageResource(hint2Res)
}
// Dialog中
private val guideItems: List<HealthPulseDeviceGuideItem> by lazy {
val dialog4Images = when (LanguageSP().language) {
"zh_TW" -> listOf(R.mipmap.dialog_4_tw, R.mipmap.dialog_5_tw)
"en" -> listOf(R.mipmap.dialog_4_en, R.mipmap.dialog_5_en)
else -> listOf(R.mipmap.dialog_4, R.mipmap.dialog_5)
}
listOf(
HealthPulseDeviceGuideItem(
R.string.health_pulse_guide_title_4,
R.string.health_pulse_guide_content_4,
dialog4Images
)
)
}
XML保留默认值
XML中的 android:src 保留为简体中文图片作为默认值。代码运行时会覆盖;如果代码路径未执行(异常情况),至少显示简体图片,不至于空白:
<ImageView
android:id="@+id/iv_check_hint"
android:src="@mipmap/dialog_4" /> <!-- 默认值,代码会覆盖 -->
案例二:TTS设备名的本地化
问题
设备连接成功后,应用会语音播报"XX已连接",其中XX是设备名。字符串模板已经三语翻译:
<!-- values/strings.xml -->
<string name="health_pulse_connected_auto_check">%1$s已连接</string>
<!-- values-zh-rTW/strings.xml -->
<string name="health_pulse_connected_auto_check">%1$s已連接</string>
<!-- values-en/strings.xml -->
<string name="health_pulse_connected_auto_check">%1$s is connected</string>
看起来没问题?实际测试发现,英文模式下播报的是"脉搏健康监测仪 is connected"——设备名是中文,"已连接"是英文,中英混杂。
根因
传入的 deviceName 来自服务端 HealthAccessoryModel.name 字段,这是服务端返回的中文产品名,不是本地化字符串:
// 原来的逻辑
private fun getPulseIntroDeviceName(): String {
return pulseHealthDevice?.nickname?.ifBlank {
pulseHealthDevice?.name.orEmpty() // ← 服务端中文产品名
}?.ifBlank {
getString(R.string.pulse_health_monitor) // 本地化兜底
} ?: getString(R.string.pulse_health_monitor)
}
优先级是:nickname(用户自定义)→ name(服务端中文)→ 本地化兜底。
问题出在第二步:name 是服务端存储的产品名,永远是中文(如"脉搏健康监测仪")。当用户没有设置 nickname 时,就会用这个中文名,导致英文模式下出现混杂。
解决方案:跳过服务端name
private fun getPulseIntroDeviceName(): String {
// 优先用户自定义昵称(用户自己输入的语言无需本地化),否则用本地化产品名
// 跳过服务端 name 字段(中文产品名,非本地化)
return pulseHealthDevice?.nickname?.takeIf { it.isNotBlank() }
?: getString(R.string.pulse_health_monitor)
}
private fun getPortableEcgIntroDeviceName(): String {
return portableEcgDevice?.nickname?.takeIf { it.isNotBlank() }
?: getDefaultEcgDeviceName()
}
优先级改为:nickname(用户自定义)→ 本地化兜底,跳过服务端 name。
设计取舍
为什么保留 nickname?因为昵称是用户自己输入的——如果用户给设备起名叫"爷爷的监护仪",那不管app设什么语言,都应该播报这个名字。用户输入什么语言就用什么语言,不需要本地化。
为什么跳过 name?因为 name 是服务端产品名,是硬编码的中文,无法本地化。与其在客户端做"中文产品名 → 英文产品名"的映射表(维护成本高且容易漏),不如直接用已经三语翻译好的字符串资源。
语言值约定
项目中用 LanguageSP 存储当前语言,有三个值:
class LanguageSP {
var language: String by SharePreferences("language", "zh_cn") //默认中文
}
// 语言常量
const val LANGUAGE_SIMPLIFIED = "zh_cn" // 简体中文
const val LANGUAGE_TRADITIONAL = "zh_TW" // 繁体中文
const val LANGUAGE_ENGLISH = "en" // 英文
注意简体是 "zh_cn"(小写),繁体是 "zh_TW"(大小写混合)。这个不一致容易导致bug,使用时必须严格匹配。
收获与思考
1. 字符串国际化只是冰山一角
做多语言适配时,最容易想到的是字符串资源——放三个 strings.xml 就行。但实际项目中,图片、音频、TTS播报文本、服务端返回的数据都可能包含语言相关的硬编码。每一类都需要单独考虑本地化策略。
2. 资源限定符不是万能的
Android的资源限定符机制很强大,能自动根据Locale/density/屏幕尺寸选择资源。但它有局限性:
- 同一资源名在不同限定符目录下必须文件格式一致
- 动态创建的资源(如Dialog代码中构造的列表)无法享受自动匹配
- 如果图片来自不同渠道(设计切图格式不统一),限定符方案可能不适用
代码中 when(language) 的方式虽然"原始",但灵活性最高——可以处理任意格式、任意命名的资源,也方便在多个入口复用同一套逻辑。
3. 服务端数据不是本地化的终点
这个案例揭示了一个容易被忽略的问题:服务端返回的数据可能包含硬编码的中文。HealthAccessoryModel.name 看起来是个普通的字符串字段,但它的值是服务端写死的产品名。做多语言适配时,不能只看客户端代码,还要审查所有数据来源——服务端API返回的字段、本地缓存的SP数据、第三方SDK的回调数据,都可能是中文字符串的藏身之处。
4. "兜底"设计要考虑所有路径
原来的 getPulseIntroDeviceName 有三级fallback:nickname → name → 本地化字符串。看起来很健壮,但第二级(name)实际上是个"陷阱"——它永远不会为空(服务端总会返回产品名),所以第三级的本地化字符串永远不会被触发。修复后的代码去掉了这个"陷阱",让fallback路径真正有效。