DAY 24 | Flutter 데모 앱 만들기: 데이터베이스 연결 下

데이터 읽어오기

데이터를 읽어오는 코드를 작성하기에 앞서 지난 시간에 Firestore에 넣은 데이터 중 최종 데이터 형태와 맞지 않는 것들을 제거한다.

DB에서 JSON 형태로 전달된 데이터로부터 object를 생성하는 factory constructor를 작성한다.

lib/modesl/my_workout.dart

// 앞 부분 생략
  factory MyWorkout.fromMap(Map<String, dynamic> map) {
    return MyWorkout(
      uid: map['uid'],
      id: map['id'],
      name: map['id'],
      workoutDays: map['workoutDays'],
      imageURL: map['imageUrl'],
      minutes: map['minutes'],
      createAt: (map['createdAt'] as Timestamp).toDate(),
    );
  }
// 뒷 부분 생략

이를 사용하여 DB에서 데이터를 가져와 object로 변환하는 코드를 작성한다.

// 앞 부분 생략  Future<MyWorkout?> readMyWorkout(String workoutId) async {
    try {
      final myWorkoutCollection = _fs.collection('myWorkouts');
      final docRef = myWorkoutCollection.doc(workoutId);
      final docSnapshot = await docRef.get();
      if (!docSnapshot.exists) {
        throw Exception('no data');
      }
      final mapData = docSnapshot.data()!;
      return MyWorkout.fromMap(mapData);
    } catch (e) {
      throw Exception('db error: $e');
    }
  }
// 뒷 부분 생략

Model에서 Timestamp 를 사용하는 건 SDK에 대한 종속성이 생겨 좋은 방법은 아니지만 우리는 작은 프로젝트니까 이대로 넘어가겠다.

일괄 읽어오기

MyWorkoutList 에 접근하면 DB에 있는 현재 사용자의 모든 사용자 지정 운동 목록을 불러오는 코드를 작성해 보자.

lib/services/firestore_service.dart

// 앞 부분 생략
  Future<List<MyWorkout>> fetchAllMyWorkout(String uid) async {
    try {
      final myWorkoutCollection = _fs.collection('myWorkouts');
      final query = myWorkoutCollection
          .where('uid', isEqualTo: uid)
          .orderBy('createdAt')
          .orderBy('id');
      final querySnapshot = await query.get();
      final docSnapshotList = querySnapshot.docs;
      List<MyWorkout> returnData = [];
      for (final doc in docSnapshotList) {
        final mapData = doc.data();
        returnData.add(MyWorkout.fromMap(mapData));
      }
      return returnData;
    } catch (e) {
      throw Exception('db error: $e');
    }
  }
// 뒷 부분 생략

이때, View에서 Service에 직접 접근하지 않고 ViewModel을 사용하도록 한다.

lib/logics/my_workout_provider.dart

// 앞 부분 생략
  Future<void> fetchAllMyWorkout() async {
    if (_auth.user == null) {
      return;
    }
    final fetchedWorkouts = await _firebaseStore.fetchAllMyWorkout(_auth.user!.uid);
    _workouts.addAll(fetchedWorkouts);
    notifyListeners();
  }
// 뒷 부분 생략

그리고 이 ViewModel method를 View에서 페이지가 처음 열리는 순간 call해 주어야 한다. initState() 에서 해주면 된다. MyWorkoutListPage 를 StatefulWidget으로 변경한 후 initState() 를 작성한다.

lib/pages/my_workout_list_page.dart

// 앞 부분 생략
class _MyWorkoutListPageState extends State<MyWorkoutListPage> {
   
