🧭

[Flutter x Firebase] Cloud Firestoreと連携してカウントを保持する

2020/11/09に公開

やること

  • 前回から作成しているソーシャルログイン機能つきのカウンターアプリとCloud Firestoreを連携して、カウントを保持できるカウンターアプリにする。

前回

  1. [Flutter x Firebase] カウンターアプリに認証機能を追加する
  2. [Flutter x Firebase] カウンターアプリに認証機能を追加する 〜自動ログイン〜
  3. [Flutter x Firebase] Crashlyticsと連携してクラッシュレポートを取得する

CrashlyticsはなくてもOK。
今回は認証情報を誰のカウントなのかわかるようにするために使用する。(ユーザーIDとかとカウントを紐付ける)

カウンターアプリのいけてないところ

  • アプリ起動中でしか カウントを保持できない。
    →Cloud Firestoreでログインユーザーごとにカウントを保持する

カウントを保持するだけだったらSharedPreferencessqliteなどのDBでもできるが、今回はFirebaseのお勉強ということで。。。

成果物

動作イメージ

左側がFirebaseコンソールのCloud Firestoreの管理画面。
カウントを増やすと リアルタイムに反映されていることがわかる。

ソースコード

https://github.com/popy1017/firebase_counter_app/tree/v0.4

Cloud Firestoreを理解する

料金

個人&お試しで使う分には無料で使えそう。

データモデル

詳しくはこちら → Cloud Firestore データモデル  |  Firebase

  • CollectionDocumentの概念が存在する。
  • Collectionの中に複数のDocumentが含まれる。
  • Documentにはjson形式のデータを格納することができる。
CollectionとDocumentのイメージ
users: [ // Collection
  user1: {  // Document
    name: {
      first: "taro",
      last: "tanaka"
    },
    age: 23,
  },
  user2: {  // Document
    name: {
      first: "hanako",
      last: "yamada",
    },
    age: 36,
  },
  ・・・
]

Documentを追加する

詳しくはこちら → Cloud Firestore | FlutterFire

まずは、FirebaseFirestore.instance.collection(コレクション名)でCollectionへのリファレンスを取得する。Collectionはこの時勝手にできるっぽい?
そして、リファレンス.add(データ)でDocumentを追加する。
この場合、作成したDocumentには自動生成されたIDが割り振られる。

参考ページより引用
  // Create a CollectionReference called users that references the firestore collection
  CollectionReference users = FirebaseFirestore.instance.collection('users');

  Future<void> addUser() {
    // Call the user's CollectionReference to add a new user
    return users
           .add({
             'full_name': fullName, // John Doe
             'company': company, // Stokes and Sons
             'age': age // 42
            })
           .then((value) => print("User Added"))
           .catchError((error) => print("Failed to add user: $error"));
    }

今回はユーザーを特定するためのIDとカウントを紐付けたいので、自分でIDを設定してDocumentを作成したい。
自分でIDを指定したい場合は、doc(ドキュメントID)set(データ)を用いる。

参考ページより引用
CollectionReference users = FirebaseFirestore.instance.collection('users');

Future<void> addUser() {
  return users
    .doc('ABC123')  // <= IDを指定
    .set({
      'full_name': "Mary Jane",
      'age': 18
    })
    .then((value) => print("User Added"))
    .catchError((error) => print("Failed to add user: $error"));
}

Documentを読み込む

リファレンス.doc(ドキュメントID).get()で取ってこれる。

参考ページより引用
    return FutureBuilder<DocumentSnapshot>(
      future: users.doc(documentId).get(),
      builder:
          (BuildContext context, AsyncSnapshot<DocumentSnapshot> snapshot) {

        if (snapshot.hasError) {
          return Text("Something went wrong");
        }

        if (snapshot.connectionState == ConnectionState.done) {
          Map<String, dynamic> data = snapshot.data.data();
          return Text("Full Name: ${data['full_name']} ${data['last_name']}");
        }

        return Text("loading");
      },
    );

リアルタイムに変更を適用したい時は以下のようにリファレンス.snapshots()StreamBuilderを使う。

参考ページより引用
class UserInformation extends StatelessWidget {
  
