对应Demo: I18nDemo | 难度: 中级 | 关键词: i18n, intl, $r资源引用, 多语言适配

去年我们团队做过一个出海项目,App要同时支持中文、英文、阿拉伯文。中文和英文还好说,阿拉伯文一上来直接懵了——文字从右到左排列,布局要镜像翻转,数字的千位分隔符用逗号还是点号每个国家不一样,日期格式更是五花八门。当时用了各种硬编码if-else判断语言,代码里到处是locale等于ar就rtl否则ltr这种补丁,维护到后来没人敢碰。后来重构时系统学习了HarmonyOS NEXT的国际化体系,发现官方已经提供了i18n、intl、$r三个层面的解决方案,只是我之前没用对。今天就把这三个工具的能力边界和使用方法讲清楚,帮后来者少走弯路。

国际化这个词听起来很大,很多开发者觉得"就是把文字翻译成英文嘛"。实际上文字翻译只是国际化的冰山一角——水面下还有数字格式差异(千位分隔符、小数点、货币符号位置)、日期格式差异(年月日顺序、十二小时制还是二十四小时制)、复数规则差异(中文没有复数变化、英语有两种、阿拉伯语有六种)、排版方向差异(阿拉伯语从右到左)。如果你只做了文字翻译就声称"支持国际化",那用户切换到阿拉伯语环境后看到的App一定是千疮百孔的。真正的国际化是从系统层面理解不同语言环境的使用习惯,然后用合适的工具逐一适配。HarmonyOS提供的i18n加intl加$r三件套就是干这个的。


请添加图片描述

一、国际化三层架构:i18n管系统信息,intl管格式化,$r管静态文本

HarmonyOS的国际化方案分三个层次,各自解决不同的问题。很多人搞混这三者的职责,要么用$r硬搞一切,要么用i18n做格式化,结果都是事倍功半。

i18n模块(来自@kit.LocalizationKit)提供系统语言环境信息的读取能力。它能告诉你当前系统的语言、地区、日历类型、数字方向等信息。典型API:i18n.System.getSystemLocale()、i18n.System.getSystemLanguage()、i18n.System.getSystemRegion()。i18n是"知道用户在哪"的工具——你不知道用户用什么语言、在哪个国家,后续的格式化和切换都无从谈起。

intl模块(ECMAScript 402标准的HarmonyOS实现)提供格式化能力。它能把数字、日期、排序等按照特定地区的习惯格式化输出。典型API:intl.NumberFormat、intl.DateTimeFormat、intl.Collator。intl是"按照用户的习惯输出"的工具——你拿到了用户语言,接下来要把数字和日期按照这个语言的习惯展示出来,不能在中国用户面前显示3/15/2024这种美式日期格式。

r资源引用机制提供静态文本的多语言切换能力。在resources目录下按语言建子目录(en、zh、ar等),r资源引用机制提供静态文本的多语言切换能力。在resources目录下按语言建子目录(en、zh、ar等),r资源引用机制提供静态文本的多语言切换能力。在resources目录下按语言建子目录(enzhar等),r(‘app.string.xxx’)会根据系统语言自动选择对应目录下的字符串。r是"自动切换到用户的语言"的工具——UI上的文字标签、按钮文案、提示信息,用r是"自动切换到用户的语言"的工具——UI上的文字标签、按钮文案、提示信息,用r"自动切换到用户的语言"的工具——UI上的文字标签、按钮文案、提示信息,用r引用就不需要写任何语言判断逻辑。

三者的关系可以这样理解:i18n是"知道用户在哪",intl是"按照用户的习惯输出",r是"自动切换到用户的语言"。一个完整的国际化方案需要三者配合使用,缺一不可。只用r是"自动切换到用户的语言"。一个完整的国际化方案需要三者配合使用,缺一不可。只用r"自动切换到用户的语言"。一个完整的国际化方案需要三者配合使用,缺一不可。只用r不做格式化,数字日期的展示会出错;只用intl不切换文本,界面文字还是中文的;只用i18n读取信息但不应用,等于知道问题但不解决。


二、i18n模块:读取系统语言环境

先看i18n模块。这是国际化的第一步——搞清楚当前用户的语言和地区设置。

import { i18n } from '@kit.LocalizationKit'

@Entry
@Component
struct I18nDemo {
  @State systemLocale: string = ''
  @State systemLanguage: string = ''
  @State systemRegion: string = ''
  @State displayLanguage: string = ''
  @State displayCountry: string = ''

