[Flutter] Firestore Realtime Infinite Scroll with Riverpod

Parrottkim·2023년 3월 1일
5
post-thumbnail

시작하기에 앞서

해당 포스트에서는 Firestore 구성과 Riverpod에 이해가 있다는 가정하에 작성했습니다.

Riverpod과 Firestore를 이용해 게시물이 실시간으로 추가/삭제/업데이트 되는 상황에서 Infinite Scroll ListView를 구현하는 방법이 생각보다 구현하기가 어려운 점이 많았습니다.
실제 서비스하는 프로젝트에서 구현하면서 여러 우여곡절을 겪은 경험을 바탕으로 Riverpod으로 어떻게 Firestore 데이터를 실시간으로 페이징하는지 작성해보도록 하겠습니다.

Firestore 모델

[collection] comments
└── [document]
    └── title: [String]
    └── text: [String]
    └── createdAt: [Timestamp]

구현을 간소화 하기 위해 다음과 같이 아주 간단한 Firestore 모델을 만들었습니다.

Flutter 구현

lib/models/comment.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:json_annotation/json_annotation.dart';

part 'comment.g.dart';

@JsonSerializable()
class Comment {
  final String? id;
  final String title, text;
  final DateTime createdAt;

  Comment({
    this.id,
    required this.title,
    required this.text,
    required this.createdAt,
  });

  factory Comment.fromFirestore(QueryDocumentSnapshot<Map> doc) =>
      _$CommentFromFirestore(doc);

  /// Connect the generated [_$PersonToJson] function to the `toJson` method.
  Map<String, dynamic> toJson() => _$CommentToJson(this);
}
lib/models/comment.g.dart
// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'comment.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

// 수정한 부분
Comment _$CommentFromFirestore(QueryDocumentSnapshot<Map> doc) => Comment(
      id: doc.id,
      title: doc.data()['title'] as String,
      text: doc.data()['text'] as String,
      createdAt: doc.data()['createdAt'].toDate(),
    );

Map<String, dynamic> _$CommentToJson(Comment instance) => <String, dynamic>{
      'title': instance.title,
      'text': instance.text,
  	  // DateTime 그대로 Firestore 업로드가 가능하므로 toIso8601String() 삭제
      'createdAt': instance.createdAt,
    };

json_serializable 패키지를 이용해 모델을 만들어줍니다.
한가지 유의할 점은, Json으로 받아오지 않고 QueryDocumentSnpashot으로 받아오는 코드 부분입니다.
이렇게 수정하면 Document Id를 함께 가져올 수 있게됩니다.

lib/pages/home/home_controller.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_firestore_realtime_pagination/model/comment.dart';
import 'package:riverpod_firestore_realtime_pagination/repositories/comment_repository.dart';

final commentProvider =
    StateNotifierProvider<CommentNotifier, AsyncValue<List<Comment>>>(
        (ref) => CommentNotifier(ref: ref));

class CommentNotifier extends StateNotifier<AsyncValue<List<Comment>>> {
  CommentNotifier({required this.ref}) : super(AsyncLoading()) {
    _fetchFirestoreData();
    controller.addListener(() => _scrollListeners());
  }

  final ScrollController controller = ScrollController();
  final CommentRepository _repository = CommentRepository();

  final Ref ref;

  bool _isLoading = false;
  int totalCount = 0;

  _fetchFirestoreData() async {
    // 로딩 중인 경우, return
    if (_isLoading) return;
    _isLoading = true;

    // Firestore 전체 문서 갯수 로드
    totalCount = await _repository.commentTotalCount();
    if (totalCount == 0) {
      // 전체 문서가 비어있으면 AsyncValue 빈 리스트로 지정
      // 빈 리스트로 지정하지 않으면 계속 AsyncLoading인 상태가 유지
      state = AsyncValue.data([]);
    }

    // Firestore 문서 목록 스트림
    _repository.listenCommentStream().listen((event) async {
      state = AsyncValue.data(event);
    });

    // 작업이 끝나면 로딩 중이 아닌 상태로 지정
    _isLoading = false;
  }

  _scrollListeners() async {
    // 스크롤이 전체 범위의 중간 이상을 넘어갔는지 여부
    final reachMaxExtent =
        controller.offset >= controller.position.maxScrollExtent - 20.0;
    // 스크롤이 전체 범위를 벗어나지 않고, 최상단이 아닌지 여부
    final outOfRange =
        !controller.position.outOfRange && controller.position.pixels != 0;
    if (reachMaxExtent && outOfRange) {
      // Firestore 다음 목록 로딩
      await _fetchFirestoreData();
    }
  }
}

AsyncValue는 비동기 데이터를 다루기 위한 도구입니다.
초기 상태는 AsyncLoading(로딩 중)인 상태로 지정한 뒤, 로딩이 끝나면 AsyncValue.data()를 이용해 데이터를 처리합니다.

