のんびり精進

調べた情報などをおすそ分けできれば。

【Flutter】依存オブジェクトの注入

1ヶ月ちょっと前に Flutter の provider というパッケージに関する記事を書きました。

qiita.com

そこでは、provider の機能の一つとして「DI の仕組みを提供」と説明しています。
しかし記事を複雑化するのを避けて掘り下げないままにしました。
代わりにここで補足しておこうと思います。

記事更新情報

  • 2019/12/15
    • 2019/11 下旬の provider 3.2.0 で 各プロバイダの builder という引数名が create に変わりましたので、本記事の関連箇所を更新しました。
  • 2020/5/8
    • provider 4.1.0 によって異なる書き方ができるようになった旨を少し追記しました。

DI(Dependency Injection)とは

DI はデザインパターンの一つであり、依存性の注入 と訳されることが多いものです。
とっつきにくい言葉です。

それを次の記事ではとてもわかりやすく解説されています。
PHP のコードが出てきますが、Java 等に似た文法で理解は難しくないと思います。

blog.a-way-out.net

オブジェクト A の中でオブジェクト B を生成すると、A は B に強く依存してしまいます。
それを避ける方法が Dependency Injection のはずです。
それなのに、依存性の注入 という訳だと逆に依存させてしまうように聞こえますね。

上の記事の著者も「混乱の元」と考え、わかりやすい別表現で説明されています。

dependency」は使われるオブジェクト(サービスと呼ぶ)であり、「injection」とはそのオブジェクト(=サービス)を、それを使うオブジェクト(クライアントと呼ぶ)に渡すことです。

Dart におけるオブジェクトの注入

void main() {
  final service = Service();
  final client = Client(service);  // ServiceをClientに渡す(注入)
  client.doSomething();
}
class Service {
  void doSomething() {
    print('ServiceのdoSomething()が実行されました。');
  }
}
class Client {
  Service _service;

  Client(this._service);  // Serviceを受け取る

  void doSomething() {
    _service.doSomething();
  }
}

外から ClientService のオブジェクトを渡しています。
しかしこれでは単に注入しただけになっていて、ClientService への依存が強いままです。
Service2 というオブジェクトを渡したくても、ClientService しか受け取れません。

Dart における「抽象的な」オブジェクトの注入

強く依存しないようにしてこそ DI と呼べると思います。
そうするには、OOP の特性の一つである多態性を活用できます。

「使われるオブジェクト」の共通インタフェース(例: ServiceInterface)を Client が受けるようにすれば、ServiceInterface を実装した ServiceService2 はどちらも渡すことができます。

void main() {
  Client(Service()).doSomething();   // ServiceのdoSomething()が実行されました。
  Client(Service2()).doSomething();  // Service2のdoSomething()が実行されました。
}
abstract class ServiceInterface {
  void doSomething();
}
class Service extends ServiceInterface {
  void doSomething() {
    print('ServiceのdoSomething()が実行されました。');
  }
}
class Service2 extends ServiceInterface {
  void doSomething() {
    print('Service2のdoSomething()が実行されました。');
  }
}
class Client {
  ServiceInterface _service;

  Client(this._service);

  void doSomething() {
    _service.doSomething();
  }
}

インタフェース

Dart には interface というキーワードは存在しないそうです。
共通のインタフェースを用意するには 暗黙的なインタフェース抽象クラス を使います。
ここでは、中身を定義しない抽象メソッドを用意したかったので abstract を付けて抽象クラスにしました。

テスタビリティ

抽象的なオブジェクトにしか依存しなくなると、テストしやすくもなります。
ServiceInterface を実装したクラスを Service の代わりに使ってテストできます。
例えば Service が DB を必要とするものであっても、DB 処理をなくしたテスト用クラスを使えます。

注入の方法

注入する方法は複数あり、先ほど参考にしたページでは次の3つが挙げられています。

  • コンストラクタインジェクション : コンストラクタに渡す
  • セッターインジェクション : セッターを使って渡す
  • プロパティインジェクション : プロパティに直接代入して渡す