  aboutToAppear(): void {
    this.systemLocale = i18n.System.getSystemLocale()
    this.systemLanguage = i18n.System.getSystemLanguage()
    this.systemRegion = i18n.System.getSystemRegion()

    let locale: string = this.systemLocale
    this.displayLanguage = i18n.System.getDisplayLanguage(locale, locale, true)
    this.displayCountry = i18n.System.getDisplayCountry(locale, locale, true)
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('系统语言环境信息')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)

        this.InfoRow('系统Locale', this.systemLocale)
        this.InfoRow('系统语言', this.systemLanguage)
        this.InfoRow('系统地区', this.systemRegion)
        this.InfoRow('语言显示名', this.displayLanguage)
        this.InfoRow('地区显示名', this.displayCountry)

        Divider()

        Text('格式化演示')
          .fontSize(20)
          .fontWeight(FontWeight.Medium)

        this.FormatDemoSection()
      }
      .width('100%')
      .padding(20)
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  InfoRow(label: string, value: string) {
    Row() {
      Text(label + ':')
        .fontSize(15)
        .fontColor('#666666')
        .width(120)
      Text(value)
        .fontSize(15)
        .fontColor('#333333')
        .layoutWeight(1)
    }
    .width('100%')
  }
}

i18n.System.getSystemLocale()返回系统设置的完整Locale标识,比如zh-Hans-CN(中文-简体-中国大陆)、en-US(英文-美国)、ar-SA(阿拉伯文-沙特阿拉伯)。这个标识由三部分组成:语言代码(zh)、脚本代码(Hans表示简体)、国家代码(CN)。脚本代码有时候会省略——比如en-US没有脚本代码,因为英文不区分简繁体。

i18n.System.getSystemLanguage()只返回语言部分,比如zh-Hans、en、ar。注意这里可能包含脚本代码(zh-Hans),也可能不包含(en),取决于语言本身是否需要区分。

i18n.System.getSystemRegion()只返回地区部分,比如CN、US、SA。这是判断用户所在国家最可靠的方式——比从Locale字符串里解析更准确,因为用户可能把语言设成英文但地区设成中国,Locale显示en-US但getSystemRegion()返回CN。

getDisplayLanguage和getDisplayCountry是两个实用方法——它们能把语言代码和地区代码转换成用户可读的名称。比如getDisplayLanguage(‘en’, ‘zh-Hans-CN’, true)返回"英语",而不是冷冰冰的"en"。第三个参数sentenceCase表示是否首字母大写。这两个方法在设置页面的语言选择器里特别有用——显示"英语(美国)"比显示"en-US"友好得多。

注意:i18n的import路径必须是@kit.LocalizationKit,不是旧的@kit.InternationalizationKit。后者已经废弃,用旧路径编译会报模块找不到的错误。这个坑我在开发过程中踩过,浪费了半小时排查,希望你不要重蹈覆辙。


三、intl模块:数字、日期、复数的格式化

3.1 intl.NumberFormat:数字格式化

不同地区对数字的书写习惯差异大到超出你的想象。一千二百三十四点五六这个数字,不同地区的写法:

  • 中文环境:1,234.56(逗号作千位分隔,句号作小数点)
  • 德语环境:1.234,56(句号作千位分隔,逗号作小数点——和中文完全相反)
  • 法语环境:1 234,56(空格作千位分隔,逗号作小数点)
  • 阿拉伯语环境:١٬٢٣٤٫٥٦(使用阿拉伯-印度数字,逗号作千位分隔,中间点作小数点)

手动处理这些差异是噩梦——你得为每种语言写一套格式化规则,维护成本爆炸。intl.NumberFormat一行搞定:

@Builder
FormatDemoSection() {
  Column({ space: 12 }) {
    Text('数字格式化')
      .fontSize(16)
      .fontWeight(FontWeight.Medium)

    let locales: string[] = ['zh-Hans-CN', 'en-US', 'de-DE', 'ar-SA']
    let amount: number = 1234567.89

    ForEach(locales, (locale: string) => {
      Row() {
        Text(locale)
          .fontSize(13)
          .fontColor('#666666')
          .width(120)
        Text(new intl.NumberFormat(locale, { style: 'currency', currency: 'CNY' }).format(amount))
          .fontSize(15)
          .fontColor('#333333')
          .layoutWeight(1)
      }
      .width('100%')
    })

    Divider()

    Text('日期格式化')
      .fontSize(16)
      .fontWeight(FontWeight.Medium)

    let now: Date = new Date()
    ForEach(locales, (locale: string) => {
      Row() {
        Text(locale)
          .fontSize(13)
          .fontColor('#666666')
          .width(120)
        Text(new intl.DateTimeFormat(locale, { dateStyle: 'long', timeStyle: 'short' }).format(now))
          .fontSize(15)
          .fontColor('#333333')
          .layoutWeight(1)
      }
      .width('100%')
    })
  }
}

