【Riverpod3.0】SharedPreferenceを使ったオフラインサポートを実装してみた

image

はじめに

最近発表されたRiverpod3.0の新機能として、オフライン対応が強化されました。これは、アプリがインターネットに接続されていない状態でも状態を保持できるようにするもので、これまでは、別のパッケージを利用してバックエンドサービスと手動で状態の永続化を実装する必要がありました。

今回、riverpodのデフォルトの機能として、オフラインサポートが追加されたことで、いちいち面倒なロジックを書かなくても、簡単にオフライン対応ができるようになりました。 しかし、デフォルトでは sqflite をオフライン用のデータベース(DB)に使用しています。そこで、よりシンプルで馴染みのある SharedPreferences を使ってオフラインサポートを実装してみました。

※ 基本的に、デフォルトのsqfliteの利用で問題ないのですが、特別な事情があって他のDBを使いたい人向けの記事です。 また、SharedPreferences側が今後のアップデートで対応してくれる可能性があります

※ この機能は実験的な要素であり、本番環境などへの導入は慎重に行う必要があります。

🚀 GitHubで開く

デフォルトのオフラインサポートの使い方

まずは、デフォルトの使い方を紹介します。詳しくは、公式ドキュメントを参照してください。

  1. 基本的には、コードの通りですが、オフライン対応のためのDBとして、riverpod_sqfliteパッケージで提供されているメソッドを利用してstorage(オフライン用のDB)を定義します

  2. オフライン対応させたいデータのモデルクラスを今まで通りに定義します。ここでは、freezedパッケージを使って、Todoモデルを定義します。

  3. オフライン対応のためのRiverpodのNotifierを定義します。ここでは、TodosNotifierを定義しています。ここで、@JsonPersist()アノテーションを付けることを忘れないでください。

  4. TodosNotifierの中で、persistメソッドを呼び出すことで、オフライン対応のDBにデータを保存します。これにより、アプリがオフラインになっても状態が保持されます。また、persistメソッドのoptions引数で、キャッシュ時間を設定できます。ここでは、無制限に設定していますが、特に設定しなければ、デフォルトで2日間のキャッシュ時間が設定されます。

// オフラインサポート用のDBを提供するプロバイダ
Future<JsonSqFliteStorage> storage(Ref ref) async {
  return JsonSqFliteStorage.open(
    join(await getDatabasesPath(), 'riverpod.db'),
  );
}

/// オフライン対応させたいデータのモデルクラス

abstract class Todo with _$Todo {
  const factory Todo({
    required int id,
    required String description,
    required bool completed,
  }) = _Todo;

  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}

// オフライン対応のためのRiverpodのNotifier

()
class TodosNotifier extends _$TodosNotifier {
  
  FutureOr<List<Todo>> build() async {
    await persist(
      // オフライン用のDBを設定
      ref.watch(storageProvider.future), 

      // オフライン対応の設定
      // ここでは、キャッシュ時間を無制限に設定しています
      options: const StorageOptions(cacheTime: StorageCacheTime.unsafe_forever),
    );

    // オフラインに保存されたデータがあればそれを返す
    return state.value ?? [];
  }

  // ここはこれまで通りの実装で自動的にオフラインに保存してくれる
  Future<void> add(Todo todo) async {
    state = const AsyncLoading();
    final repository = ref.watch(todoRepositoryProvider);
    state = await AsyncValue.guard(() => repository.addTodo(todo));
  }
}

以上の設定で、アプリがオフラインになっても、TodosNotifierの状態が保持されるようになります。非常に簡単ですね!

UI部分のコードと実際の画面は次のようになります。

コードを見る
class TodosPage extends ConsumerWidget {
  const TodosPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final todosAsync = ref.watch(todosNotifierProvider);
    final notifier = ref.watch(todosNotifierProvider.notifier);

