5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

この記事はどすこい塾13日目の記事になります。
https://qiita.com/advent-calendar/2024/dosukoi-juku

最近はFlutterアプリ開発に勤しんでいるどすこいです。
昨今のFlutterアプリ開発ではよくRiverpodが使われていますね。私も例に漏れずRiverpod使っています。
皆さんはRiverpod使っていますか?

さて今回はそんなRiverpodを使ったFlutterアプリ開発の中でアンチパターンとされている例を紹介していきたいと思います。

注意点

アンチパターンの紹介のため「~~する」という表記をしていますが、本来は「~~してはいけない」という意味なので読み替えてください

Ephemeral Stateにproviderを使用する

まずEphemeral Stateとはなんぞやという話ですが、大体はここに書かれています。
https://docs.flutter.dev/data-and-backend/state-mgmt/ephemeral-vs-app

UIステートとかローカルステートとか呼ばれているもので、1つのWidgetの状態のことです。
例えば

  • PageViewの現在のページ
  • フォームの入力状況

などです。
これらにはRiverpodのStateNotifierなどを用いることはなるべく避けるように、Riverpodの公式ドキュメントに書かれています。
https://riverpod.dev/docs/essentials/do_dont#avoid-using-providers-for-ephemeral-state

RiverpodのProviderやNotifierはグローバルに定義されるもので、意図しない変更が起きてしまう可能性があるから、とのことです。
代替としてflutter_hooksを使いましょう。

MVVMのViewModelとしてはChangeNotifierが代替先ですね。

イベントハンドラでwatchを使う

まずはよくない例です。

ElevatedButton(
    child: Text("Let's Increment!"),
    onPressed: () {
        ref
            .watch(counterProvider.notifier)
            .increment(count: count++);
    }
);

ElevatedButtonのonPressedでref.watchをしていますね。
不具合を引き起こすものではありませんが、あまり推奨されていません。

正しくはこちら

ElevatedButton(
    child: Text("Let's Increment!"),
    onPressed: () {
        ref
            .read(counterProvider.notifier)
            .increment(count: count++);
    }
);

ref.readを使うようにしましょう。

Providerの値をreadで受け取る

Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.read(counterProvider);

    return Text(count.toString());
}

こちらは何がいけないのでしょう。
counterProviderの値をref.readで受け取っていますね。
これだとcounterProviderの値が変更されてもこのWidgetはrebuildされません。(つまり表示が更新されません)

正しくはこちら

Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Text(count.toString());
}

ref.watchを使うようにしましょう。

パラメータを受け取るProviderをkeepAliveにする

以下のようなProviderがあるとします

@Riverpod(keepAlive: true)
Future<User?> findUserById(Ref ref, {String userId}) async {
    final response = await http.get('https://example.com/users/${userid}');
    if (response.statusCode != 200) {
        return null;
    }
    final json = jsonDecode(response.body) as Map<String, dynamic>;
    return User.fromJson(json);
}

これは何がいけないのでしょう。
通常ProviderはkeepAliveオプションをつけない場合、AutoDisposeProviderやAutoDisposeFutureProviderを生成します。
このAutoDisposeというのはref.watchをしている箇所がない場合Providerを破棄してくれるものです。

アプリ全体で監視する状態があり、かつ破棄されては困るものにはkeepAliveオプションをつけるのは良いかもしれません。
ただ今回のProviderはパラメータを受け取るもので、このProviderに渡すuserIdが変更されるたびに新しいProviderを生成します。しかも使わなくなったProviderも破棄されずメモリ上に残ってしまいます。
これが大量に生成されてしまった場合はメモリリークを引き起こす可能性があるでしょう。

特別な理由がない限りはパラメータを受け取るProviderをkeepAliveにすることは避けましょう。

WidgetでProviderの初期化をする

昔からアプリ開発をしていた方は以下のコードに見覚えがあると思います。

class ExampleFragment : Fragment() {

    private val viewModel: ExampleViewModel by viewModels()
    
    override onViewCreated(~~) {
        viewModel.init()
    }
}
class ExampleViewController : ViewController {
    private let viewModel: ExampleViewModel = ExampleViewModel()

    override func viewDidLoad() {
        viewModel.init();
    }
}

Viewの作成が終わりUIの状態やイベントの更新の準備ができた段階で、命令的にViewModelを初期化するというものです。

しかしRiverpodではこういった命令的な初期化というのが推奨されていません。
例えばこんな感じ

class ExampleState extends ConsumerState<ExampleWidget> {
    @override
    void initState() {
        super.initState();
        ref.read(exampleProvider).init();
    }
}

Providerは宣言的に初期化されるべきでView側から初期化の命令をされるものではありません。
万能的な解決策はありませんが例えばこんな感じ

Future<User> getUser() async {
    // ここが実質初期化
    final response = await ~~;
    ----
    return User.fromJson(json);
}

class UserState extends _$Article {
    @override
    Future<User> build() async {
        // ここが実質初期化
        final response = await ~~;
        ---
        return User.fromJson(json);
    }
}

のような感じです。

selectを使った過度なパフォーマンスチューニング

class User {
    const User({
        required this.firstName,
        required this.lastName,
        required this.email,
        required this.age,
    });
    final String firstName;
    final String lastName;
    final String email;
    final int age;
}

このようなUserクラスがあったとして、emailの変更だけを受け取りたいとします。
その場合は以下のようにselectを使ったwatchができます。

class EmailWidget extends ConsumerWidget {
    @override
    Widget build(BuildContext context, WidgetRef ref) {
        final email = ref.watch(userStateProvider.select((user) => user.email));

        return Text(email);
    }
}

これはUserStateがUserを返すと仮定して、返ってくるUserのemailの変更のみを受け取るというものです。
selectを使うことでemail以外の変更は受け取らず、rebuildの回数を減らしパフォーマンスの改善が見込めるとのことです。

ただしRiverpod公式ドキュメントには以下のようなことが書かれています。

Using select slightly slows down individual read operations and increase a tiny bit the complexity of your code.
It may not be worth using it if those "other properties" rarely change.

selectを使用すると、個々の読み取り操作がわずかに遅くなり、コードの複雑さがわずかに増します。
"他のプロパティ"がほとんど変更されない場合は、使用する価値がないかもしれません。

selectは読み取りがわずかに遅くなり、かつ複雑さが増すというものです。
selectを使った過度なパフォーマンスチューニングは避けたほうがいいというものですね。

あなたに名言を送ります。
「推測するな計測せよ」

まとめ

とここまでほぼ公式ドキュメントに書いてあるアンチパターンを紹介してきました。
上記のことは絶対にしてはいけないというわけではなく、なるべくしないようにしようねというもので今すぐに直さないといけないものではありません。
しかしなるべく避けようねというものなのでこれから実装する方は上記のことはせずに正しくRiverpodを使って実装していきましょう。

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?