NumberFormat的style参数决定格式类型:decimal(普通数字,默认值)、currency(货币,需要额外指定currency参数如CNY、USD、EUR)、percent(百分比,数字乘以100后加%号)、unit(带单位的数字,比如"5 liter")。currency风格是最常用的国际化数字格式——同一个金额,不同地区显示的货币符号、位置、小数位数都不一样。

3.2 intl.DateTimeFormat:日期格式化

日期格式化的差异同样惊人,甚至比数字格式化更复杂:

  • 中文环境:2024年3月15日 下午3:30
  • 美式英文:March 15, 2024, 3:30 PM(月日在年前,12小时制)
  • 英式英文:15 March 2024, 15:30(日在月年前,24小时制)
  • 德语环境:15. März 2024, 15:30(日用句号分隔)
  • 阿拉伯语环境:١٥ مارس ٢٠٢٤، ٣:٣٠ م(从右到左排列,阿拉伯-印度数字)

DateTimeFormat支持dateStyle和timeStyle两个参数,每个参数可选full/long/medium/short四个级别,控制显示的详细程度。full级别会显示星期和完整时区,short级别只显示数字。也可以完全自定义各个部分的格式:

let formatter: intl.DateTimeFormat = new intl.DateTimeFormat('zh-Hans-CN', {
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
  hour: '2-digit',
  minute: '2-digit',
  hour12: false
})
let formatted: string = formatter.format(new Date())

自定义格式的好处是你能精确控制输出内容,坏处是不同地区的同一套自定义参数可能产生不同效果——比如hour12设为false在中文环境是24小时制,但在某些locale下可能被忽略。如果只需要"标准格式",用dateStyle/timeStyle更安全;如果需要精确控制格式(比如后端API要求固定格式),用自定义参数。

3.3 复数规则:PluralRules

国际化中有一个容易被忽略的问题:复数。中文没有复数变化,"1条消息"和"5条消息"用同一个"条"字,开发时完全不需要考虑复数。但英语有:1 message vs 5 messages。俄语更复杂,有单数、双数、复数三种形式。阿拉伯语有六种复数形式!如果你只按中文的思维开发,出海到这些语言环境就会出现语法错误。

intl.PluralRules帮你判断应该用哪种复数形式:

let pr: intl.PluralRules = new intl.PluralRules('en')
pr.select(1)
pr.select(5)

let prRu: intl.PluralRules = new intl.PluralRules('ru')
prRu.select(1)
prRu.select(2)
prRu.select(5)

select方法返回的值是字符串常量:zero、one、two、few、many、other。英语只有one和other两种,俄语有one、few、many和其他,阿拉伯语有zero、one、two、few、many和其他。你可以根据返回值选择对应的字符串模板或者拼接规则。

不过HarmonyOS的$r资源系统本身不支持复数选择(不像Android的plurals标签),所以复数逻辑需要代码层面处理。这确实增加了开发量,但换来的是完全可控的复数规则实现。


四、$r资源引用:静态文本的多语言切换

r是ArkUI的资源引用语法,支持string、color、float、media、profile等资源类型。对于国际化,最常用的是r是ArkUI的资源引用语法,支持string、color、float、media、profile等资源类型。对于国际化,最常用的是rArkUI的资源引用语法,支持stringcolorfloatmediaprofile等资源类型。对于国际化,最常用的是r(‘app.string.xxx’)引用字符串资源。

资源文件的目录结构是理解$r多语言切换机制的关键:

resources/
  base/
    element/
      string.json          // 默认语言(通常是英文或中文)
  en/
    element/
      string.json          // 英文
  zh/
    element/
      string.json          // 中文
  ar/
    element/
      string.json          // 阿拉伯文

