Dart/Flutter での多言語対応あれこれ

公式ドキュメントだけでは分かりにくいところを詳しく解説

mono 
Flutter 🇯🇵
31 min readMay 1, 2019

--

この記事でDateTimeに触れましたが、その流れでintlパッケージを紹介し、さらに後半ではFlutterの多言語対応のいろはを解説していきます。

パッケージ名の intl は、Internationalization(i18n, 国際化)の短縮名です。日本語のみ対応のアプリでは不要かというとそうではなく、諸々のデータを日本語として正しく表記するためにも必要です。

例えば、ローカルタイムで2019年5月1日を表すDateTimeを文字列にすると次のようになります。

print(DateTime(2019, DateTime.may, 1).toIso8601String());
// -> 2019-05-01 00:00:00.000

ISO8601文字列ではなく「2019年5月1日」と表示したい場合はどうすればよいでしょうか?適当なサンプルアプリなどでは以下でも良いでしょう。

final date = DateTime(2019, DateTime.may, 1);
print('${date.year}年${date.month}月${date.day}日');
// -> 2019年5月1日

ただ、サービス・アプリをリリースする場合、以下などの理由により、基本的には上のような書き方よりも国際化対応のライブラリ・フレームワークを使っておくのがベターです(これを踏まえて本当に不要であると判断できるならそれでも良いです)。

  • 自前でDateTimeから所望の文字列への変換処理を書くよりも、すでに用意されている仕組みを使った方が簡単かつ的確
  • ベタ書きだと多言語対応が困難になる(はじめは一言語で良いと思っていてもあとから多言語対応したくなったらその時に大きな余計なコストがかかる)

というわけで、Dartの国際化対応用のパッケージである intl を紹介していきます。 このパッケージは主に次のクラスから構成されています。

  • Intl: localeの設定など全体に関わること
  • DateFormat: 日時のフォーマット
  • NumberFormat: 数値のフォーマット
  • BidiFormatter: 文字列の流れる方向のright-to-left (RTL) と left-to-right (LTR)の対応用

ちなみに、Flutterの場合はflutter_localizationsに依存すると、それ経由でintlが使えるようになります。

以下、完全に網羅はせず一般的なアプリ開発に必要そうなところをかいつまんでいきますので、より詳細はドキュメントも併読ください。

デフォルトlocaleの取得・設定

Intl.defaultLocale でデフォルトlocaleの取得・設定ができます。

素のDartのデフォルトでは en_US (米国向けの英語)となっています。

print(Intl.defaultLocale);
// -> en_US (米国向けの英語)

日本語に対応したい場合は、以下のように設定します。

Intl.defaultLocale = 'ja_JP'; // 'ja' でも良い

ただ、このまま次のような、指定したロケールの文言リソースが必要なメソッドを使うと、

print(DateFormat().format(date));

次のような例外が発生してしまいます。

LocaleDataException: Locale data has not been initialized, call initializeDateFormatting(<locale>)

initializeDateFormatting をあらかじめ呼んでおく必要があり、次のようにすると成功します 👍

final date = DateTime(2019, DateTime.may, 1);
// あらかじめ1回だけ呼んでおく必要がある
await initializeDateFormatting('ja_JP');
print(DateFormat().format(date));
// -> 2019年5月1日 0:00:00

Flutterの場合

Flutterの場合、上記処理を自前で書くのでは無くフレームワークに従った書き方とするのが良いです。詳しくは記事の後半で説明します。

また、Intlクラスは、Intl.message という多言語対応に大事なメソッドがありますが、少しややこしいので後回しにします。

というわけで、まずはDateFormatとNumberFormatの2種類のFormatクラスをなぞっていきます。

DateFormat

DateTimeをUI上に適切に表示する際などに、DateFormatを使います。

Web APIとのやり取りでのフォーマットやパースで使うこともありますが、DateTimeだけでISO8601文字列とUNIX時間を扱えるので、それで済む場合はDateFormatは不要です。

