68
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-05-28

ここ数ヶ月Flutter界隈で話題になっているstate_notifierを、freezedと共に普段の業務で使用しているのですが、最近になってやっとstate_notifierのLocatorMixinを使用したので、使い方や便利な点をまとめようと思います。
ちなみにstate_notifier & freezedの使い方は、こちらの記事にわかりやすくまとめてあるのでご参考に。

環境

  • 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>継承クラス(HogeNotifierHogeControllerなど)において、providerProviderによってcontextに流されている外部サービスにアクセスする際に、Provider.of/context.read/context.watchを使用したくなると思います。
LocatorMixinHogeNotifierにmixinすることで、これらのアクセスが簡単に実現できるようになります。具体的には、read/updateHogeNotifier内で使用することができるようになります。

使い方

read

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

具体例

簡単な例を挙げると、

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);
  }
}

ということができるようになります。また、見通しをよくするために、

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を参考にしました。)

注意点

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

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>()が初期値として入っています。

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を見ると、この_locatorStateNotifier<T>継承クラスがインスタンス化された後に設定されていることがわかります。

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()使えと書いてあります。

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() {}
  ...
}

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

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を返しています。

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のようにupdateで外部サービスの変更を受け取って自身の状態を変更する際には、LocatorMixinを使いましょう。

具体例

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

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を見るとわかります。

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;
      },
      ...
    );
  }

InheritedProviderupdateで、<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'")が投げられます。

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.readStateErrorを設定しているので、厳密にはDebugモード時のみエラーとなるのですが、だからと言ってReleaseモードで使うのは避けましょう。

テスト方法

LocatorMixinを用いたStateNotifierのテストはdebugMockDependency<T>debugUpdate(), debugState を使って書けます。以下はmockioを使って書いたテスト例です。

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);
});

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ライフを!

68
44
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
68
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?