base目录是兜底——如果当前系统语言在en、zh、ar里都找不到匹配的资源,就用base里的。所以base必须包含所有字符串的默认值,否则$r会找不到资源而报错。语言目录的命名遵循BCP 47规范:两位字母语言代码(zh、en、ar)或者语言加地区代码(zh-Hans、en-US)。

string.json的格式是name-value对数组:

{
  "string": [
    { "name": "app_name", "value": "MyDemo" },
    { "name": "greeting", "value": "Hello" },
    { "name": "settings", "value": "Settings" }
  ]
}

en/element/string.json:

{
  "string": [
    { "name": "app_name", "value": "MyDemo" },
    { "name": "greeting", "value": "Hello" },
    { "name": "settings", "value": "Settings" }
  ]
}

zh/element/string.json:

{
  "string": [
    { "name": "app_name", "value": "我的Demo" },
    { "name": "greeting", "value": "你好" },
    { "name": "settings", "value": "设置" }
  ]
}

使用时只需要一行:

Text($r('app.string.greeting'))

系统会根据当前语言环境自动选择对应目录下的string.json。你不需要写任何语言判断逻辑,r帮你全管了。当系统语言是中文时,r帮你全管了。当系统语言是中文时,r帮你全管了。当系统语言是中文时,r(‘app.string.greeting’)返回"你好";当系统语言是英文时,返回"Hello"。这是最简洁、最标准的国际化方案。


五、$r返回的是Resource不是string

这是r最容易让人困惑的一点,也是国际化开发中最常见的翻车场景:r最容易让人困惑的一点,也是国际化开发中最常见的翻车场景:r最容易让人困惑的一点,也是国际化开发中最常见的翻车场景:r(‘app.string.xxx’)的返回类型是Resource,不是string。

let greeting: Resource = $r('app.string.greeting')

这意味着你不能对$r的返回值做字符串操作——不能拼接、不能substring、不能取length。下面这些写法都会编译报错或者运行时异常:

// 错误:Resource不能赋值给string类型
let text: string = $r('app.string.greeting')
// 错误:Resource不能拼接
let full: string = $r('app.string.greeting') + ' World'
// 错误:Resource没有length属性
let len: number = $r('app.string.greeting').length

正确用法是直接把r传给支持Resource类型的组件属性。Text、Button等组件的content参数和fontColor、fontSize等属性都支持Resource类型,r传给支持Resource类型的组件属性。Text、Button等组件的content参数和fontColor、fontSize等属性都支持Resource类型,r传给支持Resource类型的组件属性。TextButton等组件的content参数和fontColorfontSize等属性都支持Resource类型,r返回值可以直接传入:

Text($r('app.string.greeting'))
  .fontColor($r('app.color.primary'))
  .fontSize($r('app.float.title_size'))

但如果你需要在代码逻辑里操作国际化字符串(比如拼接动态内容),$r就不够用了。这时候需要另一种方案。

方案A:context.resourceManager

如果你需要在TypeScript代码里获取字符串的实际内容,用resourceManager:

import { resourceManager } from '@kit.LocalizationKit'

let resMgr: resourceManager.ResourceManager = getContext(this).resourceManager
let greeting: string = resMgr.getStringSync($r('app.string.greeting').id)

getStringSync通过资源ID获取对应语言的字符串,返回值是string类型,可以随意操作。注意要用r().id而不是r().id而不是r().id而不是r()本身,因为getStringSync需要的是数字类型的资源ID。这种方式的好处是仍然走$r的资源选择逻辑,语言切换自动生效;缺点是多了一步间接调用。

方案B:翻译Map

对于需要动态拼接的场景,可以建一个翻译Map:

class I18nHelper {
  private translations: Record<string, Record<string, string>> = {
    'zh': {
      'welcome': '欢迎',
      'unread': '条未读消息',
      'confirm_delete': '确认删除'
    },
    'en': {
      'welcome': 'Welcome',
      'unread': ' unread messages',
      'confirm_delete': 'Confirm Delete'
    }
  }

  getText(key: string, locale: string): string {
    let lang: string = locale.split('-')[0]
    let dict: Record<string, string> = this.translations[lang] || this.translations['en']
    return dict[key] || key
  }

  formatUnread(count: number, locale: string): string {
    let lang: string = locale.split('-')[0]
    if (lang === 'zh') {
      return String(count) + this.getText('unread', locale)
    }
    let pr: intl.PluralRules = new intl.PluralRules(locale)
    let plural: string = pr.select(count)
    let suffix: string = plural === 'one' ? ' unread message' : ' unread messages'
    return String(count) + suffix
  }
}