DateFormatでの出力フォーマット指定例

formatメソッドを使います。

DateFormatの生成は3通り書き方があって、例えばシンプルに年だけ表示したい場合は次のようになります。

final date = DateTime(2019, DateTime.may, 1, 15, 14, 13);// パターンを文字列で指定
print(DateFormat('y').format(date));
// パターンを定数で指定
print(DateFormat(DateFormat.YEAR).format(date));
// パターンに対応する名前付きコンストラクタを利用
print(DateFormat.y().format(date));
// -> 2019年

下ほど推奨されている書き方で、基本的には最後の名前付きコンストラクターを使うのが良いです。

もう少し複雑な指定をする例は次のようになります。日にちと時刻を一緒に表示したい場合は、addPatterかそれをラップしたメソッド( add_XXX )で繋げます。

print(DateFormat('yMMMMEEEEd').addPattern('jms').format(date));
print(DateFormat(DateFormat.YEAR_MONTH_WEEKDAY_DAY)
.addPattern(DateFormat.HOUR_MINUTE_SECOND)
.format(date));
print(DateFormat.yMMMMEEEEd().add_jms().format(date));
// -> 2019年5月1日水曜日 15:14:13

定数とパターン文字列の対応は以下のようになっています。

また、コンストラクタには、デフォルト以外のlocaleを指定することもできます。

print(DateFormat.yMMMMEEEEd('en_US').add_jms().format(date));
// -> Wednesday, May 1, 2019 3:14:13 PM

それぞれの指定で実際にどういう文字列になるかについては、日本語リソースは次のあたりに載っています(書いて試してみる方が手っ取り早いかもしれませんが)。

また、上記のパターンに適したものが無い場合、次のSymbolを用いて細かい指定も可能です。ただ、基本的には上記パターンから近いものを選んだ方がどの言語でもきちんと一般的な表記になってくれてメンテナンスも楽なので、個別指定はどうしてもという時のみにした方が良いと思います。

日時文字列のパース(DateTimeへの変換)

DateTimeから文字列への変換と似た要領で、formatメソッドの代わりにparseメソッドを使います。

final dateString = '1996.07.10 AD at 15:08:56';
print(DateFormat("yyyy.MM.dd G 'at' HH:mm:ss", 'en_US').parse(dateString));
// -> 1996-07-10 15:08:56.000

省略可能な第2引数にtrueをセットするか parseUtc メソッドでUTC扱いになります。

タイムゾーンの扱い

Dart の DateTime の扱いあれこれ で触れましたが、DartではDateTimeにローカル or UTCのタイムゾーン情報が含まれていて、DateFormatは単純にそれに従います。

次の例を見ると分かりやすいはずです。

final date = DateTime(2019, DateTime.may, 1);
print(DateFormat().format(date));
// -> May 1, 2019 12:00:00 AM
// ローカルタイムゾーンが日本の場合、toUTC()するとマイナス9時間
print(DateFormat().format(date.toUtc()));
// -> April 30, 2019 3:00:00 PM

つまり、渡すDateTime型のタイムゾーン情報がローカルとUTCのいずれになっているかを意識する必要があるということです。

DateTimeクラスの作りとして、明示的にUTCとしない限りは基本的にローカルタイムゾーンとなり、アプリ上の表示としてもそのままで適切なことが多いはずです。一方、サーバーAPIなどに日時を文字列で渡す場合は大抵UTCが適切なはずでそのあたりは充分注意が必要です。

ちなみに、FirestoreのTimestamp型とDateTimeの変換には、Timestamp.fromDate()およびTimestamp.toDate()メソッドを使いますが、それはUNIX時間で扱っているので注意は不要です。また、Timestamp.toDate()で受け取れるDateTimeはローカルタイムゾーンになっていてそのままUI表示用に扱う際に都合が良いです。