  Widget build(BuildContext context) {
    CollectionReference users = FirebaseFirestore.instance.collection('users');

    return StreamBuilder<QuerySnapshot>(
      stream: users.snapshots(),
      builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
        if (snapshot.hasError) {
          return Text('Something went wrong');
        }

        if (snapshot.connectionState == ConnectionState.waiting) {
          return Text("Loading");
        }

        return new ListView(
          children: snapshot.data.documents.map((DocumentSnapshot document) {
            return new ListTile(
              title: new Text(document.data()['full_name']),
              subtitle: new Text(document.data()['company']),
            );
          }).toList(),
        );
      },
    );
  }
}

Documentを更新する

doc(ドキュメントID)でDocumentを指定し、update(データ)でデータを更新する。

参考ページより引用
CollectionReference users = FirebaseFirestore.instance.collection('users');

Future<void> updateUser() {
  return users
    .doc('ABC123')
    .update({'company': 'Stokes and Sons'})
    .then((value) => print("User Updated"))
    .catchError((error) => print("Failed to update user: $error"));
}

オフラインDB

Firestoreでは、オフライン時に書き込み・読み込みがあった時のためのローカルデータベースが備わっており、オンラインになった時に自動的に同期を取ってくれる。すごい。
デフォルトでオンになっているので、使いたくない場合以外は何もしなくてもOK。

その他

今回は使わないが、指定した条件にマッチするDocumentだけ取ってくるクエリ機能もある。

手順

パッケージのインストール

dependencies:
  cloud_firestore: ^0.14.3

FirebaseコンソールからCloud Firestoreのデータベースを作成する

選択肢

  • 本番用 or テスト用 → テスト用を選択
  • リージョン(一度決めたら変えられない) → asia-northeast1 (東京)を選択

実装時のポイント

  • DocumentIDとして使うために、ユーザーのユニークなIDを取得する。
  • ログイン成功時に、そのユーザーに対するDocumentを作成する。
  • カウンター画面を描画するときに、カウントをFirestoreに保持しているカウントにセットする。
  • +ボタン押下時に+1したカウントでDocumentを更新する。

ログイン中のユーザーのユニークなID

