Flutter Bloc & Cubit Tutorial(셀프 스터디)

힐링코더·2023년 8월 30일
0

Flutter for a code monkey

목록 보기
9/14

출처: https://resocoder.com/2020/08/04/flutter-bloc-cubit-tutorial/
이 포스트와 연관된 유튜브 영상의 게시일이 3년 전이다.
따라서 나는 아래 내용을 진지하게 '현재도 통용되는 살아있는 코드'로 생각하지 않으며 'bloc이 이런 느낌이구나'하는 감을 잡는 데에만 사용한다.

그렇다면 'dead code'일 수도 있는 내용을 왜 지금 참고하는가?
Reso Coder 홈페이지 내 bloc 포스트 중에서 가장 최신 내용이 이것이기 때문이다...


1) bloc이 뭔가요?

Bloc은 플러터에서 상태 관리에 관한 잘 알려진, 확립된 라이브러리입니다. 이는 불변성과 같은 좋은 관행을 지지하며, 그 주변에 구축된 지원 패키지와 문서 생태계 중에서도 최고 수준의 것을 갖추고 있습니다. 이 모든 장점에도 불구하고, 때때로 Bloc 패키지를 사용하는 것은 번거로운 경우가 있습니다. 그 원인은 다른 데서 찾기 어렵지 않은 '보일러플레이트' 때문입니다.

Bloc 패키지의 6.0.0 버전부터는 이러한 라이브러리를 보다 사용하기 쉽게 만들어 줍니다! 이 버전은 'Cubit'이라 불리는 Bloc의 경량 버전을 사용할 수 있도록 해주며, 많은 보일러플레이트를 제거합니다.

2) cubit은 bloc이랑 같은 건데 불필요한 보일러플레이트가 제거된, 경량 버전 맞나요?

네, 맞습니다. Cubit은 Bloc과 기능적으로 비슷한 상태 관리 패턴입니다. 하지만 Bloc에 비해 보일러플레이트(반복적이고 불필요한 코드)가 더 적은 경량 버전으로 간주될 수 있습니다.

Cubit은 간단한 상태 변화와 이벤트 처리를 위한 기능을 제공하면서, Bloc처럼 상태의 비동기적 관리를 가능하게 해줍니다. Bloc보다 더 단순한 상황에서는 Cubit을 사용하여 코드를 더 간결하게 작성할 수 있습니다.

Bloc 패키지의 6.0.0 버전부터는 Cubit을 도입하여 보일러플레이트를 줄이고 상태 관리를 보다 쉽게 할 수 있도록 지원합니다. 따라서 필요에 따라 Cubit이나 Bloc 중 어떤 것을 선택할지는 프로젝트의 복잡성과 요구사항에 따라 결정하면 됩니다.

참고: 본 포스트는 Reso Coder base에 ChatGPT의 부연 설명으로 이루어진다.

3) 앱을 만들어 봅시다

이 튜토리얼은 Cubit/Bloc를 처음부터 배우고 싶은 초보자와 이미 Bloc을 경험한 개발자 모두에게 적합합니다. 이전 버전에서 마이그레이션 가이드를 보고 싶다면 공식 Bloc 웹사이트가 최적의 정보 제공처입니다.

실제 예제를 통해 배우는 것이 가장 좋습니다. 그래서 Cubit을 사용하여 먼저 간단한 날씨 예보 앱을 만들어보고, 그 다음 Bloc을 사용하여 만들어보겠습니다 (둘 사이의 전환은 매우 간단합니다). 아래에서 스테이터 프로젝트를 가져와서 UI와 상태 관리와 관련 없는 모든 코드를 확인할 수 있습니다.

시작 버전
https://github.com/ResoCoder/flutter-bloc-cubit-tutorial/tree/d362a3d7785455d88a1bbc6bb710d5f47625feb7
끝낸 버전
https://github.com/ResoCoder/flutter-bloc-cubit-tutorial

