🧑‍🚒

withConverterと仲良くなりたい

2023/01/26に公開

どんなメリットがある?

最近よく聞く、withConverterを何度か使う機会があったので、記事にしたいなと思いました。
あまり情報がないから、使い方がわからなくて困ってたりしました。
もっと困ったことは、モデルクラスを作るところですね😅
普段は、Freezedでやってるので、作り方は実はわかってなかったりする😱
書き方に正解はなさそうなので、まずは公式ドキュメントの書き方でアプリが動くのかを試して見ました。

withConverterとは?

https://firebase.flutter.dev/docs/firestore/usage


翻訳すると

CollectionReferenceとDocumentReferenceの型付け

デフォルトでは、Firestoreの参照はMap<String, dynamic>オブジェクトを操作する。欠点は、型安全性を失うことである。解決策の一つは withConverter を使うことで、CollectionReference.add や Query.where のようなメソッドを型安全なものに変更することができる。

withConverterの一般的な使用法は、次のようなシリアライズ可能なクラスと組み合わせた場合である。

class Movie {
  Movie({required this.title, required this.genre});

  Movie.fromJson(Map<String, Object?> json)
    : this(
        title: json['title']! as String,
        genre: json['genre']! as String,
      );

  final String title;
  final String genre;

  Map<String, Object?> toJson() {
    return {
      'title': title,
      'genre': genre,
    };
  }
}

そして、withConverter を使って、次のようにムービーのコレクションを操作することができる。

final moviesRef = FirebaseFirestore.instance.collection('movies').withConverter<Movie>(
      fromFirestore: (snapshot, _) => Movie.fromJson(snapshot.data()!),
      toFirestore: (movie, _) => movie.toJson(),
    );

これだけでは使い方がわからない...
cloud_firestoreのpackageの情報を見てみる。
これでも初心者はわかりにくいですね😇

// Copyright 2020, the Chromium project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';

/// Requires that a Firestore emulator is running locally.
/// See https://firebase.flutter.dev/docs/firestore/usage#emulator-usage
bool shouldUseFirestoreEmulator = false;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  if (shouldUseFirestoreEmulator) {
    FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8080);
  }
  runApp(FirestoreExampleApp());
}

/// A reference to the list of movies.
/// We are using `withConverter` to ensure that interactions with the collection
/// are type-safe.
final moviesRef = FirebaseFirestore.instance
    .collection('firestore-example-app')
    .withConverter<Movie>(
      fromFirestore: (snapshots, _) => Movie.fromJson(snapshots.data()!),
      toFirestore: (movie, _) => movie.toJson(),
    );

/// The different ways that we can filter/sort movies.
enum MovieQuery {
  year,
  likesAsc,
  likesDesc,
  rated,
  sciFi,
  fantasy,
}

extension on Query<Movie> {
  /// Create a firebase query from a [MovieQuery]
  Query<Movie> queryBy(MovieQuery query) {
    switch (query) {
      case MovieQuery.fantasy:
        return where('genre', arrayContainsAny: ['Fantasy']);

      case MovieQuery.sciFi:
        return where('genre', arrayContainsAny: ['Sci-Fi']);

      case MovieQuery.likesAsc:
      case MovieQuery.likesDesc:
        return orderBy('likes', descending: query == MovieQuery.likesDesc);

      case MovieQuery.year:
        return orderBy('year', descending: true);

      case MovieQuery.rated:
        return orderBy('rated', descending: true);
    }
  }
}

/// The entry point of the application.
///
/// Returns a [MaterialApp].
class FirestoreExampleApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Firestore Example App',
      theme: ThemeData.dark(),
      home: const Scaffold(
        body: Center(child: FilmList()),
      ),
    );
  }
}

/// Holds all example app films
class FilmList extends StatefulWidget {
  const FilmList({Key? key}) : super(key: key);

  
  _FilmListState createState() => _FilmListState();
}

class _FilmListState extends State<FilmList> {
  MovieQuery query = MovieQuery.year;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Text('Firestore Example: Movies'),