ちなみに、ローカルとUTC以外のタイムゾーンはDartの標準パッケージだけでは扱えず、対応が必要な場合は自前実装するか timezone などの外部パッケージに頼るしかありません。一般的なアプリでは不要なことが多い気がしますが、例えば世界時計作る場合などには必要になってきますね。

相対時刻文字列の取得

例えばTwitterでは1週間以内のツイートについて、15分前にされたものの場合は「15分」などと想定時刻表記になります。

DateFormatにこれ用のメソッドは用意されているものの、”NOT YET IMPLEMENTED.”のコメントがあり、結果は常に空文字となっていて、残念ながら現時点では使えません。

多言語対応など含めると対応の手間がかなり大きいからだと思います。対応Issueは古めでまったく動きが無いのであまり期待しない方が良さそうです。

というわけで、代わりの非公式パッケージとしては、 timeago が良さそうでした。

// デフォルトでロードされている英語・スペイン語以外は明示的にロードが必要
timeago.setLocaleMessages('ja', timeago.JaMessages());
final fifteenAgo = DateTime.now().subtract(Duration(minutes: 15));
print(timeago.format(fifteenAgo, locale: Intl.defaultLocale));
// -> 15 分 前

ちょっと日本語リソースが怪しいですが、forkして適宜改変(+プルリク)すれば良いと思います。

翻訳例は以下のでもサイトで確認できるようになっています。

DateFormatのパフォーマンス

モバイルアプリ開発で、よくパース処理などで大量のレスポンスを処理した時に日時のフォーマッタークラスを必要な度に生成してそれが原因で無駄に長い処理時間がかかってしまう例を目にしているので、Dartの場合どうなのかを調べてみました。

結果は以下のようになって、Dartでもボトルネックになり得ることが分かりました。

1万件などの大量の処理をしないとしても、使い回すことによって増える手間もデメリットも特に無く、それで少しでもパフォーマンスを良くできるなら、使い回すに越したことないと思っています。
(スレッドセーフでない場合少し扱いに注意が必要ですが、DartのDateFormatは時刻情報をフィールドに持っていたりしてないように見えるので多分大丈夫のはずです。さらにDartはシングルスレッドイベントループでIsolate間のメモリ共有もなされないため問題になりそうなケースが思い付きません🤔)

NumberFormat

次にNumberFormatクラスを見ていきます。

formatメソッドで数値の文字列表現を整形

数値をベタ出力すると、次のようになってしまいますが、

final num = 1000000 / 3;
print(num);
// -> 333333.3333333333

デフォルトのコンストラクタ(NumberFormat()) では、”ja_JP”の場合は次のように整数部分をコンマ3桁区切り・小数部分を最大3桁表示に整えてくれます。

print(NumberFormat().format(num));
// -> 333,333.333

ちなみに、ドイツなど主に欧州の国ではコンマとピリオドの使い方が逆だったりすることがあります。

print(NumberFormat(null, 'de').format(num));
// -> 333.333,333

このように、とりあえずNumberFormat()を使っておけば、ロケールに応じて数値を「良い感じ」の文字列に整えてくれます。

NumberFormat.decimalPattern() でも同じ結果になるように見えましたが、差が出るパターンに気づいたら追記します。

print(NumberFormat.decimalPattern().format(num));
// -> 333,333.333

任意のパターンにすることももちろんできて、例えば小数部分を1桁にしたい場合は次のようになります。

print(NumberFormat('#,##0.#').format(num));
// -> 333,333.3

細かい指定方法は以下の通りです。

  • 0 A single digit
  • # A single digit, omitted if the value is zero
  • . Decimal separator
  • - Minus sign
  • , Grouping separator
  • E Separates mantissa and expontent
  • + - Before an exponent, to say it should be prefixed with a plus sign.
  • % - In prefix or suffix, multiply by 100 and show as percentage
  • ‰ (\u2030) In prefix or suffix, multiply by 1000 and show as per mille
  • ¤ (\u00A4) Currency sign, replaced by currency name
  • ' Used to quote special characters
  • ; Used to separate the positive and negative patterns (if both present)

