모노산달로스의 행보

[Flutter] Firebase에 저장된 여러 개의 데이터를 읽어 화면에 출력하기(1/2) 본문

App/Flutter

[Flutter] Firebase에 저장된 여러 개의 데이터를 읽어 화면에 출력하기(1/2)

모노산달로스 2024. 3. 27. 18:24

플러터는 크로스 플랫폼 개발에서 강력한 파워를 보여주는 프레임워크이다. 주로 파이어베이스와 함께 애플리케이션 개발에 사용된다.

 


문제 발생

프로젝트를 진행하면서 공부하던 도중 Firebase와 연동하는 작업에 문제가 생겼다.

발생한 오류는 다음과 같다:
StreamBulder를 통해 Firebase에 저장된 데이터를 읽어 만든 List를 화면에 표시하지 못한다.

 

return StreamBuilder<QuerySnapshot>(
      stream: FirebaseFirestore.instance.collection('Goals').orderBy('timestamp').snapshots(),
      builder: (context, snapshot) {
          if (snapshot.hasError) return Text('ERROR: ${snapshot.error}');
        if (!snapshot.hasData) {
          return const Center(
            child: CircularProgressIndicator(
              backgroundColor: lightHoney,
            ),
          );
        } else {
          final goals = snapshot.data?.docs;
          List<GoalComb> goalDatas = [];
          for (var goal in goals!) {
            final goalTitle = goal.get('goalTitle');
            final goalWriter = goal.get('writer');
            final goalCompleted = goal.get('goalCompleted');
            final goalTime = goal.get('timestamp');

            final goalComb = GoalComb(
                goalTitle: goalTitle,
                goalWriter: goalWriter,
                goalCompleted: goalCompleted,
                goalStart: goalTime);
            goalDatas.add(goalComb);
          }
          return Expanded(
            child: ListView(
              padding:
                  const EdgeInsets.symmetric(horizontal: 10.0, vertical: 20.0),
              children: goalDatas,
            ),
          );
        }
      },
    );

 

내 코드는 firebase에 저장된 데이터를 QuerySnapshot 형태로 읽어와서 리스트에 저장한 다음 해당 리스트를 ListView 위젯을 통해 출력하는 방식이다.

분명 Firebase database에 저장된 document가 존재하는 것을 확인했고 에러도 발생하지 않았다. 따라서 데이터를 읽어오는 과정 혹은 읽어왔지만 화면에 표시하는 과정에서 문제가 생겼다고 판단하였다.

 

 

데이터를 올바르게 읽어왔는가?

처음에는 StreamBuilder의 사용법이 잘못되었다고 생각하여 Firebase document를 찾아보며 아래와 같이 Mapping을 하는 방식으로 재작성하였다.

 

body: StreamBuilder(
        stream: FirebaseFirestore.instance
            .collection('Goals')
            .orderBy('timestamp')
            .snapshots(includeMetadataChanges: true),
        builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
          if (snapshot.hasError) return Text('ERROR: ${snapshot.error}');
          if (snapshot.connectionState == ConnectionState.waiting) {
            print('Waiting...');
            return const Center(
              child: CircularProgressIndicator(
                backgroundColor: lightHoney,
              ),
            );
          }
          print('Connected!');
          List<DocumentSnapshot> documents = snapshot.data!.docs;
          print(documents.length);
          return ListView(
            children: documents.map((DocumentSnapshot document) {
              print(document['writer']);
              return ListTile(
                title: Text(document['goalTitle']),
              );
            }).toList(),
          );
        },
      ),
    );

 

그리고 print(documents.length)를 통해 snapshot의 데이터를 잘 가져왔는지 확인한 결과 0이 출력되는 것을 확인할 수 있었다. 즉, 데이터를 읽어오는 과정에서 문제가 있는 것을 확실히 알게 되었다.

