🧑‍💻

Flutter Riverpodでの非同期エラーハンドリングパターンとユーザー体験の向上技術

に公開

ここから記事本文

はじめに

本記事はChatGPTによって生成されました。

Flutterアプリ開発において、非同期処理はAPI通信やファイル操作、位置情報取得など多くの場面で必須の要素です。特に複雑な非同期状態管理とエラーハンドリングはUXに直結するため、開発効率と品質向上の両面から非常に重要です。RiverpodはFlutterの状態管理の中でも近年注目のパッケージであり、特にAsyncValueを活用した非同期状態管理は、ローディングやエラー表示の実装をシンプルかつ堅牢にします。

しかし、実務レベルでの適切なエラーハンドリングパターンや、ユーザー体験を向上させるための工夫は単純な公式ドキュメントだけでは網羅しきれません。そこで本記事では、Riverpodの非同期処理におけるエラー管理の実践的なパターンを体系的に解説し、特に位置情報や地図API連携が絡むモバイルアプリ開発での応用例を交えながら、UX向上のための具体的な技術を紹介します。

1. 導入:テーマの概要や重要性

FlutterはクロスプラットフォームのUIフレームワークとして急速に普及していますが、状態管理と非同期処理の複雑さは多くの開発者の課題となっています。特にネットワーク通信や位置情報取得など、非同期処理が絡む機能は失敗や遅延が避けられず、適切なエラーハンドリングとユーザーへのフィードバックがUXの鍵を握ります。

Riverpodは、これらの課題に対して宣言的かつ型安全に対応できる状態管理ライブラリです。AsyncValue型を使えば、非同期処理の状態を「データ取得中」「取得成功」「取得失敗」の3つの状態で表現でき、UIの分岐やエラーメッセージ表示を簡潔に記述可能です。

本稿では、Flutter Riverpodでの非同期エラーハンドリングの代表的パターンや、実際のモバイルアプリでよくある位置情報取得処理などの応用例を通じて、ただエラーを検知するだけでなく「ユーザーが困らない体験」を実現する技術を詳述します。

2. 背景・基礎知識

Riverpodとは

  • Flutterの状態管理ライブラリのひとつで、従来のProviderの作者Remi Rousselet氏が開発。
  • グローバルかつ安全な状態管理を提供し、依存関係注入やリビルドの最適化が特徴。

AsyncValueの概要

  • 非同期処理の状態をAsyncValue<T>で管理。
  • 状態は3つに分かれる:
    • AsyncLoading<T>:処理中
    • AsyncData<T>:成功しデータを持つ
    • AsyncError<T>:エラー発生

非同期処理での課題

  • ネットワークの遅延・エラー発生率の高さ
  • UIのローディング表示やエラーからのリカバリー
  • 位置情報などユーザー許可が必要なケースでの権限エラーハンドリング

用語整理

用語 説明
Riverpod Flutter用の状態管理パッケージ
AsyncValue 非同期状態を表現する型
Provider Riverpodで状態を提供する単位
StateNotifier 状態を管理・更新するクラス
非同期エラー処理 API通信や位置情報取得などで発生する例外処理

3. 本論:技術的な詳細や仕組み、手順

非同期処理の基本パターン

Riverpodでは非同期処理をFutureProviderStreamProviderで扱いますが、より柔軟にカスタムロジックを入れるならStateNotifierAsyncValueの組み合わせが強力です。

class LocationStateNotifier extends StateNotifier<AsyncValue<LocationData>> {
  LocationStateNotifier() : super(const AsyncValue.loading());

  Future<void> fetchLocation() async {
    try {
      state = const AsyncValue.loading();
      final location = await LocationService().getCurrentLocation();
      state = AsyncValue.data(location);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}
  • stateAsyncValueで管理し、UIはwhenメソッドで状態に応じた描画を行う。
  • エラー状態はAsyncValue.errorで保持し、リトライUIや詳細表示を可能に。

UIでのエラーハンドリング例

Consumer(
  builder: (context, ref, _) {
    final locationAsync = ref.watch(locationProvider);
    return locationAsync.when(
      data: (location) => Text('現在地: ${location.latitude}, ${location.longitude}'),
      loading: () => CircularProgressIndicator(),
      error: (error, _) => Column(
        children: [
          Text('エラーが発生しました: $error'),
          ElevatedButton(
            onPressed: () => ref.read(locationProvider.notifier).fetchLocation(),
            child: Text('再試行'),
          ),
        ],
      ),
    );
  },
);

エラーハンドリングパターン

  1. Retryパターン
    • エラー時に再試行ボタンを提示し、ユーザー操作で再度処理。
  2. Fallbackパターン
    • 代替データやキャッシュを表示してUX低下を防ぐ。
  3. Global Error Handling
    • エラーを集約しトーストやダイアログで通知。
    • 位置情報権限拒否時は設定画面誘導も含める。

アーキテクチャ図イメージ

[UI] <--> [Riverpod StateNotifier/AsyncValue] <--> [LocationService(API/OS)]
             ↑
             └-- エラー情報・ローディング状態管理

4. 具体例・コード例

以下は位置情報取得を例にした完全な実装例です。

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

final locationProvider = StateNotifierProvider<LocationStateNotifier, AsyncValue<LocationData>>(
  (ref) => LocationStateNotifier(),
);

class LocationStateNotifier extends StateNotifier<AsyncValue<LocationData>> {
  LocationStateNotifier() : super(const AsyncValue.loading()) {
    fetchLocation();
  }

  final Location _location = Location();

  Future<void> fetchLocation() async {
    try {
      state = const AsyncValue.loading();

      bool serviceEnabled = await _location.serviceEnabled();
      if (!serviceEnabled) {
        serviceEnabled = await _location.requestService();
        if (!serviceEnabled) {
          throw Exception('位置情報サービスが無効です');
        }
      }

      PermissionStatus permissionGranted = await _location.hasPermission();
      if (permissionGranted == PermissionStatus.denied) {
        permissionGranted = await _location.requestPermission();
        if (permissionGranted != PermissionStatus.granted) {
          throw Exception('位置情報の権限が拒否されました');
        }
      }

      final locData = await _location.getLocation();
      state = AsyncValue.data(locData);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}

void main() {
  runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(home: LocationPage());
  }
}

class LocationPage extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final locationAsync = ref.watch(locationProvider);

    return Scaffold(
      appBar: AppBar(title: Text('位置情報取得サンプル')),
      body: Center(
        child: locationAsync.when(
          data: (location) => Text('現在地: ${location.latitude}, ${location.longitude}'),
          loading: () => CircularProgressIndicator(),
          error: (error, _) => Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('エラー: ${error.toString()}

Discussion