如何对 iOS 应用中的文本进行本地化

当我们使用一个英文 app 时,很多人第一时间会去查看是否有对应的中文版本。可见,在 app 中显示让使用者最亲切的语言文本是何等的重要。对于相当数量的 app 来说,如果能够将 UI 中显示的文本进行了本地化转换,基本上就完成了 app 的本地化工作。本文中,我们将探讨 iOS 开发中,如何实现显示文本的本地化工作。本文的 Demo 采用 SwiftUI 编写。

健康笔记是我开发的一个iOS app,主要服务于有长期健康管理需求的人士。健康笔记提供了强大的自定义数据类型功能,可以满足记录生活中绝大多数的健康项目数据的需要。你可以为每个家庭成员创建各自的健康数据记录笔记,或者针对某个特定项目、特定时期创建对应的笔记。

推广

文本本地化的原理

作为一个程序员,如果让你考虑设计一套逻辑对原始文本针对不同语言的进行本地化转换,我想大多数人都会考虑使用字典(键值对)的解决方案。苹果也是采取了同样的处理,通过创建针对不同语言的多个字典,系统可以轻松的查找出一个原始文本(键)对应的本地化文本(值)。比如:

//en 
"hello" = "Hello";
//zh
"hello" = "你好";

这套方法就是本文中主要采取的针对文本的本地化手段。

系统在编译代码的时候,将可以进行本地化操作的文本进行了标记,当 app 运行在不同的语言环境(比如法文)时,系统会尝试尽量从法语的文本键值对文件中查找出对应的内容进行替换,如果找不到则会按照语言偏好列表的顺序继续查找。对于某些类型比如LocalizedStringKey上述动作时会自动完成,但是像代码中最常使用的String,则需要在代码中显式完成上述动作。

幸运的是,SwiftUI 的绝大多数控件(部分目前有 Bug)对于文本类型都会优先采用使用LocalizedStringKey的构造方法,这极大的减轻了开发者的手工处理工作量。

添加语言

对于当代的编程语言和开发环境来说,国际化开发能力都已是必备功能。当我们在 Xcode 中创建一个项目后,缺省情况下,该 app 仅针对其对应的 Development Language 进行开发。

因此我们必须首先让项目知道,我们将对项目进行本地化的操作、并选择对应的语言。

Project Navigation中,点击PROJECT,选择Info 可以在Localizations中进行语言的添加。

image-20210624074810238

点击 + 号,选择我们将要增加的语言。

image-20210623192036104image-20210623192106625

在这里我们只是告诉项目,我们将可能对列表中的语言进行本地化操作。但如何本地化、对那些文件、资源进行本地化,我们还需要对其单独设置。

启用 Use Base Internationalization,Xcode 会修改你的项目文件夹结构。xib 和 storeyboard 文件将被移动到 Base.lproj 文件夹,而字符串元素将被提取到项目区域设置文件夹。该选项针对使用 storyboard 的开发方式,如果你采用 SwiftUI 则无需关心。

对于 UIKit 框架,Xcode 会让你选择storyboard的关联方式,由于本文使用的 Demo 项目 为全 SwiftUI 架构,因此不会有如下的画面。

image-20210623200804552

创建文本字符串文件

在苹果的开发环境中,对应我们上文中提到的字符串文件(文本键值对文件)的文件类型为.strings。我们可以在一个 app 中创建多个字符串文件,有些名字的字符串文件是有其特殊含义的。

  • Localizable.strings

UI 默认对应的字符串文件。在不特别指明字符串文件名称的情况下,app 都将从 Localizable.strings 中获取对应的本地化文本内容

  • InfoPlist.strings

对应 Info.plist 的字符串文件。通常用于 app 名称、权限警告提示等内容的本地化。

Project Navigation中,我们选择新建文件

image-20210624074918275

文件类型选择Strings File,将其命名为 Localizable.strings

image-20210623202900471image-20210624075200921

此时的Localizable.strings文件并没有被本地化,当前你的项目中只有一个文件,在该文件中进行文本键值对的定义,仅会针对项目的开发语言,通过右侧的Localize...按钮,我们可以选择生成Localizable.strings对应的语言(语言列表为项目中添加语言设定的列表)文件。

image-20210624075240203

将右侧的两个语言都勾选上后

image-20210623203721043

左侧Project Navigation中的 Localizable.strins 将变成如下状态:

image-20210623203836721

EnglishChinese目前是空文件状态,我们现在就可以在此创建对应的文本键值对了。

可以在此处下载 Demo 项目

实战 1:汉化账单表格列名

image-20210623204627826

本节我们尝试为 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 呈现中看到的ITEMTableView中对应的代码如下:

 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" = "合计";

此时预览窗口中,我们可以看到汉化后的结果:

image-20210623210332114

恭喜你,到这里你已经掌握了文本本地化的大部分内容。