Flutter 自体でも、Widget に child という引数で様々な Widget を渡せる仕組みは DI です。
コンストラクタに渡しているのでコンストラクタインジェクションですね。

この記事で試そうとしている provider の利用は、上の3つのどれでもないもう一つの方法になります。 *1

Flutter における DI の例

スイッチの On/Off で2進数/10進数の表示を切り替えられるカウンターのサンプルです。

f:id:kabochapo:20190701211459g:plain

注入するもの(10進数用と2進数用のカウンターのオブジェクト)を差し替えることで実現しています。
ちょっと強引になってしまっていますが、雰囲気を感じてください…。 *2

※コードは一部省略しています。
 完全なコードは下記リポジトリにあります。

github.com

class _CounterText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<CounterInterface>(context);

    return Column(
      children: <Widget>[
        Text(counter.runtimeType.toString()),
        Text(counter.numberString),
      ],
    );
  }
}

カウンターの種類と数を表示する Widget です。
これがオブジェクト注入先の Client です(他の Widget にも注入していますが、ここがメイン)。
Widget ツリーの上方で provider を使ってオブジェクトが渡され、ここで受け取ります。

受け取ったオブジェクトの具体的な処理などについては、このクラスは知りません。
知っているのは下記の CounterInterface を実装していることと使い方だけです。
型名(具象クラス名)を runtimeType で得られるので、取得して表示するようにしています。

abstract class CounterInterface with ChangeNotifier {
  int _number = 0;

  int get number => _number;
  String get numberString;

  void setNumber(int number) => _number = number;

  void increment() {
    _number++;
    notifyListeners();
  }
}

この抽象クラスを実装した具象クラス(10進数用と2進数用のカウンターのモデル)を作ります。
numberString というゲッターの戻り値だけは次のように具象クラスで設定しています。

class DecCounter extends CounterInterface {
  @override
  String get numberString => _number.toString();
}

class BinCounter extends CounterInterface {
  @override
  String get numberString => _number.toRadixString(2);
}

スイッチを操作したとき、これらのカウンターを差し替えます。
そのために、使うカウンターのオブジェクトをどこかの変数に持たせます。
しかし、それだけでは表示は更新されませんので、リビルドが必要になります。
その方法は複数あります。

  • StatefulWidget にカウンター用プロパティを持たせ、差し替え時に setState() で全体リビルド
  • setState() を使わずに、差し替え時に ChangeNotifierProvider 等によって通知してリビルド

前者を使うと、provider を使わなくても機能を実現できてしまうのでやめました。*3
また、後者のほうにも問題があります。

CounterInterface counter = DecCounter();

ChangeNotifierProvider<CounterInterface>.value(
  value: counter,
  child: ...,
)

// スイッチ操作時のカウンター差し替え
counter = BinCounter();

このようにすればカウンターの差し替えを下位 Widget に伝えることができそうな気がします。
ところが、これでは反映されません。
カウンターの値が変わったら通知されますが、カウンター自体の変更は通知されないのです。

これを解決するために、次の CounterContainer を咬ませることにしました。

class CounterContainer with ChangeNotifier {
  /// ここにカウンターのインスタンスを持たせる
  CounterInterface _counter;

  CounterInterface get counter => _counter;

  /// カウンターを差し替え、そのことを通知する
  set newCounter(CounterInterface counter) {
    _counter = counter;
    notifyListeners();
  }
}
MultiProvider(
  providers: [
    ChangeNotifierProvider<CounterContainer>(
      create: (_) {
        // カウンターの入れ物を生成
        // 最初は10進数のカウンターを入れておく
        return CounterContainer()..newCounter = DecCounter();
      },
    ),
    Consumer<CounterContainer>(
      // カウンターが差し替えられるたびにこのbuilderが呼ばれる
      builder: (_, container, __) {
        return ChangeNotifierProvider<CounterInterface>.value(
          value: container.counter,
          child: ...,
        );
      },
    ),
  ],
)

CounterContainer_counter にカウンターのオブジェクトを持たせています。
差し替えたら notifyListeners() で通知されるので、カウンター自体の変更が通知されない問題は解決です。

