0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Localizable.xcstrings で日英対応した時にハマったこと【SwiftUI / Xcode 15+ String Catalog】

0
Posted at

自己紹介

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

はじめに

個人開発している筋トレ記録アプリ WorkoutDiary を日英対応した際、Xcode 15 で導入された String Catalog(Localizable.xcstrings を使いました。

.strings ファイル時代と比べてキー管理がGUIで完結する一方、Swift側の書き方を一歩間違えるとサイレントに翻訳されないという罠がいくつかあります。実際 v1.0.5 で「String(localized:) のインライン補間によるローカライズ不具合」をバグ修正としてリリースしました。

この記事では、実プロジェクトでハマったポイント5つを、Why(なぜ起きるか)と Fix(解決法)をセットで紹介します。

対象環境:

  • Xcode 15+(記事執筆時は Xcode 26.2 を使用)
  • iOS 18.0+ / Swift 6.0
  • SwiftUI

String Catalog の基本

Localizable.xcstrings は JSON 形式の単一ファイルで、各キーに対して言語別の翻訳・状態(new / translated / needs_review)を持ちます。実ファイルの一部抜粋:

{
  "sourceLanguage" : "en",
  "strings" : {
    "accessibility.addExercise" : {
      "localizations" : {
        "en" : {
          "stringUnit" : { "state" : "translated", "value" : "Add exercise" }
        },
        "ja" : {
          "stringUnit" : { "state" : "translated", "value" : "種目を追加" }
        }
      }
    }
  }
}

Swift 側で参照する典型パターンはこちら。

import SwiftUI

struct AddButton: View {
    var body: some View {
        Button {
            // ...
        } label: {
            // SwiftUI の Text は自動で xcstrings を参照する
            Text("addExercise.label")
        }
        .accessibilityLabel(
            String(localized: "accessibility.addExercise",
                   defaultValue: "Add exercise")
        )
    }
}

ここまでは公式ドキュメント通りで素直です。問題はここから。


ハマりポイント1:String(localized:) のインライン補間で翻訳されない

これが v1.0.5 で実際に修正したバグです。

Before(バグっていたコード)

import Foundation

func greetingMessage(name: String) -> String {
    // 一見動きそうに見える
    return String(localized: "Hello \(name)")
}

Why:なぜ翻訳されないのか

String(localized:) の第一引数は StaticString ではなく String.LocalizationValue 型です。Swiftコンパイラが xcstrings に登録するキーを抽出するのは ビルド時のソースコード解析(SWIFT_EMIT_LOC_STRINGS) のタイミング。

このとき登録されるキーは「ソースコード上の文字列リテラル」そのものであり、\(name) の部分は補間プレースホルダ(%@)に置き換えられた形でキー化されます。つまり、

  • ソース上:"Hello \(name)"
  • xcstrings 上のキー:"Hello %@"

となります。ここがハマりどころで、xcstrings GUI で「Hello \(name)」というキーを探しても永遠に見つからないHello %@ で探さないとダメです。

さらに悪いのが、実行時に name"Tom" だと "Hello Tom" というキーで検索しにいくケースがあること(古いランタイムや設定ミス時)。これだと当然どの言語にもヒットせず、ソース文字列がそのまま返されて「英語のまま表示される」現象になります。

Fix:キーと表示文字列を分離する

実プロジェクトで採用しているパターンは以下の2つ。

パターンA:キー方式(推奨)

import Foundation

func greetingMessage(name: String) -> String {
    // キーは固定文字列、defaultValue 内でだけ補間する
    let format = String(localized: "greeting.hello",
                        defaultValue: "Hello %@")
    return String(format: format, name)
}

xcstrings 側は greeting.hello というキーで管理し、ja の値を "こんにちは、%@さん" などに翻訳します。

パターンB:AttributedString / SwiftUI Text の補間

SwiftUI の TextLocalizedStringKey を受け取るため、View 内であればインライン補間がそのまま動きます。

import SwiftUI

struct GreetingView: View {
    let name: String
    var body: some View {
        // これは OK。SwiftUI が自動でローカライズキーとして扱う
        Text("Hello \(name)")
    }
}

ただし String(localized:) を経由するロジック層・ViewModel 層ではパターンA一択です。「View では Text、ロジックでは String(format:NSLocalizedString...)」のように使い分けがいるのが地味に面倒。


ハマりポイント2:defaultValue を省くと翻訳漏れに気づけない

実プロジェクトでは必ず defaultValue を併記しています。

// WorkoutDiary/ViewModels/StatsViewModel.swift より抜粋
case .oneMonth: String(localized: "stats.period.oneMonth", defaultValue: "1 Month")
case .threeMonths: String(localized: "stats.period.threeMonths", defaultValue: "3 Months")
case .all: String(localized: "stats.period.all", defaultValue: "All Time")

Why

defaultValue を省くと、キー名そのものsourceLanguage(en)の値として登録されます。すると xcstrings 上は:

"stats.period.oneMonth" : {
  "localizations" : {
    "en" : { "stringUnit" : { "state" : "new", "value" : "stats.period.oneMonth" } }
  }
}

英語ロケールで「stats.period.oneMonth」というキー文字列がそのまま画面に出る、という事故になります。しかも state"new" のまま放置されると、ja 翻訳をしないままアプリにバンドルされてもビルドは通ってしまう。

Fix

  • 必ず defaultValue を書く(en の表示文字列をソース上で確定させる)
  • xcstrings の各エントリの statetranslated 以外(new / needs_review)のまま残さない
  • リリース前に xcstrings を開いて Filter Needs Review / New をかけ、ゼロを確認

ハマりポイント3:DEVELOPMENT_LANGUAGE 未設定で App Store のデフォルト言語が英語に

これは初回リリース後では取り返しがつきにくい罠です。

Why

project.yml(XcodeGen)または Info.plistCFBundleDevelopmentRegion を未設定にすると、Xcode のデフォルト値である en が使われます。すると:

  • App Store Connect 上の「デフォルト言語」が English で登録される
  • アプリ名・サブタイトル・スクリーンショット・説明文のマスター言語が英語になる
  • 後から「日本語をマスターに」変えるには Apple に問い合わせるか、メタデータ全言語の翻訳整備が必要になる

日本語アプリとして提出するなら初回ビルドアップロード前に必ず設定すべきです。

Fix(XcodeGen の project.yml 例)

options:
  developmentLanguage: ja        # 必須
  localizations:
    - ja
    - en

settings:
  base:
    DEVELOPMENT_LANGUAGE: ja     # 必須
    SWIFT_EMIT_LOC_STRINGS: YES  # xcstrings へのキー自動抽出を有効化

targets:
  WorkoutDiary:
    info:
      properties:
        CFBundleDevelopmentRegion: ja_JP   # 必須

設定後は xcodegen generate.xcodeproj を再生成し、ビルドして Info.plist に CFBundleDevelopmentRegion = ja_JP が反映されているか確認します。

補足:Localizable.xcstrings 側の "sourceLanguage" は別概念で、これは「ソースコードに書いてある文字列の言語」を指します。developmentLanguage: ja のときは xcstrings の sourceLanguageja にするのが整合的ですが、僕のように「ソースは英語キー+英語 defaultValue で書きたい」場合は xcstrings 側のみ en のままにすることもあります(その場合でもアプリのデフォルト言語は ja として App Store に登録される)。


ハマりポイント4:複数形(Plurals)対応で Text("%lld 件") がハマる

「3 sets」「1 set」のように単複で表現を変えたいケース。

Before(NG パターン)

import SwiftUI

struct SetCountLabel: View {
    let count: Int
    var body: some View {
        Text("\(count) sets")  // 英語の単数/複数を考慮できない
    }
}

count == 1 でも「1 sets」と表示されてしまう。日本語は単複の区別がないので問題になりにくいですが、英語版で違和感が出ます。

Why

xcstrings には Variations → Plural という機能があり、zero / one / two / few / many / other のカテゴリを言語別に定義できます。Swift 側はこれを意識せず Text に整数を渡すだけで OK ですが、xcstrings 側で Plural 設定をしていないと単数/複数の出し分けが効きません。

Fix:xcstrings で Plural を定義

xcstrings GUI でキーを右クリック → Vary by Plural で定義します。JSON 構造はこんな形:

"setCount.label" : {
  "localizations" : {
    "en" : {
      "variations" : {
        "plural" : {
          "one"   : { "stringUnit" : { "state" : "translated", "value" : "%lld set" } },
          "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld sets" } }
        }
      }
    },
    "ja" : {
      "variations" : {
        "plural" : {
          "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld セット" } }
        }
      }
    }
  }
}

