🎃

【Flutter】Riverpodを使ってアプリのパフォーマンスを最適化する

2023/04/23に公開

はじめに

Riverpodを使い始めて1年くらいになるので、詰まったポイントについてまとめてみようと思いました。
今回はパフォーマンスの最適化についてです。

Riverpodの利点について公式ドキュメントには次のように書かれています。

アプリのパフォーマンスを最適化してくれます。 例えば、ウィジェット更新の条件を限定したり、負荷が高いステートの計算をキャッシュしたりといったことが可能になります。 プロバイダがステートの変化による外部への影響をコントロールしてくれます。

https://docs-v2.riverpod.dev/ja/docs/concepts/providers#プロバイダが必要な理由
私自身最初はRiverpodをうまく使いこなせず、アプリが重くなってしまうということがあったのですが、公式ドキュメントを全て読んでコードを修正した結果パフォーマンスを改善することができました。

今回はその内容についてまとめてみました。

selectを使う

https://docs-v2.riverpod.dev/ja/docs/concepts/reading#selectを使って更新の条件を限定する
selectを使うと更新条件を限定することができます。頻繁に更新されるProviderをwatchする場合は可能な限り、必要な情報だけをselectするようにします。
頻繁に更新される場合でなくとも、特にデメリットもないため、selectを使うのを習慣にしておくといいかもしれません。

select使用例
// 現在の時刻を公開するProvider
// 1秒ごとに更新する
final nowProvider = Provider<DateTime>((ref) {
  Timer.periodic(const Duration(seconds: 1), (timer) {
    ref.invalidateSelf();
    timer.cancel();
  });
  return ref.watch(clockProvider).now();
});

// 日付のみが必要な場合はselectで日付を計算する
// nowProviderをそのままwatchすると1秒ごとに更新されるが
// このようにselectすることで日付の変わるタイミングでのみ更新される
final today = nowProvider.select((now) => DateUtils.dateOnly(now));

Providerを使う

https://docs-v2.riverpod.dev/ja/docs/providers/provider#provider-を使ってプロバイダやウィジェットの更新の条件を限定する

selectと同じように他のProviderの結果を加工して更新条件を限定することができます。さらに、Providerの中でwatchしているPrivderが更新されない限りは計算結果がキャッシュされます。

Provider使用例
// 今日の日付を計算するプロバイダ
final todayProvider = Provider<DateTime>((ref) {
  final now = ref.watch(nowProvider);
  return DateUtils.dateOnly(now);
});

final today = ref.watch(todayProvider);

意味的にはselectを使っても同じですが、Providerとして定義しておくことで計算結果のキャッシュもでき、簡単に使いまわせるようになります。

何カ所かで使う場合や計算が複雑な場合はProviderで定義し、計算が単純な場合や一カ所でしか使わない場合などはselectで対応するように使い分けると良いと思います。

Widgetの抽出/Consumerの活用

最後にWidgetの抽出、Consumerの活用による更新範囲の限定についてです。
下のように、build関数の中で3つのString型のstateをwatchして、ColumnでそれぞれのstateをTextとして表示する例を考えます。

class ParentWidget extends ConsumerWidget {
  const ParentWidget(this.id, {super.key});

  final String id;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state1 = ref.watch(state1Provider);
    final state2 = ref.watch(state2Provider);
    final state3 = ref.watch(state3Provider);

    return Column(
      children: [
        Text(state1),
	const SizedBox(height: 8),
	Text(state2),
	const SizedBox(height: 8),
        Text(state3),
      ],
    );
  }
}

このとき、state2が変わったならText(state2)だけをリビルドしてほしいですが、state2が更新されると、constキーワードがついたWidgetを除くbuild関数内すべてのWidgetがリビルドされてしまいます。
これを防ぐためには、Widgetを抽出し、子でProviderをwatchする必要があります。
VSCodeにはWidgetを抽出する機能があり、これを使うと勝手に新しいWidgetを作ってくれます。


class ParentWidget extends ConsumerWidget {
  const ParentWidget(this.id, {super.key});

  final String id;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state1 = ref.watch(state1Provider);
    final state2 = ref.watch(state2Provider);
    final state3 = ref.watch(state3Provider);

    return Column(
      children: [
        State1Widget(state1: state1),
	const SizedBox(height: 8),
	Text(state2),
	const SizedBox(height: 8),
        Text(state3),
      ],
    );
  }
}


class State1Widget extends StatelessWidget {
  const State1Widget({
    super.key,
    required this.state1,
  });

  final String state1;

  
  Widget build(BuildContext context) {
    return Text(state1);
  }
}

しかし、この状態だとまだref.watchがParentWidget内にあり、更新範囲は変わっていないのでStatelessWidget→ConsumerWidgetに変更し、WidgetRef内にrefを移動します。


class ParentWidget extends ConsumerWidget {
  const ParentWidget(this.id, {super.key});

  final String id;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state2 = ref.watch(state2Provider);
    final state3 = ref.watch(state3Provider);

    return Column(
      children: [
        const State1Widget(),
	const SizedBox(height: 8),
	Text(state2),
	const SizedBox(height: 8),
        Text(state3),
      ],
    );
  }
}


