はじめに
ソーイ株式会社の臼井です。前回は、Vue.jsとLaravelを使用したWebアプリの多言語対応について書きました。今回は、Flutterアプリでslangを使用して多言語対応を実装した方法を紹介します。
実装中にbuild_runnerによる翻訳コードの生成が正常に動作しなくなったため、発生した現象や試した対応についてもまとめています。
なお、執筆時点では、すべての画面の多言語対応およびユーザーによる言語切り替え機能は完成しておらず、現在のデプロイ環境では日本語表示に固定しています。
目次
想定読者
この記事は、次のような方を対象としています。
- Flutterアプリで多言語対応を実装したい方
-
slangの使い方を知りたい方 -
slangを使用していて、翻訳コード生成時のエラーについて調べている方
開発環境
本記事は、以下の環境を前提としています。
| 項目 | バージョン |
|---|---|
| OS | macOS arm64 |
| FVM | 4.1.2 |
| Flutter | 3.44.2 stable |
| Dart | 3.12.2 |
| slang | 4.16.0 |
| slang_flutter | 4.16.0 |
| build_runner | 2.15.0 |
| slang_build_runner | 4.16.0 |
使用するパッケージ
今回の構成では、次の4つのパッケージを使用しています。
dependencies:
slang: ^4.16.0
slang_flutter: ^4.16.0
dev_dependencies:
build_runner: ^2.15.0
slang_build_runner: ^4.16.0
それぞれの役割は次のとおりです。
| パッケージ | 役割 |
|---|---|
slang |
翻訳コードの生成と多言語対応の基本機能 |
slang_flutter |
FlutterのLocaleやWidgetとの連携 |
build_runner |
Dartのコード生成処理を実行する仕組み |
slang_build_runner |
Slangのコード生成をbuild_runnerから実行するための連携パッケージ |
FlutterでSlangを使用するだけであれば、基本となるのはslangとslang_flutterです。
build_runner経由で翻訳コードを生成する場合に、追加でbuild_runnerとslang_build_runnerが必要になります。Slang公式では、開発中はdart run slangによる生成が推奨され、build_runner方式はCIや初回チェックアウト時に使用できる代替手段として案内されています。
本プロジェクトでは、ほかのコード生成処理とまとめて実行するため、build_runner方式を採用しています。
多言語対応の方針
今回のモバイルアプリでは、Webアプリと比べて翻訳対象の規模が小さいため、翻訳キーに階層を設けず、フラットな構成にしています。
例えば、次のような階層構造にはしていません。
{
"account": {
"title": "アカウント管理"
}
}
代わりに、次のような構成にしています。
{
"accountTitle": "アカウント管理"
}
アプリの規模が大きくなった場合は、キーの重複や管理の難しさが発生する可能性があります。その場合は、機能や画面単位で階層化することも検討します。
翻訳作業には、Web版と同様にChatGPTも利用しています。
ただし、モバイル版の翻訳ファイルは1ファイルあたりの内容が比較的小さいため、ファイル全体を毎回生成するのではなく、追加・修正するキーだけを出力してもらうことが多いです。
全体構成
翻訳ファイルから画面へ文言が表示されるまでの流れは、次のようになります。
翻訳元となるJSONファイルを修正したあと、build_runnerでstrings.g.dart strings_en.g.dart strings_ja.g.dartを生成し、生成された型安全な翻訳キーをFlutterのWidgetから参照します。
実装方法
翻訳ファイルを作成する
翻訳ファイルは、lib/i18n配下に配置します。
lib/
└── i18n/
├── en.i18n.json
└── ja.i18n.json
英語の翻訳ファイル
lib/i18n/en.i18n.json
{
"accountSave": "Save",
"accountCancel": "Cancel"
}
日本語の翻訳ファイル
lib/i18n/ja.i18n.json
{
"accountSave": "保存",
"accountCancel": "キャンセル"
}
build.yamlを設定する
プロジェクトルートにbuild.yamlを配置します。
プロジェクトルート/
├── pubspec.yaml
├── build.yaml
└── lib/
今回の設定は次のとおりです。
targets:
$default:
builders:
slang_build_runner:
options:
base_locale: en
fallback_strategy: base_locale
input_directory: lib/i18n
input_file_pattern: .i18n.json
output_directory: lib/i18n
output_file_name: strings.g.dart
locale_handling: true
flutter_integration: true
namespaces: false
translate_var: i18n
enum_name: AppLocale
class_name: Translations
translation_class_visibility: private
key_case: camel
key_map_case: camel
param_case: camel
string_interpolation: dart
各設定の内容は次のとおりです。
| 設定 | 指定値 | 内容 |
|---|---|---|
base_locale |
en |
基準言語を英語に設定します。 |
fallback_strategy |
base_locale |
選択中の言語に翻訳がない場合、基準言語である英語の翻訳を使用します。 |
input_directory |
lib/i18n |
翻訳ファイルを配置するディレクトリを指定します。 |
input_file_pattern |
.i18n.json |
.i18n.jsonで終わるファイルを翻訳ファイルとして読み込みます。 |
output_directory |
lib/i18n |
自動生成されるDartファイルの出力先を指定します。 |
output_file_name |
strings.g.dart |
共通の自動生成ファイル名を指定します。 |
locale_handling |
true |
LocaleSettingsやAppLocaleなど、表示言語を管理するためのコードを生成します。 |
flutter_integration |
true |
Flutterとの連携に必要なコードを生成します。 |
namespaces |
false |
翻訳ファイルを名前空間単位で分割せず、通常の翻訳キーとして扱います。 |
translate_var |
i18n |
翻訳文を参照するグローバル変数名をi18nに設定します。 |
enum_name |
AppLocale |
対応言語を表す列挙型の名前をAppLocaleに設定します。 |
class_name |
Translations |
生成される翻訳クラスの名前をTranslationsに設定します。 |
translation_class_visibility |
private |
言語ごとに生成される翻訳クラスを外部へ公開しない設定です。 |
key_case |
camel |
通常の翻訳キーをcamelCaseへ変換します。 |
key_map_case |
camel |
Map形式で定義したキーをcamelCaseへ変換します。 |
param_case |
camel |
翻訳文で使用するパラメータ名をcamelCaseへ変換します。 |
string_interpolation |
dart |
翻訳文の変数展開にDart形式の文字列補間を使用します。 |
今回の設定では、次のファイルがlib/i18n配下に生成されます。
lib/i18n/
├── strings.g.dart
├── strings_en.g.dart
└── strings_ja.g.dart
Flutter側では、基本的に共通ファイルであるstrings.g.dartを読み込みます。
import 'package:project/i18n/strings.g.dart';
なお、現在使用しているSlang 4.16.0では、output_formatは廃止されています。そのため、実際の設定では次の行を削除できます。
output_format: single_files
translate_varのデフォルトはtですが、今回はi18nに変更しています。
また、key_case、key_map_case、param_caseのデフォルトはnullです。nullはcamelCaseを意味するのではなく、元のキー名を変換しない設定です。
今回は翻訳ファイル内の表記にかかわらず、生成されるDart側の名前をcamelCaseへそろえるため、3項目すべてにcamelを指定しています。Slang公式の設定一覧でも、これらのデフォルト値はnullとされています。
翻訳コードを生成する
翻訳ファイルとbuild.yamlを用意したら、プロジェクトルートで次のコマンドを実行します。
fvm dart run build_runner build -d
-dは、次のオプションの省略形です。
--delete-conflicting-outputs
すでに存在する生成ファイルと新しく生成するファイルが競合した場合に、競合する出力を削除してから生成するためのオプションです。
正常に完了すると、次のようなファイルが生成されます。
lib/i18n/
├── strings.g.dart
├── strings_en.g.dart
└── strings_ja.g.dart
生成されたファイルは自動生成ファイルであるため、原則として直接編集しません。
翻訳JSONを変更した場合は、再度コード生成を実行します。
fvm dart run build_runner build -d
main.dartで言語を設定する
main.dartでは、アプリで使用する言語を設定します。
import 'package:project/i18n/strings.g.dart';
void main() async {
await runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
// 現在は多言語対応が未完了のため、日本語へ固定している
await LocaleSettings.setLocale(AppLocale.ja);
/*
// デバッグ中または全画面の対応完了後に使用予定
LocaleSettings.useDeviceLocale();
*/
runApp(
TranslationProvider(
child: const App(),
),
);
}, (error, stackTrace) {
// 既存のエラー処理
});
}
現在は、すべてのページが多言語対応できているわけではないため、日本語に固定しています。
await LocaleSettings.setLocale(AppLocale.ja);
すべての画面の対応が完了したあとは、端末の言語設定を使用する予定です。
LocaleSettings.useDeviceLocale();
画面の文言を翻訳キーへ置き換える
翻訳コードを生成したら、画面に直接記述していた文言を翻訳キーへ置き換えます。
まず、対象のDartファイルで、自動生成されたstrings.g.dartをimportします。
import 'package:project/i18n/strings.g.dart';
翻訳ファイルには、日本語と英語で同じキーを定義します。
ja.i18n.json
{
"accountSave": "保存"
}
en.i18n.json
{
"accountSave": "Save"
}
修正前
import 'package:flutter/material.dart';
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
Widget build(BuildContext context) {
return const Text('保存');
}
}
修正後
翻訳キーの参照方法には、主に次の2つがあります。
パターン1:Translations.of(context)を使用する
import 'package:flutter/material.dart';
import 'package:project/i18n/strings.g.dart';
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
Widget build(BuildContext context) {
final translations = Translations.of(context);
return Text(translations.accountSave);
}
}
Translations.of(context)を使用すると、現在のBuildContextに設定されている言語に対応する翻訳オブジェクトを取得できます。
パターン2:translate_varで設定した変数を使用する
import 'package:flutter/material.dart';
import 'package:project/i18n/strings.g.dart';
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
Widget build(BuildContext context) {
return Text(i18n.accountSave);
}
}
build.yamlで次のようにtranslate_varを設定しているため、i18n.accountSaveの形式でも翻訳キーを参照できます。
translate_var: i18n
translate_varのデフォルト値はtですが、今回はグローバル変数名をi18nに変更しています。
どちらの方法でも翻訳文を取得できますが、本プロジェクトでは、各WidgetでTranslations.of(context)を使用する方法に統一しています。
翻訳ファイルを変更したあとは、コード生成を再度実行します。
fvm dart run build_runner build -d
コード生成によってaccountSaveが翻訳クラスへ追加され、現在設定されている言語に応じて「保存」または「Save」が表示されます。
Translations.of(context)を使用すると、Localeが変更された際に、そのWidgetが再構築されて表示文言も切り替わります。
発生した問題
Flutterのバージョンを更新したあと、翻訳ファイルから生成コードを作成できない現象が発生しました。
Flutterの更新が直接的な原因かどうかは特定できていませんでしたが、更新後に問題が表面化したため、当初はFlutterが原因ではないかと考えていました。調査を進めると、slang_build_runnerとbuild_runnerの出力管理に関連する既知の問題に近いことが分かりました。
確認できた主な現象は次のとおりです。
- 生成ファイルが存在しないのに
Asset already existsと表示される - 翻訳JSONを変更しても、生成処理がスキップされる
- 1回目だけ成功し、2回目以降は失敗する
- キャッシュを削除しても改善しない
エラーの例は次のようなものです。
InvalidOutputException
Asset already exists
設定を1つずつ変更しながら原因を調査しましたが、デバッグ中には突然生成に成功することもあり、安定した再現手順を作れませんでした。
関連するIssue
今回の現象に近いIssueとして、主に次の3件がありました。
slang Issue #351
slang_build_runner 4.13.0以降で、次の問題が報告されています。
-
generate_forを指定するとBuilderがスキップされる - 複数回ビルドすると
Asset already existsが発生する
Issue本文では、以前はstrings.g.dartがSlangの出力として認識されていたものの、バージョン更新後に認識されなくなったと説明されています。
2026年7月26日時点では、このIssueはOpenのままであり、公式な修正は行われていません。
build Issue #4402
PostProcessBuilderが未宣言の出力を生成する場合、次のオプションを指定しても、競合ファイルが削除されない問題です。
fvm dart run build_runner build -d
つまり、--delete-conflicting-outputsを指定してもAsset already existsが発生します。
2026年7月26日時点では、このIssueはOpenのままであり、公式な修正は行われていません。
build Issue #4975
こちらは、出力ファイルが実際には存在しないにもかかわらず、Asset already existsが発生する問題です。
Issue本文には、対象ファイルを削除しても解決しないことが明記されています。
また、再現環境にはbuild_runner: 2.15.0が含まれており、今回使用している環境と同じバージョンでした。
試した対応
問題を調査する中で、主に次の3つの対応を試しました。
1. キャッシュと依存関係を作り直す
最初に、Flutterとbuild_runnerのキャッシュを削除し、依存関係を再取得しました。
fvm flutter clean
fvm dart run build_runner clean
rm -rf .dart_tool/build
fvm flutter pub get
fvm dart run build_runner build -d
環境をある程度初期化できるため、一般的なコード生成エラーであれば改善する可能性があります。
しかし、実行しても生成に成功することは稀で、安定した解決方法にはなりませんでした。
2. 生成ファイルを直接編集する
strings.g.dartを直接編集すれば、翻訳JSONからコードを生成しなくても、一時的に多言語対応を進めることはできます。
しかし、次の理由から採用しませんでした。
- 翻訳JSONと比較して編集箇所がかなり多い
- 次回のコード生成時に変更が上書きされる
- 生成ファイルの意味がなくなる
生成ファイルは生成元から作り直せる状態を維持するべきであり、直接編集は避けた方が安全です。
3. 未使用の出力先を指定する
試行錯誤の中で、build.yamlのoutput_directoryに、過去に使用したことのないフォルダを指定すると確実に生成できることが分かりました。
output_directory: lib/i18n/generated202607261
フォルダ名は、次の形式で管理しています。
generated + 日付 + 連番
例:
generated202607261
generated202607262
generated202607263
実行手順は次のとおりです。
# 翻訳コードを生成する
fvm dart run build_runner build -d
# 生成されたファイルを本来の配置先へコピーする
cp -R lib/i18n/generated202607261/. lib/i18n/
# コピー元の一時フォルダを削除する
rm -rf lib/i18n/generated202607261
この方法は、社内のメンバー数名へ相談した際、各メンバー固有のフォルダ名を出力先として試したことがきっかけで見つかりました。
一度使用した出力先では再び生成に失敗することがあったため、現在は日付と連番を付けています。
キャッシュと依存関係をすべて作り直す方法と比べると、出力先の変更と3つのコマンドで完了するため、運用しやすい方法だと思っています。
ただし、生成するたびに出力先の変更、生成ファイルのコピー、一時フォルダの削除が必要になるため、通常のコード生成と比べて手順が増えます。
legacy Builderという公式の回避策
この記事を書く中で、Slang 4.18.0からlegacy Builderが追加されていることを知りました。
Slang 4.18.0の変更履歴では、Asset already existsを回避する目的で、slang_build_runner:legacyが追加されたと説明されています。
通常のBuilderは、build_runnerのAsset Writerを経由して生成ファイルを書き込みます。
一方、legacy Builderは、従来と同様にdart:ioを使用してファイルへ直接書き込みます。これにより、Slangの生成処理においてIssue #4402や#4975につながる出力経路を回避できます。
これはbuild_runner自体の不具合を修正するものではありません。Slangの生成処理だけ、問題が発生する経路を通らないようにする回避策です。
設定例は次のとおりです。
targets:
$default:
builders:
slang_build_runner:
enabled: false
slang_build_runner:legacy:
enabled: true
options:
base_locale: en
fallback_strategy: base_locale
input_directory: lib/i18n
input_file_pattern: .i18n.json
output_directory: lib/i18n
locale_handling: true
flutter_integration: true
namespaces: false
translate_var: i18n
enum_name: AppLocale
class_name: Translations
translation_class_visibility: private
key_case: camel
key_map_case: camel
param_case: camel
string_interpolation: dart
ただし、今回の環境は次のバージョンです。
slang: 4.16.0
slang_flutter: 4.16.0
slang_build_runner: 4.16.0
legacy Builderが追加されたのは4.18.0であるため、使用するにはSlang関連パッケージのアップデートが必要です。
また、legacy Builderは次のワークスペース実行には対応していません。
fvm dart run build_runner build --workspace
通常の単一Flutterプロジェクトで、次のコマンドを使用する場合は、この制約には該当しません。
fvm dart run build_runner build -d
legacy Builderの設定方法と--workspace非対応については、公式ドキュメントにも記載されています。
現時点では、既存プロジェクトにおけるパッケージ更新の影響を確認する必要があるため、すぐにはlegacy builderへ移行せず、日付と連番を付けた出力先を使用して対応を進めています。
今後、Slang関連パッケージを4.18.0以降へ更新するタイミングで、legacy Builderへの移行を検討します。
学びと振り返り
バージョン情報を最初に記録する
今回の問題では、Flutter、Dart、Slang、build_runnerなど、複数のパッケージが関係していました。
問題が発生したときは、設定を変更する前に次の情報を記録しておく必要があります。
fvm flutter --version
fvm dart --version
fvm flutter pub deps --style=compact
git diff
特に、pubspec.yamlのバージョン制約だけでなく、pubspec.lockで実際に使用されているバージョンを確認することが重要です。
一度に複数の設定を変更しない
原因を調べる際に複数の設定を同時に変更すると、どの変更によって結果が変わったのか分からなくなります。
次のように、1項目ずつ変更して結果を記録する方が、再現条件を特定しやすくなります。
1. output_directoryだけ変更する
2. キャッシュだけ削除する
3. パッケージバージョンだけ変更する
4. 実行ログを保存する
ローカルの回避策と公式の回避策を分ける
今回使用した「出力先へ日付と連番を付ける方法」は、私の環境では有効でしたが、公式な解決方法ではありません。
一方、slang_build_runner:legacyは、Slang側が案内している公式の回避策です。
記事や社内資料へ残す場合は、次のように区別する必要があります。
公式の回避策
→ slang_build_runner:legacy
今回の環境だけで確認できた暫定対応
→ 未使用のoutput_directoryを指定する
まとめ
本記事では、FlutterとSlangを使って、日本語・英語の多言語対応を実装する流れを紹介しました。
翻訳JSONから型安全なDartコードを生成することで、画面内の文言を一元管理できます。
一方で、build_runnerによるコード生成では、未解決Issueに関連する不安定な挙動も確認されました。
現在は動作を確認できている方法を採用しつつ、今後も適切な運用方法を検討していきます。
参考文献
お知らせ
技術ブログを週1〜2本更新しています。
ソーイ株式会社のQiita Organizationをフォローして、最新の記事をチェックしていただけるとうれしいです!