その通知は Consumer<CounterContainer>() に伝わって builder が呼ばれます。
そこに ChangeNotifierProvider<CounterInterface>.value() があり、新たなカウンターをセットします。 *4

この注入部分の他にボタンやスイッチの記述も合わせたものが下記になります。
コードはこれで終わりです。

class DependencyInjectionPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // ここはほぼ先ほどの説明のとおり
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<CounterContainer>(
          create: (_) => CounterContainer()..newCounter = DecCounter(),
        ),
        Consumer<CounterContainer>(
          builder: (_, container, __) {
            return ChangeNotifierProvider<CounterInterface>.value(
              value: container.counter,
              child: Scaffold(
                appBar: AppBar(actions: <Widget>[_Switch()]),
                body: _CounterText(),
                floatingActionButton: _FloatingButton(),
              ),
            );
          },
        ),
      ],
    );
  }
}

class _Switch extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final container = Provider.of<CounterContainer>(context, listen: false);
    final counter = container.counter;

    return Switch(
      value: counter.runtimeType == BinCounter,
      onChanged: (value) {
        // スイッチを切り替えたときにカウンターを差し替える
        container.newCounter = value ? BinCounter() : DecCounter();

        // 新しいカウンターは値を持たないので、元の値をセットする
        container.counter.setNumber(counter.number);
      },
    );
  }
}

class _FloatingButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<CounterInterface>(context, listen: false);

    return FloatingActionButton(
      onPressed: counter.increment,
    );
  }
}

二つのプロバイダで CounterContainerCounterInterface を注入しているのは少しややこしいですね。
Client である _CounterText に対して CounterContainer(カウンターの入れ物丸ごと)を渡しても機能を実現できるのですが、アンチパターンとされるサービスロケータのようになってしまうため、Service に相当する CounterInterface(入れ物の中身だけ)を注入すべきと考えました。

追記(2020/5/8)

provider が先日 v4.1.0 になり、各 provider に Builder の機能に相当する builder という引数が追加され、Consumer を使わなくても直前の ChangeNotifierProvider で設定された値を取得できるようになりました。 上記のコードを更新していませんが、リポジトリでは更新済み ですので、そちらをご覧ください。

まとめ

苦労が見えるサンプルだったと思います。
provider によって楽をできたのは間違いないですが、とても簡単というわけでもありません。
少し癖があって、慣れが必要です。
このサンプルで癖を理解できたなら、きっと様々なことに応用できます。

しかし場合によっては、provider を使わないのが好ましいこともあります。
例えば、遷移先のページに値を渡すのはコンストラクタインジェクションのほうがシンプルです。 *5

Navigator.of(context).push(
  MaterialPageRoute<void>(builder: (context) => NextPage(foo)),
)

また、provider は Widget を子とするので、UI 層のコードで用いるものです。
他の層でデータ保存処理のオブジェクトをどこかに注入するといった用途には使えません。

実装したい機能に応じた注入方法を選びましょう。

*1:Widget 間で値をやり取りする必要がなかったり、provider の便利な機能(値変更の伝播、Widget ツリーから除去されるときの自動破棄など)を使わなかったりする場合の単純な DI には、他にパッケージ(get_it 等)のほうが向いているかもしれません。用途に適したものを選びましょう。

*2:2進数/10進数の値を返すメソッドを持ったクラスのオブジェクトを共通のカウンターモデルに注入すればもっとシンプルにできますが、モデルではなく Widget に対して provider を使ってカウンターを注入するサンプルとしてこうなりました。

*3:簡単に機能を実現できるのは良いことですが、そういうやり方では大きなアプリになったときに困る場合が出てくるかもしれません。

*4:Consumer + ChangeNotifierProvider の代わりに ChangeNotifierProxyProvider が使えそうですが、使えません。 ChangeNotifierProxyProvider は create で最初にインスタンスを生成してから update が呼ばれるたびにそのインスタンスを使い回すものであり、このサンプルのようにカウンターを差し替えるたびにインスタンスが変わる場合には使い方が合いません。

*5:理由は Qiitaの記事 に書いているので、そちらをご覧ください。