国际化与本地化:资源文件、字符串格式化、日期/数字格式化

国际化(i18n)和本地化(l10n),说白了就是让你的 App 能说「人话」——不同国家的人都能看懂的话。我早年做海外项目时,第一次接触这玩意儿,心想不就是翻译几个字符串吗?结果踩了一堆坑。嗯,今天咱们就把这事彻底聊透。

1. 资源文件:你的多语言「字典」

Kotlin/Android 的资源文件放在 res/values 目录下。默认语言用 strings.xml,其他语言加后缀,比如 values-zhvalues-ja

核心原则:所有用户可见的字符串,必须放在资源文件里。硬编码是万恶之源。

举个例子,你的默认 strings.xml

<resources>
    <string name="app_name">MyApp</string>
    <string name="welcome">Welcome, %s!</string>
    <string name="order_count">You have %d orders.</string>
</resources>

中文版 values-zh/strings.xml

<resources>
    <string name="app_name">我的应用</string>
    <string name="welcome">欢迎,%s!</string>
    <string name="order_count">您有 %d 个订单。</string>
</resources>

我在项目中遇到过最坑的事:有人把「登录」直接写死在代码里,结果日语版上线后,登录按钮还是中文。你想想看,用户看到会怎么想?

2. 字符串格式化:别把变量拼死在字符串里

很多新手喜欢这样写:

val msg = "Hello, " + userName + "!"

千万别!不同语言的语序完全不同。比如英语说「You have 3 orders」,日语说「3件の注文があります」。你硬拼的话,翻译根本没法做。

正确做法是用 getString 配合占位符:

// 在 Activity 或 Fragment 中
val msg = getString(R.string.welcome, userName)

// 在 ViewModel 或其他非 Context 类中
// 需要传入 Resources 对象
val msg = resources.getString(R.string.order_count, orderCount)

我的习惯:在资源文件中,占位符尽量用 %s%d,别用 %1$s 这种索引形式——除非你需要重复引用同一个参数。索引形式虽然灵活,但可读性差,翻译人员容易搞错。

还有一个细节:复数形式。英语有单复数之分(1 order vs 2 orders),但中文没有。Android 提供了 <plurals> 标签:

<plurals name="orders">
    <item quantity="one">%d order</item>
    <item quantity="other">%d orders</item>
</plurals>

使用时:

val text = resources.getQuantityString(R.plurals.orders, count, count)

我曾经在阿拉伯语项目里吃过亏——阿拉伯语的复数规则有 6 种形态,onetwofewmanyotherzero。嗯,当时我直接用了默认的英语规则,结果 QA 提了一堆 bug。

3. 日期/数字格式化:每个国家都有自己的「脾气」

日期格式,各国差异巨大。美国用 MM/dd/yyyy,中国用 yyyy-MM-dd,日本用 yyyy年MM月dd日。数字也是:美国写 1,234.56,德国写 1.234,56

Kotlin 里用 java.time 包(Java 8+)或者 java.text 包来处理。我个人强烈推荐 java.time,因为它更现代、线程安全。

日期格式化示例

import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale

fun formatDate(date: LocalDate, locale: Locale): String {
    // 使用用户当前 locale
    val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
        .withLocale(locale)
    return date.format(formatter)
}

// 使用
val date = LocalDate.of(2024, 3, 15)
println(formatDate(date, Locale.US))      // Mar 15, 2024
println(formatDate(date, Locale.CHINA))   // 2024年3月15日
println(formatDate(date, Locale.JAPAN))   // 2024/03/15

数字格式化示例

import java.text.NumberFormat
import java.util.Locale

fun formatNumber(number: Double, locale: Locale): String {
    val formatter = NumberFormat.getNumberInstance(locale)
    formatter.maximumFractionDigits = 2
    return formatter.format(number)
}

fun formatCurrency(amount: Double, locale: Locale): String {
    val formatter = NumberFormat.getCurrencyInstance(locale)
    return formatter.format(amount)
}

// 使用
println(formatNumber(1234.56, Locale.US))    // 1,234.56
println(formatNumber(1234.56, Locale.GERMANY)) // 1.234,56
println(formatCurrency(99.99, Locale.US))     // $99.99
println(formatCurrency(99.99, Locale.JAPAN))  // ¥100

注意:不要用 SimpleDateFormatString.format 做国际化格式化。前者线程不安全,后者依赖默认 locale,容易出问题。我见过一个线上 bug:某用户的手机 locale 是阿拉伯语,结果日期格式全乱了,因为阿拉伯语是从右往左写的。

4. 知识体系总览

下面这张图,帮你把国际化与本地化的核心脉络理清楚:

国际化与本地化核心知识体系 资源文件 res/values/strings.xml values-zh, values-ja... plurals 复数处理 字符串数组 RTL 布局适配 字符串格式化 %s, %d 占位符 getString(R.string.xx, arg) getQuantityString 避免硬编码拼接 索引占位符 %1$s 日期/数字格式化 java.time 包 DateTimeFormatter NumberFormat 货币格式化 Locale 参数传递 三者配合,才能做出真正「说人话」的多语言应用

5. 实战中的避坑指南

说了这么多理论,来点实际的。我这些年踩过的坑,总结成几条:

  • 不要用 Locale.getDefault() 做格式化——用户可能改了系统语言,但你的 App 需要保持自己的语言设置。我建议在 Application 初始化时,保存用户选择的 Locale,全局使用。
  • 字符串拼接是大忌——我曾经接手过一个项目,代码里到处都是 "您有" + count + "条消息"。后来要加日语支持,直接崩溃。因为日语里「您有3条消息」要说成「3件のメッセージがあります」,语序完全不同。
  • RTL 布局要提前规划——阿拉伯语、希伯来语是从右往左写的。如果你的布局用了 start/end 而不是 left/right,那基本不用改。但如果你用了 leftMargin 硬编码,那就等着哭吧。
  • 测试要覆盖所有语言——不要只测英语和中文。我见过一个 App,德语下日期格式显示正常,但土耳其语下月份名全乱了,因为土耳其语有特殊的字符映射。

我的小技巧:在开发阶段,可以写一个测试用例,遍历所有支持的 Locale,检查每个字符串是否都能正确格式化。这样能提前发现很多问题。

6. 总结

国际化与本地化,说白了就是「尊重用户」。尊重他们的语言、他们的日期习惯、他们的数字写法。技术上并不难,难的是细心和全局思维。

嗯,今天的内容就到这里。记住:资源文件是基础,字符串格式化是桥梁,日期/数字格式化是细节。三者缺一不可。


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