  void initState() {
    super.initState();
    Provider.of<MyWorkoutProvider>(context, listen: false).fetchAllMyWorkout();
  }
// 뒷 부분 생략

View는 State를 소유하지 않는다

MVVM 아키텍처에서는 View가 State를 직접 소유하지 않는다. ViewModel이 가지고 있는 State를 listening 하고 있을 뿐이다. State의 변동 사항은 Provider를 통해 전달된다.

View에 리스트가 비어 있는 경우에 대한 처리를 해준다. 비어 있는 것보다는 로딩 인디케이터를 출력하거나 비어 있음을 명시해 주는 게 좋다.

// 앞 부분 생략
      body: Consumer<MyWorkoutProvider>(
        builder: (context, myWorkoutProvider, child) {
          List<MyWorkout> workouts = myWorkoutProvider.workouts;
          if (workouts.isEmpty) {
            return Center(
              child: Text('운동 데이터가 로드되지 않았습니다.'),
            );
          }
          return ListView.builder(
            // 생략
          );
        }
      ),
// 뒷 부분 생략

그리고 이제 DB에 있는 데이터만을 보여줄 것이므로 샘플 데이터를 삭제한다.

이 상태로 실행을 하면... DB 에러가 발생하며 운동 데이터가 로드되지 않는다.

whereorderBy 가 사용된 query에 index가 생성되지 않았기 때문이다. 단일 인덱스는 자동 생성되기 때문에 문제 없이 실행되지만 복합 인덱스를 사용할 경우 개발자가 직접 생성해 주어야 한다. id를 통해 값을 가져오는 건 단일 인덱스라 괜찮지만 방금 것은 복합 인덱스라 오류가 난 것이다. Firebase Console에서 복합 인덱스를 생성해 주도록 한다.

에러 메시지의 링크를 누르면 자동으로 생성되지만 잘 되지 않는다면 수동으로 해주어야 한다.

화면이 그려지는 도중에 DB로부터 데이터가 asynchronous하게 fetch되면 화면을 그리다 말고 화면을 다시 그려야 하는 이슈가 있다. 이를 해결하기 위해 initState() 코드를 다음과 같이 수정할 수 있다.

lib/pages/my_workout_list_page.dart

// 앞 부분 생략
  
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_){
      Provider.of<MyWorkoutProvider>(context,listen:false).fetchAllMyWorkouts();
    });
  }
// 뒷 부분 생략

페이지네이션

한 번에 모든 정보를 가져오기엔 부하가 커질 수 있다. 따라서 몇 개만 불러오고 필요 시 추가로 불러오도록 한다. 여기선 5개만 가져오고 스크롤 시 추가로 5개씩 불러오도록 구현하겠다.

이를 위해 query에 limit을 추가한다. argument로 받되 기본값을 5로 두겠다.

lib/services/firestore_service.dart

// 앞 부분 생략
  Future<List<MyWorkout>> fetchAllMyWorkout({required String uid, int limit=5}) async {
    try {
      final myWorkoutCollection = _fs.collection('myWorkouts');
      final query = myWorkoutCollection
          .where('uid', isEqualTo: uid)
          .orderBy('createdAt')
          .orderBy('id')
          .limit(limit);
      final querySnapshot = await query.get();
      final docSnapshotList = querySnapshot.docs;
      List<MyWorkout> returnData = [];
      for (final doc in docSnapshotList) {
        final mapData = doc.data();
        returnData.add(MyWorkout.fromMap(mapData));
      }
      return returnData;
    } catch (e) {
      throw Exception('db error: $e');
    }
  }
// 뒷 부분 생략

named parameter를 사용하는 형태로 변경하였으니 Provider도 수정해 주어야 한다.

lib/logics/my_workout_provider.dart

// 앞 부분 생략
  Future<void> fetchAllMyWorkout() async {
    if (_auth.user == null) {
      return;
    }
    final fetchedWorkouts = await _firebaseStore.fetchAllMyWorkout(
      uid: _auth.user!.uid,
      limit: 5,
    );
    _workouts.addAll(fetchedWorkouts);
    notifyListeners();
  }
// 뒷 부분 생략

스크롤 시 데이터를 추가로 fetch하도록 하기 위해 마지막으로 불러온 운동이 빈 값이 아니라면 추가로 fetch하도록 코드를 수정하겠다.

lib/services/firestore_service.dart