이 앱은 주어진 도시에서 무작위로 생성된 온도를 표시하며, 이를 통해 데이터의 비동기적 가져오기를 보여줍니다. 우리는 또한 Future를 기다리는 동안 로딩 인디케이터를 표시하고, 예외가 발생할 때마다 오류 스낵바를 보여줄 것입니다.

이제 이미 구현된 클래스들을 간단히 살펴보겠습니다. 이 앱은 "Weather"라는 모델 클래스를 중심으로 구성되어 있습니다.

class Weather {
  final String cityName;
  final double temperatureCelsius;

  Weather({
    @required this.cityName,
    @required this.temperatureCelsius,
  });

  // == and hashCode overrides...
}

이 "Weather" 객체는 FakeWeatherRepository에서 가져온 무작위로 생성된 온도를 포함하고 있습니다. 또한 NetworkException이 발생할 가능성도 있습니다.

abstract class WeatherRepository {
  /// Throws [NetworkException].
  Future<Weather> fetchWeather(String cityName);
}

class FakeWeatherRepository implements WeatherRepository {
  @override
  Future<Weather> fetchWeather(String cityName) {
    // Simulate network delay
    return Future.delayed(
      Duration(seconds: 1),
      () {
        final random = Random();

        // Simulate some network exception
        if (random.nextBool()) {
          throw NetworkException();
        }

        // Return "fetched" weather
        return Weather(
          cityName: cityName,
          // Temperature between 20 and 35.99
          temperatureCelsius: 20 + random.nextInt(15) + random.nextDouble(),
        );
      },
    );
  }
}

class NetworkException implements Exception {}

프로젝트 설명 더 들어가기 전에 큐빗 간단 소개

플러터에서 상태를 관리하는 다양한 방법이 있지만, 본질적으로는 가변 또는 불변 상태를 가지는 것으로 요약됩니다. 우리는 아마도 StatefulWidget의 State 객체나 ChangeNotifier 내부에서 개별 필드를 변경하는 것으로 시작할 것입니다. 상태를 변경하는 메서드는 상태 자체와 동일한 객체 내부에 있습니다.

내가 뜬금 없이 큐빗 이야기를 하려는 게 아니고
블로그에서도 갑자기 이 지점에서 큐빗을 소개한다.

class MyChangeNotifier extends ChangeNotifier {
  int field1;
  String field2;

  void changeState() {
    field2 = 'New value';
    notifyListeners();
  }
}

이런 클래스가 있다고 치자.
얘를 큐빗으로 immutable하게 바꾸면 아래와 같다.

class MyState {
  final int field1;
  final String field2;

  MyState(this.field1, this.field2);

  // == and hashCode overrides...
}

class MyCubit extends Cubit<MyState> {
  MyCubit() : super(MyState(0, 'Initial value'));

  void changeState() {
    emit(MyState(0, 'New value'));
  }
}

개별 필드를 변경하는 대신 새로운 MyState 객체를 완전히 새로 발행합니다. 또한 상태는 상태를 변경하는 역할을 하는 클래스와 별도의 클래스에 있습니다.

Bloc과 Cubit은 새로 발행된 상태를 UI에 전달하기 위해 내부적으로 스트림을 사용합니다. 이는 더 구체적인 구현 세부사항입니다. 왜냐하면 고급 기능을 사용하지 않는 한 낮은 수준의 스트림 인터페이스를 그다지 많이 사용하지 않을 것입니다.


자... 그래서, 어쩌라고?

4) Cubit vs Bloc

만약 이전에 Bloc을 사용해 보셨다면 무언가 빠진 것을 알 수 있을 것입니다. 이러한 부분은 일반적인 메서드로 대체됩니다. Bloc에 GetWeather라는 이벤트를 추가하는 대신, Cubit에 getWeather()라는 메서드를 호출할 것입니다. 따라서 코드가 더 짧고 깔끔해지며 클래스를 관리해야 할 필요가 줄어들게 될 것입니다.