不知道大家注意没有,目前的English文件是空的,Chinese文件我们也只对四个内容设置了对应的本地化文本。所有我们没有设置的内容,app 都将显示我们在代码中设置的原始文本。

在字符串文件中进行定义时,很容易出现两个错误,1:错误的输入了中文标点,2: 忘记了后面的分号。

实战 2:汉化付款按钮

image-20210623212059142

本节我们尝试将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 杯饮品付款";
image-20210623213451585

这样我们就得到了想要的结果。当你尝试添加或减少饮料数量时,文本中的数量都会跟随变化。

请为你的插值选择正确对应的格式说明符,比如上面的例子如果设置为%d 的话将被系统认为是另一个键而无法完成转换。

实战 3:汉化 App 的程序名

在 Xcode 项目中,我们通常会在Info.plist文件中对一些特定的系统参数进行配置,比如说Bundle identifierBundle name等。如果需要对其中的一些配置进行本地化处理的话,我们可以使用上文中提到的InfoPlist.strings

使用创建Localizable.strings文件同样的步骤,我们创建一个名为InfoPlist.strings的字符串文件(不要忘记为创建好的文件进行本地化操作,确认中文、英文都已被勾选)。

分别在 InfoPlist.strings 的ChineseEnglish文件中加入如下内容:

//chinese
"CFBundleDisplayName" = "肥嘟嘟酒吧";
//english
"CFBundleDisplayName" = "FatbobBar";

此时,再在模拟器或者真机上安装 app,app 的名称将会在不同的语言下显示对应的文字。

在最近两个版本的 Xcode 中,可以不直接设置 Info.plist,通常在 Target 的 Info 中查看或修改值

image-20210624075411064

我们需要本地化的配置无需一定要出现在 info 或 Info.plist 中,只要我们在 InfoPlist.strings 中对其进行了本地化键值对设定,app 将会优先采用该设定。通常我们会在 InfoPlist.strings 中进行本地化的除了 app 的名称CFBundleDisplayName外,还有CFBundleNameCFBundleShortVersionStringNSHumanReadableCopyright以及各种系统权限的申请描述,比如NSAppleMusicUsageDescriptionNSCameraUsageDescription等。更多关于 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类型数据,可能会经常做类似的转换

再次运行,你将可以看到表格中的饮品名称已经更改为正确的中文显示

image-20210624090150062

同样对ItemListView中的代码进行修改:

//将
Button(drink.name)
//改成
Button(LocalizedStringKey(drink.name)) 

饮品添加列表的显示也正常了:

image-20210624103137706

修改后的代码可以正常的显示饮料名称的中文了。

上面的方法在绝大多数的情况下都是很好的解决问题的手段,但并不适合完全依赖Export Localizations...生成用于本地化键值对的项目。目前 Xcode15 并不会输出使用 LocalizdStringKey 构造的文本。

为了能够更精确的对本地化后的文本进行排序,我们也可以对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: " ")
image-20210624104828379

我们难道不能直接当Drinkname定义为LocalizedStringKey类型吗? 由于LocalizedStringKey不支持Identifiable,Hashable,Comparable协议,同时官方也没有提供任何LocalizedStringKey转换成String的方法。因此,如果我们想将name定义成LocalizedStringKey类型需要使用一些特殊手段(需通过 Mirror,本文就不展开介绍了)。

健康笔记是我开发的一个iOS app,主要服务于有长期健康管理需求的人士。健康笔记提供了强大的自定义数据类型功能,可以满足记录生活中绝大多数的健康项目数据的需要。你可以为每个家庭成员创建各自的健康数据记录笔记,或者针对某个特定项目、特定时期创建对应的笔记。

推广

创建字符串字典文件

一些在中文里并不会存在的困扰,在其他一些语言中却是不小的问题。比较典型的如复数。如果你的 app 只有英文版并且只需应对较少名词时,或许可以将复数规则写死在代码里面。比如:

if cups <= 1 {
  cupstring = "cup"
}
else {
  cupstring == "cups"
}

但这一方面不利于代码的维护,另一方面对于某些具有复杂复数规则的语言(比如俄语,阿拉伯语等)灵活性就太差了。

为了解决如何定义不同语言的复数规则,苹果在.strings之外又提供了另一种解决方案.stringdict字符串字典文件。

它是一个带有.stringsdict文件扩展名的属性列表文件,对它的操作和编辑其他的属性列表完全一样(比如 Info.plist)。

.stringsdict最初是为了解决复数问题而提出的,不过这几年又陆续增加了针对不同的数值显示不同的文本(通常用于屏幕尺寸的变化),以及针对特定平台(iphone、ipad、mac、tvos)显示对应的文本等功能。

image-20210624135629220

上图中,我们分别制定了使用NSStringLocalizedFormatKey的复数规则、NSStringVariableWidthRuleType可变宽度规则以及NSStringDeviceSpecificRuleType特定设备内容规则