翻译Map的优点是完全可控,可以动态加载、支持插值、支持复数。缺点是需要自己维护翻译数据,容易和resources目录下的string.json产生重复和不同步——改了一个忘了改另一个,线上就出现中英文混搭的尴尬场面。


六、$r vs intl API vs 翻译Map:选哪个

对比维度 $r资源引用 intl格式化API 翻译Map
适用场景 静态UI文本 数字/日期/排序格式化 动态拼接文本
返回类型 Resource string string
可拼接
运行时切换 需重启生效 立即生效 立即生效
维护成本 低(资源文件管理) 低(系统内置规则) 高(手动维护Map)
复数支持 PluralRules 手动实现
编译期检查 有(资源名不存在会报错)

结论:三者不互斥,而是互补。UI标签用r(静态文本零代码切换),数字日期用intl(格式化规则不用手写),动态拼接用翻译Map或者resourceManager(拿到string后自由操作)。不要试图用一个方案解决所有问题——r(静态文本零代码切换),数字日期用intl(格式化规则不用手写),动态拼接用翻译Map或者resourceManager(拿到string后自由操作)。不要试图用一个方案解决所有问题——r(静态文本零代码切换),数字日期用intl(格式化规则不用手写),动态拼接用翻译Map或者resourceManager(拿到string后自由操作)。不要试图用一个方案解决所有问题——r不是万能的,intl也不是,翻译Map更不是。正确的姿势是三者在各自擅长的领域发挥作用,组合起来形成完整的国际化方案。


七、RTL布局适配:阿拉伯语的从右到左

阿拉伯语和希伯来语的书写方向是从右到左(RTL)。这不仅是文字排列方向的变化,整个UI布局都需要镜像翻转。这不是简单的"文字从右往左写",而是整个视觉流的方向反转:

  • 左对齐变成右对齐
  • 前进箭头从向右变成向左
  • 列表图标从左侧移到右侧
  • 进度条从右向左填充
  • 返回按钮从左上角移到右上角

ArkUI的Flex和Row组件支持direction属性,可以设置为FlexDirection.Rtl。但逐个组件设direction太繁琐,更好的做法是用Direction.Rtl属性控制整个容器的方向:

@Component
struct DirectionAwareContainer {
  @Prop content: string = ''
  private locale: string = i18n.System.getSystemLocale()

  build() {
    Row({ space: 12 }) {
      SymbolGlyph($r('sys.symbol.ohos_folder_badge_plus'))
        .fontSize(20)
        .fontColor([Color.Gray])
      Text(this.content)
        .fontSize(15)
        .layoutWeight(1)
    }
    .width('100%')
    .direction(this.locale.startsWith('ar') ? Direction.Rtl : Direction.Ltr)
  }
}

Direction.Rtl会让Row的子组件从右到左排列,不需要手动调整顺序。Text组件也会自动切换为RTL排列。但有些细节Direction解决不了——比如图标本身的方向(箭头图标需要水平翻转),比如进度条的填充方向,比如边框和阴影的方向性。这些还是需要代码处理。

RTL适配的最大坑是margin和padding的方向。在LTR布局下,margin({ left: 16 })表示左间距16。切换到RTL后,如果你用left和right这样的绝对方向属性,间距不会自动镜像——左间距还是左间距,但视觉上"左"在RTL布局里应该对应"右"。ArkUI目前的margin和padding属性不支持start和end这样的逻辑方向语义(不像Android的marginStart/marginEnd),只能用逻辑判断手动交换left和right。这是目前RTL适配中最繁琐的部分,期待后续版本改进。


八、踩坑实录:国际化开发中的暗坑

坑一:r返回Resource不能当string用。这个前面重点说了,但还是要再提,因为它太容易犯了。最典型的翻车场景是在网络请求的参数里用r返回Resource不能当string用。这个前面重点说了,但还是要再提,因为它太容易犯了。最典型的翻车场景是在网络请求的参数里用r返回Resource不能当string用。这个前面重点说了,但还是要再提,因为它太容易犯了。最典型的翻车场景是在网络请求的参数里用r——你以为传了个字符串,实际上传了个Resource对象,序列化后变成[object Object],服务端解析直接报错:

