打破发版枷锁:Just Eat Takeaway 模块化 iOS 应用的轻量级 OTA 本地化架构实战

打破发版枷锁:Just Eat Takeaway 模块化 iOS 应用的轻量级 OTA 本地化架构实战

在移动端工业化研发体系中,功能特性的动态干预已经司空见惯:功能开关(Feature Flags)、动态配置(Remote Config)、服务端驱动 UI(SDUI)乃至混合 Web 容器层出不穷。然而,承载用户界面最基础体验的“文案与本地化多语言资源(Localizations)”,却长期被绝大多数 iOS 团队默认视为静态不可变的“坚硬基石”——文案被编译在 .strings 与 .stringsdict 中打包进 App Bundle,任何一处错别字或翻译缺陷的修复,都不得不耗时数天乃至数周,等待下一轮漫长而沉重的主版本发版周期。

欧洲外卖巨头 Just Eat Takeaway(JET)的主端 iOS 应用拥有超过 30 个独立业务模块的 Monorepo 架构。面对跨国多语言敏捷更正、文案独立灰度实验以及新功能多语言排期解耦的强诉求,市面上主流商用 OTA 本地化 SDK 侵入性过高且无法契合模块化资源 Bundle 隔离的现实。JET 核心架构团队另辟蹊径,打造了一套仅数百行客户端代码、零第三方依赖且每模块仅需改动一行的自研 OTA 本地化系统。本文将深度剖析这套兼具极简美感与极高鲁棒性的移动架构实践。


1. 痛点溯源:为什么本地化文案长期游离于动态化之外?

在现代移动应用开发中,产品经理与运营团队习惯了随时在配置后台切换功能逻辑或上线营销实验。但一旦线上界面出现关键翻译歧义、汇率单位拼写错误或法律合规文案漏洞,工程师往往只能无奈地摊开双手:“这需要提交 App Store 审核发版,请等下一趟发版列车。”

传统发版 vs 自研 OTA 动态化模式对比

1.1 模块化 Monorepo 的现实阻碍

部分第三方本地化 SaaS 平台提供了开箱即用的 OTA SDK,但当团队尝试将其落地到拥有 30+ 业务模块的大型 iOS Monorepo 时,往往会撞上严重的架构冲突:

  1. 强行劫持全局查找路径:商用 SDK 通常假设整个应用是一个扁平的整体,或者通过私有运行时黑魔法(如 Method Swizzling)劫持 Bundle.main;
  2. 破坏模块边界与资源隔离:在规范的模块化架构中,每个功能组件(如 Checkout、Account、Orders)拥有完全属于自己的 SPM Resource Bundle,互不侵扰;
  3. 沉重的第三方依赖链:为每一个基础模块引入沉重的第三方 SDK,会迅速膨胀二进制体积与依赖图复杂度。

1.2 核心业务用例驱动

JET 架构团队将以下三个用例视为同等优先级的核心诉求:

  • 线上译文极速热更(Production Hotfix):翻译人员或本地化代理商提交了错误的文案,修复仅需在后台执行一次“Publish”,无需移动端重新打包与审核;
  • 新功能多语言交付解耦(Decoupling Feature Shipping):研发提测与多国语言翻译往往难以做到绝对时间同步,让多语言独立交付,彻底解除与 App 编译构建的硬耦合;
  • 产品与增长敏捷实验(Copy Control):按钮 CTA、空状态提示语、新手引导文案的文风迭代不再受发版列车拖累。

2. 灵魂洞见:原生 Bundle 已经替我们做完了所有重活

当工程师着手设计一套远程文案更新方案时,最直觉的诱惑往往是“自己造一个内存字符串存储器”:

  • 从服务器下载一份 JSON 键值对字典;
  • 解析到内存 Dictionary 中;
  • 封装一个自定义的查询管理器,在运行时拦截所有的字符串查找。

然而,这正是绝大多数失败方案的起点。

2.1 自己手写字典查找的微架构灾难

一旦走上“自制字典”的道路,你很快会绝望地发现,你必须在 Swift 层面重新实现整套 Apple 经过十数年打磨的国际化底层逻辑:

  1. 庞大复杂的语言回退链(Locale Fallback Chain):当系统语言为加拿大法语(fr-CA)时,若找不到对应词条,必须按规范降级回退到通用法语(fr),若仍缺失,再退回基础语言(Base 或 en);
  2. .stringsdict 变态的复数规则(Plural Rules):俄语、阿拉伯语、波兰语拥有极其繁复的单复数(Zero, One, Two, Few, Many, Other)语法规则;
  3. 区域变量与排序匹配。