// 앞 부분 생략
  Future<List<MyWorkout>> fetchAllMyWorkout({
    required String uid,
    int limit = 5,
    MyWorkout? lastWorkout,
  }) async {
    try {
      final myWorkoutCollection = _fs.collection('myWorkouts');
      Query<Map<String, dynamic>> query = myWorkoutCollection
          .where('uid', isEqualTo: uid)
          .orderBy('createdAt')
          .orderBy('id')
          .limit(limit);
      if (lastWorkout != null) {
        query = query.startAfter([
          Timestamp.fromDate(lastWorkout.createdAt),
          lastWorkout.id,
        ]);
      }
      final querySnapshot = await query.get();
      final docSnapshotList = querySnapshot.docs;
      List<MyWorkout> returnData = [];
      for (final doc in docSnapshotList) {
        final mapData = doc.data();
        returnData.add(MyWorkout.fromMap(mapData));
      }
      return returnData;
    } catch (e) {
      throw Exception('db error: $e');
    }
  }
// 뒷 부분 생략

lib/logics/my_workout_provider.dart

// 앞 부분 생략
  Future<void> fetchAllMyWorkouts() async {
    if (_auth.user == null) {
      return;
    }
    final fetchedWorkouts = await _firebaseStore.fetchAllMyWorkout(
      uid: _auth.user!.uid,
      limit: 5,
      lastWorkout: _workouts.lastOrNull,
    );
    _workouts.addAll(fetchedWorkouts);
    notifyListeners();
  }
// 뒷 부분 생략

이제 스크롤 했을 때 fetch가 수행되도록 controller를 작성한다.

lib/pages/my_workout_page.dart

// 앞 부분 생략
class _MyWorkoutListPageState extends State<MyWorkoutListPage> {
  final ScrollController _scrollController = ScrollController();

  void _scrollListener() {
    if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
      Provider.of<MyWorkoutProvider>(context,listen:false).fetchAllMyWorkouts();
    }
  }

  
  void initState() {
    super.initState();
    _scrollController.addListener(_scrollListener);
    WidgetsBinding.instance.addPostFrameCallback((_){
      Provider.of<MyWorkoutProvider>(context,listen:false).fetchAllMyWorkouts();
    });
  }
 
  
  void dispose() {
    _scrollController.removeListener(_scrollListener);
    _scrollController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    ColorScheme colorScheme = Theme.of(context).colorScheme;
    return Scaffold(
      appBar: AppBar(title: Text('MyWorkoutList')),
      body: Consumer<MyWorkoutProvider>(
        builder: (context, myWorkoutProvider, child) {
          List<MyWorkout> workouts = myWorkoutProvider.workouts;
          if (workouts.isEmpty) {
            return Center(child: Text('운동 데이터가 로드되지 않았습니다.'));
          }
          return ListView.builder(
            controller: _scrollController,
            physics: AlwaysScrollableScrollPhysics(),
// 뒷 부분 생략

잘 작동하는지 확인하기 위해 샘플 데이터를 넣어보도록 하겠다.

수정 및 삭제

수정 및 삭제 시 DB에 반영하는 코드를 추가한다.

lib/services/firestore_service.dart

// 앞 부분 생략
 Future<void> updateMyWorkout(MyWorkout myWorkout) async {
    try {
      final myWorkoutsCollection = _fs.collection('myWorkouts');
      final documentRef = myWorkoutsCollection.doc(myWorkout.id);
      documentRef.update(myWorkout.toMap());
    } catch (e) {
      throw Exception('db error: $e');
    }
  }

  Future<void> deleteMyWorkout(String workoutId) async {
    try {
      final myWorkoutsCollection = _fs.collection('myWorkouts');
      final documentRef = myWorkoutsCollection.doc(workoutId);
      await documentRef.delete();
    } catch (e) {
      throw Exception('db error: $e');
    }
  }
// 뒷 부분 생략

lib/logics/my_workout_provider.dart

// 앞 부분 생략
  Future<void> deleteMyWorkout(int index) async {
    _firebaseStore.deleteMyWorkout(_workouts[index].id!);
    _workouts.removeAt(index);
    notifyListeners();
  }

  Future<void> updateMyWorkoutDays({
    required List<bool> isSelected,
    required int workoutIndex,
  }) async {
    _workouts[workoutIndex].workoutDays = isSelected;
    _firebaseStore.updateMyWorkout(_workouts[workoutIndex]);
    notifyListeners();
  }
// 뒷 부분 생략

>>> GitHub Repository at this point (c9475e6)

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글