首页 / 资讯中心 / 文章详情

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

Storyboard本地化:基于.strings实现iOS/macOS多语言 ★ FEATURED ARTICLE
我接手过一个已经稳定运营两年的 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.stringsBase.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 文件中那些奇怪的 key3.1 Key 的组成方式初次打开 .strings 文件的人多半会对abc-12-def.normalTitle这种 key 感到莫名其妙。它的结构其实很简单前一段是 Storyboard XML 里控件的id属性后一段是控件可本地化属性的路径中间用小圆点连接。在 Storyboard 里打开一个控件的 Identity 面板能看到 Document Object ID 就是abc-12-def这种三段式字符串。它只在当前 Storyboard 内有唯一性要求跨 Storyboard 可以不同。翻译文件里第一行注释会写明控件的类型和原始文本方便翻译人员知道这句在界面上长什么样。3.2 常用可本地化属性与 key 的对照不同控件可本地化的属性路径不一样。我把项目里最常见的几类列出来控件类型可本地化属性示例 keyUIButtonnormalTitleabc-12.normalTitleUIButtonhighlightedTitleabc-12.highlightedTitleUILabeltextabc-12.textUITextFieldplaceholderabc-12.placeholderUITextFieldtextabc-12.textUINavigationItemtitleabc-12.titleUIBarButtonItemtitleabc-12.titleNSAttributedStringattributedTextabc-12.attributedTextmacOS 的控件键也遵循同样的规律NSTextField 对应placeholderStringNSButton 对应titleNSToolbarItem 对应label。注意 macOS 菜单栏的本地化文本通常不在 Storyboard 里而是通过 MainMenu 的独立 strings 文件处理后面第 6 节我会展开。3.3 为什么代码赋值会覆盖 Storyboard 的 .stringsStoryboard 的本地化文本只在控件从 nib 加载时生效。如果 viewDidLoad 里写了下面这句不管 .strings 文件里的法语、日语配得多完整显示出来的始终是这个硬编码字符串loginButton.setTitle(登录, for: .normal)因为代码赋值发生在加载之后且系统的本地化机制不会再去碰你手动设置的 title。这是 Storyboard 本地化最容易被误解的一个点它不是“魔法”不会持续监视文本变化。场景里凡是会用代码动态改文案就老老实实用 NSLocalizedString别指望 Storyboard 的 .strings 能兜底。3.4 “不生效”时按这个顺序排查如果某个控件死活不出翻译我通常按下面顺序排查效率很高确认控件的 objectID 没有打错去 Storyboard 源码视图搜索控件类型或者原始文本复制真正的 ID。确认 .strings 文件的名字和 Storyboard 名字完全一致比如Main.storyboard.strings对应Main.storyboard多一个字母都不行。确认目标语言 .lproj 已经加入了 project而不是只在文件系统里放了一个文件夹。检查代码里是否对这个控件的文本属性赋过值覆盖优先级高于 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 version1.0 encodingUTF-8? plist version1.0 dict keyorder.count.%d/key dict keyNSStringLocalizedFormatKey/key string%#count/string keycount/key dict keyNSStringFormatSpecTypeKey/key stringNSStringPluralRuleType/string keyNSStringFormatValueTypeKey/key stringd/string keyone/key string你有 %d 个订单/string keyother/key string你有 %d 个订单/string /dict /dict /dict /plistStoryboard 本身不处理复数这个问题几乎都发生在代码生成的动态文案上所以提醒一点只要代码里有数字量级文案就不要只靠 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 .forceLeftToRightmacOS 上的 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、何时该弃用 Storyboard6.1 翻译完整性检查基线对比代替人肉看稿Storyboard 的漏翻译很隐蔽。因为缺失 key 不会报错界面直接显示 Base 语言。如果是中文和日文这种有些汉字字形相近的语言视觉上甚至看不出异常用户却实实在在拿不到本地化体验。我们团队的检查流程是这样的用 ibtool 导出当前 Main.storyboard 的基线 strings。对每个语言版本的 Main.storyboard.strings 做一次比对收集缺失的 objectID。再对 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转换。这个坑看着小真遇到时排查半天也想不明白。
阅读完成 · 觉得有帮助?
咨询建站