            // This is a example use for 'snapshots in sync'.
            // The view reflects the time of the last Firestore sync; which happens any time a field is updated.
            StreamBuilder(
              stream: FirebaseFirestore.instance.snapshotsInSync(),
              builder: (context, _) {
                return Text(
                  'Latest Snapshot: ${DateTime.now()}',
                  style: Theme.of(context).textTheme.caption,
                );
              },
            )
          ],
        ),
        actions: <Widget>[
          PopupMenuButton<MovieQuery>(
            onSelected: (value) => setState(() => query = value),
            icon: const Icon(Icons.sort),
            itemBuilder: (BuildContext context) {
              return [
                const PopupMenuItem(
                  value: MovieQuery.year,
                  child: Text('Sort by Year'),
                ),
                const PopupMenuItem(
                  value: MovieQuery.rated,
                  child: Text('Sort by Rated'),
                ),
                const PopupMenuItem(
                  value: MovieQuery.likesAsc,
                  child: Text('Sort by Likes ascending'),
                ),
                const PopupMenuItem(
                  value: MovieQuery.likesDesc,
                  child: Text('Sort by Likes descending'),
                ),
                const PopupMenuItem(
                  value: MovieQuery.fantasy,
                  child: Text('Filter genre Fantasy'),
                ),
                const PopupMenuItem(
                  value: MovieQuery.sciFi,
                  child: Text('Filter genre Sci-Fi'),
                ),
              ];
            },
          ),
          PopupMenuButton<String>(
            onSelected: (_) => _resetLikes(),
            itemBuilder: (BuildContext context) {
              return [
                const PopupMenuItem(
                  value: 'reset_likes',
                  child: Text('Reset like counts (WriteBatch)'),
                ),
              ];
            },
          ),
        ],
      ),
      body: StreamBuilder<QuerySnapshot<Movie>>(
        stream: moviesRef.queryBy(query).snapshots(),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Center(
              child: Text(snapshot.error.toString()),
            );
          }

          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }

          final data = snapshot.requireData;

          return ListView.builder(
            itemCount: data.size,
            itemBuilder: (context, index) {
              return _MovieItem(
                data.docs[index].data(),
                data.docs[index].reference,
              );
            },
          );
        },
      ),
    );
  }

  Future<void> _resetLikes() async {
    final movies = await moviesRef.get(
      const GetOptions(
        serverTimestampBehavior: ServerTimestampBehavior.previous,
      ),
    );

    WriteBatch batch = FirebaseFirestore.instance.batch();

    for (final movie in movies.docs) {
      batch.update(movie.reference, {'likes': 0});
    }
    await batch.commit();
  }
}

/// A single movie row.
class _MovieItem extends StatelessWidget {
  _MovieItem(this.movie, this.reference);

  final Movie movie;
  final DocumentReference<Movie> reference;

  /// Returns the movie poster.
  Widget get poster {
    return SizedBox(
      width: 100,
      child: Image.network(movie.poster),
    );
  }

  /// Returns movie details.
  Widget get details {
    return Padding(
      padding: const EdgeInsets.only(left: 8, right: 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          title,
          metadata,
          genres,
          Likes(
            reference: reference,
            currentLikes: movie.likes,
          )
        ],
      ),
    );
  }

  /// Return the movie title.
  Widget get title {
    return Text(
      '${movie.title} (${movie.year})',
      style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
    );
  }

  /// Returns metadata about the movie.
  Widget get metadata {
    return Padding(
      padding: const EdgeInsets.only(top: 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.only(right: 8),
            child: Text('Rated: ${movie.rated}'),
          ),
          Text('Runtime: ${movie.runtime}'),
        ],
      ),
    );
  }

  /// Returns a list of genre movie tags.
  List<Widget> get genreItems {
    return [
      for (final genre in movie.genre)
        Padding(
          padding: const EdgeInsets.only(right: 2),
          child: Chip(
            backgroundColor: Colors.lightBlue,
            label: Text(
              genre,
              style: const TextStyle(color: Colors.white),
            ),
          ),
        )
    ];
  }

  /// Returns all genres.
  Widget get genres {
    return Padding(
      padding: const EdgeInsets.only(top: 8),
      child: Wrap(
        children: genreItems,
      ),
    );
  }

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 4, top: 4),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          poster,
          Flexible(child: details),
        ],
      ),
    );
  }
}