2.2 优雅的架构降维:借力 Apple Bundle

Apple 原生的 Bundle 类早已在系统底层原生、完美且高效地支持了以上全部能力!

  • NSLocalizedString 原生接收一个 bundle: Bundle 参数;
  • SwiftUI 的 Text("key", bundle: customBundle) 同样原生支持指定自定义 Bundle。

于是,整个客户端的核心架构设计瞬间收敛为极其优雅的一句话:

“在磁盘上动态组装一个结构与 Apple 资源包完全一致的物理目录,生成原生 Bundle 实例,并将其传递给 NSLocalizedString 即可。”

沙盒目录动态 Bundle 结构与业务调用

2.3 极致的单行代码接入

在 JET 的工程规范中,所有模块均通过统一的 LocalizableStrings 协议解析文案。改造前后的对比令人赞叹:

改造前:

extension Bundle {
    class var myModule: Bundle {
        Bundle.module // SPM 自动生成的静态内嵌 Bundle
    }
}

protocol LocalizableStrings {
    func localizedString(_ key: String, comment: String) -> String
}

extension LocalizableStrings {
    func localizedString(_ key: String, comment: String) -> String {
        NSLocalizedString(key, bundle: .myModule, comment: comment)
    }
}

改造后:

import OTALocalizations

extension Bundle {
    class var localizationBundle: Bundle {
        // 核心:一行代码无缝降级!
        OTALocalizedString.bundle(for: "MyModule") ?? .myModule
    }
}

extension LocalizableStrings {
    func localizedString(_ key: String, comment: String) -> String {
        NSLocalizedString(key, bundle: .localizationBundle, comment: comment)
    }
}

每个模块只需引入 OTALocalizations,并将原有的 .myModule 替换为 OTALocalizedString.bundle(for: "MyModule") ?? .myModule。

利用 Swift 的空合并操作符(??),模块获得了 100% 透明的兜底保证:

  • 当本地无 OTA 资源、网络异常、下载未完成或功能开关关闭时,bundle(for:) 干净利落地返回 nil,无缝使用编译时内嵌的本地资源;
  • 业务模块无需感知网络请求、无需编写繁琐的 do-catch、更无需关心 Feature Flag 的判断。

3. 服务端与 CI/CD 流水线:静态化与单一真理来源

许多团队在做动态化时,喜欢让客户端直接去请求翻译管理系统(如 Lokalise、Crowdin、Phrase)的 Open API。这是一个极度危险的反模式:

  • 平台 API 绝非高并发低延迟的全球 CDN,无法承受数以百万计的移动客户端轮询;
  • 客户端必须内置平台 API 凭证,存在严重密钥泄露风险;
  • 最致命的是破坏了代码审查防线:翻译人员在后台随手一改,甚至手滑清空了词条,未经代码审查(PR Review)与自动化测试拦截,就会瞬间直推线上千万设备。

3.1 仓库作为单一真理来源(Single Source of Truth)

JET 严格遵循以下原则:App Git 代码仓库中经过 PR 审查合并的 .strings 和 .stringsdict,才是 OTA 发布的唯一合法源头。

Git 单一真理来源与 CI/CD 自动化分发时序图

3.2 CI 流水线的四个标准动作

在每次上传 TestFlight 或发布正式版时,CI 自动执行自动化打包:

  1. 模块化物理打包:为每个包含本地化的模块,将其全部 .lproj 目录压制为一个以组件命名的独立 ZIP(如 Checkout.zip);
  2. 注入开发语言元数据:在 ZIP 根目录必须生成标准的 Info.plist,显式指定 CFBundleDevelopmentRegion 为 en,以确保 iOS 系统在找不到特定语言时能正确将 Base.lproj 映射为英语:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>en</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundlePackageType</key>
    <string>BNDL</string>