// 错误
let params: Record<string, Resource> = { 'name': $r('app.string.user_name') }
// 正确
let resMgr: resourceManager.ResourceManager = getContext(this).resourceManager
let name: string = resMgr.getStringSync($r('app.string.user_name').id)
let params: Record<string, string> = { 'name': name }

坑二:RTL布局下margin和padding的方向混淆。前面说了,ArkUI的margin和padding不支持start/end逻辑方向。在RTL布局下,你需要手动判断方向并交换left和right:

let isRtl: boolean = i18n.System.getSystemLocale().startsWith('ar')
let marginValue: Margin = isRtl ? { right: 16 } : { left: 16 }

这种判断代码散落在各个组件里,维护成本很高。建议封装一个工具函数统一处理方向相关的间距。

坑三:复数规则的地区差异。中文没有复数变化,开发者容易忽略其他语言的复数需求。当你的App只有中文版时没问题,一旦出海到英语、俄语、阿拉伯语地区,"1 items"这种语法错误会让用户觉得很不专业,甚至怀疑App的质量。从第一天起就用PluralRules处理复数,即使中文版暂时用不到——将来出海时你会感谢自己的先见之明。

坑四:日期格式的时间戳精度。intl.DateTimeFormat.format()接受Date对象。但如果你从服务端拿到的是时间戳(毫秒数),需要先new Date(timestamp)转换。注意JavaScript的Date构造函数接受的是毫秒级时间戳,如果你的后端返回的是秒级时间戳(Unix timestamp,这是很多后端框架的默认行为),需要乘以1000:

let serverTimestamp: number = 1710460800
let date: Date = new Date(serverTimestamp * 1000)
let formatted: string = new intl.DateTimeFormat('zh-Hans-CN').format(date)

忘乘1000是最常见的日期格式化Bug——日期会显示在1970年,因为毫秒级时间戳1710460800只相当于1970年1月20日。

坑五:资源文件不生效的排查。$r引用字符串但不显示,可能的原因有:语言目录名拼写错误(比如把zh-Hans写成zh,或者en-US写成en_US——下划线是错的,必须用短横线)、string.json的JSON格式有语法错误(多一个逗号少一个引号)、资源name有重复定义(同一文件里两个string的name相同,后者覆盖前者)。排查时先检查resources目录结构是否正确,再检查JSON文件的合法性——可以用JSON校验工具在线检查。

坑六:运行时切换语言需要重启应用。r的资源选择是在应用启动时根据系统Locale确定的,运行期间如果用户在系统设置里切换了语言,已运行的App不会自动更新r的资源选择是在应用启动时根据系统Locale确定的,运行期间如果用户在系统设置里切换了语言,已运行的App不会自动更新r的资源选择是在应用启动时根据系统Locale确定的,运行期间如果用户在系统设置里切换了语言,已运行的App不会自动更新r的引用——需要重启App才能生效。HarmonyOS目前没有提供应用内语言切换的官方API(不像Android的LocaleList)。如果你需要应用内切换语言(不重启),只能用翻译Map方案,手动维护当前语言和对应的字符串。

坑七:货币格式化的位数差异。intl.NumberFormat的currency风格在不同地区显示的小数位数不一样。日元没有小数(1日元就是1,不存在0.5日元),所以NumberFormat(‘ja-JP’, { style: ‘currency’, currency: ‘JPY’ })格式化后的结果没有小数部分。而人民币和美元默认显示两位小数。如果你在表格里对齐金额列,不同货币的小数位数差异会导致对不齐。解决办法是在NumberFormat的options里显式指定minimumFractionDigits和maximumFractionDigits,统一小数位数。

坑八:阿拉伯数字和阿拉伯-印度数字的混淆。阿拉伯语环境下,数字默认使用阿拉伯-印度数字(٠١٢٣٤٥٦٧٨٩),不是我们熟悉的0123456789。这对用户来说没问题,因为他们习惯了。但如果你的应用需要把格式化后的数字传给后端API,后端可能不认识阿拉伯-印度数字,解析报错。解决办法是在传给后端时用NumberFormat(‘en-US’)重新格式化,确保是西方阿拉伯数字。


九、实战Demo整合:i18n信息加格式化加方向适配

把上面所有知识点整合到一个完整的Demo里:

import { i18n } from '@kit.LocalizationKit'
import { intl } from '@kit.ArkUI'
import { resourceManager } from '@kit.LocalizationKit'
import { common } from '@kit.AbilityKit'

