【Flutter】state_notifierのLocatorMixinの便利な使い方

ここ数ヶ月Flutter界隈で話題になっている[state_notifier](https://pub.dev/packages/state_notifier)を、[freezed](https://pub.dev/packages/freezed)と共に普段の業務で使用しているのですが、最近になってやっとstate_notifierの`LocatorMixin`を使用したので、使い方や便利な点をまとめようと思います。
ちなみにstate_notifier & freezedの使い方は、[こちらの記事](https://qiita.com/_masaokb/items/fe77495db0aeba226d2a)にわかりやすくまとめてあるのでご参考に。

# 環境

- state_notifier: 0.4.0
- flutter_state_notifier: 0.4.2
- Flutter: 1.19.0-1.0.pre(channel dev)
- Dart: 2.9.0 (build 2.9.0-7.0.dev 092ed38a87)

# LocatorMixinとは
`StateNotifier<T>`継承クラス(`HogeNotifier`や`HogeController`など)において、[provider](https://pub.dev/packages/provider)の`Provider`によってcontextに流されている外部サービスにアクセスする際に、`Provider.of`/`context.read`/`context.watch`を使用したくなると思います。
`LocatorMixin`を`HogeNotifier`にmixinすることで、これらのアクセスが簡単に実現できるようになります。具体的には、`read`/`update`を`HogeNotifier`内で使用することができるようになります。

# 使い方

## `read`

`read<T>()`は`Provider.of<T>(context, listen: false)`/`context.read<T>()`に相当する関数です。

### 具体例
簡単な例を挙げると、

```dart
class UserNotifier extends StateNotifier<UserState> with LocatorMixin {
  UserNotifier({@required User user}) : super(UserState(user: user));

  Future<void> updateUser({String name, int age}) async {
    // 外部のUserServiceにアクセス
    final updatedUser = await read<UserService>().update(name: name, age: age);
    state = state.copyWith(user: updatedUser);
  }
}
```
ということができるようになります。また、見通しをよくするために、

```dart
class UserNotifier extends StateNotifier<UserState> with LocatorMixin {
  // 外部のUserServiceにアクセス
  UserService get _userService => read();

  UserNotifier({@required User user}) : super(UserState(user: user));

  Future<void> updateUser({String name, int age}) async {
    final updatedUser = await _userService.update(name: name, age: age);
    state = state.copyWith(user: updatedUser);
  }
}
```
という風にgetterを書くといいかもしれません。(@mono0926さんのtweetを参考にしました。)

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">LocatorMixinを使うとコンストラクターインジェクションに比べて依存が分かりにくい件、僕はこうやってまとめて並べておくことであまり困ってない(　´･‿･｀)<a href="https://t.co/5PyodUfaJZ">https://t.co/5PyodUfaJZ</a> <a href="https://t.co/HF3XZBwbwk">pic.twitter.com/HF3XZBwbwk</a></p>&mdash; mono 🎯 @自宅 💙 (@_mono) <a href="https://twitter.com/_mono/status/1261856048798773248?ref_src=twsrc%5Etfw">May 17, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

### 注意点
1つ気をつけなければならないのが、`read<T>()`はコンストラクタでは使用できないということです。
どういうことかというと、

```dart
class UserNotifier extends StateNotifier<UserState> with LocatorMixin {
  final String userID;
  UserService get _userService => read();

  UserNotifier({@required this.userID}) : super(const UserState()) {
    // コンストラクタでread()を使用🙅‍♂️ => DependencyNotFoundException
    _fetchUser();
  }

  Future<void> _fetchUser() async {
    final user = await _userService.fetch(userID: userID);
    state = state.copyWith(user: user);
  }
}
```
この書き方は`DependencyNotFoundException<UserService>`を投げられます。
そもそも`read<T>()`の内部実装は以下のようになっていて、`_locator`には`throw DependencyNotFoundException<T>()`が初期値として入っています。

```dart
mixin LocatorMixin {
  Locator _locator = <T>() => throw DependencyNotFoundException<T>();

  @protected
  Locator get read {
    assert(_debugIsNotifierMounted());
    return _locator;
  }

  set read(Locator read) {
    assert(_debugIsNotifierMounted());
    _locator = read;
  }
  ...
}
```

[_StateNotifierProviderのbuildWithChild](https://github.com/rrousselGit/state_notifier/blob/master/packages/flutter_state_notifier/lib/flutter_state_notifier.dart#L244)を見ると、この`_locator`は`StateNotifier<T>`継承クラスがインスタンス化された後に設定されていることがわかります。

```dart
class _StateNotifierProvider<Controller extends StateNotifier<Value>, Value> 
    extends SingleChildStatelessWidget 
    implements StateNotifierProvider<Controller, Value> { 
  ...
  @override
  Widget buildWithChild(BuildContext context, Widget child) {
    return InheritedProvider<Controller>(
      create: (context) {
        // StateNotifier<T>継承クラスをインスタンス化
        final result = create(context);
        assert(result.onError == null);
        result.onError = (dynamic error, StackTrace stack) {
          FlutterError.reportError(FlutterErrorDetails(
            exception: error,
            stack: stack,
            library: 'flutter_state_notifier',
          ));
        };
        if (result is LocatorMixin) {
          // readを通して_locatorに_contextToLocator(context)をセット
          (result as LocatorMixin)
            ..read = _contextToLocator(context)
            ..initState();
        }
        return result;
      },
      ...
    );
  }
```
なので、コンストラクタで`read<T>()`を使用すると例外を吐かれてしまいます。

### initState()
ではどうすればいいかというと、`LocatorMixin`が提供する`initState()`を使いましょう。
上のコードを見ると`..read = _contextToLocator(context)`した後に`..initState()`しているのがわかります。
ドキュメントコメントにも`read`使って`StateNotifier`初期化するなら`initState()`使えと書いてあります。

```dart
mixin LocatorMixin {
  ...
  /// A life-cycle that allows initializing the [StateNotifier] based on values
  /// coming from [read].
  ///
  /// This method will be called once, right before [update].
  ///
  /// It is useful as constructors cannot access [read].
  @protected
  void initState() {}
  ...
}
```

説明が長くなりましたが、例外を吐かれていた上記の例は以下のようにすると正常に動きます。

```dart
class UserNotifier extends StateNotifier<UserState> with LocatorMixin {
  final String userID;
  UserService get _userService => read();

  UserNotifier({@required this.userID}) : super(const UserState());

  @override
  void initState() {
    super.initState();

    // initState()内でread()を使用🙆‍♂️
    _fetchUser();
  }

  Future<void> _fetchUser() async {
    final user = await _userService.fetch(userID: userID);
    state = state.copyWith(user: user);
  }
}
```

※気になった人のために
`_contextToLocator(context)`は以下のように`Provider.of`を返しています。

```dart
typedef Locator = T Function<T>();

Locator _contextToLocator(BuildContext context) {
  return <T>() {
    try {
      return Provider.of<T>(context, listen: false);
    } on ProviderNotFoundException catch (_) {
      throw DependencyNotFoundException<T>();
    }
  };
}
```

## `update`

`void update(Locator watch)`は`LocatorMixin`が提供するライフサイクルです。
引数の`watch<T>()`が`Provider.of<T>(context, listen: true)`/`context.watch<T>()`に相当します。
これを用いると外部サービスの変更を監視することができます。
`StateNotifierProvider`には`StateNotifierProxyProvider`的なものが存在しないので、[`ProxyProvider`](https://pub.dev/packages/provider#proxyprovider)のように`update`で外部サービスの変更を受け取って自身の状態を変更する際には、`LocatorMixin`を使いましょう。

### 具体例

先ほどの`UserNotifier`/`UserState`がcontextに流れている状態で、プロフィールを表示するケースを想定して説明します。
使い方はとても簡単で、`void update(Locator watch)`内で`watch<T>()`するだけです。

```dart
class ProfileNotifier extends StateNotifier<ProfileState> with LocatorMixin {
  ProfileNotifier() : super(const ProfileState());

  @override 
  void initState() {
    super.initState();

    // readで初期化
    state = state.copyWith(user: read<UserState>.user);
  }

  @override 
  void update(Locator watch) {
    super.update(watch);

    // watchでUserStateの変更を監視
    state = state.copyWith(user: watch<UserState>.user);
  }
}
```

この`Locator watch`がどこからきているのかというと、[_StateNotifierProviderのbuildWithChild](https://github.com/rrousselGit/state_notifier/blob/master/packages/flutter_state_notifier/lib/flutter_state_notifier.dart#L244)を見るとわかります。

```dart
class _StateNotifierProvider<Controller extends StateNotifier<Value>, Value> 
    extends SingleChildStatelessWidget 
    implements StateNotifierProvider<Controller, Value> { 
  ...
  @override
  Widget buildWithChild(BuildContext context, Widget child) {
    return InheritedProvider<Controller>(
      ...
      update: (context, controller) {
        if (controller is LocatorMixin) {
          final locatorMixin = controller as LocatorMixin;
          Locator debugPreviousLocator;
          assert(() {
            debugPreviousLocator = locatorMixin.read;
            locatorMixin.read = <T>() {
              throw StateError("Can't use `read` inside the body of `update");
            };
            return true;
          }());
          // Locator watchとして<T>() => Provider.of<T>(context)を渡して呼び出し
          locatorMixin.update(<T>() => Provider.of<T>(context));
          assert(() {
            locatorMixin.read = debugPreviousLocator;
            return true;
          }());
        }
        return controller;
      },
      ...
    );
  }
