Flutter – FutureBuilder / StreamBuilder

FutureBuilder

FutureBuilder 의 가장 큰 장점 중 하나는 캐싱이다. 최초 데이터를 불러오기 전에는 snapshot 의 data는 null 이지만, 그다음 build 부터는 이전에 가져온 데이터를 캐싱한다.

이 캐싱되는 기능을 잘 활용하면, 실제 데이터 로딩을 하고 있지만 유저에게는 로딩을 안하고 있는 것 처럼 보이게 할 수 있다. ex) 인스타그램 페이스북 등에서 실제 스크롤 다운하여 데이터를 로딩하고 있지만 전체가 로딩되지않고, 데이터가 불러올때까지 이전의 포스트들이 유지되는것

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:test_can_remove/main.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    const textStyle = TextStyle(
      fontSize: 16.0,
    );
    return Scaffold(
      /*
      * connectionState가 변경될때마다 builder 함수가 새로 호출된다. (화면에 State가 찍히는걸 보면 알 수 있음)
      * 따로 setState()를 호출하지 않아도 build가 다시 실행됨
      * */
      body: FutureBuilder(
        future: getNumber(),
        builder: (context, snapshot) {
          if (!snapshot.hasData) {
            /*
            *  FutureBuilder는 최초 시작 이후 build된 이전 값이 캐시되기 때문에 최초 한번만 로딩바가 실행됨
            *  예를 들어 페이스북에서 스크롤하여 새로운 값을 불러올때 전체가 재로딩되지 않음.
            *  기존 값들은 캐시 되어있고, 로딩바만 돌아가서 상대적으로 느려보이지 않게하고 데이터가 오는 순간에 화면에 있는 포스트를 바꿈
            * */
            return Center(
              child: CircularProgressIndicator(),
            );
          }
          // if (snapshot.connectionState == ConnectionState.waiting) {
          //   return Center(child: CircularProgressIndicator());// 굉장히 좋지 않음. 계속해서 indicator를 보여주면서 앱이 굉장히 느려보임
          // }
          return Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(
                'FutureBuilder',
                style: textStyle.copyWith(
                  fontWeight: FontWeight.w700,
                  fontSize: 20.0,
                ),
              ),
              Text(
                'ConState:${snapshot.connectionState}',
                style: textStyle,
              ),
              Text(
                'Data : ${snapshot.data}',
                style: textStyle,
              ),

              Text(
                'Error : ${snapshot.error}', // Future 에러가 리턴되었을때 노출, 아니면 null
                style: textStyle,
              ),
              ElevatedButton(
                onPressed: () {
                  setState(() {});
                },
                child: Text('setState'),
              ),
            ],
          );
        },
      ),
    );
  }

  Future<int> getNumber() async {
    await Future.delayed(Duration(seconds: 1));

    final random = Random();

    // throw Exception('error occured'); // ConnectionState는 에러가 던져져도 done

    return random.nextInt(100);
  }
}

일반적으론 FutureBulider에서는 3가지 케이스에 대해 에러 핸들링을 한다. (snapshot.hasData, snapshot.hasError

          if(snapshot.hasData){
            // 데이터가 있을때 위젯 렌더링
          }

          if(snapshot.hasError){
            // 에러가 났을때 위젯 렌더링
          }


StreamBuilder

StreamBuilder 도 동일하게 데이터 값이 캐싱이 되어 새로 build 함수를 실행해도 데이터 값이 null로 돌아가지 않고 마지막으로 가져왔던 데이터 값을 넣어준다. 그 다음 새로 들어온 데이터들을 렌더링한다.

StreamBuilder 는 Stream 을 닫는 작업까지 해주기 때문에 더 편하게 사용 가능.

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:test_can_remove/main.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    const textStyle = TextStyle(
      fontSize: 16.0,
    );
    return Scaffold(
      body: StreamBuilder<int>(
        // <> 제너릭에는 실제 snapshot 에 들어갈 수 있는 데이터 타입을 넣어줌(안넣어도 되긴함)
        stream: streamNumbers(),
        builder: (context, AsyncSnapshot<int> snapshot) {
          // 로딩중일때 렌더링
          return Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(
                'StreamBuilder',
                style: textStyle.copyWith(
                  fontWeight: FontWeight.w700,
                  fontSize: 20.0,
                ),
              ),
              Text(
                'ConState:${snapshot.connectionState}',
                style: textStyle,
              ),
              Text(
                'Data : ${snapshot.data}',
                style: textStyle,
              ),
              Text(
                'Error : ${snapshot.error}',
                style: textStyle,
              ),
              ElevatedButton(
                onPressed: () {
                  setState(() {});
                },
                child: Text('setState'),
              ),
            ],
          );
        },
      ),
    );
  }


  Stream<int> streamNumbers() async* {
    for (int i = 0; i < 10; i++) {
      if(i == 5){
        throw Exception('i=5'); // 에러 던지면 동일함
      }
      await Future.delayed(Duration(seconds: 1));
      yield i; // 1초마다 i 값 리턴 (active 상태 >> stream이 끝나지 않은 상태), 마지막 i 리턴되면 done으로 변경됨
    }
  }
}