class State1Widget extends ConsumerWidget {
  const State1Widget({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state1 = ref.watch(state1Provider);
    return Text(state1);
  }
}

これでstate1のを変更してもText(state2)とText(state3)はリビルドされなくなりました。
同じようにState2WidgetとState3Widgetも作成し完了です。

しかし、細かいWidgetを抽出していくとコードが膨れ上がってしまいますよね。
そこで使えるのがConsumerで、Consumerのbuilder内でwatchすることにより、リビルドの範囲を限定することができます。
Builderをご存知の方は、WidgetRefを使えるBuilderと思ってもらえるとわかりやすいかと思います。
https://zenn.dev/ryouhei_furugen/scraps/06c396dbddd472
実際に今回のプログラムをConsumerを使って書くとこうなります。

class ParentWidget extends ConsumerWidget {
  const ParentWidget(
    this.id, {
    Key? key,
  }) : super(key: key);

  final String id;

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        Consumer(builder: (context, ref, child) {
          final state1 = ref.watch(state1Provider);
          return Text(state1);
        }),
	const SizedBox(height: 8),
        Consumer(builder: (context, ref, child) {
          final state2 = ref.watch(state2Provider);
          return Text(state2);
        }),
	const SizedBox(height: 8),
        Consumer(builder: (context, ref, child) {
          final state3 = ref.watch(state3Provider);
          return Text(state3);
        }),
      ],
    );
  }
}

Widget分割の代わりにConsumerを使っています。パフォーマンスはどちらの方法でも変わらないので、抽出したいWidgetが小さくてシンプルな場合はConsumerを使い、大きくて複雑だったり、使いまわしたい場合は抽出するとよいと思います。

サンプルコード

https://github.com/yudofu502/riverpod-performance-sample
今回紹介したRiverpodのパフォーマンスの違いを確認できるサンプルです。
サンプルを実行すると、どのコンソールからどのタイミングでどのWidgetがリビルドされているかを確認することができます。
High PerformanceLow Performanceのタブがあり、それぞれの詳細は実際に見てもらえればと思いますが、この記事で書いたことを取り入れたものと取り入れてないものになっています。

アプリの内容は2つの数字の積を計算するシンプルなもので、例えば黄色の数字を0から1にしたときのHigh PerformanceLow Performanceのコンソール出力は以下のようになります。

High Performance
I/flutter (31516): build NumberContainer: 1
Low Performance
I/flutter (31516): build LowPerformanceSample
I/flutter (31516): build MyIconButton: IconData(U+0E516)
I/flutter (31516): build NumberContainer: 0
I/flutter (31516): build MyIconButton: IconData(U+0E047)
I/flutter (31516): build MyIconButton: IconData(U+0E516)
I/flutter (31516): build NumberContainer: 1
I/flutter (31516): build MyIconButton: IconData(U+0E047)
I/flutter (31516): build NumberContainer: 0

MyIconButtonは+ボタンと-ボタン、NumberContainerは数字が書かれた丸い背景のWidget、LowPerformanceSampleはこのアプリのbody部分全体を表しており、黄色の数字が0から1に変わっただけにもかかわらず、Low Performanceでは変化のない他の数字や、ボタンまでリビルドしていることが分かります。

この程度のアプリであれば特に問題ありませんが、頻繁にstateが変わったり、大規模なアプリの場合は余計なリビルドによるパフォーマンスの低下が響く場合があるので、今日紹介したパフォーマンス対策を意識するとよいでしょう。

おまけ(ChatGPTの活用)

ChatGPTで以下のプロンプトを投げたところ、自動で変換してくれました。

入力
FlutterでRiverpodを使っています。
StatelessWidgetからConsumerWidgetに変更し、state1をstate1Providerから取得するように変更してください。
ただし
WidgetRefを使用してください。

class State1Widget extends StatelessWidget {
  const State1Widget({
    super.key,
    required this.state1,
  });

  final String state1;

  @override
  Widget build(BuildContext context) {
    return Text(state1);
  }
}
出力
以下は、StatelessWidgetからConsumerWidgetへの変更例です。state1Providerから状態を取得するように変更されています。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final state1Provider = Provider<String>((ref) => 'Hello, world!');

class State1Widget extends ConsumerWidget {
  const State1Widget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final state1 = watch(state1Provider);
    return Text(state1);
  }
}
上記のコードでは、ConsumerWidgetを継承し、buildメソッドの引数であるScopedReaderを使用して、state1Providerから状態を取得しています。ScopedReaderは、ビルドされたウィジェット内でProviderの現在の値を取得するための便利な方法です。

この程度であれば手動でやったほうが早い気もしますが、ChatGPTの可能性を感じました。

筆者はChatGPT3.5を使っていますが、4.0を使うともっと高度な変換もできそうで面白いですね。

まとめ

Riverpodのパフォーマンスを最適化するためには、以下の点に注意しましょう。

  • 頻繁に更新されるProviderをwatchする場合は、必要な情報だけをselectする、もしくは新しくProviderを作りましょう。
  • Widgetの抽出/Consumerの活用による更新範囲の限定も重要です。Consumerを使うと、リビルドの範囲を限定することができます。

これらの対策によって、Riverpodをより効果的に使うことができます。

(まとめはNotionAIを使ってみました)

Discussion