ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Storyboard本地化:基于.strings实现iOS/macOS多语言

Storyboard本地化:基于.strings实现iOS/macOS多语言

我接手过一个已经稳定运营两年的 App,产品同学说要准备出海,英语、法语、日语至少三套语言。我打开 Main.storyboard 的一瞬间心态就崩了——二十多个页面,按钮标题、输入框 placeholder、空状态文案,全是中文硬编码在 Interface Builder 里,连 accessibilityLabel 都没放过。更麻烦的是,团队里不少人对 Storyboard 本地化的认知还停留在“把 Storyboard 文件复制几份,每份改成不同语言”的阶段,改一个按钮要在三四个文件里同步,版本合并时冲突不断。

这篇文章把 iOS 和 macOS 下 Storyboard 本地化的完整链路讲透,重点放在基于 String 的方式:Xcode 怎么生成翻译文件、objectID 和控件的映射关系、运行时到底怎么找到对应语言的文本、多语言切换后布局为什么崩,以及混合项目里我踩过的坑。不管你是刚接触国际化的新手,还是准备把老项目从硬编码文案里救出来的维护者,按这条路线走,都能少折腾不少。

1. Storyboard 编译后为什么能多语言:Base.lproj、storyboardc 与 .strings 的对应关系

1.1 Storyboard 编译后不是单个文件,而是 storyboardc 包

很多人在 Xcode 左侧看习惯了Main.storyboard这个蓝色图标,下意识以为它就是最终 App 包里的那个文件。实际上 Build 之后,Base.lproj/Main.storyboard会被编译成Base.lproj/Main.storyboardc。注意,这玩意儿是一个目录,不是文件。

打开你的 .app 包,展开Contents/Resources,会看到类似这样的结构:

Contents/Resources/ ├── Base.lproj/ │ ├── Main.storyboardc/ │ └── LaunchScreen.storyboardc ├── en.lproj/ │ ├── Main.strings │ └── Localizable.strings ├── fr.lproj/ │ ├── Main.strings │ └── Localizable.strings └── zh-Hans.lproj/ ├── Main.strings └── Localizable.strings

Base.lproj 里放着原始 Storyboard 编译产物,其他语言目录里放的是同名 .strings 文件。真正把界面“翻译”出来的,是那些 .strings 文件对应的文本值。运行时系统先加载 storyboardc 里的结构,再根据当前语言,去对应 .lproj 目录里找同名 .strings 文件,把控件的文本替换掉。

1.2 为什么苹果要把界面结构和翻译文本拆开

如果不拆开,最土的办法确实是复制 Storyboard,直接改里面的中文文本。这个方案在项目早期看着简单,但用起来就是灾难:每次调整布局、加约束、删控件,都要把语言版本同步一遍;如果团队多人协作,Git 合并时几乎每次都是冲突重灾区。而且 Storyboard 本质上是个 XML,里面除了文本还有大量视图层级、约束、颜色和自定义属性和类名,这些跟翻译毫无关系,复制十份纯粹是自找麻烦。

把翻译抽取成 .strings 之后,界面结构只有一份,翻译文件是独立的“影子文件”。布局改了,只要不删控件,翻译文件基本不受影响;新增控件也只要往对应语言文件里补一两个 key 就行。这是 Xcode 从很久以前就沉淀下来的成熟设计。

1.3 三种语言包状态:完整翻译、部分翻译、零翻译

对应 .lproj 目录下没有同名 .strings 文件时,系统不会报错,界面会直接显示 Base.lproj 里的原文,这是“零翻译”状态。只有一部分 key 有翻译值,那部分显示翻译,缺失的部分回退到 Base 原文,这是“部分翻译”状态。很多人排查问题时会忽略这个机制,看到一个 key 没翻译成功,第一反应是代码写错了,其实更可能的是目标语言文件夹里压根没有这个 key,或者 key 名对不上。

这同时意味着翻译质量检查和资源清理不能靠人肉数,要用工具生成基线再比对,后面我会专门讲。

2. 提取 Storyboard 可翻译文本的两种路径:Xcode Export 与 ibtool 手动基线

2.1 路径一:Xcode 菜单导出导入

如果你要处理的是一整个 Target 的全部翻译,最省事的方式是 Xcode 的导出功能。

选中最外层的 Target,在 Xcode 菜单栏找到 Editor,里面有两个选项:

  • Export for Localization...
  • Import Localizations...