(역자)
bloc에서는 ui가 bloc에 events를 보낸다.
cubit에서는 ui가 cubit에 functions를 보낸다.
이 부분이 다르다.

이벤트의 부재는 큰 차이점을 만듭니다. 코드를 상당히 간소화하지만, 들어오는 이벤트(즉, 변경 사항)를 쉽게 추적하는 기능을 잃게 됩니다. 이벤트 소싱을 수행하려면 Cubit 대신에 Bloc을 선택하세요.

만약 Bloc 패키지에 아예 처음이라면 걱정하지 마세요. 이 튜토리얼에서는 나중에 "옛날 방식"의 Bloc도 다룰 예정입니다.


5) 큐빗으로 다시 돌아가서 앱을 만들어 보자

첫 번째 단계는 pubspec.yaml에 종속성을 추가하는 것입니다. Cubit과 Bloc은 상호운용 가능하며, 사실 Bloc 클래스는 Cubit을 확장합니다. 이는 bloc 및 flutter_bloc 라이브러리만 가져와도 Cubit이 무료로 번들로 제공된다는 것을 의미합니다.

dependencies:
  flutter:
    sdk: flutter
  bloc: ^6.0.1
  flutter_bloc: ^6.0.1

2023년 8월 30일, 오늘 bloc의 latest version은
flutter_bloc: ^8.1.3이다.

다음으로, WeatherCubit 및 WeatherState 클래스를 보유할 파일을 생성해야 합니다. 이러한 파일과 보일러플레이트를 수동으로 생성할 수 있지만, VS Code 및 IntelliJ/Android Studio에 편리한 확장 프로그램도 있습니다. 이 튜토리얼에서는 VS Code를 사용하겠습니다.

확장 프로그램을 설치한 후에 lib 폴더를 마우스 오른쪽 버튼으로 클릭하고 Cubit: New Cubit을 선택하세요. 이름을 "weather"로 지정하세요. 이제 탐색기에서 다음과 같이 나타나는 것을 볼 수 있을 것입니다:

WeatherState
일반적으로 상태 클래스를 먼저 구축하는 것이 좋습니다. 그들은 처음에 Cubit을 생성하려는 이유입니다, 맞죠? Cubit의 상태를 제대로 나타내지 않으면 새로운 상태를 발행하는 로직을 작성할 수 없습니다. 그렇다면 어떤 종류의 WeatherState가 필요할까요?

우리는 비동기적으로 하나의 리소스인 Weather 모델을 로드할 것입니다. 이러한 경우에는 상태를 WeatherState 추상 클래스의 여러 하위 클래스로 나타내는 것이 가장 좋습니다. 확장 프로그램에 의해 생성된 weather_state.dart 파일을 살펴보면 이미 하나의 하위 클래스가 생성된 것을 볼 수 있습니다:

part of 'weather_cubit.dart';

abstract class WeatherState {
  const WeatherState();
}

class WeatherInitial extends WeatherState {
  const WeatherInitial();
}

part of~는 왜 써?

part of 문은 하나 이상의 파일에 걸쳐 작성된 코드를 연결하고 라이브러리의 일부로 그들을 그룹화하기 위해 사용됩니다. 하나의 라이브러리에 여러 개의 파일이 포함되어 있을 때, 이러한 파일들은 같은 라이브러리 네임스페이스를 공유하며, part of 문을 사용하여 해당 라이브러리의 일부임을 명시적으로 지정합니다.

특히 Dart에서는 한 개의 파일 안에 하나의 클래스만을 정의하는 것이 강제되지 않으며, 여러 개의 파일에 걸쳐 클래스를 정의할 수 있습니다. 이때 part 키워드를 사용하여 클래스가 속한 라이브러리의 구성 요소임을 나타내고, part of 문을 사용하여 어떤 라이브러리의 일부임을 선언합니다. 이렇게 함으로써 클래스 및 함수들이 동일한 라이브러리의 일부로 취급되어 서로 참조할 수 있게 됩니다.

