当我们使用一个英文 app 时,很多人第一时间会去查看是否有对应的中文版本。可见,在 app 中显示让使用者最亲切的语言文本是何等的重要。对于相当数量的 app 来说,如果能够将 UI 中显示的文本进行了本地化转换,基本上就完成了 app 的本地化工作。本文中,我们将探讨 iOS 开发中,如何实现显示文本的本地化工作。本文的 Demo 采用 SwiftUI 编写。
文本本地化的原理
作为一个程序员,如果让你考虑设计一套逻辑对原始文本针对不同语言的进行本地化转换,我想大多数人都会考虑使用字典(键值对)的解决方案。苹果也是采取了同样的处理,通过创建针对不同语言的多个字典,系统可以轻松的查找出一个原始文本(键)对应的本地化文本(值)。比如:
//en
"hello" = "Hello";
//zh
"hello" = "你好";
这套方法就是本文中主要采取的针对文本的本地化手段。
系统在编译代码的时候,将 可以进行本地化操作的文本
进行了标记,当 app 运行在不同的语言环境(比如法文)时,系统会尝试尽量从法语的文本键值对文件中查找出对应的内容进行替换,如果找不到则会按照语言偏好列表的顺序继续查找。对于某些类型比如 LocalizedStringKey
上述动作时会自动完成,但是像代码中最常使用的 String
,则需要在代码中显式完成上述动作。
幸运的是,SwiftUI 的绝大多数控件(部分目前有 Bug)对于文本类型都会优先采用使用 LocalizedStringKey
的构造方法,这极大的减轻了开发者的手工处理工作量。
添加语言
对于当代的编程语言和开发环境来说,国际化开发能力都已是必备功能。当我们在 Xcode 中创建一个项目后,缺省情况下,该 app 仅针对其对应的 Development Language 进行开发。
因此我们必须首先让项目知道,我们将对项目进行本地化的操作、并选择对应的语言。
在 Project Navigation
中,点击 PROJECT
,选择 Info
可以在 Localizations
中进行语言的添加。
点击 + 号,选择我们将要增加的语言。
在这里我们只是告诉项目,我们将可能对列表中的语言进行本地化操作。但如何本地化、对哪些文件、资源进行本地化,我们还需要对其单独设置。
启用 Use Base Internationalization,Xcode 会修改你的项目文件夹结构。xib 和 storeyboard 文件将被移动到 Base. lproj 文件夹,而字符串元素将被提取到项目区域设置文件夹。该选项针对使用 storyboard 的开发方式,如果你采用 SwiftUI 则无需关心。
对于 UIKit 框架,Xcode 会让你选择 storyboard
的关联方式,由于本文使用的 Demo 项目 为全 SwiftUI 架构,因此不会有如下的画面。
创建文本字符串文件
在苹果的开发环境中,对应我们上文中提到的 字符串文件
(文本键值对文件)的文件类型为 .strings
。我们可以在一个 app 中创建多个字符串文件,有些名字的字符串文件是有其特殊含义的。
-
Localizable. strings
UI 默认对应的字符串文件。在不特别指明字符串文件名称的情况下,app 都将从 Localizable. strings 中获取对应的本地化文本内容
-
InfoPlist. strings
对应 Info. plist 的字符串文件。通常用于 app 名称、权限警告提示等内容的本地化。
在 Project Navigation
中,我们选择新建文件
文件类型选择 Strings File
,将其命名为 Localizable. strings
此时的 Localizable.strings
文件并没有被本地化,当前你的项目中只有一个文件,在该文件中进行文本键值对的定义,仅会针对项目的 开发语言
,通过右侧的 Localize...
按钮,我们可以选择生成 Localizable.strings
对应的语言(语言列表为项目中添加语言设定的列表)文件。
将右侧的两个语言都勾选上后
左侧 Project Navigation
中的 Localizable. strins 将变成如下状态:
English
和 Chinese
目前是空文件状态,我们现在就可以在此创建对应的文本键值对了。
可以在此处下载 Demo 项目
实战 1:汉化账单表格列名
本节我们尝试为 ITEM、QUANTITY、UNIT PRICE 和 AMOUNT 提供对应的中文本地化文本。
按照上面的键值对声明规则,我们在 Localizable.Strings(Chinses)
文件中添加如下内容:
"ITEM" = "种类";
"QUANTITY" = "数量";
"UNIT PRICE" = "单价";
"AMOUNT" = "合计";
打开 TableView
,在预览中添加本地化环境配置
TableView()
.environmentObject(Order.sampleOrder)
.previewLayout(.sizeThatFits)
.environment(\.locale,Locale(identifier: "zh"))
此时我们从 Preview 的区域会看到什么变化?什么都没有变!
原因是,我们在 字符串文件
中设定的 键
是有问题的。我们在 app 呈现中看到的 ITEM
在 TableView
中对应的代码如下:
HStack{
Text("Item")
.frame(maxWidth:.infinity)
Text("Quantity")
.frame(maxWidth:.infinity)
Text("Unit Price")
.frame(maxWidth:.infinity)
Text("Amount")
.frame(maxWidth:.infinity)
}
.foregroundStyle(.primary)
.textCase(.uppercase) //转换成大写
Text
中会将 Item
用作查找的 Key,但是我们定义是 ITEM
,因此没有找到对应的值。注意:字符串文件中的 键
是 大写小敏感
的。
将 chinese
文件修改如下:
"Item" = "种类";
"Quantity" = "数量";
"Unit Price" = "单价";
"Amount" = "合计";
此时预览窗口中,我们可以看到汉化后的结果:
恭喜你,到这里你已经掌握了文本本地化的大部分内容。
不知道大家注意没有,目前的 English
文件是空的,Chinese
文件我们也只对四个内容设置了对应的本地化文本。所有我们没有设置的内容,app 都将显示我们在代码中设置的原始文本。
在字符串文件中进行定义时,很容易出现两个错误,1:错误的输入了中文标点,2: 忘记了后面的分号。
实战 2:汉化付款按钮
本节我们尝试将 Pay for 4 drinks
中的文字进行中文化。
该按钮在 ButtonGroupView
中的定义如下:
Button {
showPayResult.toggle()
} label: {
Text("Pay for \(order.totalQuantity) drinks")
}
Pay for \(order.totalQuantity) drinks
该如何在 Localizable.strings
文件中设置对应的 键
呢?
对于这种使用了字符串插值的 LocalizedString
,我们需要使用 字符串格式说明符
,苹果的 官方文档 为我们提供了详细的对照用法说明。
代码中,order.totalQuantity
对应的是 Int
(Swift 在 64 位系统上 Int
对应的为 Int64
),因此我们需要在键值对中使用 %lld
来将其进行替换。在 Chinese
文件中做如下定义:
"Pay for %lld drinks" = "为%lld 杯饮品付款";
这样我们就得到了想要的结果。当你尝试添加或减少饮料数量时,文本中的数量都会跟随变化。
请为你的插值选择正确对应的格式说明符,比如上面的例子如果设置为%d 的话将被系统认为是另一个键而无法完成转换。
实战 3:汉化 App 的程序名
在 Xcode 项目中,我们通常会在 Info.plist
文件中对一些特定的系统参数进行配置,比如说 Bundle identifier
、Bundle name
等。如果需要对其中的一些配置进行本地化处理的话,我们可以使用上文中提到的 InfoPlist.strings
使用创建 Localizable.strings
文件同样的步骤,我们创建一个名为 InfoPlist.strings
的字符串文件(不要忘记为创建好的文件进行本地化操作,确认中文、英文都已被勾选)。
分别在 InfoPlist. strings 的 Chinese
和 English
文件中加入如下内容:
//chinese
"CFBundleDisplayName" = "肥嘟嘟酒吧";
//english
"CFBundleDisplayName" = "FatbobBar";
此时,再在模拟器或者真机上安装 app,app 的名称将会在不同的语言下显示对应的文字。
在最近两个版本的 Xcode 中,可以不直接设置 Info. plist,通常在 Target 的 Info 中查看或修改值
我们需要本地化的配置无需一定要出现在 info 或 Info. plist 中,只要我们在 InfoPlist. strings 中对其进行了本地化键值对设定,app 将会优先采用该设定。通常我们会在 InfoPlist. strings 中进行本地化的除了 app 的名称
CFBundleDisplayName
外,还有CFBundleName
、CFBundleShortVersionString
、NSHumanReadableCopyright
以及各种系统权限的申请描述,比如NSAppleMusicUsageDescription
、NSCameraUsageDescription
等。更多关于 info. plist 参数的内容请查看 官方文档
实战 4:本地化饮品名称
在 Localizable(Chinese)
字符串文件中添加如下内容
"Orange Juice" = "橙汁";
"Tea" = "茶";
"Coffee" = "咖啡";
"Coke" = "快乐水";
"Sprite" = "透心凉";
关于饮料的定义请查看 Model/Drink.swift
代码
通过设置本地环境变量查看预览,或者将模拟器语言改成中文,亦或者在 Scheme 中将 App Lanuguage 改成中文。
执行 app,我们并没有获得预期的效果。饮品的名称并没有变成中文。此时通过查看 Drink.swift
我们可以找出原因:对于已经明确了的 String
类型,Text 是不会将其视作 LocalizedStringKey
的。
之前在 ItemRowView
中,我们通过如下代码显示饮品名称:
Text(item.drink.name)
.padding(.leading,20)
.frame(maxWidth:.infinity,alignment: .leading)
而饮品的名称在 Drink
中的定义如下
struct Drink:Identifiable,Hashable,Comparable{
let id = UUID()
let name:String //String 类型
let price:Double
let calories:Double
因此最简单的办法就是修改 ItemRowView
的代码
Text(LocalizedStringKey(item.drink.name))
.padding(.leading,20)
.frame(maxWidth:.infinity,alignment: .leading)
在某些情况下,我们只能获得
String
类型数据,可能会经常做类似的转换
再次运行,你将可以看到表格中的饮品名称已经更改为正确的中文显示
同样对 ItemListView
中的代码进行修改:
//将
Button(drink.name)
//改成
Button(LocalizedStringKey(drink.name))
饮品添加列表的显示也正常了:
修改后的代码可以正常的显示饮料名称的中文了。
上面的方法在绝大多数的情况下都是很好的解决问题的手段,但并不适合完全依赖
Export Localizations...
生成用于本地化键值对的项目。
为了能够更精确的对本地化后的文本进行排序,我们也可以对 Drink
的比较函数做近一步修改:
//将
lhs.name < rhs.name
//改为
NSLocalizedString(lhs.name,comment: "") < NSLocalizedString(rhs.name,comment: "")
NSLocalizedString
可以通过给定的文本键
获取对应后的文本值
将 InfoView
中的
var list:String {
order.list.map(\.drink.name).joined(separator: " ")
}
改为:
order.list.map{NSLocalizedString($0.drink.name, comment: "")}.joined(separator: " ")
我们难道不能直接当
Drink
的name
定义为LocalizedStringKey
类型吗?由于
LocalizedStringKey
不支持Identifiable
,Hashable
,Comparable
协议,同时官方也没有提供任何LocalizedStringKey
转换成String
的方法。因此,如果我们想将name
定义成LocalizedStringKey
类型需要使用一些特殊手段(需通过 Mirror,本文就不展开介绍了)。
为本地化占位符添加位置索引
在声明本地化字符串时,相同类型的占位符在不同的语言中可能会出现语序不一样的情况。例如下面的日期和地点:
// 英文
Go to the hospital on May 3
// 中文
五月三日去医院
可以通过为占位符添加位置索引的方式,方便在不同语言版本的 Localizable. strings 文件中调整语序。例如:
// Localizable.strings - en
"GO %1$@ ON %2$@" = "Go to %1$@ on %2$@";
"HOSPITAL" = "the hospital";
// Localizable.strings - zh
"GO %1$@ ON %2$@" = "%2$@去%1$@";
"HOSPITAL" = "医院";
暂时我们只能通过 String.localizedStringWithFormat
方法按照位置索引顺序添加插值内容:
var string:String{
let formatString = NSLocalizedString("GO %1$@ ON %2$@", comment: "")
let location = String(localized: "HOSPITAL", comment: "")
return String.localizedStringWithFormat(
formatString,
location,
Date.now.formatted(.dateTime.month().day())
)
}
Text(string)
此种方式无法在预览中通过修改环境值实时查看变化( 在模拟器或实机中均可正确可以 )
创建字符串字典文件
一些在中文里并不会存在的困扰,在其他一些语言中却是不小的问题。比较典型的如 复数
。如果你的 app 只有英文版并且只需应对较少名词时,或许可以将复数规则写死在代码里面。比如:
if cups <= 1 {
cupstring = "cup"
}
else {
cupstring == "cups"
}
但这一方面不利于代码的维护,另一方面对于某些具有复杂复数规则的语言(比如俄语,阿拉伯语等)灵活性就太差了。
为了解决如何定义不同语言的复数规则,苹果在 .strings
之外又提供了另一种解决方案 .stringdict
字符串字典文件。
它是一个带有 .stringsdict
文件扩展名的属性列表文件,对它的操作和编辑其他的属性列表完全一样(比如 Info. plist)。
.stringsdict
最初是为了解决复数问题而提出的,不过这几年又陆续增加了针对不同的数值显示不同的文本(通常用于屏幕尺寸的变化),以及针对特定平台(iphone、ipad、mac、tvos)显示对应的文本等功能。
上图中,我们分别制定了使用 NSStringLocalizedFormatKey
的复数规则、NSStringVariableWidthRuleType
可变宽度规则以及 NSStringDeviceSpecificRuleType
特定设备内容规则
.stringdict
的根节点为 Strings Dictionary
,我们的规则都需要建立在它之下。我们需要为每个规则首先建立一个 Dictionary
。上图中,三条规则分别对应的 键
为 device %lld
、GDP
、book %lld cups
。程序在碰到满足这三个 键
定义的文本内容时,将使用其对应的规则来生成正确的本地化内容。
所以尽管看起来和 .strings
略有不同,但实际上内在的逻辑是一致的。
- 我们可以在其中制定任意数量的规则。
- 默认对应的字符串字典文件名为
Localizable.stringsdict
。 .stringdict
的执行优先级高于.strings
,比如我们在两个文件中都对GDP
做了定义,则只会使用.stringdict
对应的内容
制定复数规则
-
数量类别的含义取决于语言,并非所有语言都有相同的类别。
例如,英语只使用
one
和other
类别来表示复数形式。阿拉伯语对zero
、one
、two
、few
、many
、other
类别有不同的复数形式。虽然俄语也使用many
类别,但数字many
类别中的规则与阿拉伯语规则不同。 -
除
other
外,所有类别都是可选的。但是,如果您不为所有特定语言类别提供规则,您的文本在语法上可能不正确。相反,如果您为语言不使用的类别提供规则,则会忽略它并使用
other
格式字符串。 -
在
zero
、one
、two
、few
、many
、other
格式字符串中使用NSStringFormatValueTypeKey
格式说明符是可选的。比如上面的定义当数字为 1 时,返回的是 one cup,不需要必须包含对应的%lld
如何在各个语言中定义复数规则请查看 UNICODE 官方文档
可变宽规则
同复数和设备规则不同,系统不会自动适配返回值,需要用户在定义本地化文本时显式的进行标注,比如:
let gdp = (NSLocalizedString("GDP",comment: "") as NSString).variantFittingPresentationWidth(25)
Text(gdp) //返回 GDP(Billon Dollor)
let gdp = (NSLocalizedString("GDP",comment: "") as NSString).variantFittingPresentationWidth(100)
Text(gdp) //返回 GDP(anything you want to talk about)
没有完全相同的数字时,将返回最接近的内容。
它的使用场景,我感觉并非不可替代。毕竟在代码上的参与量多了些。
特定设备规则
目前支持的设备类型有:appletv、apple watch、ipad、iphone、ipod、mac
使用者不需要在代码中进行介入,系统将根据使用者的硬件设备返回对应的内容
实战 5:重新设定付款按钮
使用复数规则完善付款按钮。
付款按钮的代码在 ButtonView
中:
Button {
showPayResult.toggle()
} label: {
Text("Pay for \(order.totalQuantity) drinks")
}
我们需要对 Pay for \(order.totalQuantity) drinks
进行设置。
首先创建 Localizable.stringsdict
文件
对于英文来说,我们需要设置 zero、one、和 other 的情况。在 English
中进行如下设置:
中文,只需要设置 zero 和 other
调整订单数量,按钮将根据不同的语言、不同的订单数量返回对应的本地化文本
我们在实战 2 中曾经在 Localizable.strings
中为 Pay for %lld drinks
设置了键值对,但由于 .stringdict
的优先级更高,所以系统将优先使用 NSStringPluralRuleType
规则。
实战 6:戳我还是点我
根据不同的设备,在添加饮料的按钮上显示不同的内容。
比如,我们可以在 iphone、ipad 上显示 tap
、在 appletv 上显示 select
、在 mac 上显示 click
在 Chinese
中添加
在 English
中添加
Formatter 格式化输出
仅对显示标签进行本地化是远远不够的。在应用中,还有大量的数字、日期、货币、度量单位、人名等等方面内容都有本地化的需求。
苹果投入了巨大的资源,为开发者提供了一个完整的解决方案——Formatter。
在今年(2021),苹果对 Formatter 做了进一步的升级,不仅提高了 Swift 下的调用便利性,而且推出了适合 Swift 下使用的 FormatStyle 协议。
Formatter 涉及的内容非常多,单独编写一篇文章都未必介绍完全。下文中将通过 Demo 中的几个例子让大家有个基本的了解。
实战 7: 日期、货币、百分比
日期
Text(order.date,style: .date) //显示年月日
Text(order.date.formatted(.dateTime.weekday())) //显示星期
在 Demo 中我们通过了两种方式来本地化日期的显示。
-
Text 本身支持日期的格式化输出,不过这种方式可定制性不高。
-
使用了新的 FormatStyle 来链式定义输出内容:
order.date.formatted(.dateTime.weekday())
将只显示星期几
货币
- 创建 NumberFormatter
private func currencyFormatter() -> NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
if locale.identifier != "zh_CN" {
formatter.locale = Locale(identifier: "en-us")
}
return formatter
}
Demo 中仅提供两种货币的价格,当系统的的区域的设置不是中国大陆的话,则将货币设置为美元。
- 在 Text 中应用 Formatter
Text(NSNumber(value: item.amount),formatter:currencyFormatter() )
由于在 Text 中,Formatter 仅能用于 NSObject,因此需要将 Double 转换成 NSNumber。
目前 FormatStyle 提供的 Currency 可配置项太少,暂不采用。
百分比
Text(order.tax.formatted(.percent))
直接使用 formatStyle。
实战 8: 度量单位、序列
卡路里
使用 MeasureMent 定义能量单位。一个测量对象 (MeasureMent object) 代表一个数量和测量单位。测量类型提供了一个编程接口,用于将测量值转换为不同的单位,以及计算两个测量值之间的和或差。
init(name: String, price: Double, calories: Double) {
self.name = String.localizedStringWithFormat(NSLocalizedString(name, comment: name))
self.price = price
self.calories = Measurement<UnitEnergy>(value:calories,unit: .calories) //设置时将原始数据设为 calorie
}
测量对象同样可以进行数据计算:
var totalCalories:Measurement<UnitEnergy>{
items.keys.map{ drink in
drink.calories * Double(items[drink] ?? 0)
}.reduce(Measurement<UnitEnergy>(value: 0, unit: .calories), +)
}
创建描述 MeasureMent 的 Formatter
var measureFormatter:MeasurementFormatter{
let formatter = MeasurementFormatter()
formatter.unitStyle = .medium
return formatter
}
在 SwiftUI 中显示
Text(order.totalCalories,formatter: measureFormatter)
序列
创建符合不同语言习惯的连字方式(标点、和或等)。
var list:String {
order.list.map{NSLocalizedString($0.drink.name, comment: "")}.formatted(.list(type: .and))
}
其他
使用 tabname 指定特定名称字符串文件
可以创建多个字符串文件,当该文件名不是 Localizabl 时,我们需要指明文件名称,比如 Other.strings
Text("Item",tableName: "Other")
tableName
同样适用于 .stringdict
指定其他 Bundle 中的字符串文件
如果你的 app 中使用了包含多语言资源的其他 Bundle 时,可以指定使用其他 Bundle 中的字符串文件
import MultiLanguPackage // ML
Text("some text",bundle:ML.self)
在包含多语言资源的 Package 中,可以使用以下代码指定 Bundle
Text("some text",bundle:Self.self)
markdown 符号支持
苹果在 WWDC 2021 上,宣布可以在 Text 中直接使用部分 markdown 符号。比如:
Text("**Hello** *\(year)*")
我们同样可以在字符串文件中使用 markdown 符号
"**Hello** *%lld*" = "**你好** *%lld*";
另外,新增的 AttributedString
类型可以为文本带来更多的创造性。
总结
本文原为我针对 iOS 的本地化主题系列文章中的一篇,不过由于琐事较多,始终没有最终完成。
其他内容,例如:资源本地化、本地化调试、本地化预览、本地化文件编辑、Formatter 深入研究等,今后再一同探讨。