🎯

AsyncCacheのススメ(非同期処理の多重実行防止のための個人的ベタープラクティス)

2023/09/14に公開
5

この記事は何?

ボタンをタップすることでAPIコールなどの非同期処理を実行するような実装をしている場合に、ボタン連打によって非同期処理が何度も呼び出されてしまう問題を回避するため実装について、個人的なベタープラクティスを伝える記事です。

この記事が対象としている読者

  • Flutterを使い始めてまだ日が浅い開発者
  • 「Flutterらしい書き方って何だろう」と考えるようになった開発者
  • 「AsyncCacheって何?」と気になった開発者

結論

非同期処理を重複して実行させないような実装を行う際には、asyncパッケージの AsyncCache.ephemeral() を使うと手軽に多重実行を防止できるため便利でおすすめです。

cacheStrategy = AsyncCache.ephemeral();

~ 省略 ~

ElevatedButton(
  onPressed: () async {
    await cacheStrategy.fetch(() => asyncFunc());
  },
  child: const Text('tapme'),
),
AsyncCache.ephemeral() を使ったコードサンプル(全文)
import 'package:async/async.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late final AsyncCache cacheStrategy;

  
  initState() {
    super.initState();
    /// 実行中の処理が完了した後に無効化するキャッシュを作成
    cacheStrategy = AsyncCache.ephemeral();
  }

  Future<void> asyncFunc() async {
    await Future.delayed(const Duration(seconds: 3), () {
      final dateText = DateTime.now().toIso8601String();
      print(dateText);
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
	    /// asyncFunc() の実行が完了するまでは、ボタンを何度タップしても処理は1回だけしか実行されない
            await cacheStrategy.fetch(() => asyncFunc());
          },
          child: const Text('tapme'),
        ),
      ),
    );
  }
}

ボタンを連打されると非同期処理が何度も実行されちゃうんだけどなんとかならない?

Flutter においてボタンをタップすることで何かしらの非同期処理を実行するような実装をすることは一般的だと思います。
また、セクションタイトルのような不具合を作ってしまうことも Flutter を使い始めて間もない開発者にとってはあるあるでしょう。

実行中かどうかを表す変数を使えば解決?

前セクションの不具合を解消するためにまず考えつくことは、実行中かどうかを管理する変数を用意して、非同期処理の前後でその値を書きかえることだと思います。
これによって不具合は解消されますが、いちいち running の値を切り替えるのは手続き的な処理の書き方になってしまい Flutter らしい書き方ではないです。(正直、私個人の感想レベルの意見なので、そう考える人もいるんだな程度に受け取ってください。)

runnnig = false;

~ 省略 ~

ElevatedButton(
  onPressed: running
    ? null
    : () async {
      setState(() {
        running = true;
      });
      try {
        await asyncFunc();
      } finally {
        setState(() {
          running = false;
        });
      }
    },
  child: const Text('tapme'),
)
コードサンプル(全文)
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  bool running = false;

  
  initState() {
    super.initState();
    running = false;
  }

  Future<void> asyncFunc() async {
    await Future.delayed(const Duration(seconds: 3), () {
      final dateText = DateTime.now().toIso8601String();
      print(dateText);
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: ElevatedButton(
          onPressed: running
              ? null
              : () async {
                  setState(() {
                    running = true;
                  });
                  try {
                    await asyncFunc();
                  } finally {
                    setState(() {
                      running = false;
                    });
                  }
                },
          child: const Text('tapme'),
        ),
      ),
    );
  }
}

じゃあどうしたらいいのか?

「実行中かどうかを見て非同期処理が実行できるかどうかを決める」のではなく、「何度実行しようとしても非同期処理の実行完了までは1回しか実行させない」という考え方に変えてみましょう。
それを実現できるのが、 async パッケージ内の AsyncCache というクラスです。
AsyncCache には、指定した時間が経過した後にその内容を無効にするキャッシュ(AsyncCache)と
実行中のリクエストが完了した後に無効化するキャッシュ(AsyncCache.ephemeral)の2種類があるため、要件に応じて使い分けましょう。
今回は、AsyncCache.ephemeralが適当そうですね。