다시 돌아가서...

이 WeatherInitial 상태는 아직 사용자가 어떤 작업도 수행하지 않았음을 나타내며 초기 UI를 표시해야 함을 나타냅니다. 그렇다면 날씨를 비동기적으로 로드하기 위해 어떤 다른 상태 하위 클래스가 있어야 할까요? 응답을 기다리는 동안 진행 표시기를 표시하고, 그런 다음 성공 또는 가능한 오류를 처리할 수 있어야 합니다.

class WeatherLoading extends WeatherState {
  const WeatherLoading();
}

class WeatherLoaded extends WeatherState {
  final Weather weather;
  const WeatherLoaded(this.weather);

  @override
  bool operator ==(Object o) {
    if (identical(this, o)) return true;

    return o is WeatherLoaded && o.weather == weather;
  }

  @override
  int get hashCode => weather.hashCode;
}

class WeatherError extends WeatherState {
  final String message;
  const WeatherError(this.message);

  @override
  bool operator ==(Object o) {
    if (identical(this, o)) return true;

    return o is WeatherError && o.message == message;
  }

  @override
  int get hashCode => message.hashCode;
}

위의 코드는 오버라이딩된 참조적 동등성을 값 동등성으로 바꾸는 작업 때문에 길어 보일 수 있습니다. 이것은 Bloc 패키지에 관한 튜토리얼이기 때문에 추가 패키지를 사용하고 싶지 않습니다. 그러나 실제 프로젝트에서는 항상 코드가 더 짧고 안전한 freezed union을 사용합니다.

상태 클래스의 동등성을 항상 오버라이드하세요. Bloc은 연속으로 두 개의 동일한 상태를 발생시키지 않습니다.

WeatherCubit
상태에 대한 작업을 마쳤으므로 이제 WeatherRepository에서 날씨를 가져오고 상태를 발생시키는 로직을 수행할 WeatherCubit을 구현해보겠습니다. 일부 코드는 이미 확장 기능에 의해 생성되었습니다.

part 'weather_state.dart';

class WeatherCubit extends Cubit<WeatherState> {
  WeatherCubit() : super(WeatherInitial());
}

WeatherInitial을 super 생성자에 전달하면 놀랍게도 초기 상태가 됩니다. 이것은 사용자가 도시를 검색할 때까지 UI에서 작동할 것입니다.

이제 이 클래스에 WeatherRepository 의존성을 추가하고 getWeather 메서드를 구현하려고 합니다.

part 'weather_state.dart';

class WeatherCubit extends Cubit<WeatherState> {
  final WeatherRepository _weatherRepository;

  WeatherCubit(this._weatherRepository) : super(WeatherInitial());

  Future<void> getWeather(String cityName) async {
    try {
      emit(WeatherLoading());
      final weather = await _weatherRepository.fetchWeather(cityName);
      emit(WeatherLoaded(weather));
    } on NetworkException {
      emit(WeatherError("Couldn't fetch weather. Is the device online?"));
    }
  }
}

위의 코드는 매우 명확합니다. 우리는 Cubit의 emit 메서드를 사용하여 새로운 상태를 발생시킵니다.

User interface
먼저 위젯 트리에 WeatherCubit을 제공해야 합니다. starter 프로젝트에서는 WeatherSearchPage를 BlocProvider로 래핑하여 수행합니다.

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Material App',
      home: BlocProvider(
        create: (context) => WeatherCubit(FakeWeatherRepository()),
        child: WeatherSearchPage(),
      ),
    );
  }
}

Cubit에 대한 BlocProvider? 네! flutter_bloc 패키지의 모든 위젯은 이름에 "Bloc"이 포함되어 있으며, Bloc이 Cubit을 확장하기 때문에 두 가지 모두와 작동합니다.

