国际化与本地化:让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>
小技巧: 我习惯在默认的 strings.xml 里用中文,这样国内团队开发时一目了然。但如果你面向全球市场,建议默认用英文。

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)
注意: 阿拉伯语有6种复数形式,波兰语有3种。别想当然只写 one 和 other。

4. RTL布局:从右往左的适配

阿拉伯语、希伯来语是从右往左读的。你的UI如果写死了左对齐,那在这些地区就完全反了。

Android 从 API 17 开始支持 RTL。你需要在 AndroidManifest.xml 里声明:

<application
    android:supportsRtl="true">
    ...
</application>

然后布局里用 startend 代替 leftright

<!-- 正确 -->
<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-CNzh-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. 知识体系总览

下面这张图,是我梳理的国际化与本地化核心脉络:

国际化与本地化核心知识体系 国际化 (i18n) 字符串资源管理 参数与复数规则 RTL布局适配 区域设置 (Locale) values/ values-en/ %s %d <plurals> start/end supportsRtl Locale LocaleList 目标:一套代码,适配全球用户

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 里通过 LiveDataStateFlow 暴露给 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 都有翻译。

推荐工具: Android Studio 的 Translations Editor 可以帮你管理多语言字符串。另外,Crowdin、POEditor 这些平台适合团队协作翻译。

好了,国际化这块儿其实不难,但细节很多。记住一点:从项目一开始就把国际化考虑进去,后面会省很多事。别像我一样,等到产品要出海了才回头补课。


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