ここ数ヶ月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>
継承クラス(HogeNotifier
やHogeController
など)において、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>()
に相当する関数です。
具体例
簡単な例を挙げると、
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を参考にしました。)
LocatorMixinを使うとコンストラクターインジェクションに比べて依存が分かりにくい件、僕はこうやってまとめて並べておくことであまり困ってない( ´・‿・`)https://t.co/5PyodUfaJZ pic.twitter.com/HF3XZBwbwk
— mono 🎯 @自宅 💙 (@_mono) May 17, 2020
注意点
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を見ると、この_locator
はStateNotifier<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;
},
...
);
}
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'")
が投げられます。
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を使って書いたテスト例です。
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ライフを!