'BlocProvider'는 Bloc 패키지에서 제공하는 위젯이다. 이름에만 프로바이더가 들어가는 거지 프로바이더와 관계 없다(아마도).

이 기사의 시작 부분에서 보신 동영상에서 볼 수 있듯이, 이 앱은 초기 및 오류 상태에서는 도시 검색 바만 표시하며, 로딩 상태에서는 진행 표시기를 보여주며, 마지막으로 로드된 상태에서는 온도와 도시 이름을 표시합니다. 또한 오류가 발생할 경우 SnackBar를 표시하려고 합니다.

모든 위젯은 이미 시작 프로젝트에 준비되어 있습니다. 단순히 이러한 위젯을 WeatherCubit에서 발생한 상태와 연결하여 BlocBuilder를 사용하여 해당 상태의 유형을 확인하고 상태에 맞게 위젯을 반환하면 됩니다.

class WeatherSearchPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Weather Search"),
      ),
      body: Container(
        padding: EdgeInsets.symmetric(vertical: 16),
        alignment: Alignment.center,
        child: BlocBuilder<WeatherCubit, WeatherState>(
          builder: (context, state) {
            if (state is WeatherInitial) {
              return buildInitialInput();
            } else if (state is WeatherLoading) {
              return buildLoading();
            } else if (state is WeatherLoaded) {
              return buildColumnWithData(state.weather);
            } else {
              // (state is WeatherError)
              return buildInitialInput();
            }
          },
        ),
      ),
    );
  }
  // more code here...
}

WeatherError가 발생할 때 SnackBar를 표시하거나 다른 부작용을 수행해야 하는 경우에는 위젯 빌드 중에 직접 수행해서는 안 됩니다. 이렇게 하면 잘 알려진 "setState() 또는 markNeedsBuild()가 빌드 중에 호출됨" 오류 메시지와 빨간 화면이 표시됩니다. 또한, 위젯은 여러 번 재구성될 수 있으므로 새로운 오류 상태가 발생하지 않았을 때에도 여러 개의 SnackBar가 표시될 수 있습니다.

이러한 문제를 해결하기 위해 BlocListener를 사용할 수 있습니다. 그러나 이미 BlocBuilder를 사용하고 있으며 불필요하게 추가적인 위젯과 중첩을 도입하고 싶지 않다면 BlocConsumer를 사용해보는 것이 좋습니다. BlocConsumer는 빌더와 리스너가 결합된 형태입니다.

BlocConsumer<WeatherCubit, WeatherState>(
  listener: (context, state) {
    if (state is WeatherError) {
      Scaffold.of(context).showSnackBar(
        SnackBar(
          content: Text(state.message),
        ),
      );
    }
  },
  builder: (context, state) {
    // Previously written code here...
  },
),

뭔가 빠뜨린 것 같지 않나요? 맞아요! 아직 getWeather 메서드를 호출하지 않았죠. 이제 CityInputField 위젯에서부터 weather_search_page.dart 파일까지 호출해보겠습니다.

class CityInputField extends StatelessWidget {
  // Code here...

  void submitCityName(BuildContext context, String cityName) {
    final weatherCubit = context.bloc<WeatherCubit>();
    weatherCubit.getWeather(cityName);
  }
}

우리는 BuildContext 클래스에서 새로운 멋진 bloc 확장을 사용하고 있지만, BlocProvider.of< WeatherCubit >(context)와 같은 전통적인 방법을 사용할 수도 있습니다. <- <> 속 빈칸 없애야 함, 마크다운 문법이 적용돼서 저렇게 띄워 높음

Switching to a Bloc
아... 이제야 큐빗 끝내고 블록 하는 건가...

이제 앱은 Cubit을 사용하여 완전히 완성되었습니다. 기능을 변경하지 않고 기능을 그대로 유지한 채로 Bloc으로 전환해 보겠습니다. 이 튜토리얼에서는 두 구현을 모두 남겨서 쉽게 비교할 수 있도록 하겠습니다.