@Entry
@Component
struct I18nDemo {
  @State locale: string = ''
  @State language: string = ''
  @State region: string = ''
  @State numberDemo: string = ''
  @State dateDemo: string = ''
  @State pluralDemo: string = ''
  @State isRtl: boolean = false
  private resMgr: resourceManager.ResourceManager = getContext(this).resourceManager

  aboutToAppear(): void {
    this.locale = i18n.System.getSystemLocale()
    this.language = i18n.System.getSystemLanguage()
    this.region = i18n.System.getSystemRegion()
    this.isRtl = this.locale.startsWith('ar')

    let amount: number = 9876543.21
    this.numberDemo = new intl.NumberFormat(this.locale, { style: 'currency', currency: 'CNY' }).format(amount)

    let now: Date = new Date()
    this.dateDemo = new intl.DateTimeFormat(this.locale, { dateStyle: 'long', timeStyle: 'medium' }).format(now)

    let pr: intl.PluralRules = new intl.PluralRules(this.locale)
    this.pluralDemo = 'count=1: ' + pr.select(1) + ', count=5: ' + pr.select(5) + ', count=0: ' + pr.select(0)
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text($r('app.string.app_name'))
          .fontSize(24)
          .fontWeight(FontWeight.Bold)

        Text('Locale: ' + this.locale)
          .fontSize(15)
        Text('Language: ' + this.language)
          .fontSize(15)
        Text('Region: ' + this.region)
          .fontSize(15)
        Text('Direction: ' + (this.isRtl ? 'RTL' : 'LTR'))
          .fontSize(15)

        Divider()

        Text('Currency Format:')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
        Text(this.numberDemo)
          .fontSize(15)

        Text('Date Format:')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
        Text(this.dateDemo)
          .fontSize(15)

        Text('Plural Rules:')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
        Text(this.pluralDemo)
          .fontSize(15)
      }
      .width('100%')
      .padding(20)
    }
    .width('100%')
    .height('100%')
  }
}

这个Demo展示了:i18n读取系统信息、intl格式化数字和日期、intl.PluralRules判断复数、isRtl检测RTL方向、$r引用静态文本。一个页面覆盖了国际化的核心能力。你可以把这个Demo作为国际化开发的起点——在你的项目里创建类似的页面,逐步加入业务相关的国际化逻辑。


请添加图片描述

十、国际化测试策略

开发完国际化功能只是完成了一半,测试是另一半。国际化测试有几个特殊要点需要关注:

伪语言测试。Google和Apple都提供了伪语言(Pseudo Language)用于测试国际化覆盖度——伪语言会在所有字符串前后加标记字符(比如[xxx]),如果界面上有没被包裹的字符串,就说明这个字符串没有走国际化流程。HarmonyOS目前没有内置伪语言支持,但你可以自己模拟——在base/string.json里给所有value加上方括号前缀,然后检查界面上哪些文字没有方括号,那些就是硬编码的字符串。

超长文本测试。德语的复合词可以非常长,比如"Donaudampfschifffahrtsgesellschaft"这种连写词。如果你的UI布局没有做文本自适应(maxLines、textOverflow、自适应宽度),超长的翻译文本可能撑爆布局。建议在测试时用极端长度的字符串验证每个Text组件的布局弹性。

RTL镜像测试。如果你支持阿拉伯语,需要在真机上切换到阿拉伯语环境,逐一检查每个页面的布局是否正确镜像。重点检查:文字是否从右到左排列、图标和文字的相对位置是否翻转、返回按钮的方向是否正确、对话框的按钮顺序是否镜像(LTR下"取消"在左"确认"在右,RTL下应该反过来)。

截图对比测试。对于支持多语言的应用,可以为每种语言录制一套UI截图作为基准。后续版本更新时,自动截图对比,发现布局偏移或文字截断问题。这属于视觉回归测试的范畴,可以用自动化工具实现。


写在最后

国际化的本质不是"翻译文字",而是"适配差异"——数字格式的差异、日期顺序的差异、复数规则的差异、排版方向的差异。i18n告诉你差异在哪,intl帮你处理差异,r帮你切换差异。三件套各司其职,别指望一个r帮你切换差异。三件套各司其职,别指望一个r帮你切换差异。三件套各司其职,别指望一个r搞定一切,也别用翻译Map替代intl的格式化能力。出海的App,国际化不是可选项,是基本功。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