localization-ios

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Localization iOS — Expert Decisions

iOS本地化——专家决策指南

Expert decision frameworks for localization choices. Claude knows NSLocalizedString and .strings files — this skill provides judgment calls for architecture decisions and cross-language complexity.

本地化选择的专家决策框架。Claude熟悉NSLocalizedString与.strings文件——本技能为架构决策和跨语言复杂度问题提供判断依据。

Decision Trees

决策树

Runtime Language Switching

运行时语言切换

Do users need in-app language control?
├─ NO (respect system language)
│  └─ Standard localization
│     NSLocalizedString, let iOS handle it
│     Simplest and recommended
├─ YES (business requirement)
│  └─ Does app restart work?
│     ├─ YES → UserDefaults + AppleLanguages
│     │  Simpler, more reliable
│     └─ NO → Full runtime switching
│        Complex: Bundle swizzling or custom lookup
└─ Single language override only (e.g., always English)
   └─ Don't localize
      Bundle.main with no .lproj
The trap: Implementing runtime language switching when system language suffices. It adds complexity and can break third-party SDKs that read system locale.
用户是否需要应用内语言控制?
├─ 否(遵循系统语言)
│  └─ 标准本地化方案
│     使用NSLocalizedString,交由iOS处理
│     最简单且推荐的方案
├─ 是(业务需求)
│  └─ 应用重启是否可行?
│     ├─ 是 → UserDefaults + AppleLanguages
│     │  更简单、更可靠
│     └─ 否 → 完整运行时切换
│        复杂度高:需使用Bundle Swizzling或自定义查找逻辑
└─ 仅需单一语言覆盖(例如始终使用英语)
   └─ 无需本地化
      使用不带.lproj的Bundle.main
误区:在系统语言足以满足需求时仍实现运行时语言切换。这会增加复杂度,还可能导致依赖系统区域设置的第三方SDK出现问题。

String Key Architecture

字符串键架构

How to structure your keys?
├─ Small app (< 100 strings)
│  └─ Flat keys with prefixes
│     "login_title", "login_email_placeholder"
├─ Medium app
│  └─ Hierarchical dot notation
│     "auth.login.title", "auth.login.email"
├─ Large app with teams
│  └─ Feature-based files
│     Auth.strings, Profile.strings, etc.
│     Each team owns their strings file
└─ Design system / component library
   └─ Component-scoped keys
      "button.primary.title", "input.error.required"
如何设计字符串键结构?
├─ 小型应用(少于100个字符串)
│  └─ 带前缀的扁平键
│     "login_title", "login_email_placeholder"
├─ 中型应用
│  └─ 层级点符号
│     "auth.login.title", "auth.login.email"
├─ 多团队协作的大型应用
│  └─ 基于功能的文件拆分
│     Auth.strings、Profile.strings等
│     每个团队负责各自的字符串文件
└─ 设计系统/组件库
   └─ 组件作用域的键
      "button.primary.title", "input.error.required"

Pluralization Complexity

复数规则复杂度

Which languages do you support?
├─ Western languages only (en, es, fr, de)
│  └─ Simple plural rules
│     one, other (maybe zero)
├─ Slavic languages (ru, pl, uk)
│  └─ Complex plural rules
│     one, few, many, other
│     e.g., Russian: 1 файл, 2 файла, 5 файлов
├─ Arabic
│  └─ Six plural forms!
│     zero, one, two, few, many, other
│     MUST use stringsdict
└─ East Asian (zh, ja, ko)
   └─ No grammatical plural
      But may need counters/classifiers
你需要支持哪些语言?
├─ 仅支持西方语言(英语、西班牙语、法语、德语)
│  └─ 简单复数规则
│     one、other(可能包含zero)
├─ 斯拉夫语言(俄语、波兰语、乌克兰语)
│  └─ 复杂复数规则
│     one、few、many、other
│     例如俄语:1 файл、2 файла、5 файлов
├─ 阿拉伯语
│  └─ 六种复数形式!
│     zero、one、two、few、many、other
│     必须使用stringsdict
└─ 东亚语言(中文、日语、韩语)
   └─ 无语法复数
      但可能需要使用量词/分类词

RTL Support Level

RTL支持等级