/// Displays and manages the movie 'like' count.
class Likes extends StatefulWidget {
  /// Constructs a new [Likes] instance with a given [DocumentReference] and
  /// current like count.
  Likes({
    Key? key,
    required this.reference,
    required this.currentLikes,
  }) : super(key: key);

  /// The reference relating to the counter.
  final DocumentReference<Movie> reference;

  /// The number of current likes (before manipulation).
  final int currentLikes;

  
  _LikesState createState() => _LikesState();
}

class _LikesState extends State<Likes> {
  /// A local cache of the current likes, used to immediately render the updated
  /// likes count after an update, even while the request isn't completed yet.
  late int _likes = widget.currentLikes;

  Future<void> _onLike() async {
    final currentLikes = _likes;

    // Increment the 'like' count straight away to show feedback to the user.
    setState(() {
      _likes = currentLikes + 1;
    });

    try {
      // Update the likes using a transaction.
      // We use a transaction because multiple users could update the likes count
      // simultaneously. As such, our likes count may be different from the likes
      // count on the server.
      int newLikes = await FirebaseFirestore.instance
          .runTransaction<int>((transaction) async {
        DocumentSnapshot<Movie> movie =
            await transaction.get<Movie>(widget.reference);

        if (!movie.exists) {
          throw Exception('Document does not exist!');
        }

        int updatedLikes = movie.data()!.likes + 1;
        transaction.update(widget.reference, {'likes': updatedLikes});
        return updatedLikes;
      });

      // Update with the real count once the transaction has completed.
      setState(() => _likes = newLikes);
    } catch (e, s) {
      print(s);
      print('Failed to update likes for document! $e');

      // If the transaction fails, revert back to the old count
      setState(() => _likes = currentLikes);
    }
  }

  
  void didUpdateWidget(Likes oldWidget) {
    super.didUpdateWidget(oldWidget);
    // The likes on the server changed, so we need to update our local cache to
    // keep things in sync. Otherwise if another user updates the likes,
    // we won't see the update.
    if (widget.currentLikes != oldWidget.currentLikes) {
      _likes = widget.currentLikes;
    }
  }

  
  Widget build(BuildContext context) {
    return Row(
      children: [
        IconButton(
          iconSize: 20,
          onPressed: _onLike,
          icon: const Icon(Icons.favorite),
        ),
        Text('$_likes likes'),
      ],
    );
  }
}


class Movie {
  Movie({
    required this.genre,
    required this.likes,
    required this.poster,
    required this.rated,
    required this.runtime,
    required this.title,
    required this.year,
  });

  Movie.fromJson(Map<String, Object?> json)
      : this(
          genre: (json['genre']! as List).cast<String>(),
          likes: json['likes']! as int,
          poster: json['poster']! as String,
          rated: json['rated']! as String,
          runtime: json['runtime']! as String,
          title: json['title']! as String,
          year: json['year']! as int,
        );

  final String poster;
  final int likes;
  final String title;
  final int year;
  final String runtime;
  final String rated;
  final List<String> genre;

  Map<String, Object?> toJson() {
    return {
      'genre': genre,
      'likes': likes,
      'poster': poster,
      'rated': rated,
      'runtime': runtime,
      'title': title,
      'year': year,
    };
  }
}

まずは画面にデータ写せるのを作る

私なりにですけど、映画の情報を表示するには、どんなコードを書くのか考えてみました。

  • やったこと
    • モデルクラスを定義する.
    • クラスのプロパティの型は全部String型.
    • StreamBuilderにクラスを型として使う.
    • withConverterを使用して、コードを書きやすくする.

modelディレクトリを作成して、movie_model.dartを作成します。
同じファイル内に、StreamBuilderで使用するwithConverterを定義します。

model/movie_model.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class Movie {
  Movie({required this.title, required this.genre});

  Movie.fromJson(Map<String, Object?> json)
      : this(
          title: json['title']! as String,
          genre: json['genre']! as String,
        );

  final String title;
  final String genre;

  Map<String, Object?> toJson() {
    return {
      'title': title,
      'genre': genre,
    };
  }
}

final moviesRef =
    FirebaseFirestore.instance.collection('movies').withConverter<Movie>(
          fromFirestore: (snapshot, _) => Movie.fromJson(snapshot.data()!),
          toFirestore: (movie, _) => movie.toJson(),
        );