```

`InheritedProvider`の`update`で、`<T>() => Provider.of<T>(context)`を渡して`locatorMixin.update`を呼び出しているのがわかります。
`locatorMixin.update(<T>() => Provider.of<T>(context));`の上下で色々やっているのは次の注意点の話に関係しています。

### 注意点

注意点としては`update`内では`read`にアクセスできないということです。
`read`を使おうとすると、`StateError("Can't use 'read' inside the body of 'update'")`が投げられます。

```dart
class ProfileNotifier extends StateNotifier<ProfileState> with LocatorMixin {
  ProfileNotifier() : super(const ProfileState());

  @override 
  void update(Locator watch) {
    super.update(watch);
    
    // readを使用🙅‍♂️ => StateError
    state = state.copyWith(user: read<UserState>.user);
  }
}
```

先ほどの`_StateNotifierProvider`のコードを見ると、`assert`を使って`locatorMixin.read`に`StateError`を設定しているので、厳密にはDebugモード時のみエラーとなるのですが、だからと言ってReleaseモードで使うのは避けましょう。

## テスト方法

`LocatorMixin`を用いた`StateNotifier`のテストは`debugMockDependency<T>`や`debugUpdate()`, `debugState `を使って書けます。以下は[mockio](https://pub.dev/packages/mockito)を使って書いたテスト例です。

```dart
test('updateUser', () async {
  final user = User(name: 'hoge', age: 24);
  final mockUserService = MockUserService();
  final userNotifier = UserNotifier(user: user)
    ..debugMockDependency<UserService>(mockUserService);
  
  // Initial state
  expect(userNotifier.debugState.user, user);

  // Call updateUser
  final updatedUser = user.copyWith(name: 'fuga', age: 25);
  when(mockUserService.update(name: 'fuga', age: 25).thenAnswer((_) => updatedUser);
  await userNotifier.updateUser(name: 'fuga', age: 25);

  // Updated state
  expect(userNotifier.debugState.user, updatedUser);
});
```

```dart

test('initialize and update user', () {
  final user = User(name: 'hoge', age: 24);
  final userState = UserState(user: user);
  final profileNotifier = ProfierNotifier()
    ..debugMockDependency<UserState>(userState);
  
  // Not initialized state
  expect(profileNotifier.debugState.user, isNull);

  // Call initState()
  profileNotifier.debugUpdate();

  // Initial state
  expect(profileNotifier.debugState.user, user);
  
  // Update dependency
  final updatedUser = user.copyWith(name: 'fuga');
  final updatedUserState = userState.copyWith(user: updatedUser);
  profileNotifier.debugMockDependency<UserState>(updatedUserState);

  // Call update(watch)
  profileNotifier.debugUpdate();
  
  // Updated state
  expect(profileNotifier.debugState.user, updatedUser);
});
```

# まとめ

初めは`LocatorMixin`よくわかりませんでしたが、内部実装をよく読んで理解して、実際に使ってみると非常に便利なものだとわかったので、今後は積極的に使っていこうと思っています。注意点で書いたところは実際に自分がハマったところなので、皆さんもお気をつけください。。笑

それでは良いFlutterライフを！