Do you support RTL languages?
├─ NO RTL languages planned
│  └─ Still use leading/trailing
│     Future-proof your layout
├─ Arabic only
│  └─ Standard RTL support
│     layoutDirection + leading/trailing
│     Test thoroughly
├─ Arabic + Hebrew + Persian
│  └─ Each has unique considerations
│     Hebrew: different number handling
│     Persian: different numerals (۱۲۳)
└─ Mixed LTR/RTL content
   └─ Explicit direction per component
      Force LTR for code, URLs, numbers

你是否支持RTL语言?
├─ 无RTL语言支持计划
│  └─ 仍使用leading/trailing布局
│     为未来扩展做好准备
├─ 仅支持阿拉伯语
│  └─ 标准RTL支持
│     layoutDirection + leading/trailing
│     需全面测试
├─ 支持阿拉伯语+希伯来语+波斯语
│  └─ 每种语言有独特注意事项
│     希伯来语:数字处理方式不同
│     波斯语:使用不同的数字(۱۲۳)
└─ 混合LTR/RTL内容
   └─ 为每个组件设置明确方向
      强制代码、URL、数字使用LTR

NEVER Do

绝对禁忌

String Management

字符串管理

NEVER concatenate localized strings:
swift
// ❌ Breaks in languages with different word order
let message = NSLocalizedString("hello", comment: "") + " " + userName

// German: "Hallo" + " " + "Hans" = "Hallo Hans" ✓
// Japanese: "こんにちは" + " " + "田中" = "こんにちは 田中" ✗
// Should be "田中さん、こんにちは"

// ✅ Use format strings
let format = NSLocalizedString("greeting.format", comment: "")
let message = String(format: format, userName)
// greeting.format = "Hello, %@!" (en)
// greeting.format = "%@さん、こんにちは!" (ja)
NEVER embed numbers in translation keys:
swift
// ❌ Doesn't handle plural rules
"items.1" = "1 item"
"items.2" = "2 items"
"items.3" = "3 items"
// What about 0? 100? Arabic's 6 forms?

// ✅ Use stringsdict for plurals
String.localizedStringWithFormat(
    NSLocalizedString("items.count", comment: ""),
    count
)
NEVER assume string length:
swift
// ❌ German is ~30% longer than English
.frame(width: 100)  // "Settings" fits, "Einstellungen" doesn't

// ✅ Use flexible layouts
.frame(minWidth: 80)
// Or
.fixedSize(horizontal: true, vertical: false)
NEVER use left/right in layouts:
swift
// ❌ Breaks in RTL
.padding(.left, 16)
.frame(alignment: .left)

// ✅ Use leading/trailing
.padding(.leading, 16)
.frame(alignment: .leading)
绝对不要拼接本地化字符串:
swift
// ❌ 在语序不同的语言中会出错
let message = NSLocalizedString("hello", comment: "") + " " + userName

// 德语:"Hallo" + " " + "Hans" = "Hallo Hans" ✓
// 日语:"こんにちは" + " " + "田中" = "こんにちは 田中" ✗
// 正确写法应为 "田中さん、こんにちは"

// ✅ 使用格式化字符串
let format = NSLocalizedString("greeting.format", comment: "")
let message = String(format: format, userName)
// greeting.format = "Hello, %@!"(英语)
// greeting.format = "%@さん、こんにちは!"(日语)
绝对不要在翻译键中嵌入数字:
swift
// ❌ 无法处理复数规则
"items.1" = "1 item"
"items.2" = "2 items"
"items.3" = "3 items"
// 那0、100怎么办?阿拉伯语的6种形式呢?

// ✅ 使用stringsdict处理复数
String.localizedStringWithFormat(
    NSLocalizedString("items.count", comment: ""),
    count
)
绝对不要假设字符串长度:
swift
// ❌ 德语词汇比英语长约30%
.frame(width: 100)  // "Settings"能放下,但"Einstellungen"放不下

// ✅ 使用弹性布局
.frame(minWidth: 80)
// 或者
.fixedSize(horizontal: true, vertical: false)
绝对不要在布局中使用left/right:
swift
// ❌ 在RTL环境中会失效
.padding(.left, 16)
.frame(alignment: .left)

// ✅ 使用leading/trailing
.padding(.leading, 16)
.frame(alignment: .leading)

Runtime Language

运行时语言

NEVER change AppleLanguages without restart:
swift
// ❌ Partial UI update — inconsistent state
UserDefaults.standard.set(["ar"], forKey: "AppleLanguages")
// Some views updated, others not. Third-party SDKs broken.