导出时会生成 XLIFF 或者新版 Xcode 的.xcstrings目录,里面包含项目里所有可本地化内容。翻译方在拿到文件后,只需要把目标语言对应的翻译填进<target>节点,改完再通过 Import Localizations 导回来。整个过程可以交给本地化服务商,不用把源码发出去。

这里有一个容易被忽略的问题:导出时 Xcode 可能把 Storyboard、LaunchScreen、Info.plist 的权限文案甚至 Assets 里的名称都混在一次导出。你看到的 XLIFF 里会有好几种不同的<file>段,有的对应 Storyboard,有的对应 InfoPlist.strings,有的对应 Localizable.strings。翻译回来导入时,Xcode 一般能自动识别并放到对应的语言目录,但我建议在导出前先把不必要的资源排除掉,免得语言包里混入一堆用不上又影响校验的孤儿条目。

2.2 路径二:ibtool 生成 strings 基线文件

老项目里我只想检查某个 Storyboard 有没有漏翻译,或者只是想快速生成一份可编辑的文本清单,用 Xcode 导出有点重,这时直接用命令行工具 ibtool 更干脆。

ibtool --export-strings-file /tmp/Main.strings Base.lproj/Main.storyboard

执行完,/tmp/Main.strings就是一个标准 .strings 文件,它会列出这个 Storyboard 里所有可本地化的文本,key 由控件的 objectID 加属性路径组成:

/* Class = "UIButton"; normalTitle = "登录"; ObjectID = "abc-12-def"; */ "abc-12-def.normalTitle" = "登录"; /* Class = "UILabel"; text = "欢迎回来"; ObjectID = "ghi-34-jkl"; */ "ghi-34-jkl.text" = "欢迎回来";

拿到这个基线文件之后,你可以选择手动维护,也可以把它作为后续校验的参照物。我个人的习惯是:每次发版前跑一遍,把这个文件存档,然后拿所有语言版本的 .strings 跟它 diff 一遍,凡是基线上有但语言包里有缺失的 key,都算漏翻译。

2.3 Xcode 15 之后 String Catalog 的介入

从 Xcode 15 开始,苹果主推.xcstrings,也就是 String Catalog。它不是另一个格式,本质上还是“基于 String”的那一套,只是把所有语言条目收敛到一个 JSON 结构的目录文件里。Storyboard 的本地化同样可以按这个方式管理。

一个 .xcstrings 片段长这样:

{ "sourceLanguage" : "zh-Hans", "strings" : { "abc-12-def.normalTitle" : { "localizations" : { "en" : { "value" : "Sign In" }, "fr" : { "value" : "Connexion" } } } } }

看到那堆 objectID 打头的 key,你应该已经明白:不管外层容器变成 XLIFF 还是 xcstrings,内核依然是“Storyboard 控件 ID + 属性路径 → 翻译文本”。新项目的建议是直接建 String Catalog,老项目迁移也很平滑。迁移时 Xcode 会给一个 Safe Import 的选项,建议勾上,它只会把字符串挪进新格式,不删原文件,万一出了问题还能回滚。

2.4 导入时令人头疼的“合并”逻辑

Xcode 的 Import Localizations 不是简单的整文件覆盖,而是合并。举一个真实场景:某个 Storyboard 里按钮标题从“登录”改成了“立即登录”,你重新导出基线时,旧的 keyabc-12-def.normalTitle已经不存在,但导入回来的语言包里可能还残留着这一条。这种 key 在编译时不会被使用,却被保留在翻译文件中。时间一长,文件里堆满死数据,翻译人员还会对着它白干活。

所以导入后用 ibtool 重新生成当前基线的字符串文件,再对比语言包,把没有出现在基线里的 key 清理掉,这步不能省。

3. objectID 与属性路径:读懂 .strings 文件中那些奇怪的 key

3.1 Key 的组成方式

初次打开 .strings 文件的人,多半会对"abc-12-def.normalTitle"这种 key 感到莫名其妙。它的结构其实很简单:前一段是 Storyboard XML 里控件的id属性,后一段是控件可本地化属性的路径,中间用小圆点连接。

在 Storyboard 里打开一个控件的 Identity 面板,能看到 Document Object ID 就是abc-12-def这种三段式字符串。它只在当前 Storyboard 内有唯一性要求,跨 Storyboard 可以不同。翻译文件里第一行注释会写明控件的类型和原始文本,方便翻译人员知道这句在界面上长什么样。

3.2 常用可本地化属性与 key 的对照

不同控件,可本地化的属性路径不一样。我把项目里最常见的几类列出来:

