🤔

Riverpodで複雑なデータ加工をしたけどこの方法は本当にあっているのか?

2023/05/19に公開

ある日こんな実装をした

  • リモートから流れてくるあるリストデータを加工して表示する
  • 加工内容は、リスト内の一要素に対してランダムな整数値を加えること
    • 整数値はリスト内で重複しない
  • リストの要素数には上限がある
    • 上限値は別途取得する
  • リストから要素が削除されたとき、各要素に振られた値は維持する

業務の話なので抽象化してますが、大体こんな感じのことをやりました。
アーキテクチャ上このデータの加工はRiverpodのProviderを使ってやるのが最適そうだったので考えました。
データの流れを図にすると以下のような感じです。

data flow

というわけで実装してみた

サンプルコードを提示します。

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 において setStatesetState 内で呼ぶことと同じで、たしかに回避策として 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,
  );

この部分は、usersSourceref.listen により購読してキャッシュの更新を行うというコードになりますが、result として結果を ref.listen のスコープ外に出しています。
これは usersWithValue 自体の更新にもこの結果を使うためです。

ref.state に結果を代入することで usersWithValue は更新されるので、usersWithValue の戻り値としては初期値を返すというコードでも一見良さそうに思います。

  ref.listen(
    usersSource,
    (_, next) {
      ref.state = useCase.update(next);
    },
    fireImmediately: true,
  );

  return Iterable<UserWithValue>.empty();

ただしそれだけだとテストを書いたときに失敗する可能性が高いです(自分のケースでは初期値しか読み取れず失敗しました)。

変数に結果を格納して返す方法を取ると、ここでは

  1. 最初にProviderの値を読み取ったときは直接加工結果が返ってくる
  2. usersSource が更新されたら ref.state の更新を経由して加工結果が返ってくる

といった動きになります。


この内容はどちらかと言うと備忘録的な立ち位置に近いですが、もしかしたら世の中で必要としている人がいるかも知れない情報なので記事にしてみました。
どなたかの参考になれば幸いです。

Sun* Developers

Discussion