해당 포스트에서는 Firestore 구성과 Riverpod에 이해가 있다는 가정하에 작성했습니다.
Riverpod과 Firestore를 이용해 게시물이 실시간으로 추가/삭제/업데이트 되는 상황에서 Infinite Scroll ListView를 구현하는 방법이 생각보다 구현하기가 어려운 점이 많았습니다.
실제 서비스하는 프로젝트에서 구현하면서 여러 우여곡절을 겪은 경험을 바탕으로 Riverpod으로 어떻게 Firestore 데이터를 실시간으로 페이징하는지 작성해보도록 하겠습니다.
[collection] comments
└── [document]
└── title: [String]
└── text: [String]
└── createdAt: [Timestamp]
구현을 간소화 하기 위해 다음과 같이 아주 간단한 Firestore 모델을 만들었습니다.
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);
}
// 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를 함께 가져올 수 있게됩니다.
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()를 이용해 데이터를 처리합니다.
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에서 데이터를 가져와 처리하는 부분입니다.
기본적인 수행방식은 다음과 같습니다.
- lib/pages/home/home_controller.dart에서 listenCommentStream을 호출
- 제한된 양의 게시물 요청 (20개)
- 문서 업데이트 내용이 있으면 추가/삭제
- StreamController를 이용해 브로드캐스트
하단의 commentTotalCount는 AggregateQuery를 이용한 전체 문서 갯수를 조회하는 메서드입니다. AggregateQuery를 이용하면 전체 문서를 조회해도 Firestore 읽기 횟수를 한 번만 사용하게 됩니다.
자세한 내용은 다음을 참고해주세요.
Count documents with aggregation queries
다음과 같이 구성 후, 추가/삭제 등의 기능을 넣으면...
다음과 같이 실시간으로 기능하는 Infinite Scroll ListView가 만들어집니다.
Riverpod과 Firestore를 이용하여 실시간으로 추가/삭제/업데이트에 대응하는 방법에 대해서 살펴봤습니다. 조금 더 신경을 쓴다면 Firestore 사용량을 더욱 줄이는 방식으로도 개선이 가능할 것 같지만 이정도만 해도 사용하기에는 충분하다고 느껴서 포스팅하게 되었습니다.
다음에 또 뭔가 나눌 이야기 있으면 새로운 글에서 뵙겠습니다 :)
Great!