控件类型可本地化属性示例 key
UIButtonnormalTitle"abc-12.normalTitle"
UIButtonhighlightedTitle"abc-12.highlightedTitle"
UILabeltext"abc-12.text"
UITextFieldplaceholder"abc-12.placeholder"
UITextFieldtext"abc-12.text"
UINavigationItemtitle"abc-12.title"
UIBarButtonItemtitle"abc-12.title"
NSAttributedStringattributedText"abc-12.attributedText"

macOS 的控件键也遵循同样的规律,NSTextField 对应placeholderString,NSButton 对应title,NSToolbarItem 对应label。注意 macOS 菜单栏的本地化文本通常不在 Storyboard 里,而是通过 MainMenu 的独立 strings 文件处理,后面第 6 节我会展开。

3.3 为什么代码赋值会覆盖 Storyboard 的 .strings

Storyboard 的本地化文本只在控件从 nib 加载时生效。如果 viewDidLoad 里写了下面这句,不管 .strings 文件里的法语、日语配得多完整,显示出来的始终是这个硬编码字符串:

loginButton.setTitle("登录", for: .normal)

因为代码赋值发生在加载之后,且系统的本地化机制不会再去碰你手动设置的 title。这是 Storyboard 本地化最容易被误解的一个点:它不是“魔法”,不会持续监视文本变化。场景里凡是会用代码动态改文案,就老老实实用 NSLocalizedString,别指望 Storyboard 的 .strings 能兜底。

3.4 “不生效”时按这个顺序排查

如果某个控件死活不出翻译,我通常按下面顺序排查,效率很高:

  1. 确认控件的 objectID 没有打错,去 Storyboard 源码视图搜索控件类型或者原始文本,复制真正的 ID。
  2. 确认 .strings 文件的名字和 Storyboard 名字完全一致,比如Main.storyboard.strings对应Main.storyboard,多一个字母都不行。
  3. 确认目标语言 .lproj 已经加入了 project,而不是只在文件系统里放了一个文件夹。
  4. 检查代码里是否对这个控件的文本属性赋过值,覆盖优先级高于 Storyboard 本地化。

最后还要提醒一点,如果控件在 IB 里用的是 Attributed 类型文本,key 对应的就是attributedText,而不再是text。很多项目从纯文本切到富文本样式后,旧翻译就神秘失效,多半是这个原因。

4. 运行时翻译如何生效:Bundle 查找顺序与 NSLocalizedString 的配合

4.1 系统按 AppleLanguages 的顺序找语言

iOS 和 macOS 内部维护了一个语言偏好数组,用户可以在系统设置里调整语言的优先级顺序,比如“简体中文优先,英语次之”。App 启动并加载 Main.storyboard 时,底层就是按这个顺序在可用的 .lproj 目录里找资源。

查找规则可以简化成一句话:遍历语言偏好数组,看 bundle 里有没有对应语言的资源,有就用;全部没有,就用 Base.lproj 里的原始内容。

所以调试语言回退时,如果界面显示的是 Base 语言,先确认两件事:当前语言是不是真的在设备的偏好数组最前面,以及构建的 App 包里是否真的包含了目标语言的.lproj文件夹。怎么看?你可以在运行时打印资源路径:

if let path = Bundle.main.path(forResource: "Main", ofType: "storyboardc") { print("Storyboard path: \(path)") } if let stringsPath = Bundle.main.path(forResource: "Main", ofType: "strings", inDirectory: nil, forLocalization: "en") { print("English strings path: \(stringsPath)") }

连.lproj都没有,说明资源根本没打进包里,翻译自然无从谈起。

4.2 Localizable.strings 和 Storyboard 的 .strings 是两回事

这一点必须反复强调,因为太多人在这里栽跟头了。

  • Localizable.strings是给代码里的NSLocalizedString("key", comment: "")用的。
  • Main.storyboard.strings、Second.storyboard.strings是给对应的 Storyboard 控件用的。

它们放在同一个.lproj目录下,但作用对象完全不同。你不可能在 Localizable.strings 里写"abc-12-def.normalTitle" = "登录",然后期待 Storyboard 上的按钮被翻译;也不可能在 Main.storyboard.strings 里放一个业务逻辑里的 key 让NSLocalizedString读到。

写代码时用的动态文案示例:

let greeting = NSLocalizedString("greeting.title", comment: "Home screen greeting") statusLabel.text = String(format: NSLocalizedString("order.count.%d", comment: "Order count"), orderCount)