    return Scaffold(
      appBar: AppBar(title: const Text('Todos')),
      body: switch (todosAsync) {
        AsyncData(value: final todos) => Center(
          child: ListView.builder(
            itemCount: todos.length,
            itemBuilder: (context, index) {
              final todo = todos[index];
              return ListTile(
                title: Text(todo.title),
                trailing: Checkbox(
                  value: todo.isCompleted,
                  onChanged: (value) {
                    notifier.toggleCompletion(todo.id);
                  },
                ),
              );
            },
          ),
        ),
        AsyncLoading() => const Center(child: CircularProgressIndicator()),
        AsyncError(error: final error, stackTrace: _) => Center(
          child: Text('Error: $error'),
        ),
      },
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          final newTodo = Todo(
            id: DateTime.now().millisecondsSinceEpoch.toString(),
            title: 'New Todo',
            isCompleted: false,
          );
          await notifier.add(newTodo);
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

画面は次のようになります。ホットリロードを行っても状態が保持されていることが確認できます。

結果

SharedPreferencesを使ったオフラインサポートの実装

ここからは、SharedPreferencesを使ったオフラインサポートの実装方法を紹介します。基本的な流れは、デフォルトのsqfliteを使った方法と同じですが、SharedPreferencesを使うためのカスタムのStorageクラスを作成します。手順を説明します。

  1. PersistedDataというオフラインサポート用に使われる内部のデータクラスのFreezedクラスFreezedPersistedDataを用意します

  2. 1.で定義したFreezedPersistedDataを使って、SharedPreferencesにデータを保存するためのカスタムのStorageクラスを作成します

  3. TodosNotifierの中で、SharedPreferencesを使ったカスタムのStorageクラスを使うように変更します

具体的なコードは次のようになります(コピペで動きます)。

  1. PersistedDataのFreezedクラス

abstract class FreezedPersistedData with _$FreezedPersistedData {
  const factory FreezedPersistedData({
    required String value,
    required String? destroyKey,
    required DateTime? expireAt,
  }) = _FreezedPersistedData;

  factory FreezedPersistedData.fromJson(Map<String, dynamic> json) =>
      _$FreezedPersistedDataFromJson(json);
}
  1. SharedPreferencesを使ったカスタムのStorageクラス
コードを見る
class SharedPrefsStorage implements Storage<String, String> {
  final SharedPreferences _prefs;
  SharedPrefsStorage(this._prefs);

  
  FutureOr<void> delete(String key) async {
    final prefs = _prefs;
    await prefs.remove(key);
  }

  
  FutureOr<PersistedData<String>?> read(String key) async {
    final prefs = _prefs;
    final result = prefs.getString(key);

    if (result == null) return null;

    final data = FreezedPersistedData.fromJson(jsonDecode(result));

    return PersistedData<String>(
      data.value,
      destroyKey: data.destroyKey,
      expireAt: data.expireAt,
    );
  }

  
  FutureOr<void> write(String key, String value, StorageOptions options) async {
    final prefs = _prefs;
    final duration = options.cacheTime.duration;
    final json = jsonEncode(
      FreezedPersistedData(
        value: value,
        destroyKey: options.destroyKey,
        expireAt: duration == null ? null : DateTime.now().add(duration),
      ).toJson(),
    );
    await prefs.setString(key, json);
  }
}


SharedPrefsStorage sharedPrefsStorage(Ref ref) {
  final sharedPrefs = ref.watch(sharedPreferencesProvider);
  return SharedPrefsStorage(sharedPrefs);
}


  1. TodosNotifierの中で、SharedPreferencesを使ったカスタムのStorageクラスを使うように変更
 
  FutureOr<List<Todo>> build() async {
    // [追加] SharedPreferencesを使ったカスタムのStorageを取得
    final sharedPrefStorage = ref.watch(sharedPrefsStorageProvider); 
    
    await persist(
      // [変更] SharedPreferencesを使ったカスタムのStorageを利用
      storage: sharedPrefStorage,
      options: const StorageOptions(cacheTime: StorageCacheTime.unsafe_forever),
    );

    // オフラインに保存されたデータがあればそれを返す
    return state.value ?? [];
  }

まとめ

Riverpod3.0の新機能であるオフラインサポートの実装方法を紹介しました。これにより、よりシンプルで馴染みのある方法でオフライン対応が可能になります。今まで面倒だからという理由でオフライン対応を避けていた方も、これなら簡単に実装できるのではないでしょうか。

また、SharedPreferencesを使ったカスタムのStorageクラスを作成することで、より柔軟なオフライン対応が可能になります。これにより、アプリのアーキテクチャや要件に応じて、適切な永続化方法を選択できるようになります。

ただしこのオフラインサポートの機能ですが、現状は、Notifierクラスにしか対応していないそうです。個人的には、streamProviderfutureProviderなどにも対応してくれるとアーキテクチャの面でもありがたいです。

参考

お知らせ

可茂IT塾ではFlutter/Reactのインターンを募集しています!

可茂IT塾ではFlutter/Reactのインターンを募集しています!

可茂IT塾ではFlutter/Reactのインターンを募集しています!可茂IT塾のエンジニアの判断で、一定以上のスキルをを習得した方には有給でのインターンも受け入れています。

Read More
U30可茂ITインターンハッカソン

U30可茂ITインターンハッカソン

12月28,29日開催。2日間でアプリ開発の企画から完成までを目指す!U30可茂ITインターンハッカソンを開催します。

Read More

タグ

Flutter (121)初心者向け (31)イベント (19)Google Apps Script (17)Nextjs (12)可茂IT塾 (10)React (8)riverpod (7)Firebase (7)デザイン (6)ChatGPT (5)vscode (5)Figma (5)JavaScript (5)新卒 (4)就活 (4)Dart (4)お知らせ (4)FlutterWeb (3)Prisma (3)NestJS (3)Slack (3)AI (3)TypeScript (3)ワーケーション (3)インターン (3)設計 (2)線型計画法 (2)事例 (2)Git (2)CSS (2)Freezed (2)Image (2)File (2)Material Design (2)経験談 (2)画像 (2)iOS (2)アプリ開発 (2)React Hooks (2)tailwindcss (2)社会人 (2)大学生 (2)RSS (1)Google (1)Web (1)CodeRunner (1)NotebookLM (1)個人開発 (1)Android (1)Unity (1)WebView (1)Twitter (1)フルリモート (1)TextScaler (1)textScaleFactor (1)学生向け (1)supabase (1)Java (1)Spring Boot (1)shell script (1)正規表現 (1)table (1)テーブル (1)hooks (1)パワーポイント (1)MCPサーバー (1)Obsidian (1)趣味 (1)モンスターボール (1)SCSS (1)Swift (1)MapBox (1)Cupertino (1)ListView (1)就活浪人 (1)既卒 (1)保守性 (1)iPad (1)シェアハウス (1)スクレイピング (1)PageView (1)画面遷移 (1)flutter_hooks (1)Gmail (1)GoogleWorkspace (1)ShaderMask (1)google map (1)Google Places API (1)GCPコンソール (1)Google_ML_Kit (1)Vercel (1)Google Domains (1)DeepLeaning (1)深層学習 (1)Google Colab (1)コード生成 (1)GitHub Copilot (1)GitHub Actions (1)オンラインオフィス (1)html (1)オブジェクト指向 (1)クラスの継承 (1)ポリモーフィズム (1)LINE Messaging API (1)LINE Notify (1)LINE (1)Bitcoin (1)bitFlyer (1)コミュニティー (1)文系エンジニア (1)build_runner (1)ヒーター (1)作業効率 (1) (1)Flutter実践開発 (1) (1)permission_handler (1)flutter_local_notifications (1)markdown (1)GlobalKey (1)ValueKey (1)Key (1)アイコン (1)go_router (1)FireStorage (1)debug (1)datetime_picker (1)Apple Store Connect (1)FlutterGen (1)デバッグ (1)Widget Inspector (1)VRChat (1)API (1)検索機能 (1)Shader (1)SharedPreferences (1)オフラインサポート (1)Navigator (1)メール送信 (1)FlutterFlow (1)Firebase App Distribution (1)Fastlane (1)Dio (1)CustomClipper (1)ClipPath (1)video_player (1)IMA (1)カスタム認証 (1)アニメーション (1)Arduino (1)ESP32 (1)フリーランス (1)会社員 (1)mac (1)csv (1)docker (1)GithubActions (1)Dialog (1)BI (1)LifeHack (1)ショートカット (1)Chrome (1)高校生 (1)キャリア教育 (1)非同期処理 (1)生体認証 (1)BackdropFilter (1)レビュー (1)getAuth (1)クローズドテスト (1)PlayConsole (1)Algolia (1)コンサルティング (1)Symbol (1)

お知らせ

可茂IT塾ではFlutter/Reactのインターンを募集しています!

可茂IT塾ではFlutter/Reactのインターンを募集しています!

可茂IT塾ではFlutter/Reactのインターンを募集しています!可茂IT塾のエンジニアの判断で、一定以上のスキルをを習得した方には有給でのインターンも受け入れています。

Read More
U30可茂ITインターンハッカソン

U30可茂ITインターンハッカソン

12月28,29日開催。2日間でアプリ開発の企画から完成までを目指す!U30可茂ITインターンハッカソンを開催します。

Read More