.stringdict的根节点为 Strings Dictionary,我们的规则都需要建立在它之下。我们需要为每个规则首先建立一个Dictionary。上图中,三条规则分别对应的device %lldGDPbook %lld cups。程序在碰到满足这三个定义的文本内容时,将使用其对应的规则来生成正确的本地化内容。

所以尽管看起来和.strings略有不同,但实际上内在的逻辑是一致的。

  • 我们可以在其中制定任意数量的规则。
  • 默认对应的字符串字典文件名为Localizable.stringsdict
  • .stringdict的执行优先级高于.strings,比如我们在两个文件中都对GDP做了定义,则只会使用.stringdict对应的内容

制定复数规则

编组@3x
  • 数量类别的含义取决于语言,并非所有语言都有相同的类别。

例如,英语只使用oneother类别来表示复数形式。阿拉伯语对zeroonetwofewmanyother类别有不同的复数形式。虽然俄语也使用many类别,但数字many类别中的规则与阿拉伯语规则不同。

  • other外,所有类别都是可选的。

但是,如果您不为所有特定语言类别提供规则,您的文本在语法上可能不正确。相反,如果您为语言不使用的类别提供规则,则会忽略它并使用other格式字符串。

  • zeroonetwofewmanyother格式字符串中使用NSStringFormatValueTypeKey格式说明符是可选的。比如上面的定义当数字为 1 时,返回的是 one cup,不需要必须包含对应的%lld

如何在各个语言中定义复数规则请查看 UNICODE 官方文档

可变宽规则

nsstringvariablewidthruletype_pic@3x

同复数和设备规则不同,系统不会自动适配返回值,需要用户在定义本地化文本时显式的进行标注,比如:

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)

没有完全相同的数字时,将返回最接近的内容。

它的使用场景,我感觉并非不可替代。毕竟在代码上的参与量多了些。

特定设备规则

nsstringdevicespecificruletype-pic@3x

目前支持的设备类型有: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文件

image-20210624152114132image-20210624152245613

对于英文来说,我们需要设置 zero、one、和 other 的情况。在English中进行如下设置:

image-20210624152837921

中文,只需要设置 zero 和 other

image-20210624153559265

调整订单数量,按钮将根据不同的语言、不同的订单数量返回对应的本地化文本

stringdict_button

我们在实战 2 中曾经在Localizable.strings中为Pay for %lld drinks设置了键值对,但由于.stringdict的优先级更高,所以系统将优先使用NSStringPluralRuleType规则。

实战 6:戳我还是点我

根据不同的设备,在添加饮料的按钮上显示不同的内容。

比如,我们可以在 iphone、ipad 上显示 tap、在 appletv 上显示select、在 mac 上显示click

Chinese中添加

image-20210624154950158

English中添加

image-20210624155049064local<em>text</em>finish

Formatter 格式化输出

仅对显示标签进行本地化是远远不够的。在应用中,还有大量的数字、日期、货币、度量单位、人名等等方面内容都有本地化的需求。

苹果投入了巨大的资源,为开发者提供了一个完整的解决方案——Formatter。

在今年(2021),苹果对 Formatter 做了进一步的升级,不仅提高了 Swift 下的调用便利性,而且推出了适合 Swift 下使用的 FormatStyle 协议。

Formatter 涉及的内容非常多,单独编写一篇文章都未必介绍完全。下文中将通过 Demo 中的几个例子让大家有个基本的了解。

实战 7: 日期、货币、百分比

日期

image-20210926143214864
Text(order.date,style: .date) //显示年月日          
Text(order.date.formatted(.dateTime.weekday())) //显示星期

在 Demo 中我们通过了两种方式来本地化日期的显示。

  • Text 本身支持日期的格式化输出,不过这种方式可定制性不高。
  • 使用了新的 FormatStyle 来链式定义输出内容:

order.date.formatted(.dateTime.weekday())将只显示星期几

货币

image-20210926145606972
  • 创建 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 可配置项太少,暂不采用。

百分比

image-20210926150144189
 Text(order.tax.formatted(.percent))

直接使用 formatStyle。

实战 8: 度量单位、序列

卡路里

使用 MeasureMent 定义能量单位。一个测量对象 (MeasureMent object) 代表一个数量和测量单位。测量类型提供了一个编程接口,用于将测量值转换为不同的单位,以及计算两个测量值之间的和或差。

image-20210926150326836
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)

序列

image-20210926151111505image-20210926151203232

创建符合不同语言习惯的连字方式(标点、和或等)。

    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 深入研究等,今后再一同探讨。

希望本文能够对你有所帮助。

本博客所有文章除特别声明外,均采用CC 4.0许可协议。转载请注明出处和作者。

关注微信公共号肘子的Swift记事本或在推特上关注@fatbobman,永远不会错过新内容! 您的支持和鼓励将为我的博客写作增添更多的动力! 如果您或身边的朋友有健康数据管理的需求,请使用我开发的app【健康笔记】,正是因为它我才创建了这个博客。

关注