对于格式化字符串,强烈建议把 key 设计成带占位符描述的形式。比如"order.count.%d",不要用"order.count"然后在代码里拼接,因为不同语言里单复数和名词位置完全不同,拆开拼在翻译阶段根本没法处理。最佳做法是连同占位符整体放入翻译:

"order.count.%d" = "你有 %d 条新通知";

到了法语里,占位符可以出现在句子不同位置:

"order.count.%d" = "Vous avez %d notifications";

4.3 复数用 .stringsdict,而不是硬凑

阿拉伯语、俄语、中文都有各自复杂的复数规则,iOS 平台对应干活的叫Localizable.stringsdict。这是一个 plist 格式的文件,放在.lproj下,和 Localizable.strings 平级。它允许你针对zero/one/two/few/many/other这些分类分别给翻译。

一个简单例子:

<?xml version="1.0" encoding="UTF-8"?> <plist version="1.0"> <dict> <key>order.count.%d</key> <dict> <key>NSStringLocalizedFormatKey</key> <string>%#@count@</string> <key>count</key> <dict> <key>NSStringFormatSpecTypeKey</key> <string>NSStringPluralRuleType</string> <key>NSStringFormatValueTypeKey</key> <string>d</string> <key>one</key> <string>你有 %d 个订单</string> <key>other</key> <string>你有 %d 个订单</string> </dict> </dict> </dict> </plist>

Storyboard 本身不处理复数,这个问题几乎都发生在代码生成的动态文案上,所以提醒一点:只要代码里有数字量级文案,就不要只靠 Storyboard 本地化覆盖,必须单独维护一套 stringsdict。

5. 换了语言之后布局崩了:文本长度、RTL 和约束的连锁反应

5.1 翻译后的文本可能比原文长 80%

Storyboard 本地化只解决“文字变成什么语言”,解决不了“文字变长之后控件装不下”。中文一个“设置”两个字符,到了德语可能变成四五个甚至更长,比如“Einstellungen”。如果按钮宽度是固定 80,德语版本大概率会变成省略号,严重的话直接截断半个字符。

不同语言文本长度的差异,经验值可以参考:

原文语言目标语言大致长度变化
中文英语20% - 50%
中文德语40% - 80%
中文法语30% - 60%
中文阿拉伯语30% - 70%(同时变 RTL)

在实际界面中,最稳的做法是不要给控件固定宽度。用 Auto Layout 时,按钮两端的约束尽量设成>=,让宽度由内容撑开;Label 设置numberOfLines = 0,行数交给系统根据文本自动决定;约束的 priority 也要分布合理,保留足够的 Compression Resistance Priority,别让文本被压缩成省略号。

5.2 用自动布局和约束优先级给文本留余量

一个非常典型的场景:一行标题旁边放一个开关,约束用的是“标题 label 的宽度固定为 100”。中文版没问题,德语版标题直接超出。正确的做法通常是:

  • 标题 label 的 horizontal compression resistance priority 设成 750 以上,确保系统优先压缩其他可以伸缩的控件。
  • 用UILabel的adjustsFontSizeToFitWidth要谨慎,这个属性会造成字号缩到很小,影响可读性。
  • 竖排容器里如果 Label 需要多行,记得 height 不要写死,或者设置一个>=的最小高度。

这些约束规则跟 Storyboard 本地化本身无关,但却是多语言项目上线前必须检查的硬指标。建议在翻译稿基本确定后,找德语、法语、阿拉伯语各跑一遍截图回归。

5.3 RTL:别亲自动手镜像布局

阿拉伯语、希伯来语这类语言,界面是从右往左的。很多人第一次适配时,想直接改约束方向,把左边距改成右边距,结果英文版一回归又乱了。

正确的思路是把方向交给系统语义。UIKit 对UIStackView、UICollectionViewFlowLayout自动支持跟随语言方向翻转,你在 Storyboard 里写约束时,尽量用 leading/trailing 而不是 left/right。left/right 是物理方向,RTL 下不会自动翻转;leading/trailing 是语义方向,系统会按照当前语言自动调整。

如果某个 View 因为特定原因不想跟随语言方向翻转,可以设置:

customView.semanticContentAttribute = .forceLeftToRight

macOS 上的 NSButton、NSStackView 也会跟随用户首选语言的方向,不过 AppKit 在 RTL 适配的历史包袱比 UIKit 多,自定义绘制的视图很多时候要自己处理坐标镜像。

5.4 语言切换为什么不能热生效