Swift 側はこう書きます。

import SwiftUI

struct SetCountLabel: View {
    let count: Int
    var body: some View {
        Text("setCount.label \(count)")  // SwiftUI 経由なら自動で Plural 解決
    }
}

// ロジック層から取りたい場合
func label(for count: Int) -> String {
    let format = String(localized: "setCount.label",
                        defaultValue: "%lld sets")
    return String(format: format, count)
}

英語の「1 set / 2 sets」が自動で切り替わるようになります。


ハマりポイント5:Widget 拡張ターゲットの設定漏れ

WorkoutDiary には Widget 拡張ターゲットがあり、ここでもハマりました。

Why

メインターゲットの CFBundleDevelopmentRegionja_JP にしても、Widget 拡張ターゲットの Info.plist は別管理です。設定が漏れると:

  • ホーム画面の Widget だけ英語表記になる
  • Widget が Localizable.xcstrings を参照しているのに、Bundle のロケール解決が違って翻訳が当たらない

特に、Widget 用に別の .xcstrings を用意している場合や、Bundle(for:) で別バンドルを参照している場合は要注意。

Fix

project.yml で Widget ターゲット側にも同じ設定を入れます。

targets:
  WorkoutDiaryWidget:
    type: app-extension
    platform: iOS
    info:
      properties:
        CFBundleDevelopmentRegion: ja_JP   # メインと同じ値を必ず指定
    settings:
      base:
        SWIFT_EMIT_LOC_STRINGS: YES

