2021年12月追記
- 今はfreezedを使って似たようなことをしています
- document_id と 実際のデータのラッパークラスは記事と同様に使っています
元記事
Flutterに限らずシステム開発をしているとデータ構造を表すEntityクラスをDBにCRUDすることは多いと思います。FlutterではFirestoreのプラグインはありますが、Dartのカスタムクラスを引数にとるCRUDのメソッドはありません。つまり一度Mapに変換する必要があるということです。
本エントリではDartのクラスとMapを相互変換してFirestoreを使いやすくする方法について紹介します。
まずはシンプルに次のような構造のユーザーデータを使用する場合のクラスを考えます。
{
nickname: "Bob",
age: 23,
isPushEnabled: true,
createdAt: 2018-12-27 // Firestoreの日付型
}
エンティティ
User
例に示したユーザーデータを表すUserクラスを次のように定義しています。
class User {
String name;
int age;
bool isPushEnabled;
DateTime createdAt;
// コンストラクタ
User();
// コンストラクタ
// FirestoreのMapからインスタンス化
User.fromMap(Map<String, dynamic> map) {
this.name = map[UserField.name];
this.age = map[UserField.age];
this.isPushEnabled = map[UserField.isPushEnabled];
// DartのDateに変換
final originCreatedAt = map[UserField.createdAt];
if (originCreatedAt is Timestamp) {
this.createdAt = originCreatedAt.toDate();
}
}
// Firestore用のMapに変換
Map<String, dynamic> toMap() {
return {
UserField.name: this.name,
UserField.age: this.age,
UserField.isPushEnabled: this.isPushEnabled,
UserField.createdAt: this.createdAt, // Dateはそのまま渡せる
};
}
}
UserクラスにはUser.fromMap
というFirestoreのレスポンスのMapからインスタンスを生成するためのコンストラクタを定義しています。
また逆にFirestoreに登録するときに使用するためにtoMap
というインスタンスのデータをMapに変換するための関数も定義しています。
この2つの関数を定義することでFirestoreのデータとDartのクラスの相互変換を可能にしています。
UserField
UserクラスにUserField
というクラスが使用されていることに気づいた方もいると思います。
このクラスはFirestoreのフィールド名をタイポしないために定義したものです。
class UserField {
static const name = "name";
static const age = "age";
static const isPushEnabled = "isPushEnabled";
static const createdAt = "createdAt";
}
UserDocument
リレーショナルDBの場合IDは1つのフィールドに含まれているので、Userクラスのプロパティの一つとして組み込みやすいです。
Firestoreの場合データ自体にはIDは含まれていないので、同じUserクラスにまとめるのは少し違和感がありました。
しかしリストビューのアイテムを設定する時のように、IDとデータをセットで扱いたいことは多いです。
この問題を解決するためにIDとデータをセットにしたクラスをUserDocument
クラスとして別途定義しています。
class UserDocument {
String documentId;
User user;
UserDocument(this.documentId, this.user);
}
データストア
CRUDのための機能を1箇所にまとめるために専用のクラスを以下のように作っています。
このクラスの関数に渡すのはDartのクラスであるUserクラスのインスタンスです。
このためFirestoreのデータ構造については関数の呼び出し元は特に意識しなくて良くなっています。
基本的なCRUDはaddUser
getUser
updateUser
deleteUser
です。
特定のフィールドのみ更新したい場合はupdateName
のように実装します。
class UserDatastore {
static String getCollectionPath() {
return "users";
}
static String getDocumentPath(String documentId) {
return "users/$documentId";
}
static String addUser(User user) {
final newDocument =
Firestore.instance.collection(getCollectionPath()).document();
newDocument.setData(user.toMap());
return newDocument.documentID;
}
static Future<User> getUser(String documentId) async {
final snapshot =
await Firestore.instance.document(getDocumentPath(documentId)).get();
if (snapshot.exists) {
return User.fromMap(snapshot.data);
} else {
throw Error();
}
}
static void deleteUser(String documentId) {
Firestore.instance.document(getDocumentPath(documentId)).delete();
}
static void updateUser(String documentId, User user) {
Firestore.instance
.document(getDocumentPath(documentId))
.setData(user.toMap());
}
static void updateName(String documentId, String name) {
Firestore.instance
.document(getDocumentPath(documentId))
.updateData({UserField.name: name});
}
}
Listenする系の関数についてはここに定義するかはちょっと迷っています。
なぜならListenの停止や再開などをウィジェット側とうまく分離できるか分からないからです。
現状の私のプロジェクトではListenする系のリクエストはウィジェットに書いてしまっています。
#全コード
使用したコードの全文をのせておきます。
エンティティ
import 'package:cloud_firestore/cloud_firestore.dart';
class User {
String name;
int age;
bool isPushEnabled;
DateTime createdAt;
// コンストラクタ
User();
// コンストラクタ
// FirestoreのMapからインスタンス化
User.fromMap(Map<String, dynamic> map) {
this.name = map[UserField.name];
this.age = map[UserField.age];
this.isPushEnabled = map[UserField.isPushEnabled];
// DartのDateに変換
final originCreatedAt = map[UserField.createdAt];
if (originCreatedAt is Timestamp) {
this.createdAt = originCreatedAt.toDate();
}
}
// Firestore用のMapに変換
Map<String, dynamic> toMap() {
return {
UserField.name: this.name,
UserField.age: this.age,
UserField.isPushEnabled: this.isPushEnabled,
UserField.createdAt: this.createdAt, // Dateはそのまま渡せる
};
}
}
class UserField {
static const name = "name";
static const age = "age";
static const isPushEnabled = "isPushEnabled";
static const createdAt = "createdAt";
}
class UserDocument {
String documentId;
User user;
UserDocument(this.documentId, this.user);
}
データストア
import '../entity/User.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'dart:async';
class UserDatastore {
static String getCollectionPath() {
return "users";
}
static String getDocumentPath(String documentId) {
return "users/$documentId";
}
static String addUser(User user) {
final newDocument =
Firestore.instance.collection(getCollectionPath()).document();
newDocument.setData(user.toMap());
return newDocument.documentID;
}
static Future<User> getUser(String documentId) async {
final snapshot =
await Firestore.instance.document(getDocumentPath(documentId)).get();
if (snapshot.exists) {
return User.fromMap(snapshot.data);
} else {
throw Error();
}
}
static void deleteUser(String documentId) {
Firestore.instance.document(getDocumentPath(documentId)).delete();
}
static void updateUser(String documentId, User user) {
Firestore.instance
.document(getDocumentPath(documentId))
.setData(user.toMap());
}
static void updateName(String documentId, String name) {
Firestore.instance
.document(getDocumentPath(documentId))
.updateData({UserField.name: name});
}
}
エンティティとデータストアを使用するウィジェット
import 'package:cloud_firestore/cloud_firestore.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import './entity/User.dart';
import './datastore/UserDatastore.dart';
// 実験用
class SampleApp extends StatefulWidget {
@override
State createState() => SampleAppState();
}
class SampleAppState extends State<SampleApp> {
UserDocument userDocument;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text("Flutter Firestore Example"),
),
body: Column(
children: <Widget>[
Text(userDocument == null
? "No user"
: "User Name: ${userDocument.user.name}"),
FlatButton(
child: Text("Create"),
onPressed: () {
final user = User();
user.name = "Takashi";
user.age = 36;
user.isPushEnabled = false;
user.createdAt = DateTime.now();
final documentId = UserDatastore.addUser(user);
print("added:${documentId}");
setState(() {
userDocument = UserDocument(documentId, user);
});
},
),
FlatButton(
child: Text("Read"),
onPressed: () async {
final user =
await UserDatastore.getUser(userDocument.documentId);
print("getUser success:${user.name}");
},
),
FlatButton(
child: Text("Update"),
onPressed: () {
setState(() {
userDocument.user.name = "Jiro";
userDocument.user.age = 44;
});
UserDatastore.updateUser(
userDocument.documentId, userDocument.user);
},
),
FlatButton(
child: Text("Delete"),
onPressed: () {
UserDatastore.deleteUser(userDocument.documentId);
setState(() {
userDocument = null;
});
},
),
FlatButton(
child: Text("Update Name"),
onPressed: () {
setState(() {
userDocument.user.name = "Bob";
});
UserDatastore.updateName(userDocument.documentId, "Bob");
},
),
],
),
),
);
}
}