560 lines
13 KiB
Markdown
560 lines
13 KiB
Markdown
# ARBファイル移行ガイド
|
||
|
||
**対象**: 現在のMap-based翻訳システム → ARB (Application Resource Bundle) への移行
|
||
**推奨タイミング**: 翻訳キーが100個を超えたとき、または3言語目追加時
|
||
|
||
---
|
||
|
||
## 🎯 なぜARBへ移行すべきか?
|
||
|
||
### 現在の実装 (Map-based) の限界
|
||
|
||
```dart
|
||
// lib/utils/translations.dart (109行)
|
||
static const Map<String, Map<String, String>> _translations = {
|
||
'home': {'ja': 'ホーム', 'en': 'Home'},
|
||
'save': {'ja': '保存', 'en': 'Save'},
|
||
// ... 61個のキー
|
||
};
|
||
```
|
||
|
||
**問題点:**
|
||
1. ✗ ファイルが肥大化 (200キー超えると500行以上)
|
||
2. ✗ 翻訳者がDartコードを触る必要がある
|
||
3. ✗ パラメータ埋め込みが弱い (`"Welcome, ${name}"` は手動実装)
|
||
4. ✗ 複数形対応が困難 (`1 item` vs `2 items`)
|
||
5. ✗ IDE補完が弱い (タイポに気づきにくい)
|
||
|
||
### ARB形式のメリット
|
||
|
||
```json
|
||
// lib/l10n/app_ja.arb
|
||
{
|
||
"@@locale": "ja",
|
||
"welcomeMessage": "ようこそ、{name}さん",
|
||
"@welcomeMessage": {
|
||
"description": "ユーザーへの歓迎メッセージ",
|
||
"placeholders": {
|
||
"name": { "type": "String" }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**メリット:**
|
||
1. ✓ 翻訳者フレンドリー (JSON形式、説明付き)
|
||
2. ✓ Flutter公式標準 (長期サポート保証)
|
||
3. ✓ 型安全な自動生成コード
|
||
4. ✓ パラメータ・複数形対応
|
||
5. ✓ IDE補完が効く
|
||
6. ✓ 翻訳管理ツールと連携可能 (Crowdin, Lokaliseなど)
|
||
|
||
---
|
||
|
||
## 📋 移行手順 (推定時間: 2-3時間)
|
||
|
||
### Step 1: 設定ファイル作成 (5分)
|
||
|
||
#### 1-1. `l10n.yaml` を作成
|
||
|
||
```yaml
|
||
# l10n.yaml (プロジェクトルート)
|
||
arb-dir: lib/l10n
|
||
template-arb-file: app_ja.arb
|
||
output-localization-file: app_localizations.dart
|
||
output-dir: lib/l10n/generated
|
||
synthetic-package: false
|
||
```
|
||
|
||
#### 1-2. `pubspec.yaml` を更新
|
||
|
||
```yaml
|
||
# pubspec.yaml
|
||
flutter:
|
||
generate: true # この行を追加
|
||
uses-material-design: true
|
||
assets:
|
||
- assets/
|
||
```
|
||
|
||
---
|
||
|
||
### Step 2: 既存の翻訳をARBファイルに変換 (30分)
|
||
|
||
#### 2-1. `lib/l10n/app_ja.arb` を作成
|
||
|
||
```json
|
||
{
|
||
"@@locale": "ja",
|
||
|
||
"_comment_navigation": "=== ナビゲーション ===",
|
||
"home": "ホーム",
|
||
"@home": {
|
||
"description": "ホームタブのラベル"
|
||
},
|
||
|
||
"scan": "スキャン",
|
||
"@scan": {
|
||
"description": "スキャンタブのラベル"
|
||
},
|
||
|
||
"sommelier": "ソムリエ",
|
||
"map": "マップ",
|
||
"myPage": "マイページ",
|
||
"promo": "販促",
|
||
"analytics": "分析",
|
||
"shop": "店舗",
|
||
|
||
"_comment_actions": "=== 共通アクション ===",
|
||
"save": "保存",
|
||
"cancel": "キャンセル",
|
||
"delete": "削除",
|
||
"close": "閉じる",
|
||
"ok": "OK",
|
||
"confirm": "確認",
|
||
|
||
"_comment_home": "=== ホーム画面 ===",
|
||
"menuCreation": "お品書き作成",
|
||
"searchPlaceholder": "銘柄・酒蔵・都道府県...",
|
||
"sort": "並び替え",
|
||
|
||
"_comment_params": "=== パラメータ付き ===",
|
||
"welcomeMessage": "ようこそ、{name}さん",
|
||
"@welcomeMessage": {
|
||
"description": "ユーザーへの歓迎メッセージ",
|
||
"placeholders": {
|
||
"name": {
|
||
"type": "String",
|
||
"example": "太郎"
|
||
}
|
||
}
|
||
},
|
||
|
||
"itemCount": "{count, plural, =0{お酒がありません} =1{{count}件のお酒} other{{count}件のお酒}}",
|
||
"@itemCount": {
|
||
"description": "お酒の件数表示",
|
||
"placeholders": {
|
||
"count": {
|
||
"type": "int",
|
||
"example": "5"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2-2. `lib/l10n/app_en.arb` を作成
|
||
|
||
```json
|
||
{
|
||
"@@locale": "en",
|
||
|
||
"home": "Home",
|
||
"scan": "Scan",
|
||
"sommelier": "Sommelier",
|
||
"map": "Map",
|
||
"myPage": "My Page",
|
||
"promo": "Promo",
|
||
"analytics": "Analytics",
|
||
"shop": "Shop",
|
||
|
||
"save": "Save",
|
||
"cancel": "Cancel",
|
||
"delete": "Delete",
|
||
"close": "Close",
|
||
"ok": "OK",
|
||
"confirm": "Confirm",
|
||
|
||
"menuCreation": "Menu Creation",
|
||
"searchPlaceholder": "Brand, Brewery, Prefecture...",
|
||
"sort": "Sort",
|
||
|
||
"welcomeMessage": "Welcome, {name}",
|
||
"itemCount": "{count, plural, =0{No sake} =1{1 sake} other{{count} sake}}"
|
||
}
|
||
```
|
||
|
||
#### 2-3. 自動変換スクリプト (Optional)
|
||
|
||
```dart
|
||
// tools/convert_to_arb.dart
|
||
import 'dart:io';
|
||
import 'dart:convert';
|
||
|
||
void main() {
|
||
// translations.dartから読み込み
|
||
final translations = {
|
||
'home': {'ja': 'ホーム', 'en': 'Home'},
|
||
'save': {'ja': '保存', 'en': 'Save'},
|
||
// ... (現在の61キーをコピー)
|
||
};
|
||
|
||
// ARB形式に変換
|
||
final jaArb = {'@@locale': 'ja'};
|
||
final enArb = {'@@locale': 'en'};
|
||
|
||
translations.forEach((key, values) {
|
||
jaArb[key] = values['ja'];
|
||
enArb[key] = values['en'];
|
||
});
|
||
|
||
// ファイルに書き込み
|
||
File('lib/l10n/app_ja.arb').writeAsStringSync(
|
||
JsonEncoder.withIndent(' ').convert(jaArb)
|
||
);
|
||
File('lib/l10n/app_en.arb').writeAsStringSync(
|
||
JsonEncoder.withIndent(' ').convert(enArb)
|
||
);
|
||
|
||
print('✅ ARBファイル生成完了');
|
||
}
|
||
```
|
||
|
||
実行:
|
||
```bash
|
||
dart run tools/convert_to_arb.dart
|
||
```
|
||
|
||
---
|
||
|
||
### Step 3: コード生成 (5分)
|
||
|
||
```bash
|
||
# 依存パッケージ取得 & コード生成
|
||
flutter pub get
|
||
|
||
# 自動的に lib/l10n/generated/app_localizations.dart が生成される
|
||
```
|
||
|
||
生成されるファイル:
|
||
```
|
||
lib/l10n/generated/
|
||
├── app_localizations.dart # メインクラス
|
||
├── app_localizations_ja.dart # 日本語実装
|
||
└── app_localizations_en.dart # 英語実装
|
||
```
|
||
|
||
---
|
||
|
||
### Step 4: アプリ設定を更新 (10分)
|
||
|
||
#### 4-1. `main.dart` を更新
|
||
|
||
```dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; // 追加
|
||
|
||
class MyApp extends ConsumerWidget {
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final locale = ref.watch(localeProvider);
|
||
|
||
return MaterialApp(
|
||
// 追加: localizationsDelegates
|
||
localizationsDelegates: const [
|
||
AppLocalizations.delegate, // 追加
|
||
GlobalMaterialLocalizations.delegate,
|
||
GlobalWidgetsLocalizations.delegate,
|
||
GlobalCupertinoLocalizations.delegate,
|
||
],
|
||
|
||
// 追加: supportedLocales
|
||
supportedLocales: const [
|
||
Locale('ja'),
|
||
Locale('en'),
|
||
],
|
||
|
||
locale: locale,
|
||
|
||
// ... 既存のコード
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Step 5: 各画面のコードを置き換え (1-2時間)
|
||
|
||
#### Before (Map-based)
|
||
|
||
```dart
|
||
class HomeScreen extends ConsumerWidget {
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final userProfile = ref.watch(userProfileProvider);
|
||
final t = Translations(userProfile.locale);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(t['menuCreation']),
|
||
),
|
||
body: Text(t['welcomeMessage']), // パラメータ非対応
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### After (ARB-based)
|
||
|
||
```dart
|
||
class HomeScreen extends ConsumerWidget {
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(l10n.menuCreation),
|
||
),
|
||
body: Text(l10n.welcomeMessage('太郎')), // パラメータ対応
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 一括置換パターン
|
||
|
||
```regex
|
||
# 検索
|
||
t\['(\w+)'\]
|
||
|
||
# 置換
|
||
l10n.$1
|
||
```
|
||
|
||
---
|
||
|
||
### Step 6: 旧コードの削除 (5分)
|
||
|
||
```bash
|
||
# translations.dartを削除
|
||
rm lib/utils/translations.dart
|
||
|
||
# Gitでコミット
|
||
git add .
|
||
git commit -m "feat: Migrate to ARB-based localization"
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 移行後の使い方
|
||
|
||
### 基本的な使い方
|
||
|
||
```dart
|
||
// BuildContext経由でアクセス
|
||
final l10n = AppLocalizations.of(context)!;
|
||
|
||
Text(l10n.home) // "ホーム" or "Home"
|
||
Text(l10n.save) // "保存" or "Save"
|
||
```
|
||
|
||
### パラメータ付き翻訳
|
||
|
||
```dart
|
||
// 1つのパラメータ
|
||
Text(l10n.welcomeMessage('太郎')) // "ようこそ、太郎さん"
|
||
|
||
// 複数のパラメータ
|
||
Text(l10n.dateRange(startDate, endDate))
|
||
```
|
||
|
||
### 複数形対応
|
||
|
||
```dart
|
||
// 件数に応じて自動切り替え
|
||
Text(l10n.itemCount(0)) // "お酒がありません"
|
||
Text(l10n.itemCount(1)) // "1件のお酒"
|
||
Text(l10n.itemCount(5)) // "5件のお酒"
|
||
```
|
||
|
||
### 日付・通貨フォーマット
|
||
|
||
```dart
|
||
import 'package:intl/intl.dart';
|
||
|
||
// 日付
|
||
final formatter = DateFormat.yMd(l10n.localeName);
|
||
formatter.format(DateTime.now()); // "2026/01/20" (ja) or "1/20/2026" (en)
|
||
|
||
// 通貨
|
||
final currency = NumberFormat.currency(locale: l10n.localeName, symbol: '¥');
|
||
currency.format(1500); // "¥1,500"
|
||
```
|
||
|
||
---
|
||
|
||
## 🧪 移行後のテスト
|
||
|
||
### 1. 翻訳漏れチェック
|
||
|
||
```dart
|
||
// test/l10n_test.dart
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
void test('All translation keys exist in all locales', () {
|
||
final jaFile = File('lib/l10n/app_ja.arb');
|
||
final enFile = File('lib/l10n/app_en.arb');
|
||
|
||
final jaKeys = (jsonDecode(jaFile.readAsStringSync()) as Map)
|
||
.keys
|
||
.where((k) => !k.startsWith('@') && !k.startsWith('_'))
|
||
.toSet();
|
||
|
||
final enKeys = (jsonDecode(enFile.readAsStringSync()) as Map)
|
||
.keys
|
||
.where((k) => !k.startsWith('@') && !k.startsWith('_'))
|
||
.toSet();
|
||
|
||
expect(jaKeys, equals(enKeys), reason: '翻訳漏れがあります');
|
||
});
|
||
```
|
||
|
||
実行:
|
||
```bash
|
||
flutter test test/l10n_test.dart
|
||
```
|
||
|
||
### 2. 手動テスト
|
||
|
||
1. アプリを起動
|
||
2. 設定で言語を「English」に変更
|
||
3. 全画面を確認
|
||
4. パラメータ付き翻訳が正しく動作するか確認
|
||
|
||
---
|
||
|
||
## 📊 移行前後の比較
|
||
|
||
| 項目 | Map-based (現在) | ARB-based (移行後) |
|
||
|------|------------------|-------------------|
|
||
| **ファイル構成** | 1ファイル (109行) | 2ファイル (ja/en) |
|
||
| **翻訳者の作業** | Dartコードを編集 | JSONファイルのみ |
|
||
| **パラメータ埋め込み** | 手動実装 | 自動対応 |
|
||
| **複数形** | 非対応 | 完全対応 |
|
||
| **型安全性** | 弱い (String返却) | 強い (生成コード) |
|
||
| **IDE補完** | なし | あり |
|
||
| **翻訳管理ツール** | 非対応 | 対応 |
|
||
| **学習コスト** | 低い | 中程度 |
|
||
| **長期メンテナンス** | 困難 (肥大化) | 容易 |
|
||
|
||
---
|
||
|
||
## 🚨 注意事項
|
||
|
||
### 1. BuildContextが必要
|
||
|
||
```dart
|
||
// ❌ NG: Providerから直接アクセス不可
|
||
final localeProvider = Provider<String>((ref) {
|
||
final l10n = AppLocalizations.of(???); // BuildContextがない!
|
||
return l10n.home;
|
||
});
|
||
|
||
// ✅ OK: Widget内でアクセス
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context)!;
|
||
return Text(l10n.home);
|
||
}
|
||
```
|
||
|
||
**解決策**: BuildContext不要な場面では、localeプロバイダーから言語コードを取得して分岐:
|
||
|
||
```dart
|
||
final locale = ref.watch(localeProvider);
|
||
final text = locale.languageCode == 'en' ? 'Home' : 'ホーム';
|
||
```
|
||
|
||
### 2. 生成ファイルはGit管理不要
|
||
|
||
```.gitignore
|
||
# ARB生成ファイルは無視
|
||
lib/l10n/generated/
|
||
```
|
||
|
||
### 3. ビルド時に自動生成される
|
||
|
||
```bash
|
||
# pubspec.yamlを変更したら必ず実行
|
||
flutter pub get
|
||
```
|
||
|
||
---
|
||
|
||
## 🔧 トラブルシューティング
|
||
|
||
### Q1: `AppLocalizations`が見つからない
|
||
|
||
**原因**: コード生成されていない
|
||
|
||
**解決策**:
|
||
```bash
|
||
flutter clean
|
||
flutter pub get
|
||
flutter pub run build_runner build --delete-conflicting-outputs
|
||
```
|
||
|
||
### Q2: 翻訳が反映されない
|
||
|
||
**原因**: ARBファイルの構文エラー
|
||
|
||
**解決策**: JSONバリデーターでチェック
|
||
```bash
|
||
# jqコマンドでバリデーション
|
||
jq . lib/l10n/app_ja.arb
|
||
```
|
||
|
||
### Q3: パラメータが表示されない
|
||
|
||
**原因**: プレースホルダー定義が不足
|
||
|
||
**解決策**: `@` で始まるメタデータを追加
|
||
```json
|
||
"welcomeMessage": "Welcome, {name}",
|
||
"@welcomeMessage": {
|
||
"placeholders": {
|
||
"name": {"type": "String"}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📚 参考資料
|
||
|
||
- [Flutter公式: Internationalizing Flutter apps](https://docs.flutter.dev/ui/accessibility-and-localization/internationalization)
|
||
- [ARB Format Specification](https://github.com/google/app-resource-bundle/wiki/ApplicationResourceBundleSpecification)
|
||
- [intl Package](https://pub.dev/packages/intl)
|
||
- [flutter_localizations](https://api.flutter.dev/flutter/flutter_localizations/flutter_localizations-library.html)
|
||
|
||
---
|
||
|
||
## ✅ チェックリスト
|
||
|
||
移行完了時に確認:
|
||
|
||
- [ ] l10n.yaml作成済み
|
||
- [ ] pubspec.yamlに `generate: true` 追加済み
|
||
- [ ] app_ja.arb / app_en.arb作成済み
|
||
- [ ] flutter pub get実行済み
|
||
- [ ] 生成ファイル確認 (lib/l10n/generated/)
|
||
- [ ] main.dartにlocalizationsDelegates追加済み
|
||
- [ ] 全画面でt['key'] → l10n.key に置き換え済み
|
||
- [ ] 翻訳漏れテスト実行済み
|
||
- [ ] 手動テストで動作確認済み
|
||
- [ ] translations.dart削除済み
|
||
- [ ] Gitコミット済み
|
||
|
||
---
|
||
|
||
**移行タイミングの目安:**
|
||
- ✅ 今すぐ (基盤が整う)
|
||
- ✅ 翻訳キー100個超え時
|
||
- ✅ 3言語目追加時
|
||
- ❌ 現在 (まだ61キーのみ、Map実装で十分)
|