iOS 如果用户直接改了系统语言,App 会被系统终止,重启后读取新的语言。很多人做 App 内切换语言的功能,希望不杀进程就刷新界面,这时候会遇到一个尴尬:已经加载到内存里的 Storyboard 不会自动重新本地化。

我做过 App 内语言切换功能,验证下来最可靠的方式是:切换完语言,直接重新设置 rootViewController,或者重新加载整个 keyWindow 的根控制器。界面层级复杂的项目,准备一个applyLocalization()方法,把所有需要刷新的控件重新赋一遍从Localizable.strings取到的文本,比试图“原地更新 Storyboard”稳定得多。

macOS 上可以通过命令行参数或者用户配置修改AppleLanguages来做运行时调试,但改完仍然需要重启 App 才能看到大部分效果,别指望 Storyboard 在进程内自动回读新的语言包。

6. 混合项目落地的三点经验:验证翻译、处理菜单栏与 Info.plist、何时该弃用 Storyboard

6.1 翻译完整性检查:基线对比代替人肉看稿

Storyboard 的漏翻译很隐蔽。因为缺失 key 不会报错,界面直接显示 Base 语言。如果是中文和日文这种有些汉字字形相近的语言,视觉上甚至看不出异常,用户却实实在在拿不到本地化体验。

我们团队的检查流程是这样的:

  1. 用 ibtool 导出当前 Main.storyboard 的基线 strings。
  2. 对每个语言版本的 Main.storyboard.strings 做一次比对,收集缺失的 objectID。
  3. 再对 Localizable.strings 检查是否有新的源码 key 没有被翻译,主要靠genstrings命令提取代码里的 NSLocalizedString key 做 diff。
find . -name "*.swift" -print | xargs genstrings -o /tmp/Localizable/

genstrings会把 Swift 源码里所有 NSLocalizedString 的 key 提到一个 Localizable.strings 里。拿它和语言包文件对比,谁缺条目一目了然。

6.2 Info.plist 文案和 macOS 菜单栏别忘

Storyboard 的本地化只处理界面控件,两个地方经常被忽略:一是Info.plist里的权限描述文案(比如相册、摄像头权限),二是 macOS 菜单栏。

权限文案的翻译对应文件是InfoPlist.strings,放在对应语言的lproj目录下:

"NSCameraUsageDescription" = "用于扫描二维码";

macOS 菜单栏的情况稍复杂。如果你的 App 使用 Storyboard 承载主菜单,导出本地化时通常会在对应语言的 .strings 文件里生成MainMenu相关的 key。如果菜单是在代码里构建的,则需要服务端或本地配置。我见过最省心的做法:把菜单项的所有 title 都包一层 NSLocalizedString,和业务代码文案统一管理,这样只需要维护 Localizable.strings。

6.3 哪些项目别再死守 Storyboard 本地化

不是所有项目都适合在 Storyboard 上堆翻译。以下场景,我会更早劝你用代码搭建 UI:

  • 大量表格和集合视图的 Cell 通过代码动态配置文案;
  • 界面上有很多富文本、混排图片、动态属性字符串;
  • 团队已经统一使用纯代码布局,只有少数历史页面还在 Storyboard 里;
  • 需要远程下发文案,Storyboard 的静态翻译无法满足。

判断标准很简单:如果 Storyboard 里静态文本占整个 App 可翻译文案的比例很低,那引入 Storyboard 本地化反而多了一套文件体系;不如把文案全部收敛到代码层,统一走 NSLocalizedString。老页面偶尔需要改文案,就改成代码赋值,逐步迁移。

反过来,如果你的 App 以静态表单页为主,按钮、标题、输入框占大多数,Storyboard 的strings文件则是最高效的方式,一个页面几行 key 就能覆盖,不用写任何胶水代码。

就我接手的那个应用来说,最后真正落地的方案是 Hybrid:新的动态页面全部用代码加上本地化 key,老的 Storyboard 页面保留静态本地化文件,但把 Accessibility 相关的可本地化属性也一并补进了翻译文件,这样屏幕阅读器读出来的辅助标签也是多语言的。Build 之前跑一遍基线脚本,确保每个语言包的 key 没有缺漏。

最后分享一个细节:.strings文件默认会继承 UTF-16 编码,手动编辑时一定不要另存成 UTF-8,否则 Xcode 和命令行工具读取时可能出现乱码,尤其是包含中文字符的翻译。规范的方式是用 Xcode 自带编辑器或者plutil这类工具去改,实在要用脚本处理,注意在文件开头补上.strings应有的 BOM 或者直接用iconv转换。这个坑看着小,真遇到时排查半天也想不明白。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进