이전과 마찬가지로 lib 폴더를 마우스 오른쪽 버튼으로 클릭한 다음 Bloc: New Bloc을 선택하세요. "weather"라는 이름을 지정하세요. 이제 탐색기에서 다음과 같이 보일 것입니다:

상태(State)는 Cubit 구현과 동일하게 유지될 것이므로 이전에 작성한 코드를 새로운 weather_state.dart 파일에 복사하여 붙여넣을 수 있습니다.

다음으로 이벤트를 살펴보겠습니다. 이미 WeatherBloc이 수행해야 할 작업을 알고 있습니다. WeatherCubit의 경우 getWeather 메서드가 있었습니다. 이제 GetWeather 이벤트 클래스를 생성하겠습니다.

part of 'weather_bloc.dart';

@immutable
abstract class WeatherEvent {}

class GetWeather extends WeatherEvent {
  final String cityName;

  GetWeather(this.cityName);
}

맞습니다. 이는 추가적인 보일러플레이트이며, 아직 WeatherBloc 구현에 대해 다루지 않았습니다! 그러나 앞서 말한대로 이벤트는 추적하려는 경우나 RxDart의 debounceTime 연산자를 사용하여 이벤트를 변환하려는 경우에 유용합니다.

WeatherBloc의 구현은 WeatherCubit의 구현과 매우 유사하지만, getWeather 메서드를 직접 가지는 대신에 GetWeather 이벤트를 mapEventToState 비동기 제너레이터 메서드 내에서 처리해야 합니다.

part 'weather_event.dart';
part 'weather_state.dart';

class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
  final WeatherRepository _weatherRepository;

  WeatherBloc(this._weatherRepository) : super(WeatherInitial());

  @override
  Stream<WeatherState> mapEventToState(
    WeatherEvent event,
  ) async* {
    if (event is GetWeather) {
      try {
        yield WeatherLoading();
        final weather = await _weatherRepository.fetchWeather(event.cityName);
        yield WeatherLoaded(weather);
      } on NetworkException {
        yield WeatherError("Couldn't fetch weather. Is the device online?");
      }
    }
  }
}

기본적으로, Cubit은 상태의 Stream을 emit 메서드 뒤에 숨깁니다. Bloc에서는 이벤트를 사용할 때 yield 키워드를 사용하며, 이것은 Dart에 내장된 기능입니다.

Bloc UI
UI는 어떤가요? 변경 사항은 최소화될 것입니다. 물론 main.dart에서 WeatherBloc을 제공해야 합니다.

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Material App',
      home: BlocProvider(
        create: (context) => WeatherBloc(FakeWeatherRepository()),
        child: WeatherSearchPage(),
      ),
    );
  }
}

WeatherSearchPage 내에서 weather_cubit.dart를 import한 부분을 삭제하여 변경이 필요한 모든 위치를 확인하세요. WeatherCubit의 모든 출현을 WeatherBloc으로 대체하고 (두 번 있습니다) 그리고 마지막 문제가 발생합니다. 메서드를 호출하는 대신 블록에 이벤트를 추가해야 합니다.

void submitCityName(BuildContext context, String cityName) {
  final weatherBloc = context.bloc<WeatherBloc>();
  weatherBloc.add(GetWeather(cityName));
}

이걸로 끝! 바람직한 선택을 통해 동일한 앱을 두 번째로 구현하였습니다. 이번에는 Cubit 대신 Bloc을 사용했습니다. 더 단순한 사용법을 갖는 Cubit은 항상 올바른 선택이 아닐 수 있습니다. 이러한 지식을 바탕으로 상태 관리가 매우 쉬워질 것입니다. 😎

큐빗과 블록 찍먹 끝.
대충 느낌은 알았다.

profile
여기는 일상 블로그, 기술 블로그는 https://yourhealingcoder.tistory.com/

0개의 댓글