ログイン中のユーザーを表すクラスにはfirebase_auth.dartで定義されているUserを用いているが、
Userにはuid(The user's unique ID.)が定義されているので、それをDocumentIDに使うことにする。
今回はProviderパターンで状態管理(AuthModel)をしており、カウンター画面が定義されているmain.dartからAuthModeluser.uidを取得するには、context.selectcontext.readを使う。

final String userId = context.select((AuthModel auth) => auth.user.uid);
final String userId = context.read<AuthModel>().user.uid;

カウンターDocumentを新規に作成する

今回はログイン成功時に上記で取得したuidを用いてDocumentを作成することにした。

lib/helpers/firestore_helper.dartを作成し、createCounterを作成。

firestore_helper.dart
class FirestoreHelper {
  FirestoreHelper._internal();

  static final FirestoreHelper _instance = FirestoreHelper._internal();

  CollectionReference _counters =
      FirebaseFirestore.instance.collection('counters');

  static FirestoreHelper get instance => _instance;

  Future<bool> createCounter(String userId) async {
    try {
      await _counters.doc(userId).set({'count': 0});
    } catch (error) {
      print(error);
      return false;
    }
    return true;
  }
}

そして、models/auth_model.dartlogin関数にcreateCounterを差し込む。

auth_model.dart
class AuthModel extends ChangeNotifier {
  ~~
  Future<bool> login(ButtonType type) async {
    ~~
    try {
      UserCredential _userCredential = await _signInWithGoogle();
      
      /* ↓ 追加 ↓ */
      await FirestoreHelper.instance.createCounter(_userCredential.user.uid); 
      
      _user = _userCredential.user;
      notifyListeners();
      return true;
    } catch (error) {
      print(error);
      return false;
    }
  }
}

この状態でアプリを起動しログインに成功すると、Cloud Firestoreの管理画面のデータタブで、countersというCollectionと、長い文字列が名前のDocument({count:0})が生成されていることが確認できる。

カウンターDocumentがないときにのみ作成するようにする

上記の処理を追加することによって、ログイン成功時にDocumentを作成することができるようになったが、ログインするたびにDocumentを作成してしまうと{count:0}で上書きされてしまうためカウントが0に戻ってしまう。
そのため、createCounter指定されたIDのDocumentがなかったら新規に作成するというように変更する。

DocumentIDが同じときに上書きするか・しないかみたいな設定が見当たらなかったため。

firestore_helper.dart
class FirestoreHelper {
  ~~

  Future<bool> _hasCounter(String userId) async {
    final DocumentSnapshot snapshot = await fetchCounter(userId);
    if (snapshot == null ||                 // 指定したIDに該当するDocumentがない
        snapshot.data() == null ||          // 該当するDocumentはあるけどデータがまだない
        snapshot.data()['count'] == null) { // 該当するDocumentはあるけど、指定したFieldがない
      return false;
    }
    return true;
  }
  
  Future<bool> createCounter(String userId) async {
    try {
      // 既にカウンターがある場合は何もしないで終了
      if (await _hasCounter(userId)) {
        return true;
      }
      await _counters.doc(userId).set({'count': 0});
    } catch (error) {
      print(error);
      return false;
    }
    return true;
  }
  
  ~~
}

カウンター画面描画時にカウンターDocumentを取得する

上記でDocumentの作成を完了したので、今度はそれをカウンター画面で参照できるようにしたい。
カウンターアプリでは、カウンター画面の描画時に初期値をセットしたり、+ボタンを押すたびに値を増やしたりなど、カウンターDocumentにアクセスする機会が多いので、StreamStreamBuilderを使ってリアルタイムに値を反映させることにする。(getFutureBuilderでもできなくはない。)

まずは FirestoreHelperクラスに 指定されたIDのDocumentのStreamを取得する関数を追加する。

firestore_helper.dart
Stream<DocumentSnapshot> getStream(String userId) => _counters.doc(userId).snapshots();

次に、カウントを表示している部分(Textウィジェット)をStreamBuilderでラップした関数_buildCounter()を新たに作成する。

元々_MyHomePageStateクラスにあった変数_counter_currentCountにリネームしました。

main.dart
   Widget _buildCounter() {
    final String userId = context.select((AuthModel auth) => auth.user?.uid);
    return StreamBuilder<DocumentSnapshot>(
      stream: _firestoreHelper.getStream(userId),
      builder:
          (BuildContext context, AsyncSnapshot<DocumentSnapshot> snapshot) {
        if (snapshot.hasError) {
          print(snapshot.error);
          return Text('エラーが発生しました。');
        }

        if (snapshot.hasData) {
          final Map<String, dynamic> data = snapshot.data.data();

          if (data != null && data['count'] != null) {
            _currentCount = data['count'];
          }

          // 元々あったTextウィジェット
          return Text(
            '$_currentCount',
            style: Theme.of(context).textTheme.headline4,
          );
        }

        return CircularProgressIndicator();
      },
    );
  }

カウンターDocumentを更新する

最後に、+ボタンを押したときに現在のカウントに+1してからDocumentを更新する。
以下のようにFirestoreHelperクラスにupdateCounter()関数を追加する。

firestore_helper.dart
class FirestoreHelper {
  ~~
  Future<bool> updateCounter(String userId, int count) async {
    try {
      await _counters.doc(userId).update({'count': count});
    } catch (error) {
      print(error);
      return false;
    }
    return true;
  }
}

そして、カウンター画面の_incrementCounter()関数を以下のように書き換える。

main.dart
  Future<void> _incrementCounter() async {
    _currentCount++;

    final String userId = context.read<AuthModel>().user.uid;
    await _firestoreHelper.updateCounter(userId, _currentCount);
  }

(おまけ)カウントリセットボタンを追加する

ようやくカウントを保持できるカウンターアプリに進化したが、カウントをリセットできた方がより便利である。
ということで、AppBar.actionsにもう1つボタンを足して、リセットボタンを追加する。

main.dart
class _MyHomePageState extends State<MyHomePage> {
  ~~
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
        centerTitle: true,
        actions: [
          IconButton(
            icon: Icon(Icons.delete),
            onPressed: () => _firestoreHelper.updateCounter(
              context.read<AuthModel>().user.uid,
              0,
            ),
          ),
          IconButton(
            icon: Icon(Icons.logout),
            onPressed: _logout,
          ),
        ],
      ),
      ~~

以上。長くなってしまった。

Discussion