Riverpodで複雑なデータ加工をしたけどこの方法は本当にあっているのか?
ある日こんな実装をした
- リモートから流れてくるあるリストデータを加工して表示する
- 加工内容は、リスト内の一要素に対してランダムな整数値を加えること
- 整数値はリスト内で重複しない
- リストの要素数には上限がある
- 上限値は別途取得する
- リストから要素が削除されたとき、各要素に振られた値は維持する
業務の話なので抽象化してますが、大体こんな感じのことをやりました。
アーキテクチャ上このデータの加工はRiverpodのProviderを使ってやるのが最適そうだったので考えました。
データの流れを図にすると以下のような感じです。
というわけで実装してみた
サンプルコードを提示します。
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
final uuid = Uuid();
final random = Random();
class User {
final String id;
final String name;
User(this.id, this.name);
}
class UserWithValue {
final String id;
final String name;
final int value;
UserWithValue(this.id, this.name, this.value);
}
const usersLimit = 10;
final usersSource = StateProvider.autoDispose((ref) => <User>[]);
final usersWithValueCache =
StateProvider.autoDispose<Iterable<UserWithValue>?>((ref) => null);
final cacheUpdater =
Provider.autoDispose<void Function(Iterable<UserWithValue>)>((ref) {
final notifier = ref.watch(usersWithValueCache.notifier);
return (state) => Future.microtask(() {
if (!notifier.mounted) {
return;
}
notifier.state = state;
});
});
final usersWithValue = Provider.autoDispose((ref) {
final users = ref.watch(usersSource);
final availableValues = List.generate(usersLimit, (i) => i)..shuffle();
final updateCache = ref.watch(cacheUpdater);
final cache = ref
.listen<Iterable<UserWithValue>?>(usersWithValueCache, (_, __) {})
.read();
if (cache == null) {
final state = users.mapIndexed(
(i, e) => UserWithValue(e.id, e.name, availableValues[i]),
);
updateCache(state);
return state;
}
final usedValues = cache.map((e) => e.value);
final unusedValues = [...availableValues.whereNot(usedValues.contains)];
final result = <UserWithValue>[];
for (final user in users) {
final listedUser = cache.firstWhereOrNull((e) => e.id == user.id);
if (listedUser != null) {
result.add(listedUser);
continue;
}
result.add(UserWithValue(user.id, user.name, unusedValues.removeAt(0)));
}
updateCache(result);
return result;
});
リモートデータソースは便宜上ただの StateProvider
で擬似的に再現しています(usersSource
)。
この要件を実現するために重要なのは一度加工したデータに対して どの要素のどの整数値を与えたかを記録しておく 必要があるということです。
図中では「加工結果のキャッシュ」としているところで、これは usersWithValueCache
に該当します。
そのキャッシュを更新する関数を cacheUpdater
から提供し、これを通して管理します。
この関数はProviderの _create
関数中で呼ぶことになるので、次のmicrotaskで呼ぶように Future.microtask()
で包んでおくのと、usersWithValueCache
がdisposeされていたときには何もしないように mounted
を参照して早期リターンしています。
final usersWithValueCache =
StateProvider.autoDispose<Iterable<UserWithValue>?>((ref) => null);
final cacheUpdater =
Provider.autoDispose<void Function(Iterable<UserWithValue>)>((ref) {
final notifier = ref.watch(usersWithValueCache.notifier);
return (state) => Future.microtask(() {
if (!notifier.mounted) {
return;
}
notifier.state = state;
});
});
ここで肝になる「データを加工するProvider」に該当するのが usersWithValue
です。
依存するデータソース usersSource
をwatchしつつ、付与する整数値の元になる配列 availableValues
を生成しておき事前にランダマイズしておきます。
上述したキャッシュもここで呼び出しますが、このキャッシュの更新で usersWithValue
自体の更新はしたくないので ref.listen().read()
を使って参照します。
ちなみに ref.read()
を使えばいいじゃんという話がありますが、_create
関数内で
final users = ref.watch(usersSource);
final availableValues = List.generate(usersLimit, (i) => i)..shuffle();
final updateCache = ref.watch(cacheUpdater);
final cache = ref
.listen<Iterable<UserWithValue>?>(usersWithValueCache, (_, __) {})
.read();
これらのProviderを試せるようにFlutter用のコードも書いてみました。
実行する場合はDartPadだと uuid
が使えないと思うので、ローカルでやるかよしなに置き換えてください。
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const Home(),
);
}
}
class Home extends ConsumerWidget {
const Home({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
final users = ref.watch(usersWithValue);
return Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 16),
children: [
for (final user in users)
ListTile(
title: Text(user.name),
subtitle: Text(user.id),
leading: CircleAvatar(child: Text("#${user.value}")),
),
],
),
),
floatingActionButton: Column(
children: [
const Spacer(),
FloatingActionButton(
onPressed: () {
final state = ref.read(usersSource);
if (state.length >= usersLimit) {
return;
}
ref.read(usersSource.notifier).state = [
...state,
User(uuid.v4(), _generateName()),
];
},
child: const Icon(Icons.add),
),
const SizedBox(height: 16),
FloatingActionButton(
onPressed: () {
final state = ref.read(usersSource);
if (state.isEmpty) {
return;
}
ref.read(usersSource.notifier).state = [
...state.whereIndexed((i, _) => i != 0)
];
},
child: const Icon(Icons.remove),
),
],
),
);
}
String _generateName() => String.fromCharCodes(
List.generate(5, (index) => random.nextInt(33) + 89));
}
モヤッとする点
この実装をしてみてイマイチ納得がいってない点があります。上で少し解説した、cacheUpdater
を _create
関数内で呼ぶ、つまり _create
関数内でのProviderの更新を許してしまっている点です。
この操作はFlutterで言うところの StatefulWidget
において setState
を setState
内で呼ぶことと同じで、たしかに回避策として Future.microtask()
を使うのは有効です。
しかし本当であればそうならないように書くのがベストなので、このコードを何とか書き直せないか考えたいところです。
というわけで改善してみた
はい。一旦は許したコードですが、一日置いたら段々許せなくなってきたので改善しました。
結果はこんな感じです。usersSource
とかはそのままです。
class _UseCase {
Iterable<UserWithValue>? _cache;
_UseCase();
Iterable<UserWithValue> update(Iterable<User> users) {
final availableValues = List.generate(usersLimit, (i) => i)..shuffle();
final cache = _cache;
if (cache == null) {
final state = users.mapIndexed(
(i, e) => UserWithValue(e.id, e.name, availableValues[i]),
);
_cache = state;
return state;
}
final usedValues = cache.map((e) => e.value);
final unusedValues = [...availableValues.whereNot(usedValues.contains)];
final result = <UserWithValue>[];
for (final user in users) {
final listedUser = cache.firstWhereOrNull((e) => e.id == user.id);
if (listedUser != null) {
result.add(listedUser);
continue;
}
result.add(UserWithValue(user.id, user.name, unusedValues.removeAt(0)));
}
_cache = result;
return result;
}
}
final useCaseProvider = Provider.autoDispose((ref) => _UseCase());
final usersWithValue = Provider.autoDispose((ref) {
final useCase = ref.watch(useCaseProvider);
late Iterable<UserWithValue> result;
ref.listen(
usersSource,
(_, next) {
result = useCase.update(next);
ref.state = result;
},
fireImmediately: true,
);
return result;
});
このコードで変わった点は主に2つです。
- ロジックをクラスに閉じ込めた
- そのお陰でクラス内にキャッシュを持てるようになった
- つまりキャッシュを別Providerで保持することを諦めた
-
usersSource
の更新の検知をref.listen
で行うようになった-
usersWithValue
は直接usersSource
をwatchするわけではなく、更新ロジックの結果を通して間接的に更新するようにした
-
これにより、無理に _create
関数内でProviderの更新を行う、といったコードを書く必要がなくなりました!🎉
補足
改良版のコードに慣れてない方向けの補足をします。
late Iterable<UserWithValue> result;
ref.listen(
usersSource,
(_, next) {
result = useCase.update(next);
ref.state = result;
},
fireImmediately: true,
);
この部分は、usersSource
を ref.listen
により購読してキャッシュの更新を行うというコードになりますが、result
として結果を ref.listen
のスコープ外に出しています。
これは usersWithValue
自体の更新にもこの結果を使うためです。
ref.state
に結果を代入することで usersWithValue
は更新されるので、usersWithValue
の戻り値としては初期値を返すというコードでも一見良さそうに思います。
ref.listen(
usersSource,
(_, next) {
ref.state = useCase.update(next);
},
fireImmediately: true,
);
return Iterable<UserWithValue>.empty();
ただしそれだけだとテストを書いたときに失敗する可能性が高いです(自分のケースでは初期値しか読み取れず失敗しました)。
変数に結果を格納して返す方法を取ると、ここでは
- 最初にProviderの値を読み取ったときは直接加工結果が返ってくる
-
usersSource
が更新されたらref.state
の更新を経由して加工結果が返ってくる
といった動きになります。
この内容はどちらかと言うと備忘録的な立ち位置に近いですが、もしかしたら世の中で必要としている人がいるかも知れない情報なので記事にしてみました。
どなたかの参考になれば幸いです。
Discussion