多语言
指南Episode 08

第捌話

多语言

支持复数的翻译文案,按用户习惯显示的数字和日期,从右到左的布局,以及切换语言试用。

Asuka 应用会用用户的语言显示文案,并按照这种语言的阅读方向来布局。文案存放在目录(catalog)里,每种语言一个 JSON 文件;CLI 会根据这些文件生成一个 Go 包,为每条文案生成一个带类型的函数。

JSON
{
  "unread": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}"
}
Go
ui.Text(locales.Live.Unread(count)) // "3 unread messages";"3 ungelesene Nachrichten"

显示的内容会随 count 变化,语言切换时也会跟着变化,这些都不需要你写任何代码。examples/i18n 用一个小应用演示了本页的全部内容。

快速开始

Shell
asuka i18n init          # 生成 locales/en.json 及其函数
asuka i18n add de ar     # 生成 locales/de.json 和 ar.json,附带待翻译的文案

如果应用有一个存放目录文件的 locales 目录,就不需要任何配置。如果文件放在别处,或者应用的源语言不是英语,请在 asuka.json 里说明:

JSON
{ "i18n": { "dir": "translations", "source": "ja" } }

source 是编写应用时使用的语言(默认是 en;如果只有一个目录文件,就是那一个):它的目录包含所有文案,其他语言缺失的文案都会回退到它。应用支持哪些语言,取决于目录下有哪些文件,文件以 BCP 47 语言标签命名,比如 de.json、pt-BR.json、zh-Hans.json。

asuka dev 和 asuka build 会在启动时、以及每次目录文件变化时生成这些函数(locales/catalog_gen.go),所以译文保存后立即就能看到。请把生成的文件和目录文件一起提交,这样不用 CLI 也能正常 go build 和 go test。

在代码里导入这个包。如果没有导入,你会收到提示,因为那样应用就只能显示源语言了。

目录文件

目录文件是一个由文案组成的对象,采用 ARB 格式,键就是文案的名字。源语言的目录还会在带 @ 前缀的键下为文案添加说明,供译者参考,也会显示在编辑器的悬停提示里:

JSON
{
  "@@locale": "en",
  "unread": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}",
  "@unread": {
    "description": "Above the list of messages.",
    "placeholders": { "count": { "type": "int", "example": "3" } }
  },
  "greeting": "Hello, {name}!"
}

键以字母开头,后面跟字母、数字或 _。键没有层级:相关的文案可以用相同的前缀命名,比如 inboxTitle 和 inboxEmpty,这样它们在编辑器的自动补全里会排在一起。

占位符的 type 可以是 String、int、double、num、DateTime 或 Object,分别对应 Go 类型 string、int、float64、float64、time.Time 和 any。如果没有指定类型,被 plural、selectordinal 或 number 计数的参数是 int,date 或 time 参数是 DateTime,其余参数都是 String。

文案

文案采用 ICU MessageFormat 格式,ARB 文件、Android 和 Format.JS 用的都是这种格式:

