やりたかったことは多言語対応の一環だった。flutter_localizationsの生成コードでBuildContextからAppLocalizationは取得できるが、それと別にOSの言語設定を監視したかった。そのために他のウィジェットを包むだけのものを用意して、ミドルウェア的な挙動でOSの言語設定をアプリのDBに保管したかった。でもなぜかそれを適応すると、今度はgo_routerのStatefulShellRouteを使った遷移が機能しなくなってしまった。

(defn listen-state
  [^m/Widget .child]
  (reify :extends m/StatefulWidget
    :no-meta true
    (createState
      [_this]
      (reify :extends m/State
        :no-meta true
        (initState [this]
          (.initState ^super this))
        (dispose [this]
          (.dispose ^super this))
        (build [_this _context]
          child)))))

このコードで問題は最小限で再現していた。遷移しても、child の変更はUIに反映しなかった。なぜだ?あっちこっちにprintln仕込んでも直感的にはわからなかった。どうやらFlutterのStatefulWidgetの振る舞いを十分に理解していなかった。

Dartコードを見るともうちょっとわかりやすい違いだけど、StatefulWidgetはまさにWidget + Stateの組み合わせである。最初に描画のために作られるときに状態(State)が作られ、Widgetの構造が変わっても状態は引き継がれる。StatefulWidgetというよりもStateWithWidgetと言ってStateの方が主体と考えた方がいいかも。

ライフサイクル的にはcreateStateで作られて、後は状態の変更(setStateなど)または周辺(主に親)Widgetの変更(didUpdateWidget)でbuildが再度呼ばれる。でもcreateStateは最初の構成のときにしか行われない。

上記の俺のコードを見ればその理解の上で問題は明確になる。最初にcreateStateでreifyされるStateは、buildメソッドが固定でchildを返す。「最初の初期化の時点の」childを。理解が足りなかったから、知らず初期childを閉包してしまった。didUpdateWidgetのドキュメントを読んだら「Stateのwidget要素がフレームワークによって新しいWidgetに置き換えられる」とあったので理解した。俺のbuildはそのwidget要素((.-widget this)かな?)の値を参照してればよかったが、そうじゃなくて固定で初期childを返していた。

対応としては自前でStatefulWidgetをreifyするんじゃなく、ClojureDartのFlutterライブラリが提供する機能である :managed に頼って実装を変えたら問題なく動いた。具体的に多言語情報を監視するだけのWidgetsBindingObserverを:managedで管理している。

:managed
[_ (doto ^m/WidgetsBindingObserver
    (reify ^:mixin m/WidgetsBindingObserver
      (didChangeLocales [_this locales]
        ;; 処理
        ))
    m/WidgetsBinding.instance.addObserver)
   :dispose m/WidgetsBinding.instance.removeObserver]

これでわざわざ自前でStatefulWidgetまで作らなくても、やりたいことは達成できた。