不少iOS开发者第一次接触本地化,都是被产品一句话逼出来的:“下个月要出英文版,你准备一下。”你打开项目,看到Storyboard上几十个写死的按钮文案、Label标题、TextField占位符,瞬间头皮发麻。做过一轮之后你会发现,Storyboard本地化最核心的问题从来不是“怎么加一种语言”,而是“怎么让后续维护不崩溃”。
这篇文章我想围绕“基于String的方案”展开——也就是把Storyboard里的所有文案,拆出来放到Localizable.strings这套基于Key-Value的机制里,再通过代码统一赋值。iOS和macOS在这套机制上完全一致,用的都是NSLocalizedString这套底层工具。这个方案的好处是:可控、可测、可复用,改文案不用重新发版后还要去Storyboard里翻半天。下面我把整个流程、原理和踩过的坑一次性讲透。
1. 为什么要把Storyboard本地化单独拿出来说
1.1 一个典型的“本地化翻车”场景
想象一个登录页面:Storyboard里直接写了“登录”“请输入邮箱”“忘记密码?”三个文本。做英文版的时候,你在Xcode的Localizations面板里勾上English,Xcode会自动生成一个Main.strings文件,里面是一堆key = value;的映射,看起来好像一切都很顺利。
但真正的噩梦从第二周开始:产品改了一个按钮文案,你在Storyboard里双击改了文字,然后忘记重新导出Main.strings,或者导出之后翻译人员还没更新对应条目。结果就是中英文版本里,同一个按钮一个改了、一个没改;测试提bug,你花半小时排查才发现是本地化文件没同步。这种问题在团队协作里几乎每周都会遇到一次。
另外一个痛点:Main.strings里生成的key是XXX-XX-XXX.title这种由Xcode随机分配的ID,可读性很差。你想全局搜一下“登录”这个文案对应哪些页面,基本做不到。翻译人员拿到文件也一头雾水,不知道某条文案在哪个界面的哪个位置。
1.2 三种主流做法的本质差异
我见过团队用过三种方式处理Storyboard本地化,这里先给个结论性的对比,后面详细拆解其中一种。
| 方案 | 工作机制 | 优点 | 缺点 |
|---|---|---|---|
| Base Internationalization(默认方案) | Xcode根据Storyboard控件ID生成Main.strings | 配置简单,适合一次性小项目 | key不可读,每次改Storyboard需重新导出,容易漏翻译 |
| Storyboard中引用自定义Key | 控件Text先填占位值,再用NSLocalizedString加载真实文案 | 文案统一管理 | 需要额外机制遍历控件,调试难度较高 |
| 基于String代码赋值(推荐) | Storyboard只做布局,文案全部在代码中用Key-value加载 | 类型安全、可测试、支持动态切换语言 | 需要给控件连IBOutlet,初始工作量略大 |
我在实际项目里最终稳定使用第三种:Storyboard只负责布局、约束和控件层级,所有的文案都通过代码在viewDidLoad里用NSLocalizedString("key", comment: "")赋值。这样Storyboard变成了一个纯布局文件,甚至换UI同事重新搭建界面也不会影响多语言逻辑。
2. 核心原理:Localizable.strings到底是怎么工作的
2.1 Key-Value映射的加载机制
Localizable.strings本质上是个文本文件,里面每一行都是一对映射:
"login.button.title" = "Login";系统在运行时会根据当前App使用的语言环境,去Bundle里寻找对应语言版本的.lproj目录。比如简体中文对应zh-Hans.lproj,英文对应en.lproj。每个语言目录下都有自己的Localizable.strings。当你调用NSLocalizedString("login.button.title", comment: "")时,系统会做三步操作:
- 确定当前语言,比如当前是英文环境。
- 在
en.lproj/Localizable.strings里查找login.button.title这个key。 - 如果找到了,返回对应的value;如果没有,返回key本身。
这一步“没有找到就回退到key本身”的特性非常有用。它意味着你可以把key设计成有语义的英文短语,即使某个语言的翻译文件还没写好,界面也不会显示空白,而是显示一个还算能看懂的英文兜底文案。
2.2 为什么基于String的方案更适合Storyboard
默认的Main.strings方案虽然也能工作,但它有个结构性问题:文本信息分散在两个地方。Storyboard的XML里存着一份默认语言的文案,Main.strings里存着其他语言的翻译。这个状态在项目初期毫无问题,可一旦Storyboard文件被多个开发者同时编辑,或者经历了大规模的界面重构,同步成本会指数级上升。
基于String的方案把“文案”完全从Storyboard里剥离了。Storyboard里的Label只写占位内容(比如TITLE_PLACEHOLDER),甚至是空字符串都可以,因为运行时一定会被代码覆盖。这样带来的直接好处是:
- 文案修改不涉及Storyboard文件本身,合并冲突概率大幅降低。
- 全局搜索key可以快速定位所有使用点,不用在Storyboard XML里翻找。
- 可以针对文案做单元测试,比如断言某个key在中文和英文翻译文件里都存在、没有空值。
- App内切换语言时,只需要重新赋值一遍文本,不需要重新加载Storyboard的XML内容。
3. 工程配置:一步步把多语言基础搭起来
3.1 在Xcode项目里添加语言
这个步骤很简单,但有些细节值得注意。打开Project设置,选中你的target,在Info面板下方找到Localizations区域,点击加号添加语言。Xcode会弹出一个对话框问你要不要生成Main.strings文件,这一步可以直接取消勾选,因为我们走的是代码方案,不需要生成Storyboard相关的strings文件。
添加完语言后,Xcode会自动在项目里创建en.lproj、zh-Hans.lproj这样的目录。你可以在Finder里看到它们,但更重要的是下一步:手动创建Localizable.strings文件。
3.2 创建Localizable.strings文件的细节
新建文件时选择Strings文件类型(iOS平台在Resource分类下能找到),文件名必须精确写成Localizable,这样NSLocalizedString才能默认找到它。创建之后,你需要手动把它移动到对应的语言目录下,或者利用Xcode的File Inspector添加本地化。
正确做法是这样的:
- 在项目里创建一个
Localizable.strings文件,初始内容可以先放英文。 - 选中文件,打开右侧File Inspector,在Localization区域点击
Localize...按钮,选择Base语言。 - 再次打开Localization下拉列表,勾选你添加过的所有语言(比如English、Chinese)。
- Xcode会自动生成每个语言版本的文件,分别放在对应的
.lproj目录下。
这里有个新手容易搞错的地方:Xcode生成的每个语言版本文件是独立的物理文件,你在编辑时要在菜单栏的“Show file variants”选项里切换当前编辑的语言版本,或者直接用Finder去对应目录里编辑。如果直接双击项目里显示的Localizable.strings,你可能一直在编辑同一个Base版本而浑然不知。
3.3 一个实用的Key命名规范
Localizable.strings文件本身没有语法检查,key怎么命名完全看团队自觉。我建议按“页面.组件.状态”的层级来命名。比如登录页面登录按钮的标题,写成login.button.login;设置页面的版本号Label,写成settings.label.version。这种命名方式在文件里是分组的,可读性好,翻译人员也更容易理解上下文。
英文文件保持key和value一致是常见习惯,比如:
"login.button.login" = "Login"; "login.alert.invalidEmail" = "Invalid email address";中文文件则是:
"login.button.login" = "登录"; "login.alert.invalidEmail" = "邮箱格式不正确";这种方式还有一个附加好处:英文版本即便没做翻译,直接用key本身也能看懂,兜底逻辑非常自然。
4. 基于String把Storyboard“拆干净”的实操流程
4.1 第一步:清理Storyboard中的硬编码文案
这是改造工作的重头戏。打开每一个Storyboard场景,把Label、UIButton、TextField的Placeholder、NavigationBar的Title等所有跟用户可见文本相关的内容清理掉。实际操作时不必完全删除文字,而是改成无意义的占位符,比如VIEW_TITLE、BTN_LOGIN。
我不建议直接把所有文字清为空字符串,原因有两个:第一,空字符串在Interface Builder里看起来跟没设置一样,容易误判是否已经接好代码;第二,用你习惯的Key当占位符,随时可以对照代码里的NSLocalizedString("VIEW_TITLE", ...),一眼就能看出哪个页面还没处理完。
清理过程中顺手把每个需要设置文本的控件连好IBOutlet。命名上我习惯跟key对齐,比如一个显示版本号的Label叫versionLabel,对应key是settings.label.version。这样代码可读性好很多,后期接手的同事不用翻Storyboard也能猜到按钮是干嘛的。
4.2 第二步:建立Key到文本的映射表
在Localizable.strings里把所有用到的key都列出来。这个步骤其实是在编写一份“界面文案词典”。我一般先写英文版,因为这版同时是key的解释,翻译成其他语言时可以参考英文语义。
补充一个很多人不知道的小技巧:strings文件里也可以写注释,注释格式用/* */包裹,写在key前面。翻译工具(比如苹果的官方导入导出工具)能识别这些注释并辅助翻译人员理解上下文。
/* 登录页面的登录按钮标题 */ "login.button.login" = "Login";这个习惯在文案条目超过200条之后非常有用,否则翻译人员完全无法判断main.item3.value5到底是什么鬼。
4.3 第三步:在viewDidLoad里统一赋值
这是整个方案的核心代码环节。看一个具体的例子,一个典型的登录页面实现:
import UIKit final class LoginViewController: UIViewController { @IBOutlet weak var titleLabel: UILabel! @IBOutlet weak var emailTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var loginButton: UIButton! @IBOutlet weak var forgotPasswordButton: UIButton! override func viewDidLoad() { super.viewDidLoad() titleLabel.text = NSLocalizedString("login.label.title", comment: "Login screen title") emailTextField.placeholder = NSLocalizedString("login.placeholder.email", comment: "Email placeholder") passwordTextField.placeholder = NSLocalizedString("login.placeholder.password", comment: "Password placeholder") loginButton.setTitle(NSLocalizedString("login.button.login", comment: "Login button title"), for: .normal) forgotPasswordButton.setTitle(NSLocalizedString("login.button.forgotPassword", comment: "Forgot password button"), for: .normal) } }注意几个赋值细节:UIButton要用setTitle(_:for:)而不是直接.text;UITextField要赋给.placeholder;UILabel直接赋.text。不同的控件类型属性名不同,这是初接触本地化时最容易写错的地方。
macOS上对应的控件改为NSButton、NSTextField、NSLabel(实际上是NSTextField的label样式),赋值方式大同小异,只是NSButton调用的是title属性直接赋值。逻辑模型完全一样。
4.4 动态切换语言:完整流程
基于String方案的最大红利是支持App内动态切换语言,不需要杀掉进程重启。实现思路不复杂:切换语言时,把选择的语言代码存到UserDefaults,然后发一个通知,让所有正在显示的控制器重新加载文案。
首先封装一个读取“当前应用语言Bundle”的工具。默认的Bundle.main会根据系统语言自动选择资源,但我们自己控制的时候,就需要手动指定Bundle:
enum LanguageManager { static var currentBundle: Bundle { let languageCode = UserDefaults.standard.string(forKey: "AppLanguage") ?? "en" if let path = Bundle.main.path(forResource: languageCode, ofType: "lproj") { return Bundle(path: path) ?? .main } return .main } static func setLanguage(_ code: String) { UserDefaults.standard.set(code, forKey: "AppLanguage") NotificationCenter.default.post(name: .languageChanged, object: nil) } }注意这里静态属性currentBundle每次读取时都会重新计算路径,确保你切换语言后立刻拿到新语言的Bundle。然后所有业务代码里的NSLocalizedString要改成传Bundle的版本——也就是NSLocalizedString(key, bundle: LanguageManager.currentBundle, comment: "")。
为了让这个方案有统一的入口,我通常会写一个全局函数:
func Localized(_ key: String) -> String { return NSLocalizedString(key, bundle: LanguageManager.currentBundle, comment: "") }原来代码里所有NSLocalizedString(...)调用同步替换成Localized(...)。这个函数作为唯一入口,后续增加其他逻辑(比如自定义字体适配、文案埋点)都非常方便。
控制器里监听通知并刷新文案:
override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver(self, selector: #selector(refreshTexts), name: .languageChanged, object: nil) } @objc private func refreshTexts() { titleLabel.text = Localized("login.label.title") loginButton.setTitle(Localized("login.button.login"), for: .normal) }之后的流程就是调用LanguageManager.setLanguage("zh-Hans"),界面上所有监听通知的页面就会自动更新,体验非常接近原生App。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 改了strings文件运行没变化 | 文件没有同步到Bundle,或模拟器缓存 | 重新Clean Build;查看文件确实在对应.lproj目录下 |
| 文案显示成key本身 | 当前语言对应的strings文件里没有这个key | 检查key拼写;确认文件放入了正确的语言目录 |
| 中文显示乱码 | 文件编码不是UTF-8 | 用Xcode打开并重新保存为UTF-8编码 |
| 动态切换语言后部分页面没刷新 | 该控制器没监听languageChanged通知 | 检查是否在viewDidLoad注册了通知,并且在deinit移除 |
| 按钮文字在代码里设置后又被改回去 | Storyboard的按钮配置了AttributedTitle | 在Storyboard中清理按钮的所有Title和AttributedTitle设置 |
修改完Main.strings后Storyboard文案没有按预期更新 | 还在用默认的Base Internationalization方案 | 确认你已经切换到基于String代码赋值的方案,不要在Main.strings里继续维护 |
5.2 两个我反复踩过的坑
第一个坑:字符串带格式化参数。"You have %d new messages"这种文案,如果直接写Localized("message.count")然后自己拼接数字,不同语言的语序完全不一样。正确做法是在key里写完整格式,代码里用String(format:)处理。比如英文是"你收到了 %d 条新消息",日文可能是"%d件の新しいメッセージがあります",语序完全不同,只能让翻译人员把格式字符串完整地保留在翻译内容里,同时用%d这类占位符。相关需要注意的还有%lld用于Int64,%@用于对象,写错了运行时会崩溃。
第二个坑:复数与单数处理。"1 item"和"3 items"不能简单靠两个key硬编码,因为俄语、阿拉伯语这套语言的复数规则跟中文英文完全不一样。苹果给的方案是.stringsdict文件,里面通过NSStringPluralRuleType定义zero、one、two、few、many、other等分类。这个文件同样按语言放在.lproj目录下。如果你只分发到中文和英文环境,可以用两个key硬编码先顶着,但只要涉及第三种语言,强烈建议直接上.stringsdict,不然后面返工量非常大。
5.3 模拟器与真机验证的一些经验
模拟器里调整语言最直接的方法是:在模拟器App的Settings里修改语言和地区,然后杀掉App重新启动。但这个方法每次都要手动操作一遍,来回切换比较费时间。我习惯用启动参数的方式来验证:在Scheme的Arguments面板里加一个-AppleLanguages (zh-Hans),模拟器启动App时就会直接进入到中文环境,调试效率高很多。
真机验证不能依赖这个启动参数,还是得在物理机的系统设置里改语言。大多数情况下国际版App用模拟器验证就够了,但涉及推送文案、系统控件(比如系统弹窗按钮)这类依赖系统语言的行为,建议真机再过一遍。
6. 进阶主题:格式字符串、复数与翻译质量保障
6.1 带参数的本地化字符串
带参数的文案是本地化里最容易被低估的复杂性。举个例子,欢迎用户回流的弹窗文案是"欢迎回来,%@!",英文是"Welcome back, %@!"。字符串拼接出来效果一样,但不同的语序下直接拼接就完全行不通。
正确写法是这样:
let message = String(format: Localized("welcome_back.message"), userName)对应strings文件里的条目:
"welcome_back.message" = "Welcome back, %@!";注意事项主要是类型匹配。%@对应任意Object,包括Swift String;%d对应Int,但在64位设备上,Swift的Int传给%@不会崩溃,只是显示不正确。如果参数来自Int64等类型,建议用%lld,并用Int64类型传入。这种问题在界面测试里并不容易发现,但在某些老设备上可能直接闪退,所以规范写法一开始就要坚持。
6.2 使用stringsdict处理复数
单独提一下.stringsdict,因为它在本地化中真的是一个救命的机制。很多人开始以为Localizable.strings搞定一切,后来被阿拉伯语/俄语的复数规则折磨到怀疑人生。
一个标准文件的格式大致长这样:
<?xml version="1.0" encoding="UTF-8"?> <plist version="1.0"> <dict> <key>message.count</key> <dict> <key>NSStringLocalizedFormatKey</key> <string>%#@messageCount@</string> <key>messageCount</key> <dict> <key>NSStringFormatSpecTypeKey</key> <string>NSStringPluralRuleType</string> <key>NSStringFormatValueTypeKey</key> <string>d</string> <key>one</key> <string>%d new message</string> <key>other</key> <string>%d new messages</string> </dict> </dict> </dict> </plist>代码里调用方式跟普通字符串一致:
let text = String(format: Localized("message.count"), messageCount)系统会读取.stringsdict,根据当前语言的复数规则自动选择one还是other,甚至few、many。这也是为什么一开始就建议把Localized封装成一个独立入口——以后要从strings过渡到stringsdict,只需要把文件加进去,代码一行都不用改。
6.3 防止翻译遗漏的检查清单
本地化的质量保障核心是“可追踪”。我养成的习惯是每轮迭代前跑一个脚本,读取所有.strings文件,比对key集合是否一致。简单场景下用shell命令就能完成:
# 提取所有key grep -o '^"[^"]*"' en.lproj/Localizable.strings | sort > /tmp/en_keys.txt grep -o '^"[^"]*"' zh-Hans.lproj/Localizable.strings | sort > /tmp/zh_keys.txt # 对比差异 diff /tmp/en_keys.txt /tmp/zh_keys.txt有差异就说明某个语言漏了翻译或者key拼错了。这个脚本可以作为本地检查工具,也可以挂到CI上,在每天构建时自动跑一遍。另外,翻译质量上建议至少让懂业务的人review一遍,很多“机器直译”在技术上没有错误,但语气和产品调性完全不搭,改起来比从零翻译还费劲。最好在翻译文件里加注释说明上下文,比如这个按钮点击之后会发生什么,避免翻译出来的文案产生误导。
结尾:这轮改造之后我最大的体感
把Storyboard本地化彻底切换到基于String的方案之后,最明显的变化是:产品改文案不再需要开发去翻Storyboard了。翻译文件交给运营,他们在对应语言的strings文件里直接改,改完下一版自然生效;App内动态切换语言让测试人员不用再频繁修改系统设置,一个按钮就能看到所有页面的多语言状态。维护了几个月,Main.strings导致的bug数量直接降为零,这在之前的项目里是不可想象的。
最后分享一个我实际工作中一直用的习惯:给Localized函数加上一个调试开关,比如在DEBUG模式下如果某个key的value和key完全一样(说明还没翻译),就自动弹出控制台警告。你可以在Localized函数里比较返回值与key是否相同,然后输出一条带key名的警告日志。这样新加的语言文件有没有漏翻,跑一遍App看日志就能知道。这个小技巧帮我堵住了很多发布前“系统语言切成法语就显示英文key”的尴尬场景。多语言这件事,看着简单,真正做好拼的都是这些细节。