</dict>
</plist>
  1. 计算 SHA-256 哈希:对生成的每个 ZIP 计算唯一内容哈希;
  2. 生成版本隔离清单 manifest.json:将文件推送至按 App Marketing Version 隔离的 S3 路径:
{
  "publishedAt": "2026-07-24T10:00:00Z",
  "modules": [
    { "name": "App",      "path": "App.zip",      "sha256": "d3f9cd1e8a2b..." },
    { "name": "Account",  "path": "Account.zip",  "sha256": "8b1a2c3f4e5d..." },
    { "name": "Checkout", "path": "Checkout.zip", "sha256": "f4e2d1a09c8b..." }
  ]
}

[!IMPORTANT]
绝对排除 InfoPlist.strings:
InfoPlist.strings 用于定义 App 桌面显示名称(CFBundleDisplayName)以及相机、相册、定位等敏感权限弹窗提示语。这些内容是由 iOS 操作系统层在 App 进程之外独立读取的,任何下载的动态 Bundle 都无法覆写它。因此,流水线必须显式剔除该文件,避免制造无效覆盖的幻觉。


4. 客户端双层解耦:Actor 服务与无状态高速读取

在大型工程中,跨模块共享组件往往容易被写成泛滥的单例模式(Singleton)。单例模式不仅难以在 App 层注入环境配置(如 CDN 域名与 App 版本),更引入了全局可变状态的竞态风险。

JET 采用了教科书级的读写职责分离(CQRS 思想)双层架构:

读写职责分离(CQRS)双层架构拓扑

4.1 OTALocalizationService:应用层重型 Actor

  • 存在层级:仅在 App 壳工程层可见,通过依赖注入容器注册;
  • 核心职责:唯一触碰网络与文件系统写入的实体,封装了所有 async 下载、ZIP 校验、原子替换与磁盘清理逻辑;
  • 模块隔离:30 多个业务功能模块完全不知道这个类型的存在。

4.2 OTALocalizedString:模块级轻量无状态入口

  • 存在层级:基础库 OTALocalizations 的公开 API,无任何依赖;
  • 核心职责:对外暴露极简的只读 API:
public struct OTALocalizedString {
    @MainActor public static func loadBundlesFromDisk()
    @MainActor public static func clearBundleCache()
    public static func bundle(for componentName: String) -> Bundle?
}

4.3 极速热路径上的精妙并发设计:nonisolated(unsafe)

在 iOS 应用冷启动期间,bundle(for:) 会随着各个 UI 组件的构建被调用数千甚至数万次。

  • 它绝不能是 async(因为 NSLocalizedString 是纯同步函数);
  • 它绝不能强制标注为 @MainActor(否则 30+ 业务模块所有读取文案的地方都会被强制传染为 @MainActor);
  • 它更不应在每次读取时去争夺一把昂贵的多读单写锁。

JET 架构团队做出了一个深思熟虑的技术决策:

// All writes (loadBundlesFromDisk, clearBundleCache) are @MainActor.
// There is no concurrent read/write window in practice.
// nonisolated(unsafe) avoids a @MainActor requirement on the hot bundle(for:)
// read path, which must remain a plain synchronous call for use in NSLocalizedString.
private nonisolated(unsafe) static var bundleCache: [String: Bundle] = [:]

在严格的 Swift 并发模型下,nonisolated(unsafe) 是一个显式的逃逸口。之所以在这里被证明是安全且合理的,是因为系统维持了严密的主线程单向写入屏障:

  1. 写操作 100% 局限在 @MainActor:无论是启动时的 loadBundlesFromDisk 还是熔断时的 clearBundleCache,均运行在主线程;
  2. 初始化先于渲染:在 didFinishLaunching 极早阶段,首个视图渲染前就已经完成了磁盘 Bundle 到内存字典的挂载;
  3. 读路径完全无锁直出:bundle(for:) 退化为最普通、最平坦的静态字典查表,耗时仅几纳秒。

5. 下载周期与容灾哲学:原子替换与静默自愈

在移动端网络与文件 IO 中,“一切皆有可能发生故障”是基本常识:网络在中途断开、用户在写入时强杀进程、手机突然电量耗尽关机。

如果一个包含 10 个语言目录的模块,在解压到第 4 个时进程被杀死,下一次启动时该模块就会陷入“部分语言是最新的,部分语言是缺失的”严重混乱状态。

5.1 操作系统级原子替换:replaceItemAt

JET 坚决禁止直接将解压内容流式写入目标目录,而是采用了严密的“三段式”原子落盘策略:

操作系统级原子替换流程

核心落地代码:

if fileManager.fileExists(atPath: targetURL.path) {
    _ = try fileManager.replaceItemAt(targetURL, 
                                      withItemAt: tempExtractDir,
                                      backupItemName: nil, 
                                      options: [])
} else {
    try fileManager.moveItem(at: tempExtractDir, to: targetURL)
}

FileManager.replaceItemAt 是底层的 POSIX 原语操作。如果在解压过程中发生任何意外,原有的有效 Bundle 丝毫不受影响,彻底终结了“半写状态(Half-written Bundle)”的技术风险。

5.2 零簿记成本的自愈重试机制(Self-healing Manifest)

在一次更新周期中,假设 App 与 Account 模块下载成功,而 Checkout 模块因为网络波动下载失败,系统该如何设计重试?
很多工程师会为此编写庞大的重试计数器、退避算法或失败持久化队列。

JET 的设计极具智慧——利用 manifest.json 的提交原子性实现自愈:

do {
    try await bundleDownloader.downloadAndExtract(componentName: entry.name, path: entry.path)
    downloaded += 1
} catch {
    // OTA 是尽力而为系统:单个模块故障绝对不能打断全局流程
    failed += 1
}

// 核心:仅当 100% 模块全部成功时,才将新 manifest 固化到磁盘
if failed == 0 {
    try store.saveManifest(manifest)
}
  • 如果 Checkout.zip 失败,成功的 App 和 Account 依然会原子替换落盘;
  • 但由于 failed > 0,本地保存的旧 manifest.json 不会被推进到最新版本;
  • 当下次 App 回到前台或重启触发周期时,系统重新对比 CDN 清单:
    • 发现 App 和 Account 的本地文件哈希与线上匹配,直接跳过(Skip);
    • 发现只有 Checkout 缺失或不匹配,自动对其发起重试!
  • 不增加一行额外的重试逻辑代码,依靠状态机的自然收敛实现了完美的网络自愈。

5.3 前台唤醒拉取,冷启动统一生效

JET 经过深思熟虑,明确将“会话内动态热更(Mid-session Live Updates)”列为严禁实施的 Non-Goal:

  • 用户体验视角的 Bug:试想用户正注视着界面甚至填写表单,界面的文案突然动态变异,对用户而言这属于惊悚的视觉灵异 Bug;
  • 架构侵入度爆炸:若要让每个正在展示的页面支持文案热刷新,每一个 Text 与 UILabel 必须全部改写为响应式数据流绑定,改造成本不可承受。

因此,JET 制定了“前台预热拉取,冷启统一生效”的不对称生命周期:

前台预热拉取与冷启动统一生效生命周期状态机

5.4 真正的“物理级”熔断开关(True Kill Switch)

许多远程系统的“开关关闭”,仅仅意味着“停止向服务端请求新数据”,但旧的本地缓存依然在起作用。
JET 实现的是具备彻底清空效应的物理级 Kill Switch:

func otaLocalizationsOperation() {
    Task { @MainActor in
        await tryResolver {
            let toggleAccessor = try self.resolver.protectedModule(ToggleAccessor.self)
            guard toggleAccessor.isOTALocalizationsEnabled else {
                // 1. 立即清空内存字典映射,当前会话立刻回退到内嵌 Bundle
                OTALocalizedString.clearBundleCache()
                // 2. 取消所有在途下载任务,并将 Application Support 磁盘目录彻底抹除
                let service = try self.resolver.module((any OTALocalizationServicing).self)
                await service.deleteAllBundles()
                return
            }
            let service = try self.resolver.module((any OTALocalizationServicing).self)
            await service.start()
        }
    }
}

关停开关时,先清空内存指针,再抹除磁盘文件,不给旧文案留下任何“僵尸复活”的物理缝隙。


6. 巨人的取舍:那些被否决的技术路径

一个优秀的系统架构,其精髓往往不在于它实现了什么,而在于它坚决拒绝了什么。

