🎷

FirestoreでSNSぽい設計をしてみる①

2023/11/19に公開

二つのコレクションのデータを同じ場所に表示したい

タイトルが、SNSぽい設計と書きましたが、実際のところは実験で作ったので、それぽいものではないですね。

今回やること

  1. 二つのコレクションのデータを取得して、同じカードの中に表示.
  2. 一つのコレクションに全てのデータを保存しないので、正規化している.
  3. Firestoreは、非正規化するものですけど、分けないと必要ないデータをuserコレクションは持ってしまう.

🥏こんな設計にした

userコレクションIDは、nameフィールドしか今回ありません。画像も欲しいですが、今回は省きます。ダミーのデータを作成するときは、userコレクションもtaskコレクションもドキュメントIDは同じものを使用してください。

簡単ですけど、DBの設計図です。

userコレクションIDは、String型のnameフィールド1個だけです。

taskコレクションIDは、taskはString型、likesは、array型で、このなかに今回は仮の0x1111というString型のダミーのIDを入れていますが、実際には、UIDを保存して、これを数えていいねしてくれた数を数えます。
commentsは、array型で、他のユーザーのテキスト情報をString型で保存して、数えてコメント数を数えます。

userより先に、taskを作成したら、画面に表示されて変なUIになるのではと思ったのですが、userコレクションのデータが表示されていないと、taskは表示されないようです?
これであってるか疑問ですけど???

🖊️ソースコード

今回は、riverpod generatorとFreezedを使用しています。全体のコードはGithubのレポジトリを見ていただいた方が良いと思われます。

userモデル
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

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


class User with _$User {
  const factory User({
    ('') String name,
  }) = _User;

  factory User.fromJson(Map<String, Object?> json)
      => _$UserFromJson(json);
}
taskモデル
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'task.freezed.dart';
part 'task.g.dart';


class Task with _$Task {
  const factory Task({
    ('') String task,
    ([]) List<String> likes,
    ([]) List<String> comments,
  }) = _Task;

  factory Task.fromJson(Map<String, Object?> json)
      => _$TaskFromJson(json);
}
Firebaseと接続するプロバイダー
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:task_app/model/user.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../model/task.dart';
part 'firebase_provider.g.dart';


FirebaseFirestore firebaseFirestore(FirebaseFirestoreRef ref) {
  return FirebaseFirestore.instance;
}


Stream<List<User>> userStream(UserStreamRef ref) {
  final firestore = ref.watch(firebaseFirestoreProvider);
  return firestore
      .collection('user').limit(5)
      .snapshots()
      .map((snapshot) => snapshot.docs.map((doc) => User.fromJson(doc.data())).toList());
}


Stream<List<Task>> taskStream(TaskStreamRef ref) {
  final firestore = ref.watch(firebaseFirestoreProvider);
  return firestore
      .collection('task').limit(5)
      .snapshots()
      .map((snapshot) => snapshot.docs.map((doc) => Task.fromJson(doc.data())).toList());
}
SNSぽいUIを表示するページ
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:task_app/provider/firebase_provider.dart';

import 'firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseFirestore.instance.settings = const Settings(
    persistenceEnabled: true,
  );

  runApp(const ProviderScope(child: MyApp()));
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends ConsumerWidget {
  const HomePage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final users = ref.watch(userStreamProvider);
    final tasks = ref.watch(taskStreamProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task App'),
      ),
      body: users.when(
        data: (users) => tasks.when(
          data: (tasks) => ListView.builder(
            itemCount: users.length,
            itemBuilder: (context, index) {
              final user = users[index];
              final task = tasks[index];
              return SizedBox(
                width: 200,
                height: 150,
                child: Card(
                  child: Column(
                    children: [
                      Align(
                          alignment: Alignment.centerLeft,
                          child: Text(user.name)),
                      const SizedBox(height: 10),
                      Align(
                          alignment: Alignment.centerLeft,
                          child: Text(task.task)),
                      const SizedBox(height: 10),
                      Row(
                        children: [
                          Text('いいね ${task.likes.length.toString()}'),
                          Text('コメント ${task.comments.length.toString()}'),
                        ],
                      ),
                    ],
                  ),
                ),
              );
            },
          ),
          loading: () => const Center(child: CircularProgressIndicator()),
          error: (error, stackTrace) => Center(child: Text(error.toString())),
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stackTrace) => Center(child: Text(error.toString())),
      ),
    );
  }
}

こんな感じのUIになります

最後に

今回は、SNSのUIを見ていて、userコレクションIDに全部データ入ってるはずないなと思い、taskコレクションIDを作成して、そこからデータを取得して、whenメソッドをネストさせて、同じCart Widgetに、2つのコレクションのデータを表示できるようにしました。
Firestoreには、参照型というものもあるのですが、これはなんども読み取ると、読み取りコストがかかるらしいです?
TOPレベルのコレクション(別々のコレクション)ってことですね。サブコレクションとかにしないで、データを取得して表示してます。
やったとしてもwhenメソッドをネストさせてる気がします?

完成品のソースコード
https://github.com/sakurakotubaki/SNSLike

Discussion