이전에 만들었던 예제 프로그램과 차이를 분석한 결과 firebase의 document를 가져오는 방식에서 약간의 차이가 존재했다.

 

 body: StreamBuilder<DocumentSnapshot>(
        stream: FirebaseFirestore.instance.collection('Users').doc(currentUser.email).snapshots(),
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            final userData = snapshot.data!.data() as Map<String, dynamic>;
            print(userData.length);
            return ListView(
              children: [
                const SizedBox(
                  height: 50.0,
                ),
                Stack(
                  alignment: Alignment.center,
                  children: [
                    userData['imageLink'] != null
                        ? CircleAvatar(
                            radius: 80,
                            backgroundImage:
                                NetworkImage(userData['imageLink']),
                          )
                        : const CircleAvatar(
                            radius: 80,
                            backgroundImage: NetworkImage(
                                'https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png'),
                          ),
                          ...

 

해당 코드는 User Collection에서 데이터를 읽어오는 코드이다. 내가 작성한 코드와 차이로 QuerySnapshot이 아닌 DocumentSnapshot으로 가져왔으며 현재 유저의 email로 읽어올 document를 한정지었다.

 

데이터를 읽어오는 새로운 방법

 

이를 해결하기위해 다른 Firebase document를 참고하여 새로운 코드를 만들어보았다.

 

class _HoneycombListState extends State<HoneycombList> {
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;

  @override
  Widget build(BuildContext context) {
    List<GoalComb> goalList = [];
    final goals = _firestore.collection('Goals');
    final goal1 = <String, dynamic>{
      'goalTitle': 'Example',
      'writer': 'example wirter',
      'goalCompleted': false,
      'timestamp': Timestamp.now(),
    };
    goals.doc('tester').set(goal1);
    final goal2 = <String, dynamic>{
      'goalTitle': 'Example2',
      'writer': 'example wirter2',
      'goalCompleted': false,
      'timestamp': Timestamp.now(),
    };
    goals.doc('tester2').set(goal2);

    final ref = goals.doc('tester').withConverter(
        fromFirestore: GoalComb.fromFirestore,
        toFirestore: (GoalComb goalComb, _) => goalComb.toFirestore());
    final docRef = goals.doc("tester");

    goals.get().then(
      (querySnapshot) {
        print('Successfully completed');
        for (var docSnapshot in querySnapshot.docs) {
          print('${docSnapshot.id} => ${docSnapshot.data()}');
          final goal = GoalComb(
            goalTitle: docSnapshot.get('goalTitle'),
            goalWriter: docSnapshot.get('writer'),
            goalCompleted: docSnapshot.get('goalCompleted'),
            goalIndex: 1,
            goalStartTime: docSnapshot.get('timestamp'),
          );
          goalList.add(goal);
          print('goalListLength : ${goalList.length}');
        }
      },
      onError: (e) => print('Error completing: $e'),
    );
    print('goalListLength : ${goalList.length}');

    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (BuildContext context) => const PostScreen(),
            ),
          );
        },
        // onPressed: createNewObject,
        child: const Icon(Icons.add),
      ),
      body: ListView.builder(
        itemBuilder: (context, index) {
          return Text(goalList[index].goalTitle);
        },
        itemCount: goalList.length,
      ),
    );
  }
}

 

가장 처음에 작성했던 코드와 유사하게 데이터를 가져온 다음 리스트에 넣어 화면에 출력하는 방식을 취하고있다.

 

 

firebase document reference를 만드는 코드

    final ref = goals.doc('tester').withConverter(
        fromFirestore: GoalComb.fromFirestore,
        toFirestore: (GoalComb goalComb, _) => goalComb.toFirestore());
    final docRef = goals.doc("tester");

 

 

GoalComb 파일에 저장된 Convertion 메서드

factory GoalComb.fromFirestore(
    DocumentSnapshot<Map<String, dynamic>> snapshot,
    SnapshotOptions? options,
  ) {
    final data = snapshot.data();
    return GoalComb(
        goalTitle: data?['title'],
        goalWriter: data?['writer'],
        goalCompleted: data?['completed'],
        goalIndex: data?['index'],
        goalStartTime: data?['timestamp']);
  }
  Map<String, dynamic> toFirestore() {
    return {
      "title": goalTitle,
      "writer": goalWriter,
      "completed": goalCompleted,
      "index": goalIndex,
      "timestamp": goalStartTime,
    };
  }

 

GoalComb라는 custom object를 사용하기 때문에 convertion 메서드를 통해 document reference를 새로 만들어 주어야 한다.

 

이러한 과정을 거친 reference를 사용하면 custom object를 읽어올 수 있다.

 

이후 get.then() 함수를 통해서 각 문서의 데이터를 가져와 GoalComb 위젯을 만들어 리스트에 저장하였다.

 

 

그 결과 위와 같은 출력을 얻을 수 있었다. 데이터를 올바르게 읽어오고 리스트에도 정상적으로 삽입되었음을 확인하였다.

하지만 화면은 여전히 백지상태였다. 따라서 이번에는 읽어온 데이터를 올바르게 화면에 표시하는 방법을 고민해야 했다.

 

데이터를 화면에 출력하는 방법

body: ListView.builder(
        itemBuilder: (context, index) {
          return Text(goalList[index].goalTitle);
        },
        itemCount: goalList.length,
      ),

 

앞서 서술한 방법으로 만든 리스트를 ListView.builer() 메서드를 통해서 출력하려고 시도하였지만 실패하였다. 막막한 상황이었지만, 앞서 디버깅을 위해 goalList.length를 출력한 방법을 다시 한 번 사용해보았다. 그러자 문제점이 보이기 시작했다.

 

return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (BuildContext context) => const PostScreen(),
            ),
          );
        },
        // onPressed: createNewObject,
        child: Text('${goalList.length}'),
      ),
      body: ListView.builder(
        itemCount: goalList.length,
        itemBuilder: (context, index) {
          final goal = goalList[index];
          return ListTile(
            title: Text(goal.goalTitle),
            subtitle: Text(goal.goalWriter),
          );
        },
      ),
    );

 

Scaffold를 return하는 과정에서 goalList.length가 0으로 표시되었다. 즉, 리스트의 내용이 초기화 되어 화면에 출력할 데이터가 없었던 것이다.

 

이를 해결하기 위해 List goalList = []; 코드를 build 밖으로 꺼내 전역변수로 선언하였다.

 

 

그러자 마침내 데이터가 화면에 출력되었다!

추가로 발생한 문제점

Firebase로부터 데이터를 읽어와서 화면에 출력하는 것은 성공하였다. 하지만 새로운 문제점이 발생했다.

  1. 다른 화면으로 나갔다가 오면 출력이 초기화 된다
  2. 데이터를 읽어오기 위해서 HotReload를 실행해야 한다
  3. HotReload를 여러번 실행하면 데이터를 중복하여 읽어온다

다음 글에서는 세가지 문제점을 해결해보도록 하겠다.