次に、pagesディレクトリを作成して、FireStoreのデータを表示するmovie_page.dartを作成します。
withConverterを使ってみて、みたことがないコードがあったので、調べてみたらどうやら独自の機能があるみたいです。

このコードは、どんな役割があるのか?

final data = snapshot.requireData;

内部を調べてみるとこんなことが書かれていました。
データがあれば取得して、なければ例外処理を投げるようですね。

  /// 最新の受信データを返し、データがない場合は失敗する。
  ///
  /// hasError]の場合、[error]をスローします。StateError]を投げる。[hasData]
  /// nor [hasError].
  T get requireData {
    if (hasData) {
      return data!;
    }
    if (hasError) {
      Error.throwWithStackTrace(error!, stackTrace!);
    }
    throw StateError('Snapshot has neither data nor error');
  }

lengthの代わりに使われているコードはどんな機能があるのか?

itemCount: data.size,

数を数えているのは、変わらないようですね。

  /// このスナップショットのサイズ(ドキュメント数)を返します。
  int get size;
pages/movie_page.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:timelineview_app/model/movie_model.dart';

class MoviePage extends StatelessWidget {
  const MoviePage({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('StartPage'),
      ),
      body: StreamBuilder<QuerySnapshot<Movie>>(
        // クラスを型として使う.
        stream: moviesRef.snapshots(), // withConverterのデータをstreamで流す.
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Center(
              child: Text(snapshot.error.toString()),
            );
          }

          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }

          final data = snapshot.requireData;

          return ListView.builder(
            // ListView.builderで画面に描画する.
            itemCount: data.size,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text(
                    data.docs[index].data().title), // Movieクラスのtitleプロパティを使う.
                subtitle: Text(
                    data.docs[index].data().genre), // Movieクラスのgenreプロパティを使う.
              );
            },
          );
        },
      ),
    );
  }
}

アプリを実行するコード

アプリを実行すると、リアルタイムでデータが取得できました!

main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:timelineview_app/pages/movie_page.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MoviePage(),
    );
  }
}

スクリーンショット
これで一旦入門レベルはクリアですね。

データを書き換えたらどうなる?

モデルクラスに、Timestampを追加したら、エラーが出ました!
クラスに変数増やしただけで、エラー出るのは変だなと思い、debugするとFireStoreの値がnullと表示されていました🤔

moviesコレクションのフィールドに、createdAtを追加したら、解決しました。
もし、このモデルクラスを使う場合は、最初から設定が必要みたいですね😅
でないと、後で変数を追加するとデータに矛盾が生じて、エラーが発生するみたいです🙄

でも、Timestamp以外の変数だったら、エラーが出なかったですね?
他のモデルクラスを作ってたときは、subTitleと追加した時に、エラーが発生したことないので、Timestampだけなのかも?

FireStoreのデータ

変更したコード

Timestamp型の変数を追加。UI側のコードで型を変換するので、こちらのページでは設定は必要なし。

import 'package:cloud_firestore/cloud_firestore.dart';

class Movie {
  Movie({required this.title, required this.genre, required this.createdAt});

  Movie.fromJson(Map<String, Object?> json)
      : this(
          title: json['title']! as String,
          genre: json['genre']! as String,
          createdAt: json['createdAt']! as Timestamp,
        );

  final String title;
  final String genre;
  final Timestamp createdAt;

  Map<String, Object?> toJson() {
    return {
      'title': title,
      'genre': genre,
      'createdAt': createdAt,
    };
  }
}

final moviesRef =
    FirebaseFirestore.instance.collection('movies').withConverter<Movie>(
          fromFirestore: (snapshot, _) => Movie.fromJson(snapshot.data()!),
          toFirestore: (movie, _) => movie.toJson(),
        );

UIを作るコードのファイルで、モデルクラスに追加したTimestampの型を変換します。
まずは、.toDate()を使って、TimestampをDateTimeに変換します。
その後に、String型に変化数コードを追加すれば、型のエラーが消えるので、画面に時間を表示することができます。

内部の仕組みはこんな風になってます。