// ✅ Require restart or use custom bundle
UserDefaults.standard.set(["ar"], forKey: "AppleLanguages")
showRestartRequiredAlert()  // User restarts app
NEVER forget to set locale for formatters:
swift
// ❌ Uses device locale, not app's selected language
let formatter = DateFormatter()
formatter.dateStyle = .medium
let date = formatter.string(from: Date())  // Wrong language!

// ✅ Set locale explicitly
let formatter = DateFormatter()
formatter.locale = Locale(identifier: selectedLanguage.rawValue)
formatter.dateStyle = .medium
绝对不要不重启应用就修改AppleLanguages:
swift
// ❌ UI更新不完整——状态不一致
UserDefaults.standard.set(["ar"], forKey: "AppleLanguages")
// 部分视图更新,部分未更新,第三方SDK可能失效

// ✅ 要求重启应用或使用自定义Bundle
UserDefaults.standard.set(["ar"], forKey: "AppleLanguages")
showRestartRequiredAlert()  // 用户重启应用
绝对不要忘记为格式化器设置区域:
swift
// ❌ 使用设备区域设置,而非应用选定的语言
let formatter = DateFormatter()
formatter.dateStyle = .medium
let date = formatter.string(from: Date())  // 语言错误!

// ✅ 显式设置区域
let formatter = DateFormatter()
formatter.locale = Locale(identifier: selectedLanguage.rawValue)
formatter.dateStyle = .medium

Pluralization

复数处理

NEVER use simple if/else for plurals:
swift
// ❌ Fails for Russian, Arabic, etc.
func itemsText(_ count: Int) -> String {
    if count == 1 {
        return "1 item"
    } else {
        return "\(count) items"
    }
}

// Russian: 1 товар, 2 товара, 5 товаров, 21 товар, 22 товара...
// This requires CLDR plural rules

// ✅ Use stringsdict — iOS handles rules automatically
NEVER hardcode numeral systems:
swift
// ❌ Arabic users may expect Arabic-Indic numerals
Text("\(count) items")  // Shows "5 items" even in Arabic

// ✅ Use NumberFormatter with locale
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "ar")
formatter.string(from: count as NSNumber)  // "٥"
绝对不要使用简单的if/else处理复数:
swift
// ❌ 在俄语、阿拉伯语等语言中会失效
func itemsText(_ count: Int) -> String {
    if count == 1 {
        return "1 item"
    } else {
        return "\(count) items"
    }
}

// 俄语:1 товар、2 товара、5 товаров、21 товар、22 товара...
// 这需要CLDR复数规则

// ✅ 使用stringsdict——iOS会自动处理规则
绝对不要硬编码数字系统:
swift
// ❌ 阿拉伯语用户可能期望使用阿拉伯-印度数字
Text("\(count) items")  // 即使在阿拉伯语环境中仍显示"5 items"

// ✅ 使用带区域设置的NumberFormatter
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "ar")
formatter.string(from: count as NSNumber)  // "٥"

RTL Layouts

RTL布局

NEVER use fixed directional icons:
swift
// ❌ Arrow points wrong way in RTL
Image(systemName: "arrow.right")

// ✅ Use semantic icons or flip
Image(systemName: "arrow.forward")  // Semantic
// Or
Image(systemName: "arrow.right")
    .flipsForRightToLeftLayoutDirection(true)
NEVER force layout direction globally when it should be per-component:
swift
// ❌ Phone numbers, code, etc. should stay LTR
.environment(\.layoutDirection, .rightToLeft)

// ✅ Apply selectively
VStack {
    Text(localizedContent)  // Follows RTL

    Text(phoneNumber)
        .environment(\.layoutDirection, .leftToRight)  // Always LTR

    Text(codeSnippet)
        .environment(\.layoutDirection, .leftToRight)
}

绝对不要使用固定方向的图标:
swift
// ❌ 在RTL环境中箭头方向错误
Image(systemName: "arrow.right")

// ✅ 使用语义化图标或翻转图标
Image(systemName: "arrow.forward")  // 语义化图标
// 或者
Image(systemName: "arrow.right")
    .flipsForRightToLeftLayoutDirection(true)
绝对不要在需要按组件设置时全局强制布局方向:
swift
// ❌ 电话号码、代码等内容应保持LTR
.environment(\.layoutDirection, .rightToLeft)

// ✅ 选择性应用
VStack {
    Text(localizedContent)  // 遵循RTL方向

    Text(phoneNumber)
        .environment(\.layoutDirection, .leftToRight)  // 始终保持LTR

    Text(codeSnippet)
        .environment(\.layoutDirection, .leftToRight)
}

