国际化与本地化:让App说用户的语言
说实话,国际化(i18n)和本地化(l10n)这事儿,很多开发者一开始都不太重视。我早期做项目时也是这样——先把功能怼出来再说,字符串全写死在代码里。结果呢?产品要出海,改得我头皮发麻。
今天我们就来聊聊,怎么在Kotlin + MVVM架构下,把国际化做得既规范又优雅。
1. 字符串资源:从硬编码到多语言
先看个反面教材:
// 千万别这么写
textView.text = "欢迎使用我们的App"
正确的做法,是把所有字符串都放到 res/values/strings.xml 里:
<!-- res/values/strings.xml -->
<resources>
<string name="app_welcome">欢迎使用我们的App</string>
<string name="login_button">登录</string>
<string name="user_greeting">你好,%s!</string>
</resources>
然后在代码里引用:
textView.text = getString(R.string.app_welcome)
要支持英文?简单。新建 res/values-en/strings.xml:
<!-- res/values-en/strings.xml -->
<resources>
<string name="app_welcome">Welcome to our App</string>
<string name="login_button">Sign In</string>
<string name="user_greeting">Hello, %s!</string>
</resources>
2. 带参数的字符串:别硬拼接
很多人喜欢这样:
val msg = "你好," + userName + "!"
嗯,这在多语言环境下会出大问题。不同语言的语序完全不同。比如日语里「你好」和「名字」的位置可能就反了。
正确做法是用 %s、%d 占位符:
<string name="user_greeting">你好,%s!</string>
textView.text = getString(R.string.user_greeting, userName)
支持复数?用 %d:
<string name="item_count">你有 %d 件商品</string>
textView.text = getString(R.string.item_count, count)
3. 复数规则:英语和中文不一样
中文里「1个苹果」「2个苹果」都是「苹果」,但英语里是「1 apple」「2 apples」。Android 提供了 <plurals> 来处理:
<!-- res/values/strings.xml -->
<plurals name="apple_count">
<item quantity="one">%d 个苹果</item>
<item quantity="other">%d 个苹果</item>
</plurals>
<!-- res/values-en/strings.xml -->
<plurals name="apple_count">
<item quantity="one">%d apple</item>
<item quantity="other">%d apples</item>
</plurals>
代码里这样用:
val count = 3
val text = resources.getQuantityString(R.plurals.apple_count, count, count)
4. RTL布局:从右往左的适配
阿拉伯语、希伯来语是从右往左读的。你的UI如果写死了左对齐,那在这些地区就完全反了。
Android 从 API 17 开始支持 RTL。你需要在 AndroidManifest.xml 里声明:
<application
android:supportsRtl="true">
...
</application>
然后布局里用 start 和 end 代替 left 和 right:
<!-- 正确 -->
<TextView
android:layout_marginStart="16dp"
android:gravity="start" />
<!-- 错误 -->
<TextView
android:layout_marginLeft="16dp"
android:gravity="left" />
我遇到过一个问题:自定义 View 里硬编码了 canvas.drawText() 的 x 坐标。结果在 RTL 下文字全跑出屏幕了。后来改成根据 layoutDirection 动态计算才解决。
5. 区域设置:不仅仅是语言
区域设置(Locale)包含语言和地区。比如 zh-CN 和 zh-TW 虽然都是中文,但用词和数字格式不同。
Android 会自动根据系统语言切换资源。但你也可以手动设置:
val locale = Locale("zh", "TW")
val config = Configuration(resources.configuration)
config.setLocale(locale)
resources.updateConfiguration(config, resources.displayMetrics)
不过要注意,从 Android 7.0 开始,推荐用 LocaleList:
val localeList = LocaleList(Locale("zh", "TW"))
config.setLocales(localeList)
6. 知识体系总览
下面这张图,是我梳理的国际化与本地化核心脉络:
7. 避坑指南
我踩过的坑,列出来给你参考:
- 字符串拼接SQL/URL:不同语言的参数位置不同,千万别在代码里拼字符串。用
String.format()或 Android 的占位符。 - 图片中的文字:如果图片里有文字,那就要为每种语言准备一套图。我建议尽量用代码绘制文字,或者用
TextView覆盖在图片上。 - 日期格式:美国是 MM/dd/yyyy,中国是 yyyy-MM-dd,日本是 yyyy年MM月dd日。用
SimpleDateFormat配合 Locale 来格式化。 - 数字格式:美国用 1,000.50,德国用 1.000,50。用
NumberFormat.getInstance(locale)来处理。
核心原则: 永远不要在代码里硬编码任何用户可见的字符串、格式、布局方向。全部交给资源文件去处理。
8. 在MVVM中实践
在 MVVM 架构下,我习惯把字符串资源放在 ViewModel 里通过 LiveData 或 StateFlow 暴露给 View:
class WelcomeViewModel(private val context: Context) : ViewModel() {
private val _welcomeText = MutableLiveData<String>()
val welcomeText: LiveData<String> = _welcomeText
fun loadWelcome(userName: String) {
val text = context.getString(R.string.user_greeting, userName)
_welcomeText.value = text
}
}
但要注意,ViewModel 里持有 Context 可能会导致内存泄漏。我建议用 AndroidViewModel 或者把资源 ID 传过去,让 View 层自己去解析。
// 更好的方式:只传资源ID
class WelcomeViewModel : ViewModel() {
private val _welcomeResId = MutableLiveData<Int>()
val welcomeResId: LiveData<Int> = _welcomeResId
fun loadWelcome() {
_welcomeResId.value = R.string.app_welcome
}
}
这样 ViewModel 就不依赖 Context 了,测试起来也方便。
9. 测试国际化
你可以在模拟器上切换语言来测试。但更高效的做法是写单元测试:
@Test
fun testWelcomeString() {
val locale = Locale("zh", "CN")
val expected = "欢迎使用我们的App"
val actual = getStringForLocale(R.string.app_welcome, locale)
assertEquals(expected, actual)
}
我曾经因为漏翻译一个字符串,导致阿拉伯语用户看到的是英文占位符。后来加了自动化检查脚本,遍历所有语言文件夹,确保每个 key 都有翻译。
好了,国际化这块儿其实不难,但细节很多。记住一点:从项目一开始就把国际化考虑进去,后面会省很多事。别像我一样,等到产品要出海了才回头补课。