🍣

【Flutter + Riverpod + Firebase + freezed】firestoreからタイプセーフにデータを取得する

2022/12/02に公開
2

巷には色々な記事が溢れていますが、私の執筆練習も兼ねて知識のアウトプットをしていければと思います。

FlutterはFirebaseとの相性が良いことに加え、NoSQLということもあり幅広く利用されていますよね。
Firebaseからデータを取得する際、以下のようなキーアクセスでJSONのデータにアクセスすることがあるかと思います。

  final snapshot = await FirebaseFirestore.instance.collection('users').get();
  snapshot.documents.forEach((doc) => {
    final name = doc['name'];
    final email = doc['email'];
    /** 以下略 */
  }

(あくまで書き方のサンプルです。)

もし、同じ記述をコード内の至る所で行う場合、JSONへのキーアクセス['name']などのスペルを間違っていると、データを取得できなくなります。
また、そもそもnameというデータで保管しなくなることが決まった場合に変更点が多くなり過ぎてしまいます。

今回は、そのような悩みを解消するため、Flutter開発ではマストアイテムであるfreezedパッケージの使い方や、JsonConverter(後述)やenumの使用例などについても書いていければと思います。
なるべく簡潔に記していこうと思いますので、よろしくお願いします。

扱うデータ

今回は例として、usersコレクションの中に以下のような型のデータを持つアプリ利用者がいる想定です。
管理者画面などでユーザーの一覧を取得するような想定でコードを書いていきます。

freezedを使ったimmutableクラスの作成

今回扱うFirestoreのUsersコレクションについて、ドキュメントごとにUserクラスに変換してアプリ内で扱う設計にしていきます。
ここから、Flutterでタイプセーフにデータを扱う方法として有名なfreezedを使用した実装方法の例を紹介します。
https://pub.dev/packages/freezed
まずは公式ドキュメントをもとに必要なパッケージを入れましょう。

flutter pub add freezed_annotation
flutter pub add --dev build_runner
flutter pub add --dev freezed
# 今回下記のパッケージも使用します。
flutter pub add json_annotation
flutter pub add --dev json_serializable
まずはUserクラスを定義します。
user.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const User._();
  const factory User({
    @JsonKey(name: 'name') required String name,
    @JsonKey(name: 'email') required String email,
    @JsonKey(name: 'status') @UserStatusConverter() required UserStatus status,
    @JsonKey(name: 'createdAt') @DateTimeConverter() required DateTime createdAt,
    @JsonKey(name: 'postCount') required int postCount,
  }) = _User;

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

クラスが定義できたら、ここで必殺技とも言えるコマンドです(fvmを使っている方はコマンドの初めにfvmを入れれば問題なく処理が走るかと思います)。

flutter pub run build_runner build --delete-conflicting-outputs

これでUserクラスに予期せぬ値を入れようとしたり、データ取得時に定義した型に合致しない場合、エラーを返すようになります。

今回例として紹介するために、二つの型をクラス内に含ませています。

@JsonKey(name: 'status') @UserStatusConverter() required UserStatus status,
@JsonKey(name: 'createdAt') @DateTimeConverter() required DateTime createdAt,

①一つ目のUserStatusは、enum値を使いアプリ全体でユーザーがアクティブか退会しているかなどを表すデータとして使用しています。(後ほど記載)

②二つ目は、DateTime(Flutter側)とTimestamp(Firebase側)の変換を行う方法を紹介するためにクラス内に含んでいます。(こちらも後述)

自分で定義した型を使ってJSONから変換する

型サポートがされていない(自作などの)データをfreezedで扱うときに、JsonConverterなるものを使用します。
また、今回はデータとして取得した文字列をenum値に変換するため、extensionも導入します。
まずはコードをご覧ください。

user_status.dart
enum UserStatus {
  /// アクティブ
  active,

  /// 退会
  withdraw,
  
  /// statusNotFound:想定のデータでない場合
  statusNotFound,
}

extension UserStatusExtension on UserStatus {
  static UserStatus fromString(String status) {
    switch (status){
      case 'active':
        return UserStatus.active;
      case 'withdraw':
        return UserStatus.withdraw;
      default:
        return UserStatus.statusNotFound;
    }
  }
  
  static String statusToString(UserStatus status) {
    switch (status){
      case UserStatus.active:
        return 'active';
      case UserStatus.withdraw:
        return 'withdraw';
      default:
        return '定義されていない値です';
    }
  }
}

enum値に関しては、extensionで対応させたい型との相互変換処理を記述しておくと良いです。
例えばアプリ内では全てenumで値を扱い、データとしてDBに送信するときにはstringに変換して格納できるようにする、といったことも可能です。

user_status_converter.dart
class UserStatusConverter implements JsonConverter<UserStatus, String> {
  const UserStatusConverter();

  @override
  UserStatus fromJson(String json) {
    return UserStatusExtension.fromString(json);
  }

  @override
  String toJson(UserStatus status) {
    return UserStatusExtension.statusToString(status);
  }
}

JsonConverterを使うことで、JSONで送られてきた値を指定の型に変換することが可能となります。
firestoreから取得するデータはJSONとして扱うことができますので、freezedの@JsonKey()を使ってプロパティをしてしておきます(キーアクセスするのはこのクラス定義部分のみです)。
fromJsonはデータ取得時、toJsonはデータ送信時に使用することが基本となります。

続いて、DateTimeについても、以下のように記述することが可能です。

datetime_converter.dart
class DateTimeConverter implements JsonConverter<DateTime, Timestamp> {
  const DateTimeConverter();

  @override
  DateTime fromJson(Timestamp json) {
    return json.toDate();
  }

  @override
  Timestamp toJson(DateTime dateTime) {
    return Timestamp.fromDate(dateTime);
  }
}

このように定義し、Userクラス内で明示的に@Converter()として処理を行うように記述することで、サポートされていない型の相互変換を行うことができます。

ここで注意点ですが、immutableクラス内でDateTime <-> Timestampのコンバートをするときは、以下のパッケージをimportしておく必要があります(unused_importと表示されますが、freezedでimmutableクラスを作る際に必要です)。
これはfreezedで生成したファイルで型を参照する際に、パッケージも参照していることが要因となります。

// ignore: unused_import ★
import 'package:cloud_firestore/cloud_firestore.dart';

Android Studioでは自動で使っていないimportを無くしてくれたりしてありがたいのですが、それを防ぐために★の記述をつけて対応しなければならない場合もあります。

<ここから先はお好みで参考にしてください>

データの取得を行う

さて、クラスの定義まで作ることができたので、あとはデータの取得となります。
私の場合はバックエンドでのデータ取得はrepository層、アプリ側での処理発火にはservice層を使い、interfaceで抽象クラスも定義することが多いです(ここの部分の説明は割愛します)。

user_repository_interface.dart
/// 引数が必要な場合は別途入れます。
abstract class UserRepositoryInterface {
  Future<List<User>> getUsersInfo();
}

interfaceを使用することで、Userクラスを扱う処理内での引数の型や命名などを統一させる狙いがあります。

user_repository.dart
class UserRepository {
  Future<List<User>> getUsersInfo() async {
    final fireStore = FirebaseFirestore.instance;
    try{
      final snapShot = await fireStore.collection('users').get();
      final userList = <User>[];
      snapShot.docs.forEach((content) {
        userList.add(User.fromJson(content.data()));
      });
      return userList;
    } on FirebaseException catch(e){
      throw e.toString();
    }
  }
}

repositoryで実際のデータの出し入れを行い、service層ではその処理を発火させ、取得データを返却します。

user_service.dart
class UserService {
  const UserService({
    required UserRepository repository,
  }) : _repository = repository;
  final UserRepository _repository;

  Future<List<User>> getUsersInfo() {
    return _repository.getUsersInfo();
  }
}

RiverpodのFutureProviderを使って状態管理を行う

最後に、riverpodのFutureProviderを使用してデータの取得の状態管理を行います。
グローバルに扱うデータに関しては、一つのファイルにまとめたり、ページごとにまとめるなど工夫すると見易いかもしれませんね。

providers.dart
final usersInfoProvider = FutureProvider<List<User>>((ref) async {
  final _service = UserService(repository: UserRepository());
  return await _service.getUsersInfo();
});
user_list_page.dart
class UserListPage extends ConsumerWidget {
  const UserListPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final users = ref.watch(usersInfoProvider);
    return Scaffold(
      body: users.when(
        loading: () => const Center(
          child: CircularProgressIndicator(),
        ),
        data: (users) {
          // データ取得後に表示する内容
        },
        error: (error, stack) => error.toString(), //個人的には「再読み込み」のボタンを表示してref.refreshさせることが多いです。
      ),
    );
  }
}

今回の記事では、freezedパッケージを使用してタイプセーフにfirestoreのデータを扱う方法を書いてみました。
今回のような実装方法を使うとページを複製しやすいですし、チーム開発ではある程度設計を統一して開発することも可能になります。
あくまで一例ですので、ベストプラクティスはチームによっても違います(今回はmvvmを意識しました)。
参考になりましたら幸いです!

一度freezedに慣れてしまうと、あなたはそのメリットに虜となるでしょう(本当なのか?!)

また良さそうな技術の紹介ができればと思います!

Discussion

ux-designtokyoux-designtokyo

いつも参考にさせていただいております!

user.dart
@DatetimeConverter()

@DatetimeConverter()のtが小文字になってしまっているようでしたのでお知らせいたします。