加えて、Widget 内で String(localized:) を呼ぶ際は 明示的にバンドルを指定するとトラブルが減ります。

import SwiftUI
import WidgetKit

struct WorkoutDiaryWidgetEntryView: View {
    var body: some View {
        Text(String(localized: "widget.title",
                    defaultValue: "Today's Workout",
                    bundle: .main))
    }
}

アクセシビリティラベルの統一管理

最後におまけで。VoiceOver 用ラベルも xcstrings に統一すると保守が楽になります。実プロジェクトでは accessibility.* プレフィックスでまとめています。

import SwiftUI

struct ShareStatsButton: View {
    let action: () -> Void
    var body: some View {
        Button(action: action) {
            Image(systemName: "square.and.arrow.up")
        }
        .accessibilityLabel(
            String(localized: "accessibility.shareStats",
                   defaultValue: "Share stats")
        )
    }
}

xcstrings 側で ja を「統計をシェア」と入れておけば、VoiceOver ユーザーにも日本語で読み上げられます。アクセシビリティラベルの翻訳漏れは UI からは見えないので、accessibility.* で grep して網羅チェックするフローを推奨します。


チェックリスト(リリース前)

僕がリリース前に毎回確認している項目です。

  • project.ymldevelopmentLanguage, DEVELOPMENT_LANGUAGE, CFBundleDevelopmentRegion の3箇所が設定されている
  • Widget など拡張ターゲットにも CFBundleDevelopmentRegion が設定されている
  • SWIFT_EMIT_LOC_STRINGS = YES が有効
  • String(localized:) を呼ぶ箇所で インライン補間していない
  • すべての String(localized:) 呼び出しに defaultValue が指定されている
  • xcstrings の state がすべて translatednew / needs_review がゼロ)
  • Plural の必要なキーで Variations が設定されている
  • シミュレータの言語設定を ja / en でそれぞれ起動して目視確認

おわりに

String Catalog は導入すると .strings 時代の手動マージ地獄から解放されますが、Swift API の使い方を一歩間違えると静かに翻訳されないのが難しいところです。「ビルドは通る、画面も出る、でも英語のまま」というバグはレビューで気づきにくく、ユーザーから報告が来て初めて分かります。

今回紹介した5つの罠を踏まなければ、日英2言語対応はかなりスムーズに進みます。これから新規アプリで多言語対応する方の参考になれば幸いです。

紹介

実例として登場した筋トレ記録アプリ WorkoutDiary は App Store で公開しています。よかったら触ってみてください。

  • App Store: TODO(リリース後にURL差し替え)

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?