Essential Patterns

核心模式

Type-Safe Localization with SwiftGen

使用SwiftGen实现类型安全的本地化

swift
// swiftgen.yml
// strings:
//   inputs: Resources/en.lproj/Localizable.strings
//   outputs:
//     - templateName: structured-swift5
//       output: Generated/Strings.swift

// Usage — compile-time safe
Text(L10n.Auth.Login.title)
Text(L10n.User.greeting(userName))
Text(L10n.Items.count(itemCount))

// Benefits:
// - Compiler catches missing keys
// - Auto-complete for strings
// - Refactoring safe
swift
// swiftgen.yml
// strings:
//   inputs: Resources/en.lproj/Localizable.strings
//   outputs:
//     - templateName: structured-swift5
//       output: Generated/Strings.swift

// 使用方式——编译时安全
Text(L10n.Auth.Login.title)
Text(L10n.User.greeting(userName))
Text(L10n.Items.count(itemCount))

// 优势:
// - 编译器会捕获缺失的键
// - 自动补全字符串
// - 重构安全

Stringsdict for Plurals

使用Stringsdict处理复数

xml
<!-- en.lproj/Localizable.stringsdict -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ...>
<plist version="1.0">
<dict>
    <key>items.count</key>
    <dict>
        <key>NSStringLocalizedFormatKey</key>
        <string>%#@items@</string>
        <key>items</key>
        <dict>
            <key>NSStringFormatSpecTypeKey</key>
            <string>NSStringPluralRuleType</string>
            <key>NSStringFormatValueTypeKey</key>
            <string>d</string>
            <key>zero</key>
            <string>No items</string>
            <key>one</key>
            <string>%d item</string>
            <key>other</key>
            <string>%d items</string>
        </dict>
    </dict>
</dict>
</plist>
swift
// Usage
let text = String.localizedStringWithFormat(
    NSLocalizedString("items.count", comment: ""),
    count
)
// 0 → "No items"
// 1 → "1 item"
// 5 → "5 items"
xml
<!-- en.lproj/Localizable.stringsdict -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ...>
<plist version="1.0">
<dict>
    <key>items.count</key>
    <dict>
        <key>NSStringLocalizedFormatKey</key>
        <string>%#@items@</string>
        <key>items</key>
        <dict>
            <key>NSStringFormatSpecTypeKey</key>
            <string>NSStringPluralRuleType</string>
            <key>NSStringFormatValueTypeKey</key>
            <string>d</string>
            <key>zero</key>
            <string>No items</string>
            <key>one</key>
            <string>%d item</string>
            <key>other</key>
            <string>%d items</string>
        </dict>
    </dict>
</dict>
</plist>
swift
// 使用方式
let text = String.localizedStringWithFormat(
    NSLocalizedString("items.count", comment: ""),
    count
)
// 0 → "No items"
// 1 → "1 item"
// 5 → "5 items"

RTL-Aware Layout Helpers

RTL感知的布局助手

swift
extension View {
    /// Applies leading alignment that respects RTL
    func alignLeading() -> some View {
        self.frame(maxWidth: .infinity, alignment: .leading)
    }

    /// Force LTR for content that shouldn't flip (code, URLs, phone numbers)
    func forceLTR() -> some View {
        self.environment(\.layoutDirection, .leftToRight)
    }
}

// ContentView
struct MessageCell: View {
    let message: Message

    var body: some View {
        VStack(alignment: .leading) {
            Text(message.content)
                .alignLeading()  // Respects RTL

            Text(message.codeSnippet)
                .font(.monospaced(.body)())
                .forceLTR()  // Code always LTR

            Text(message.url)
                .forceLTR()  // URLs always LTR
        }
    }
}
swift
extension View {
    /// 应用遵循RTL的左对齐
    func alignLeading() -> some View {
        self.frame(maxWidth: .infinity, alignment: .leading)
    }

    /// 强制内容保持LTR(适用于代码、URL、电话号码等不应翻转的内容)
    func forceLTR() -> some View {
        self.environment(\.layoutDirection, .leftToRight)
    }
}

// 内容视图
struct MessageCell: View {
    let message: Message

    var body: some View {
        VStack(alignment: .leading) {
            Text(message.content)
                .alignLeading()  // 遵循RTL方向

            Text(message.codeSnippet)
                .font(.monospaced(.body)())
                .forceLTR()  // 代码始终保持LTR

            Text(message.url)
                .forceLTR()  // URL始终保持LTR
        }
    }
}