日本語の場合は以下のような設定が組み込まれていて、

https://github.com/dart-lang/intl/blob/4b385517037485e3243d6915453215af315add27/lib/number_symbols_data.dart#L1076

次のように名前付きコンストラクタ経由でも使えます。

print(NumberFormat.compact().format(num));
// -> 33.3万
print(NumberFormat.compact(locale: 'en').format(num));
// -> 333K
print(NumberFormat.compactLong(locale: 'en').format(num));
// -> 333 thousand
print(NumberFormat.compact(locale: 'en').format(1111));
// -> 1.11K
print(NumberFormat.currency().format(num));
// -> JPY333,333
print(NumberFormat.simpleCurrency().format(num));
// -> ¥333,333
print(NumberFormat.compactCurrency().format(num));
// -> JPY33.3万
print(NumberFormat.compactSimpleCurrency().format(num));
// -> ¥33.3万
print(NumberFormat.percentPattern().format(0.12345));
// -> 12%
print(NumberFormat.scientificPattern().format(30000));
// -> 3E4
print(NumberFormat.scientificPattern().format(0.002));
// -> 2E-3
print(NumberFormat().format(0 / 0));
// -> NaN

それぞれ、さらにオプション引数を指定して少しカスタマイズもできたりしますが、それでも足りない時のみ任意のパターンを指定するのが良いです。

parseメソッドで文字列表現を数値に戻す

DateFormatと同様にformatの逆の操作をしたい場合はparseメソッドを使います。

print(NumberFormat.compact(locale: 'en').parse('1.11K'));
// -> 1110.0

それでは、次にFlutterの多言語対応のいろはを説明していきます。

Flutterでの多言語対応

Flutterでの多言語対応は公式ドキュメントにしっかり載っています。

[追記] 以下は以前の対応法で、今は新しい gen-l10n 方式が良いです。上記ドキュメントもそれに合わせて更新されています。またサンプルリポジトリも以下のようにブランチで対応例が変わっています:

ただ、少し分かりにくかったので自分なりにまとめてみます。

サンプルリポジトリは以下です(ちょっと荒削りなサンプルですが)。

ローカライズ対応は特に、 https://github.com/mono0926/intl_sample/commit/c5b4d9ac2e99c984e9ad8e416d2e82dbb89d5ba2 のコミットに集中しています。

多言語対応は以下のように行いますが、前者については説明済みなので、後者について焦点を当てます。

  • DateFormat/NumberFormatなどを用いて日時・数値などを適切にフォーマット
  • それらのフォーマット結果も併用しつつUIの文言を用意する

パッケージのインストール

まずは、以下のパッケージをインストールします。

  • flutter_localizations: Flutterのローカライズ全般を担う(intlにも依存している)
  • intl_translation: 多言語対応用のファイル生成(とりあえず1言語のみの対応であれば必須ではない)
name: intl_sample
description: A new Flutter project.
version: 1.0.0+1
environment:
sdk: '>=2.1.0 <3.0.0'
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter

cupertino_icons: ^0.1.2
dev_dependencies:
flutter_test:
sdk: flutter
intl_translation: any
flutter:
uses-material-design: true

ローカライズ用のクラスを作成

以下のようなお決まりのクラスを用意します。

クラス名は適当で良いですが、短めにすることをお勧めします。なぜなら、文言を次のように取得する時に書きやすくなるからです。

print(L10n.of(context).hello);
// -> こんにちは

ファイルの置き場所も適当で良いですが、以下 lib/l10n 以下にローカライズ関連のファイルをすべて置くことを前提に説明していきます。

次に上で用意した L10n クラスをFlutterのローカライズフレームワークに渡す役割を担うクラスを、LocalizationsDelegate<T>を継承して作成します。

これもほぼ定型コードですが、 isSupported で返すロケールは必要なものだけ指定します。

この時点では、 L10n クラスの以下でコンパイルエラーがあるはずです。

await initializeMessages(localeName);

