👏

[Flutter/Riverpod] Listの変更による余計なWidgetの再描画をしない方法

2022/07/03に公開

RiverpodでListを管理しているとき、Providerのstateに全く同じListが代入されてもref.watchによるWidgetの再描画が発生します。
以下のようなコードだと、この現象が起きます。

final listProvider = StateProvider<List<int>>((ref) => [1, 2, 3]);

class ListPrintPage extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // Providerから値を読み取る
    final list = ref.watch(listProvider);
    return Scaffold(
      appBar: AppBar(),
      body: Text(list.toString()),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
	  // 同じ値を入れても上のref.watchが反応しWidgetがリビルドされる
	  ref.read(listProvider.notifier).state = [1, 2, 3];
	},
      ),
    );
  }
}

この余計な再描画を解消するコードを自分なりに3つほど考えたので、まとめておきます。

そもそもなぜref.watchが反応するのか

ref.watchは状態変化を==演算子で確認しています。int型やString型は値を==演算子で等価比較できますが、ListのようなIterable型は==演算子で値を比較できません。これは値ではなく参照元を比較しているためです。
例えば、以下のlistCはlistAと同じ参照元なので比較した際はtrueになります。

List<int> listA = [1, 2, 3];
List<int> listB = [1, 2, 3];
List<int> listC = listA;

print(listA == listB);  // false
print(listA == listC);  // true

参照元が同じでないとtrueにならないため、値が同じかを確認するにはfoundation.dartlistEquals関数か、collection.dartListEquality().equalsを使用します。
https://api.flutter.dev/flutter/foundation/listEquals.html
https://api.flutter.dev/flutter/package-collection_collection/ListEquality-class.html
しかし、これらはref.watchで使用することができません。ですが次の方法であれば、同じ値が代入されても余計なWidgetの再描画が起こりません。

1. 新たにProviderを定義する

新たにProviderを用意し、そのProviderの中身に再描画の条件を書き込みます。
これで動きはしますが、書き方があまりよろしくない気がします。

final listProvider = StateProvider<List<int>>((ref) => [1, 2, 3]);

// 同じ値の代入でWidgetを再描画しないListのProvider
final comparableListProvider = Provider.autoDispose((ref) {
  ref.listen<List<int>>(listProvider, (prev, next) {
    // 代入される前の値と代入する値をlistEquals関数で比較し、違う場合は自身のstateを書き換える
    if (!listEquals(prev, next)) {
      ref.state = next;
    }
  });
  return ref.read(listProvider);
});

class ListPrintPage extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // 読み取りのときだけcomparableListProviderを使用する
    final list = ref.watch(comparableListProvider);
    return Scaffold(
      appBar: AppBar(),
      body: Text(list.toString()),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
	  ref.read(listProvider.notifier).state = [1, 2, 3];
	},
      ),
    );
  }
}

2. StateNotifierでupdateShouldNotify関数を上書きする

StateNotifierを使用している場合限定ですが、updateShouldNotifyをOverrideすることでWidgetの再描画条件を書き込めます。
updateShouldNotify関数は、返す値がtrueの場合のみ再描画通知を行います。
これが一番スッキリした書き方だと個人的には思いますね。

final listProvider = StateNotifierProvider<ListNotifier, List<int>>((ref) {
  return ListNotifier([1, 2, 3]);
});

class ListNotifier extends StateNotifier<List<int>> {
  ListNotifier(List<int> state) : super(state);

  
  bool updateShouldNotify(List<int> old, List<int> current) {
    // ここにWidgetの再描画条件を書き込む
    return !listEquals(old, current);
  }
}

class ListPrintPage extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final list = ref.watch(listProvider);
    return Scaffold(
      appBar: AppBar(),
      body: Text(list.toString()),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
	  ref.read(listProvider.notifier).state = [1, 2, 3];
	},
      ),
    );
  }
}

3. ==演算子で比較が可能なクラスを作る

==演算子で比較が可能なIterableクラスを自分で作ります。
ここではUnmodifiableListViewクラスを継承して実装します。この実装方法が一番楽だと思いますね。

class ComparableListView<E> extends UnmodifiableListView<E> {
  ComparableListView(Iterable<E> source) : super(source);
  
  // ==演算子による処理を定義することで、値の比較が可能になる
  
  bool operator ==(Object other) =>
      identical(this, other) ||
      (other is ComparableListView<E> &&
          runtimeType == other.runtimeType &&
          listEquals<E>(this, other));

  
  int get hashCode => super.hashCode;
}

// Providerの定義は通常のListで行う
final listProvider = StateProvider<List<int>>((ref) => [1, 2, 3]);

class ListPrintPage extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // selectで比較の時だけ使用するのが良さそう
    final list = ref.watch(listProvider.select((state) => ComparableListView(state)));
    return Scaffold(
      appBar: AppBar(),
      body: Text(list.toString()),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
	  ref.read(listProvider.notifier).state = [1, 2, 3];
	},
      ),
    );
  }
}

以上になります。
個人的には2番目の方法を最優先で用い、StateNotifierを使用しない場合は3番目の方法でやるのが一番かと思います。

Flutter勉強中の身です。何か間違いがありましたらコメントいただけると幸いです。

Discussion