Locale-Aware Formatting

区域感知的格式化

swift
struct LocalizedFormatters {
    let locale: Locale

    init(languageCode: String) {
        self.locale = Locale(identifier: languageCode)
    }

    func formatDate(_ date: Date, style: DateFormatter.Style = .medium) -> String {
        let formatter = DateFormatter()
        formatter.locale = locale
        formatter.dateStyle = style
        return formatter.string(from: date)
    }

    func formatNumber(_ number: Double) -> String {
        let formatter = NumberFormatter()
        formatter.locale = locale
        formatter.numberStyle = .decimal
        return formatter.string(from: NSNumber(value: number)) ?? "\(number)"
    }

    func formatCurrency(_ amount: Double, code: String) -> String {
        let formatter = NumberFormatter()
        formatter.locale = locale
        formatter.numberStyle = .currency
        formatter.currencyCode = code
        return formatter.string(from: NSNumber(value: amount)) ?? "\(amount)"
    }
}

swift
struct LocalizedFormatters {
    let locale: Locale

    init(languageCode: String) {
        self.locale = Locale(identifier: languageCode)
    }

    func formatDate(_ date: Date, style: DateFormatter.Style = .medium) -> String {
        let formatter = DateFormatter()
        formatter.locale = locale
        formatter.dateStyle = style
        return formatter.string(from: date)
    }

    func formatNumber(_ number: Double) -> String {
        let formatter = NumberFormatter()
        formatter.locale = locale
        formatter.numberStyle = .decimal
        return formatter.string(from: NSNumber(value: number)) ?? "\(number)"
    }

    func formatCurrency(_ amount: Double, code: String) -> String {
        let formatter = NumberFormatter()
        formatter.locale = locale
        formatter.numberStyle = .currency
        formatter.currencyCode = code
        return formatter.string(from: NSNumber(value: amount)) ?? "\(amount)"
    }
}

Quick Reference

快速参考

Plural Forms by Language

各语言复数形式

LanguageFormsExample (1, 2, 5)
Englishone, other1 item, 2 items, 5 items
Frenchone, other1 élément, 2 éléments
Russianone, few, many, other1 файл, 2 файла, 5 файлов
Arabiczero, one, two, few, many, other6 forms!
Japaneseother onlyNo grammatical plural
语言复数形式示例(1、2、5)
英语one、other1个项目、2个项目、5个项目
法语one、other1 élément、2 éléments
俄语one、few、many、other1 файл、2 файла、5 файлов
阿拉伯语zero、one、two、few、many、other6种形式!
日语仅other无语法复数

RTL Languages

RTL语言

LanguageScript DirectionNumerals
ArabicRTLArabic-Indic (٠١٢) or Western
HebrewRTLWestern
PersianRTLExtended Arabic (۰۱۲)
UrduRTLExtended Arabic
语言文字方向数字系统
阿拉伯语RTL阿拉伯-印度数字(٠١٢)或西方数字
希伯来语RTL西方数字
波斯语RTL扩展阿拉伯数字(۰۱۲)
乌尔都语RTL扩展阿拉伯数字

String Expansion Guidelines

字符串扩展指南

Source (English)Expansion
1-10 chars+200-300%
11-20 chars+80-100%
21-50 chars+60-80%
51-70 chars+50-60%
70+ chars+30%
源语言(英语)扩展比例
1-10个字符+200-300%
11-20个字符+80-100%
21-50个字符+60-80%
51-70个字符+50-60%
70+个字符+30%

Red Flags

危险信号

SmellProblemFix
String concatenationWord order variesFormat strings
if count == 1 elseWrong plural rulesstringsdict
.padding(.left)Breaks RTL.padding(.leading)
DateFormatter without localeWrong languageSet locale explicitly
Runtime language without restartInconsistent UIRequire restart
Fixed frame widths for textText truncationFlexible layouts
Hardcoded "1, 2, 3"Wrong numeral systemNumberFormatter with locale
问题迹象潜在问题修复方案
字符串拼接语序适配问题使用格式化字符串
if count == 1 else 逻辑复数规则错误使用stringsdict
.padding(.left)RTL环境失效使用.padding(.leading)
DateFormatter未设置区域语言显示错误显式设置区域
运行时语言切换未重启应用UI状态不一致要求用户重启应用
文本使用固定宽度布局文本被截断使用弹性布局
硬编码数字“1、2、3”数字系统不匹配使用带区域的NumberFormatter