lib/repositories/comment_repository.dart
import 'dart:async';

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:riverpod_firestore_realtime_pagination/model/comment.dart';

final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final StreamController<List<Comment>> _streamController =
    StreamController<List<Comment>>.broadcast();

class CommentRepository {
  // 각 항목이 제한된 양 (20개)로 이루어진 페이지
  // 예를 들어 전체 문서가 24개인 경우
  // [ [문서1, 문서2, ..., 문서 20], [문서21, 문서 22, ..., 문서 24] ]
  // 다음과 같은 형식으로 목록이 생성됨
  List<List<Comment>> _comments = [];
  DocumentSnapshot? _lastDocument;

  Stream<List<Comment>> listenCommentStream() {
    fetchCommentList();
    return _streamController.stream;
  }

  void fetchCommentList([int limit = 20]) {
    // 제한된 양의 문서 요청 (20개)
    var query = _firestore
        .collection('comments')
        .orderBy('createdAt', descending: true)
        .limit(limit);
    List<Comment> results = [];

    // 마지막 문서가 있으면, 쿼리를 마지막 문서 다음부터 조회하도록 조정
    if (_lastDocument != null) {
      query = query.startAfterDocument(_lastDocument!);
    }

    // 현재 요청 문서가 속한 페이지 지정
    var currentRequestIndex = _comments.length;

    // listen() 메서드를 이용해 업데이트 구독
    query.snapshots().listen((event) {
      if (event.docs.isNotEmpty) {
        var comments = event.docs
            .map((element) => Comment.fromFirestore(element))
            .toList();

        // 해당 페이지가 존재하는지 여부
        var pageExists = currentRequestIndex < _comments.length;

        // 페이지가 존재하면, 해당 페이지 업데이트
        if (pageExists) {
          _comments[currentRequestIndex] = comments;
        }
        // 페이지가 존재하지 않으면, 페이지 새로 추가
        else {
          _comments.add(comments);
        }

        // 여러 페이지를 하나의 리스트로 결합
        results = _comments.fold<List<Comment>>(
            [], (initialValue, pageItems) => initialValue..addAll(pageItems));

        // StreamController를 이용해 모든 Comment를 브로드캐스트
        _streamController.add(results);
      }

      // 업데이트된 문서는 존재하지 않는데, 문서가 수정되었을 경우
      if (event.docs.isEmpty && event.docChanges.isNotEmpty) {
        for (final data in event.docChanges) {
          // 수정된 문서의 index가 -1인 경우 (삭제된 문서)
          if (data.newIndex == -1) {
            // 전체 리스트에서 해당 문서 삭제
            results
                .removeWhere((element) => element.id == data.doc.data()?['id']);
          }
        }
        // StreamController를 이용해 모든 Comment를 브로드캐스트
        _streamController.add(results);
      }

      // 마지막 문서 지정
      if (results.isNotEmpty && currentRequestIndex == _comments.length - 1) {
        _lastDocument = event.docs.last;
      }
    });
  }

  // 전체 문서 갯수 로드
  Future<int> commentTotalCount() async {
    AggregateQuerySnapshot query = await _firestore
        .collection('comments')
        .orderBy('createdAt', descending: true)
        .count()
        .get();
    return query.count;
  }
}

Firestore에서 데이터를 가져와 처리하는 부분입니다.
기본적인 수행방식은 다음과 같습니다.

  1. lib/pages/home/home_controller.dart에서 listenCommentStream을 호출
  2. 제한된 양의 게시물 요청 (20개)
  3. 문서 업데이트 내용이 있으면 추가/삭제
  4. StreamController를 이용해 브로드캐스트

하단의 commentTotalCount는 AggregateQuery를 이용한 전체 문서 갯수를 조회하는 메서드입니다. AggregateQuery를 이용하면 전체 문서를 조회해도 Firestore 읽기 횟수를 한 번만 사용하게 됩니다.

자세한 내용은 다음을 참고해주세요.
Count documents with aggregation queries

다음과 같이 구성 후, 추가/삭제 등의 기능을 넣으면...

다음과 같이 실시간으로 기능하는 Infinite Scroll ListView가 만들어집니다.

끝으로

Riverpod과 Firestore를 이용하여 실시간으로 추가/삭제/업데이트에 대응하는 방법에 대해서 살펴봤습니다. 조금 더 신경을 쓴다면 Firestore 사용량을 더욱 줄이는 방식으로도 개선이 가능할 것 같지만 이정도만 해도 사용하기에는 충분하다고 느껴서 포스팅하게 되었습니다.

다음에 또 뭔가 나눌 이야기 있으면 새로운 글에서 뵙겠습니다 :)

GitHub Repository

profile
Flutter developer

4개의 댓글

comment-user-thumbnail
2023년 3월 2일

Great!

답글 달기
comment-user-thumbnail
2023년 3월 22일
답글 달기
comment-user-thumbnail
2023년 3월 22일

Thank you for this awesome work. Tested the code its great!

1개의 답글