Edited at
FlutterDay 19

[Flutter] DartのclassとFirestoreデータを相互変換、そしてCRUDへ

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する系のリクエストはウィジェットに書いてしまっています。


全コード

使用したコードの全文をのせておきます。


エンティティ


entity/User.dart

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);
}



データストア


entity/UserDatastore.dart

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});
}
}



エンティティとデータストアを使用するウィジェット


sample-app.dart

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");
},
),
],
),
),
);
}
}