/// [Timestamp]を[DateTime]に変換する。
  DateTime toDate() {
    return DateTime.fromMicrosecondsSinceEpoch(microsecondsSinceEpoch);
  }

時間の表示を追加したコード

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:timelineview_app/model/movie_model.dart';

class MoviePage extends StatelessWidget {
  const MoviePage({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('StartPage'),
      ),
      body: StreamBuilder<QuerySnapshot<Movie>>(
        // クラスを型として使う.
        stream: moviesRef.snapshots(), // withConverterのデータをstreamで流す.
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Center(
              child: Text(snapshot.error.toString()),
            );
          }

          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }

          final data = snapshot.requireData;

          return ListView.builder(
            // ListView.builderで画面に描画する.
            itemCount: data.size,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text(
                    data.docs[index].data().title), // Movieクラスのtitleプロパティを使う.
                subtitle: Row(
                  children: [
                    Text(data.docs[index].data().genre),
                    const SizedBox(width: 20),
                    // .toStringだと変な文字になるので、.toDate()の後に
                    // .と入力してString型に変換するコードを選ぶ.
                    Text(data.docs[index]
                        .data()
                        .createdAt
                        .toDate()
                        .toIso8601String())
                  ],
                ), // Movieクラスのgenreプロパティを使う.
              );
            },
          );
        },
      ),
    );
  }
}

FireStoreに新しいデータを追加しました。問題なく使えているので、OKですね。

まとめ

このコード何してるのかを調べたいときは、Google検索しますが、Flutter大学のAoiさんという方は、Flutterの内部を見るそうで、私も同じことを最近やってみたのですが、このコードが何をしてくれるのかが、英語で書かれていました。
見る方法は、PCやIDEによって異なりますが、MacでAndroid Studioを使っている場合だと、コードの上にマウスを置いて、command + クリックをするとファイルを開くことができます。

皆さんもwithConverterにご興味あったら使ってみてください!

おまけ

FlutterFire公式のページで、使ってない部分のコードがありました!
試しに動かしてみたのですが、withConverterでデータの追加もできるみたいですね。

翻訳
最後に、新しい変数 moviesRef を使用して、読み取りと書き込みの操作を行うことができます。

Future<void> main() async {
  // Obtain science-fiction movies
  List<QueryDocumentSnapshot<Movie>> movies = await moviesRef
      .where('genre', isEqualTo: 'Sci-fi')
      .get()
      .then((snapshot) => snapshot.docs);

  // Add a movie
  await moviesRef.add(
    Movie(
      title: 'Star Wars: A New Hope (Episode IV)',
      genre: 'Sci-fi'
    ),
  );

  // Get a movie with the id 42
  Movie movie42 = await moviesRef.doc('42').get().then((snapshot) => snapshot.data()!);
}

サンプルコード

データを追加するだけなら、この書き方ではなくても、いけそうですがやってみました。
ご興味ある方は、コードを書き換えて、withConverterで、データを追加、更新、削除やってみてください!

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';

import 'model/movie_model.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MoviePost(),
    );
  }
}

class MoviePost extends StatefulWidget {
  const MoviePost({Key? key}) : super(key: key);

  
  State<MoviePost> createState() => _MoviePostState();
}

class _MoviePostState extends State<MoviePost> {
  // FireStoreに、withConverterを型に使用してデータを追加するメソッド.
  Future<void> main() async {
    final now = DateTime.now();
    // 映画の情報を入手.
    List<QueryDocumentSnapshot<Movie>> movies = await moviesRef
        .where('genre', isEqualTo: '冒険もの')
        .get()
        .then((snapshot) => snapshot.docs);

    // 映画を追加.
    await moviesRef.add(
      Movie(
          title: '転生したらスライムだった',
          genre: '冒険もの',
          createdAt: Timestamp.fromDate(now)),
    );

    // 映画を取得する.
    Movie movie42 = await moviesRef
        .doc('oCET9AnGbIJV5C2cojXY')
        .get()
        .then((snapshot) => snapshot.data()!);
    print(movie42);
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('映画の情報を追加'),
      ),
      body: Center(
        child: Column(
          children: [
            ElevatedButton(
                onPressed: () async {
                  main();
                },
                child: Text('PostData'))
          ],
        ),
      ),
    );
  }
}

Discussion