自己紹介
株式会社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 の Text は LocalizedStringKey を受け取るため、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 の各エントリの
stateをtranslated以外(new/needs_review)のまま残さない - リリース前に xcstrings を開いて Filter
Needs Review/Newをかけ、ゼロを確認
ハマりポイント3:DEVELOPMENT_LANGUAGE 未設定で App Store のデフォルト言語が英語に
これは初回リリース後では取り返しがつきにくい罠です。
Why
project.yml(XcodeGen)または Info.plist で CFBundleDevelopmentRegion を未設定にすると、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 のsourceLanguageもjaにするのが整合的ですが、僕のように「ソースは英語キー+英語 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
メインターゲットの CFBundleDevelopmentRegion を ja_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.ymlにdevelopmentLanguage,DEVELOPMENT_LANGUAGE,CFBundleDevelopmentRegionの3箇所が設定されている -
Widget など拡張ターゲットにも
CFBundleDevelopmentRegionが設定されている -
SWIFT_EMIT_LOC_STRINGS = YESが有効 -
String(localized:)を呼ぶ箇所で インライン補間していない -
すべての
String(localized:)呼び出しにdefaultValueが指定されている -
xcstrings の
stateがすべてtranslated(new/needs_reviewがゼロ) - Plural の必要なキーで Variations が設定されている
- シミュレータの言語設定を ja / en でそれぞれ起動して目視確認
おわりに
String Catalog は導入すると .strings 時代の手動マージ地獄から解放されますが、Swift API の使い方を一歩間違えると静かに翻訳されないのが難しいところです。「ビルドは通る、画面も出る、でも英語のまま」というバグはレビューで気づきにくく、ユーザーから報告が来て初めて分かります。
今回紹介した5つの罠を踏まなければ、日英2言語対応はかなりスムーズに進みます。これから新規アプリで多言語対応する方の参考になれば幸いです。
紹介
実例として登場した筋トレ記録アプリ WorkoutDiary は App Store で公開しています。よかったら触ってみてください。
- App Store: TODO(リリース後にURL差し替え)
参考
- String Catalog | Apple Developer Documentation
String.LocalizationValue| Apple Developer Documentationinit(localized:defaultValue:table:bundle:locale:comment:)| Apple Developer DocumentationLocalizedStringKey| Apple Developer Documentation- WWDC23 - Discover String Catalogs
- Information Property List - CFBundleDevelopmentRegion
- XcodeGen Project Spec
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!