文案 显示
Hello, {name}! 在对应位置显示 name 的值
{count, plural, one {# file} other {# files}} 按文案所属语言中 count 的复数类别选择对应分支;# 表示这个数字
{count, plural, =0 {No files} one {# file} other {# files}} =0 只匹配恰好为 0 的情况,优先于复数类别
{n, plural, offset:1 =0 {Nobody} =1 {{host}} one {{host} and # other} other {{host} and # others}} 加上 offset:1 后,复数类别和 # 都按 n - 1 计算
{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} 序数类别:1st、22nd、103rd、11th
{gender, select, female {She} male {He} other {They}} replied. 选择与值同名的分支,否则用 other
{price, number}、{ratio, number, percent}、{n, number, integer} 按该语言的习惯书写数字(1,234.5;1.234,5;12,34,567)
{price, number, ::.00}、{n, number, ::group-off} 固定保留两位小数,或者不加千位分隔符
{day, date, short}、{day, time, short} 按某种样式显示日期或时间:short、medium、long 或 full
{day, date, ::yMMMd} 按 CLDR 骨架(skeleton)指定的字段显示日期,字段顺序遵循该语言的习惯(Jan 5, 2026;5. Jan. 2026)

分支可以嵌套,而且每个 plural、selectordinal 和 select 都必须有 other 分支。复数类别遵循 Unicode CLDR 中文案所属语言的规则:英语有 one 和 other,中文和日语只有 other,俄语有 one、few、many 和 other,阿拉伯语则有六种。

撇号用来转义紧随其后的花括号,在复数分支里也可以转义 #:'{' 显示为 {,两个连续的撇号显示为一个撇号。其他位置的撇号就是普通字符,所以 don't 无需转义。

在组件中使用

组件只运行一次,所以和其他 signal 一样,只有在访问器、effect 或 memo 中读取的文案才会跟随语言变化。每条文案都有三种形式:

Go
locales.Live.Title()            // 访问器,用于属性
locales.Live.Unread(count)      // ……参数同样是访问器:signal、memo 或 ui.Val(3)
locales.Unread(3)               // 字符串,使用当前显示的语言
locales.In("de").Unread(3)      // 字符串,使用另一种语言

大多数情况下用 Live 就对了。所有显示文字的属性都接受它:ui.Text、按钮的 Title,标签页、操作项、页面的标题,输入框的 Placeholder,以及视图的 Label。

Go
ui.Button(ui.ButtonProps{Title: locales.Live.Save(), Menu: []ui.Action{
	{Title: locales.Live.Duplicate(), OnPress: duplicate},
}})
ui.Tab{Title: locales.Live.Inbox(), Icon: ui.IconHome, Content: inbox}

字符串形式只要是在 effect 中读取的,也会跟随语言变化。当你需要结合 signal 的值拼出更复杂的文字时,就用这种形式:

Go
ui.Text(signal.Func(func() string {
	if n := len(items.Get()); n > 0 {
		return locales.Unread(n)
	}
	return locales.AllRead()
}))
ui.Segmented(ui.SegmentedProps{Segments: signal.Func(func() []string {
	return []string{locales.Day(), locales.Week(), locales.Month()}
})})

如果直接在组件函数体里调用文案、不在上面任何一种场景中,它就只会求值一次:

Go
ui.StaticText(locales.Title())      // 语言切换后仍保持原来的文字
ui.Val(locales.Title())             // 同上

CLI 会对这两种写法发出警告,并指出所在的文件和行号。对于只显示一次、随后就消失的内容,比如警告框或通知,直接传字符串即可:

Go
ui.Alert(ui.AlertProps{Title: locales.DeleteTitle(), Actions: []ui.Action{
	{Title: locales.Live.Delete(), Role: ui.ActionDestructive, OnPress: remove},
	{Title: locales.Live.Cancel(), Role: ui.ActionCancel},
}})

任何 goroutine 都可以获取文案:它们就是普通函数,在哪里调用都是安全的。

应用显示的语言

应用同一时间只显示一种语言,可以通过 ui.Locale() 获取。这种语言是这样选出来的:按照系统列出的用户首选语言顺序,在应用已有的语言中挑出用户最能读懂的那一种(比如读 zh-TW 的用户会看到 zh-Hant);如果一种都匹配不上,就用应用的源语言。一个只有英文的应用,对读中文的用户显示的仍是英文,这一点和 UIKit 的行为一致。

用户的首选语言来自系统:

  • iOS 和 macOS:“语言与地区”设置中的首选语言。
  • Android:系统语言。
  • Windows:用户的显示语言(“设置 > 时间和语言”)。
  • Linux:会话的 LANGUAGE,没有的话依次取 LC_ALL、LC_MESSAGES 或 LANG。
  • Web:浏览器的语言(navigator.languages)。

ui.SetLocale("de") 会立即切换到另一种语言,不需要重建任何东西:每段文字都会发送新的值,应用重新布局,屏幕上各个界面的状态都会保留。ui.SetLocale("") 则恢复跟随用户的语言。下面是一个列出应用所有语言的菜单,每种语言都用它自己的语言来显示名称:

Go
ui.List(ui.ListProps{}, ui.Val(ui.Languages()), func(t string) string { return t },
	func(tag signal.Accessor[string], _ signal.Accessor[int]) ui.Node {
		return ui.View(ui.ViewProps{OnPress: func() { ui.SetLocale(tag.Get()) }},
			ui.Text(signal.Func(func() string { return i18n.Autonym(tag.Get()) })))
	})

用户的选择会在多次启动之间保留。在系统支持为单个应用设置语言的平台上,它会保存在系统里,从而也显示在系统设置中:

  • iOS 和 macOS:保存在应用的 AppleLanguages 中,系统设置里为单个应用设置语言时写的就是它。UIKit 和 AppKit 自己的文字(比如日期选择器里的“取消”、Mac 的标准菜单)从下次启动起才会换成这种语言;你的文案则立即生效。
  • Android:Android 13 及以上使用 LocaleManager,系统设置里能看到,系统自己的文字也会立即跟着切换;更早的版本则保存在 Asuka 的偏好设置里。
  • Windows 和 Linux 没有单个应用的语言设置,因此保存在应用数据目录下的一个文件里(Windows 上是 locale,Linux 上是 asuka-locale)。
  • Web:保存在页面的 localStorage 中。

当用户在系统设置里更改了自己的语言或应用的语言时,应用会跟着切换:iOS 会由系统重新启动应用,而 Android、macOS 和 Windows 上会在运行中直接切换。Linux 上的语言来自会话,只在应用启动时读取一次;浏览器的语言则在页面加载时读取。

如果要用应用自己选定的语言来显示某些内容,请在能被追踪的地方读取它:ui.Locale() 在哪里调用都是安全的,在 effect 里调用时,语言变化会让这个 effect 重新运行。ui.PreferredLanguages() 返回用户能读懂的语言,与应用支持哪些语言无关。

选用哪条文案

对于请求的语言,会从最匹配的目录语言中取文案:zh-TW 匹配 zh-Hant,pt-AO 匹配 pt-PT,en-AU 匹配 en-GB。如果这种语言缺少某条文案,就沿着 CLDR 的父区域设置链回退(en-AU → en-001 → en;zh-Hant-HK → zh-Hant),最后回退到源语言。也就是说,缺少翻译时会显示源语言的文案,而不是什么都不显示。

数字与日期

日期、时间和数字的书写方式是一项独立的设置,即区域格式。ui.CurrentRegion() 返回这些信息:格式所用的区域设置(它可能和应用显示的语言不是同一种)、每周的第一天,以及时间采用 12 小时制还是 24 小时制,其中也包括用户在系统设置里做过的修改。系统没有给出的部分,取自 CLDR 中该地区的数据。

在 Mac 上,它们来自“语言与地区”设置;在 Windows 上是用户的区域格式;在 Linux 上是 LC_ALL、LC_TIME 或 LANG,加上 GNOME 的时钟格式;在浏览器中则是 Intl 给出的第一首选语言的格式。

文案会自动按这种方式显示其中的数字和日期。如果要自己格式化,请配合 ui.FormatLocale() 使用 i18n 包。ui.FormatLocale() 把显示语言和区域格式合二为一,就像 Apple 的 Locale.current 那样:比如以英语阅读、使用英国格式时就是 en-GB-u-fw-mon-hc-h23。和 ui.Locale 一样,在 effect 中读取时它会跟随变化:

Go
ui.Text(signal.Func(func() string {
	return i18n.FormatDate(ui.FormatLocale(), due.Get(), i18n.DateOptions{Date: i18n.MediumStyle})
}))

i18n 包的格式化结果与 ICU 和浏览器的 Intl 一致,数据来自 Unicode CLDR,覆盖其“现代”级别收录的所有区域设置:

Go
i18n.FormatNumber("de", 1234567.891, i18n.NumberOptions{})                 // 1.234.567,891
i18n.FormatNumber("hi", 1234567, i18n.NumberOptions{})                     // 12,34,567
i18n.FormatNumber("fa", 0.25, i18n.NumberOptions{Percent: true})           // ۲۵٪
i18n.FormatNumber("en", 1.5, i18n.NumberOptions{MinFractionDigits: 2})     // 1.50
i18n.FormatDate("en", t, i18n.DateOptions{Date: i18n.FullStyle})           // Monday, January 5, 2026
i18n.FormatDate("ja", t, i18n.DateOptions{Date: i18n.LongStyle, Time: i18n.ShortStyle}) // 2026年1月5日 13:07
i18n.FormatDate("ru", t, i18n.DateOptions{Skeleton: "yMMMM"})              // январь 2026 г.

Months、Weekdays 和 DayPeriods 返回月份、星期和时段的名称,Autonym 返回一种语言在该语言中的名称("Deutsch"、“简体中文”),FirstWeekday 和 HourCycle 返回某个地区每周从哪天开始、采用几小时制。日期使用公历。UTC 以外的时区一律写成偏移量(GMT+8),即使 ICU 会为其中一些时区显示名称。

从右到左

在从右到左书写的语言里,应用会自动从右侧开始布局,不需要你写任何代码:Row 从右侧开始排列,Justify 和 Align 也随之从右侧算起,文字从右边开始,平台自己的视图也会镜像翻转(导航栏及其返回手势、标签栏、push 和 pop 动画、滑块、列表的滑动操作)。横向的 Scroll 从最右端开始。布局遵循 CSS 的 direction,并与 Chrome 的结果做过比对。

需要你手动摆放的内容:

Go
ui.Style{Padding: ui.Edges{Start: 16}}          // 放在文字开始的一侧
ui.Style{Absolute: true, End: ui.Pt(8)}         // 从文字结束的一侧算起
ui.Style{Margin: ui.Edges{Left: 16}}            // 在任何语言里都在左边

Left 和 Right 与 CSS 一样,说左就是左、说右就是右。需要镜像的地方请用 Start 和 End,而几乎所有地方都应该镜像。TextStart 指文字开始的那一侧,也是默认的对齐方式。

名称里用 forward 或 backward 表示方向的图标会镜像:ui.IconForward、chevron.forward、arrow.backward。chevron.left 则不会,适合那些在任何语言里都朝左的东西(比如媒体播放器的快退按钮)。

一行文字的方向由它自身的字符决定:阿拉伯语应用里的一句英文会靠右放置,句号仍然在句子末尾。这行文字靠哪一侧由应用决定,字符的顺序则由 Unicode 双向算法决定。

各平台自己的视图会像该平台的原生应用一样镜像:

  • macOS:AppKit 的视图会从右到左排列,工具栏和标签页也从右侧开始。AppKit 只在应用启动时读取一次方向,所以如果应用的语言是从右到左的,从第一帧起就是从右到左。如果运行过程中方向发生变化,窗口的按钮和标题、纵向滚动条所在的一侧以及警告框中按钮的顺序都要到下次启动才会改变。
  • Windows:标题栏和菜单栏会镜像(窗口按钮移到左边),宿主的 header bar、标签页和控件也会从右到左排列。不过日期选择器是 Windows 自己绘制的,不会镜像。
  • Linux:GTK 的控件会跟随方向,包括 header bar、导航和弹出框(popover)。
  • Web:应用的根元素带有 dir="rtl",页面的 lang 就是应用的语言。

对于在任何语言里都按同一方向阅读的内容,比如电话号码、媒体播放器的控制按钮或代码编辑器,需要显式声明:

Go
ui.WithDirection(ui.LeftToRight, func() ui.Node { return playerControls() })

对于不会自动跟随方向的内容(比如自己绘制的图形),可以用 ui.IsRTL() 判断应用当前的方向。视图的 frame、OnLayout、OnScroll 和 ScrollTo 都使用物理坐标,从左边算起。

试用一种语言

Shell
asuka dev --platform ios --lang de .       # 本次运行以德语显示应用
asuka dev --platform ios --lang en-XA .    # [Šéļéçţ ƒîļéš one two]
asuka dev --platform ios --lang ar-XB .    # 每个单词从右到左,布局也一样
asuka dev --platform ios --dir rtl .       # 让任意应用从右到左显示,无需翻译

--lang 和 --dir 适用于 asuka dev 支持的所有平台,包括桌面端和 Web:

Shell
asuka dev --lang ar .                      # 在本机运行:macOS、Windows 或 Linux
asuka dev --platform web --dir rtl .       # 在浏览器中运行

这两个伪区域设置(pseudo locale)可以在还没有任何译文时就测试布局,和 Android 的做法一样。en-XA 会给源语言文案的字母加上变音符号,把文字拉长三分之一,并用方括号括起来:如果某段文字没有变化,说明它不在目录里;如果文字被截断,说明给它的空间太小;如果一句话里出现好几对方括号,说明它是由几个片段拼出来的,翻译时无法调整语序。ar-XB 则能暴露出哪些地方没有镜像。用 go run 运行时,设置 ASUKA_LOCALE=de 和 ASUKA_DIRECTION=rtl 有同样的效果。

检查目录文件

asuka dev、asuka build 和 asuka i18n check 都会检查目录文件。出现错误时它们会停止:

  • 文件名不是规范的语言标签,或者与其中的 @@locale 不一致;
  • 文案无法解析;
  • 译文里有源语言没有的键,或者使用了 Asuka 中不存在的 asuka.<key> 键;
  • 译文使用了源语言文案中没有的参数,或者参数的用法与其类型不符(比如用 plural 计数一个 String);
  • 两个键生成的函数同名。

警告则不会让它们停止,包括:译文缺少某些文案(会显示源语言的文案);译文漏掉了某些参数;出现了该语言没有的复数分支(比如英语里的 few),或者缺少该语言需要的复数分支(比如俄语里的 few,缺少时会用 other 代替);占位符没有在文案中使用。

系统显示的内容

asuka build 会声明目录文件中的语言(iOS 和 macOS 上是 CFBundleLocalizations 和若干 .lproj,Android 上是 locales_config)。正是这些声明,让 iOS 和 macOS 能用这些语言显示系统自己的文字,并在设置中提供应用的语言选项。Windows 和 Linux 的构建不做任何声明,因为它们没有单个应用的语言设置。目录文件中的 asuka.<name> 键用于翻译构建时写入这些地方的内容:

JSON
{
  "asuka.appName": "Notizen",
  "asuka.NSCameraUsageDescription": "Um Belege zu scannen.",
  "asuka.widget.steps.name": "Schritte",
  "asuka.widget.steps.description": "Deine Schritte heute.",
  "asuka.shortcut.newNote": "Neue Notiz"
}

Asuka 自带的文字

Asuka 自己显示的文字很少,因为大部分文字由平台提供。它们包括:警告框里唯一的那个按钮、搜索框的占位文字、桌面端的“返回”、Mac 上的“关于”“隐藏”和“退出”,以及步进器的标签。这些文字已经翻译成常用语言,你也可以在自己的目录文件里用 asuka.<key> 键修改它们或添加其他语言,比如在 de.json 中写 "asuka.ok": "Verstanden"。所有键见 internal/l10n/messages/en.json。

不使用目录文件

i18n 包也可以单独使用:NewCatalog 从任意 fs.FS 读取目录文件,ParseMessage 和 Message.Format 用于格式化文案,PluralCategory 返回一个数字在 CLDR 中的复数类别,Match 从多种语言中挑出最匹配的一种。如果应用自己负责翻译文字,需要用 ui.SetLanguages("en", "de") 告诉 Asuka 它支持哪些语言,并在 effect 中读取 ui.Locale()。