https://pub.dev/documentation/async/latest/async/AsyncCache-class.html

で、どう使えばいいわけ?

AsyncCacheインスタンスを作成して、 fetch メソッドを実行するだけです。

// STEP1: キャッシュ戦略を設定する
final cacheStrategy = AsyncCache.ephemeral();

// STEP2: 多重実行させたくない処理を fetch に渡して実行する
cacheStrategy.fetch(() {
  // 適当な非同期処理
}); 

結論(再掲)

非同期処理を重複して実行させないような実装を行う際には、asyncパッケージの AsyncCache.ephemeral() を使うと手軽に多重実行を防止できるため便利でおすすめです。

cacheStrategy = AsyncCache.ephemeral();

~ 省略 ~

ElevatedButton(
  onPressed: () async {
    await cacheStrategy.fetch(() => asyncFunc());
  },
  child: const Text('tapme'),
),
AsyncCache.ephemeral() を使ったコードサンプル(全文)
import 'package:async/async.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late final AsyncCache cacheStrategy;

  
  initState() {
    super.initState();
    /// 実行中の処理が完了した後に無効化するキャッシュを作成
    cacheStrategy = AsyncCache.ephemeral();
  }

  Future<void> asyncFunc() async {
    await Future.delayed(const Duration(seconds: 3), () {
      final dateText = DateTime.now().toIso8601String();
      print(dateText);
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
	    /// asyncFunc() の実行が完了するまでは、ボタンを何度タップしても処理は1回だけしか実行されない
            await cacheStrategy.fetch(() => asyncFunc());
          },
          child: const Text('tapme'),
        ),
      ),
    );
  }
}

Discussion

あっぷる中谷あっぷる中谷

cacheStrategy = AsyncCache.ephemeral();

こちらあまり動きを理解できていないのですが、
UI側ではなくRiverpod等の実行元で

final AsyncCache<dynamic> _postCache = AsyncCache.ephemeral();
  Future<void> postHogehoge(String id) async {
    await _postCache.fetch(() async {
      state = const AsyncLoading();
      state = await AsyncValue.guard(() => _hogeRepository.postHogehoge(id));
    });
  }

しても同様の結果を得ることができるのでしょうか?

あっぷる中谷あっぷる中谷

ありがとうございます!
Riverpod内で宣言するとハッシュコードイコールじゃなくなってcacheが上手くいかない的な感じですかね??

なので宣言時にコンストラクタにAsyncCacheを渡してProviderがwatchされる事で一意のAsyncCacheになるから機能するという感じでしょうか??

あっぷる中谷あっぷる中谷

頂いたサンプルコードを使ってコンストラクタ宣言したAsyncCache.ephemeral()をProvider内部で変数宣言に変えて実行したところ同様の結果を得たように見えました!

公式ドキュメントを見ると
https://api.flutter.dev/flutter/async/AsyncCache/AsyncCache.ephemeral.html
という風に書いてあったので
ハッシュコードが違えどfinal宣言されたAsyncCache.ephemeral()に同一型の<T>リクエストが投げられる分には最大一回のみの実行が保証されるのかもしれません!

私の方でももう少し調査してみます🙇

8rine238rine23

あっぷる中谷さん、返信ありがとうございます。
お考えの通り、AsyncCache.ephemeral() は キャッシュするかどうかをハッシュコードが等しいかではなく、同一型の<T>リクエストが投げられたかどうかで判断しているため、AsyncCache.ephemeral()をProvider内部で変数宣言に変えても問題ありません。
サンプルコードでは、コンストラクタ経由で AsyncCache.ephemeral() を渡していましたが、これは私の手癖なので気にしないでください。