🌎

easy_localizationの罠

2023/06/11に公開

現在、個人開発でeasy_localization[1]を使って多言語対応を実装しています。

多くの開発者に利用されているので、信頼できるライブラリではないかと思いつつ、導入し始めました。

しかし、基盤を作って動作確認をしようとしているところ、意外なことが起こってしまいました。

発生事象

class ParentWidget extends StatelessWidget {
  const ParentWidget({super.key});

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('hello').tr(),
        // 内部にText('hello').tr()がある
        const ChildWidgetA(),
        // 内部にText('hello').tr()がある
        const ChildWidgetB(),
      ],
    );
  }
}

上記のサンプルコードのように、親Widgetと子Widgetが存在するという構造の画面を作ってます。

そして、ChildWidgetBの中に言語を変換する処理context.setLocale(ja)を実行すると、

本来であれば、Widgetの種類を問わず、全体のhelloこんにちはになるはずですが、

実際には、ChildWidgetBのテキストがこんにちはに変わっただけで、それ以外のところはhelloのままになっています。

それはなぜでしょうか?

結論から申し上げますと、context.setLocale(ja)を呼ぶときのcontextが原因だと考えられます。

それはなぜでしょうか?

まずはsetLocale()のソースコードを見てみましょう。

  Future<void> setLocale(Locale l) async {
    _locale = l;
    await loadTranslations();
    notifyListeners();
    EasyLocalization.logger('Locale $locale changed');
    await _saveLocale(_locale);
  }

上から順に説明していきます。

  1. 変更しようとしているlocaleをメモリに保存

  2. localeの値をもとに翻訳データを取得

  3. notifyListeners()を実行してUIを更新

  4. デバッグログを出力

  5. 設定がリセットされないよう、localeの値をローカルに保存

パッと見た感じでは、すごく単純なことで特に問題なさそうですね…

でもなぜこういう挙動になっているのでしょうか?

easy_localizationのロジックを見極める

easy_localizationを導入する際に、以下のようにアプリをEasyLocalizationに囲んでいます。

    return EasyLocalization(
      child: const MyApp(),
    );

ソースコードを見れば分かると思いますが、EasyLocalization実はStatefulWidgetです。

アプリを起動すると、initState()の一つの処理として、

EasyLocalizationController(InheritedWidget)のインスタンスを作成して、

addListener()で状態変化を監視するようにしています。

上記のcontext.setLocale(ja)の処理の正体は、

  1. contextからEasyLocalizationControllerのインスタンスを取得

  2. 諸々処理をした後、notifyListeners()を実行してListenerに通知

  3. 状態変化を検知できたら、setState()を実行してUIを更新

ということになります。

しかし、EasyLocalizationControllerのインスタンスはcontextから取得したものなので、

setState()を呼んだとしても、更新されたのはcontextが所属しているWidgetのみで、親Widgetでは何も起こらないです。

そのため、setLocale()を呼ぶときのcontextは親Widget(上記の例でいうとParentWidget)のものでなければなりません。

さっそく直してみましょう

class ParentWidget extends StatelessWidget {
  const ParentWidget({super.key});

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('hello').tr(),
        // 内部にText('hello').tr()がある
        const ChildWidgetA(),
        // 内部にText('hello').tr()がある
        ChildWidgetB(context),
      ],
    );
  }
}

ParentWidgetcontextを引数としてChildWidgetBに渡してsetLocale()を呼びますと、

ParentWidgetChildWidgetAChildWidgetBのテキストが全部こんにちはになってます!

おめでとうございます!

でも、これで本当に直したと言えるのでしょうか?

新たな課題

言語を変えるとアプリ全体のテキストが一緒に変わるのが一般的だと思います。

つまり、setLocale()を呼ぶときのcontextはアプリそのもののcontextでないといけません。

アプリのcontextをどこかのボタンに渡すって、ちょっと変な実装ですね…

context以外に何か良い方法はないのか?

contextのことを考えなくてもOKの方法

以下のように、context.setLocale(ja)を呼んだあと、

WidgetsFlutterBinding.ensureInitialized().performReassemble()

も呼べばいいです。

    await context.setLocale(locale);
    await WidgetsFlutterBinding.ensureInitialized().performReassemble();

reassembleApplicationは、Flutter公式から提供されているメソッドです。

一回呼ぶと、ホットリロードみたいな感じでアプリ全体のUIが更新されます。

処理コストは高いので使うのが推奨されていないのですが、言語変更みたいな実行頻度が極めて低い処理であれば問題ないでしょう。

他の多言語対応系のライブラリのソースコードを見たところ、このメソッドを利用しているものもいくつかあります。

おまけ

上記以外の解決方法も存在するのでおまけに追記します。

const Text('hello').tr(context: context)

このように、.tr()を呼ぶたびにcontextを渡すようにすると、言語の変化を検知して即時反映できます。

毎回やるのは手間がかかるのですが、アプリ全体のリビルドに気になる方はこちらをご利用いただければと思います。

最後に

とりあえず、一行だけでこの問題を解決できました。

この結果へ辿り着くため、いろんなライブラリのソースコードを見たり、資料を読んだりして疲れたけど、楽しかったです。

FlutterのUI周りは単純に見えるがなかなか深いので、これからも勉強を続けて行きたいと思います。

脚注
  1. easy_localization: 3.0.2 ↩︎

GitHubで編集を提案

Discussion