とりあえず1言語のみの対応ではこれをコメントアウトしても問題ないです。

ただ、Intl.messageを正しく使えているかの検証にもなるので、複数言語対応の以下のフローもなぞっておいた方が無難です。

複数言語用のarbファイルの生成(オプション)

というわけで、複数言語対応の場合はこの手順が必要です。

dev_dependenciesに指定したintl_translationを使って、arbファイルの生成とそれを元に多言語対応に必要なクラスを生成します。

実際の運用

「# このタイミングで、必要に応じて、メインの言語以外のarbファイルを用意」はどうやって差分を継続的に翻訳していくかが悩ましいところでしたが、BabelEditというツールがとても良さそうです。

また、「# arbファイル群から多言語対応に必要なクラスを生成」はそうやって翻訳した後に実行するので、実際には別スクリプト・タイミングでの実行になりますが、記事では説明の便宜上まとめてしまっています。

話は戻って、上のスクリプトを実行すると、コンパイルエラーも無くなるはずです。

以下のハイライトされているものが生成されたファイルです。

対象ファイルに含まれる Intl.message が intl_messages.arb ファイルに抽出されます。

上のスクリプトでは一気に実行してしまっていますが、最後のこのコマンドの前に、必要に応じて intl_en.arb などを雛形を元に作っておきます。

flutter packages pub run intl_translation:generate_from_arb

今回の例の場合、例えば次のような intl_en.arb などを用意することになって、 messages_en.dart が生成され、プログラムではそれが利用されます。

{
"@@locale": "en",
"@@last_modified": "2019-05-01T15:19:03.319575",
"hello": "Hello🌍",
"@hello": {
"description": "挨拶",
"type": "text",
"placeholders": {}
}
}

ローカライズフレームワークに従いつつもとりあえず1言語のみの対応とする場合は、上のスクリプトを実行するだけでも良いですし、多言語対応クラスが無かったら L10n クラスであてた文言がそのまま使われるためarbファイル周りの作業は無くとも動きます。

ちなみに、arbファイルの仕様はこちらに載っています。

MaterialAppに指定

以下の指定が必須です。

GlobalMaterialLocalizations.delegateとGlobalWidgetsLocalizations.delegateはFlutterフレームワークに含まれるWidgetの多言語対応およびRTL/LTRの判定のために必要で、多言語対応する場合は自分で用意したクラスと一緒にこれも含めるのが一般的です。

記事の冒頭で紹介したDateFormatの言語リソースロードの initializeDateFormatting 相当の処理もこの指定をすると内部で呼ばれます。

採用されるロケールの確定

端末での言語優先順にアプリで対応している言語を探索して、マッチしたものが採用されます。

例えば、アプリ側で ['ja', 'en'] を対応している時に次のように、iOS端末設定の優先順がドイツ語・日本語・英語の順となっていた場合は、日本語( ja )が選ばれます。

また、iOSの場合、Xcodeでの言語指定でも追加しておかないとFlutter側に言語候補が伝わらないので注意です。

なお、採用されるロケールはデフォルトでは上記のようになっているものの、MaterialAppの次のプロパティを指定することでその確定の方式を自前で書くことができます。

端末の言語設定だけに頼らずにアプリ内設定でもロケールを選択できるようにしたい場合などはこれらも使うことになるはずです。

Intl.message の細かい使い方

以上で一通りなぞり終えたので、Intl.messageについて細々とした補足をしていきます。

Intl.message で引数を扱う

「フォロワー: 1.4K」のような表記をしたい場合は外部から引数を渡す必要があって、次のように書きます。メソッドの引数を args 引数にも渡す必要があります。

// NumberFormatは実際には使い回したりするがサンプルなので毎回生成
String followers(int count) =>
_followers(NumberFormat.compact(locale: 'en').format(count));
String _followers(String count) => Intl.message(
'フォロワー: $count',
name: '_followers',
args: [count],
);

利用例は次のようになります。