备选方案 为什么表面吸引人? 为什么被 JET 坚决否决?
运行时直连翻译平台 API 省去 CI 打包发布流水线,翻译即刻生效 客户端需内嵌密钥;平台无法承载高并发;绕过了 Git PR 审查,翻译失误直击生产
Method Swizzling 动态替换 30+ 业务模块无需改动任何一行代码 违背 Swift 静态类型哲学;运行时黑盒极难定位异常;极易与第三方库发生冲突
继承 Bundle 类做自定义代理 逻辑内聚在子类中,接口透明 Bundle 是典型的 Class Cluster(类簇),子类化跨 iOS 版本极其脆弱易崩
自制内存 JSON 字符串字典 纯 Swift 掌控力强,不需要管资源包目录 重造语言回退链(fr-CA -> fr)与 .stringsdict 变态复数规则是巨大无底洞
仅按需下载当前设备单语言包 节省单次下载网络带宽 破坏了 Bundle 依赖多语言并存的回退机制;难以优雅响应用户中途切换系统语言
全局合成单个扁平语言包 全 App 只有一个文件,管理简单 丢失了模块间 Key 的命名空间隔离;需要额外自制查找路由层,增加维护成本

关于 Xcode 15+ String Catalog(.xcstrings)的思考

很多工程师会好奇:Apple 已经主推 String Catalog,为什么 JET 依然坚持输出 .strings 和 .stringsdict?

JET 首席架构师给出了非常清醒的洞察:

  • String Catalog 只是源码阶段的编写格式:在 Xcode 编译时,构建系统依然会将 .xcstrings 编译展开为传统的 .strings 与 .stringsdict 打包进 Bundle;
  • 运行时无公开加载 API:在运行时,Bundle 和 NSLocalizedString 根本无法直接消费 .xcstrings;
  • OTA 与源文件格式正交:迁移到 String Catalog 能改善开发者编写体验,但对于解决“打破 App Store 发版周期”这一核心运行时诉求,并无实质性赋能。

7. 架构守护与规模化治理:让正确的事情自然发生

在拥有数十个业务模块、上百位开发者的团队中,再完美的架构如果缺乏自动化工具护栏,最终都会因人员流动而迅速腐化。

7.1 SwiftLint 强力禁限裸调用

如果某位开发者在新增模块时,不小心写成了 NSLocalizedString(key, bundle: .myModule, ...),代码可以正常编译和运行,但该模块将永远默默失去 OTA 动态更新的能力。

为此,团队制定了专属 SwiftLint 自定义规则:

  • 严格扫描 App/Sources 与 Modules/*/Framework/Sources 目录;
  • 全面禁止裸调用 NSLocalizedString,强制要求通过统一封装的 localizedString 扩展;
  • 规则按路径正则通配,新增模块无需额外配置即可自动纳入防护网。

7.2 脚手架生成工具(ModuleCreator)联动

JET 内部拥有标准化的模块生成脚手架工具 ModuleCreator。架构团队将改造后的 Bundle.localizationBundle 与 LocalizableStrings 模板直接固化到脚手架中。开发者每次创建新模块时,原生支持 OTA 的代码骨架自动就绪,开发者无需显式 Opt-in 即可直接享受全套能力。

7.3 可观测性(Observability)治理

在“尽力而为(Best-effort)”且具备“静默自愈”的系统中,完备的结构化日志是系统健康的唯一透视镜:

  • 每次检查周期上报结构化指标:manifest 状态码、更新模块列表、下载耗时、ZIP 体积;
  • 核心对齐指标:对终端用户而言,一次“30 个模块全部比对跳过(30 Skips)”与“30 个模块全部网络超时失败(30 Failures)”的表现完全一致(均流畅展示旧文案);但对于监控仪表盘而言,前者代表系统极度健康,后者则代表 CDN 发生雪崩,必须即刻告警。

8. 总结与架构启示

Just Eat Takeaway 的 OTA 本地化架构展现了顶级移动架构师在复杂工程环境下的审美哲学:“做少,做好,顺应平台。”

自研 OTA 本地化架构哲学体系全景思维导图
  1. 不要与操作系统对抗:当面临复杂的本地化规则时,不要试图在内存中自建字典解释器。原生 Bundle 已经处理好了方言变体、语言降级与复数运算,把物理文件组装好还给系统,是最轻量且最高效的选择;
  2. 永远保持退路透明:通过 ?? .myModule,任何单点故障都可以降维退化为最原始的本地内嵌文案,系统的最坏结果不过是“像以前一样工作”;
  3. 架构规模化的核心是工具化:把最佳实践写进 SwiftLint 规则与脚手架代码中,让团队即使不知道底层细节,也能写出高健壮性的代码。

原文链接与参考资料