L10n.of(context).followers(1400);
// -> フォロワー: 1.4K

2つのメソッドに分けている理由は、普通に次のように1メソッドで書くと、

String followers(int count) => Intl.message(
'フォロワー: ${NumberFormat.compact().format(count)}',
name: 'followers',
args: [count],
);

arbファイル化する時に次のようなエラーが出てしまうからです。Intl.messageを扱うメソッド内での自由な加工処理はできないのです。

Error IntlMessageExtractionException: Only simple identifiers and Intl.plural/gender/select expressions are allowed in message interpolation expressions.

ちなみに、 name は文言リソースのキーになるため、誤ったものを指定するとarbコマンド(intl_translation:extract_to_arb)で次のようなエラーメッセージが出て該当文言がスキップされてしまうので注意です。getterおよびメソッド名と同一にしましょう。

reason: The 'name' argument for Intl.message must match either the name of the containing function or <ClassName>_<methodName>

このように、Intl.messageの扱いには注意するべき点がいくつかあり、とりあえず1言語対応の場合も誤用をしていないかのチェックとして、たまにarb変換してエラーが出ないかチェックすると良いと思います。
(いざ複数言語対応する際に出たエラーに一括対処でも良いと思いますが。)

複数形などの対応

以下の特別な関数が用意されています。

plural: 複数形対応

次のように書くと、

String dogsCount(int howMany) => Intl.plural(
howMany,
zero: 'no dog😢',
one: 'a dog',
other: '$howMany dogs',
args: [howMany],
name: 'dogsCount',
);

次のように数に応じて文言を変えられます。複数形の概念がある英語などではほぼ必須の対応ですね。

l10n.dogsCount(0) // no dog😢
l10n.dogsCount(1) // a dog
l10n.dogsCount(5) // 5 dogs

Intl.messageの引数にも入れられるようで、下の例はうまく動いたものの他の文字列を含める( 'I have ${Intl.plural(...` のようにする)と出力結果がおかしくなってしまって、きちんと機能してないように見えました🤔

String dogsSentence(int howMany) => Intl.message(
'${Intl.plural(
howMany,
zero: 'no dog',
one: 'a dog',
other: '$howMany dogs',
)}',
name: 'dogsSentence',
args: [howMany],
);

gender (性別対応)

次のように書くと、

String hungry(String gender) => Intl.gender(
gender,
male: '僕はお腹が空きました',
female: '私はお腹が空いたわ',
other: 'はらぺこりん',
name: 'hungry',
args: [gender],
);

次のように性別に応じて文言を変えられます。

l10n.hungry('male') // 僕はお腹が空きました
l10n.hungry('female') // 私はお腹が空いたわ
l10n.hungry('other') // はらぺこりん

select (条件による場合分け)

次のように書くと、

enum Membership { normal, premius }String memberStatus(Membership membership) => Intl.select(
membership,
{
Membership.normal: '通常会員です',
Membership.premius: 'プレミアム会員です✨✨',
},
name: 'memberStatus',
args: [membership],
);

次のように引数に応じてメッセージを場合分けできます。

l10n.memberStatus(Membership.normal) // 通常会員です
l10n.memberStatus(Membership.premium) // プレミアム会員です✨✨

引数はObject型で良いですが、enumにすると便利です。

Dart/Flutterの多言語対応の解説は以上となります。

特に、Flutterの多言語対応は第一印象では面倒な印象でしたが、今では標準のiOSアプリよりはやりやすく、R.swiftSwiftGen 使った場合よりは面倒、という程度の印象です。

Flutterでも便利なパッケージやプラグインがあって、それら使うとより対応しやすくなるかもしれません。以下の一連のツイートでそれらについても触れたので、良かったらご覧ください。

標準の仕組みを知った上で、その標準の仕組みに従った作りのパッケージを使うと良いと思っていて、その考えに合う良いものを見つけたら使いたいと思いつつ、今のところは外部パッケージ無しで済ませています。

--

--