관리 메뉴

Bull

[Flutter::State] BloC 따라 연습하기 with kodeco 본문

Software Framework/Flutter

[Flutter::State] BloC 따라 연습하기 with kodeco

Bull_ 2024. 8. 6. 18:59

https://www.kodeco.com/32962047-bloc-8-0-tutorial-for-flutter-getting-started

 

Bloc 8.0 Tutorial for Flutter: Getting Started

Learn how to build a Wordle clone app in Flutter using one of the most robust state management libraries: Bloc 8.0.

www.kodeco.com

 

최근 BLoC이 필요해서 연습했지만 아직 부족함을 느끼기에 kodeco의 강의를 따라 진행해보았습니다. 강의는 기본적으로 완성된 코드에서 필요한 부분을 없애 빈칸채우기 형식으로 함과 단계별로 동작을 강화하는 형식으로 진행됩니다.

소스 코드는 해당 강의 사이트에서 다운로드 받을 수 있습니다. 

서론

강의는 첫 시작은 Plingo 라고 적혀진 게임에서 어떤 기능이 없어서 Bloc을 통해 구현해보고자 합니다. Plingo가 어떤 게임인지 정의는 찾지 못하였으나 이와 유사한 Wordle 이라는 게임을 찾았습니다. 이 게임을 모티브로 만든 것 같습니다.

위키백과

워들(Wordle)은 웨일스의 소프트웨어 엔지니어 조시 워들(Josh Wardle)이 개발한 영어 단어 맞추기 게임이다. 게임 이름은 '단어'를 뜻하는 Word와 개발자 조시의 성 Wardle을 섞어 지은 것이다.
2021년 11월 1월, 플레이어가 90명으로 집계되었다. 이후 12월 말부터 각종 SNS상에서 바이럴되며 인기를 얻어 2022년 1월 2일 기준 플레이어 30만 명 이상으로 집계되었다.2022년 1월, 뉴욕 타임스 컴퍼니에 의해 인수되었다.

Wordle

참고로 해당 강의는 22년 버전으로 몇몇 Theme의 Style 프로퍼티가 호환되지 않습니다. 빨간 줄이 생기는 부분에 어떤 프로퍼티로 대체됐다고 나오겠지만 primary -> backgroudColor, bodyText1 -> bodyLarge 이런식으로 바꾸면 해결됩니다. 자세한 설명은 생략하겠습니다.

Getting Started

맨 처음 실행화면입니다. 아무것도 적용하지 않은 상태이므로 키를 눌러도 동작하지 않습니다.

Handling Game State Changes

BloC을 사용하여 상태 관리를 구현합니다. 이번 챕터에서는 다음 세 가지의 이벤트가 존재합니다.

LetterKeyPressed: 사용자가 화면상 키보드인 PlingoKeyboard에서 키를 누르면 수행되는 이벤트입니다.
GameStarted: 게임에서 이기거나 질 때 앱을 열거나 Play Again 을 눌러 새로운 게임이 시작될 때 트리거되는 이벤트입니다.
GameFinished: 이 이벤트는 플레이어가 정확한 단어를 추측하여 승리하거나 최대 추측 시도에 도달하여 게임에서 패배할 때 발생합니다.

letter은 편지라고 해석할 수 있으나 단어라고 해석해도 괜찮습니다.

// filename: lib/presentation/bloc/game_bloc.dart

GameBloc(this._statsRepository)
    : super(GameState(
        guesses: emptyGuesses(),
      )) {
  on<GameStarted>(_onGameStarted);
}

on 메소드는 특정 이벤트가 발생했을 때 이를 처리하기 위한 함수(핸들러)를 등록하는 역할을 합니다. 특정 이벤트에 대한 반응으로 무엇을 해야 할지를 정의하는 부분입니다. Bloc에서 on을 통해 핸들러를 등록할 때는 생성자에서 정의합니다. 앞으로도 보면 익숙해지겠지만 on<GameStarted>(_onGameStarted); 부분이 연속적으로 드러남을 보실 수 있습니다. 이렇게 하면 구조의 일관성과 명확한 논리적 처리가 가능합니다.

 

GameBloc은 레파지토리에 접근하기 위해 this._statsRepository 를 등록합니다.


super(GameState(guesses: emptyGuesses(),)) 생성자를 통해 GameState의 초기 guesses 값을 빈 guesses로 초기화합니다. 여기서 geusses는 화면에 보이는 5x5 행렬 단어 칸입니다.

// filename: lib/presentation/bloc/game_bloc.dart

void _onGameStarted(
  GameStarted event,
  Emitter<GameState> emit,
) {
  print('Game has started!');
  final puzzle = nextPuzzle(puzzles);
  final guesses = emptyGuesses();
  emit(GameState(
    guesses: guesses,
    puzzle: puzzle,
  ));
}

on 메소드로 들어간 콜백함수는 eventemit을 인자로 받아야합니다. 게임이 시작되면 nextPuzzle 메소드를 통해 puzzle이 정해지는데 이 메소드는 domain.dart 파일에 puzzles 리스트를 넣어 섞어서 하나를 반환해줍니다.

emptyGuesses 메소드를 통해서 다시 guesses를 빈 칸으로 초기화해줍니다.

 

이제 emit을 통해서 GameState Bloc을 재설정해줍니다.

// filename: lib/app/app.dart

BlocProvider(
  create: (ctx) =>
      // TODO: Start game here!
      GameBloc(ctx.read<GameStatsRepository>())..add(GameStarted()),
  child: const GamePage(),
),

BlocProvider를 통해서 자식 위젯에 bloc을 전달합니다. 앱이 시작될 때 호출되므로 이제 앱은 시작될 때 마다 bloc을 관리할 수 있게 됩니다.

 

캐스케이드 연산자를 통해서 _onGameStarted 메소드를 실행해줍니다.

// filename: lib/presentation/pages/game_page.dart

onPressed: () => context.read<GameBloc>().add(const GameStarted()),

context 를 통해 게임을 다시 시작할 때도 GameBloc의 State도 재설정할 수 있습니다. 다시 플레이는 아직 나오도록 구현되지 않았으나 이후에 버튼을 누르면 다시 시작을 하여 guesses를 빈 칸으로 나타낼 수 있습니다.

Recognizing Key Presses

// filename: lib/presentation/bloc/game_bloc.dart
GameBloc(this._statsRepository)
    : super(GameState(
        guesses: emptyGuesses(),
      )) {
  on<GameStarted>(_onGameStarted);
  on<LetterKeyPressed>(_onLetterKeyPressed);
}

on 메소드를 통해 이벤트를 등록했습니다. 여기서 on에 대한 감이 잡히실 겁니다. LetterKeyPressed는 알파벳 입력에 대한 이벤트입니다.

// filename: lib/presentation/bloc/game_bloc.dart

Future<void> _onLetterKeyPressed(
  LetterKeyPressed event,
  Emitter<GameState> emit,
) async {
  final guesses = addLetterToGuesses(state.guesses, event.letter);

  emit(state.copyWith(
    guesses: guesses,
  ));
}

콜백은 eventemit을 인자로 받습니다. addLetterToGuesses를 통해서 현재 guesses 칸의 정보와 event로 입력된 알파벳을 전달합니다. addLetterToGuesses은 guesses 에서 빈칸을 발견하면 그곳에 event로 입력된 알바펫을 넣은 후 다시 guesses를 반환합니다.

 

emit 메소드를 통해 현재 state를 copyWith를 통해서 guesses만 등록하고 재설정해줍니다. 그리고 이 이벤트는 provider의 도우미인 context.watch()를 통해서 UI가 업데이트됩니다. 부가적인 설명을 더하자면 context.watch() 는 game_page에 이미 작성되있고 copyWith를 통해서 GameState의 status 값이 업데이트됩니다. 이를 통해 state.status가 업데이트되기 때문에 UI를 업데이트할 수 있는 겁니다.

// filename: lib/presentation/widgets/plingo_key.dart
onTap: () => context.read<GameBloc>().add(LetterKeyPressed(letter)),

이제 키를 눌렀을 때 화면에 누를 키값이 업데이트 되도록 해줍니다. 이 때 letter은 PlingoKeyboard 에서 전달된 알파벳 값을 통해서 인자로 받습니다.

Transforming Events

// filename: lib/presentation/bloc/game_bloc.dart

Future<void> _onLetterKeyPressed(
  LetterKeyPressed event,
  Emitter<GameState> emit,
) async {
  final guesses = addLetterToGuesses(state.guesses, event.letter);

  final randGenerator = Random();
  final shouldHold = randGenerator.nextBool();
  await Future.delayed(Duration(seconds: shouldHold ? 2 : 0), () {
    emit(state.copyWith(
      guesses: guesses,
    ));
  });

  // TODO: check if the game ended.
}

키 입력에 대한 이벤트 전송을 눈에 띄에 하기 위해서 랜덤한 확률로 2초 지연을 두는 작업을 하였습니다. 이를 확인하는 이유는 이벤트가 너무 빠르게 전송되면 순차적으로 올바르게 처리가 되지 않는 버그가 발생하기 때문입니다. 확인을 위해 import 'dart:math'; 패키지를 임포트해야 합니다. 계속해서 보겠습니다.

bloc은 이벤트를 순차적으로 처리하는 대신 동시에 처리합니다. 즉, 이벤트가 완료되는 데 너무 오래 걸리면 다른 이벤트가 변경 사항을 재정의할 수 있어 앱에서와 같은 예기치 않은 동작이 발생할 수 있습니다.

 

일반적으로 자체 트랜스포머 함수를 정의하는 것은 유지 관리가 어려울 수 있으므로 필요한 경우에만 수행해야 합니다. 다행히도 bloc_concurrency라는 동반 라이브러리에는 원하는 방식으로 이벤트를 처리할 수 있는 의견형 트랜스포머 함수 집합이 포함되어 있습니다. 다음은 bloc_concurrency에 포함된 함수의 일부 목록입니다.

  • concurrent(): 이벤트를 동시에 처리합니다.
  • sequential(): 이벤트를 순차적으로 처리합니다.
  • droppable(): 이벤트가 처리되는 동안 추가된 모든 이벤트를 무시합니다.
  • restartable(): 최신 이벤트만 처리하고 이전 이벤트 핸들러를 취소합니다.
// filename: lib/presentation/bloc/game_bloc.dart

import 'package:bloc_concurrency/bloc_concurrency.dart';

on<LetterKeyPressed>(_onLetterKeyPressed, transformer: sequential());

우리는 이벤트를 순차처리 해야하므로 on 메소드 정의에 transformer 프로퍼티로 sequential()를 등록합니다.

이제 이벤트는 추가한 순서대로 처리되지만 일부 이벤트는 이전 이벤트보다 더 오래 걸립니다. 이제 GameBloc이 처리하는 마지막 이벤트 추가를 마칠 차례입니다. GameFinished에 대한 이벤트 핸들러를 추가합니다.

// filename: lib/presentation/bloc/game_bloc.dart

on<GameFinished>(_onGameFinished);

이어서 _onGameFinished 메소드를 정의해줍니다.

// filename: lib/presentation/bloc/game_bloc.dart

// 1
Future<void> _onGameFinished(
  GameFinished event,
  Emitter<GameState> emit,
) async {
  // 2
  await _statsRepository.addGameFinished(hasWon: event.hasWon);
  // 3
  emit(state.copyWith(
    status: event.hasWon ? GameStatus.success : GameStatus.failure,
  ));
}
  1. 블록이 이 반환 유형을 지원하므로 _onGameFinished의 반환 값으로 Future를 사용하고 있습니다.
  2. 그런 다음 _statsRepository.addGameFinished가 로컬 스토리지와 상호작용하여 다양한 통계를 업데이트합니다.
  3. 해당 게임 결과(승리 시 성공 또는 패배 시 실패)가 포함된 새 상태를 출력합니다. 마지막 단계로 _onLetterKeyPressed를 다음 코드에서 대체합니다.

마지막 단계로 _onLetterKeyPressed 를 바꿔줍니다.

// filename: lib/presentation/bloc/game_bloc.dart

Future<void> _onLetterKeyPressed(
  LetterKeyPressed event,
  Emitter<GameState> emit,
) async {
  final puzzle = state.puzzle;
  final guesses = addLetterToGuesses(state.guesses, event.letter);

  // 1
  emit(state.copyWith(
    guesses: guesses,
  ));

  // 2
  final words = guesses
      .map((guess) => guess.join())
      .where((word) => word.isNotEmpty)
      .toList();

  final hasWon = words.contains(puzzle);
  final hasMaxAttempts = words.length == kMaxGuesses &&
      words.every((word) => word.length == kWordLength);
  if (hasWon || hasMaxAttempts) {
    add(GameFinished(hasWon: hasWon));
  }
}

더 이상 이전처럼 Future로 감싸지 않습니다.(Delayed 삭제)

  1. geusses가 업데이트된 후 게임이 종료되었는지 확인합니다.
  2. 사용자가 단어 퍼즐(Plingo의 승리 조건)을 맞혔는지 또는 사용자가 올바른 단어를 맞히지 않고 최대 시도 횟수에 도달했는지 확인하여 이를 수행합니다. 게임이 두 조건 중 하나를 충족하면 새 게임 완료 이벤트를 추가합니다. 앱을 다시 빌드하고 실행하여 게임에서 승리합니다. 패배한 경우 다시 플레이를 탭하여 다시 시도하세요. 이제 퍼즐을 맞추면 승리 메시지가 표시되고, 제대로 맞추지 못하고 최대 시도 횟수에 도달하면 패배 메시지가 표시됩니다.

Adding a New Cubit

Plingo 구축이 거의 완료되었지만 아직 몇 가지 사항이 누락되었습니다. StatsDialog를 열 때 표시되는 음수를 기억하시나요? 이 문제는 다음에 수정하겠습니다.

// filename: lib/presentation/cubit/stats_cubit.dart

/// Fetches the current stats of the game.
Future<void> fetchStats() async {
  final stats = await _statsRepository.fetchStats();

  emit(state.copyWith(stats: stats));
}

/// Resets the stats stored.
Future<void> resetStats() async {
  await _statsRepository.resetStats();

  await fetchStats();
}

이 메소드는 StatsCubit이 상태를 변경하는 방식입니다. 먼저 fetchStats는 로컬 저장소에서 statistics를 가져와서 업데이트된 통계로 변경 사항을 내보냅니다. 다음으로, resetStats는 로컬에 저장된 statistics를 초기화한 다음 통계를 가져와 상태를 업데이트합니다.

 

이제 StatsDialog에서 해당 함수를 호출해야 합니다.

// filename: lib/presentation/widgets/plingo_appbar.dart

IconButton(
  onPressed: () => showDialog<void>(
    context: context,
    builder: (dContext) => BlocProvider(
      create: (bContext) => StatsCubit(
        context.read<GameStatsRepository>(),
      )..fetchStats(),
      child: const GameStatsDialog(),
    ),
  ),
  icon: const Icon(Icons.leaderboard_rounded),
)

이어서 다이얼로그에서 Reset 버튼을 누를 때 resetStats 메소드도 구현해주겠습니다.

// filename: lib/presentation/dialogs/stats_dialog.dart

onPressed: () => context.read<StatsCubit>().resetStats(),

Monitoring a Bloc

이제 게임이 작동하기 시작했으니 앱 모니터링에 대해 생각해봐야 합니다. 이를 위한 좋은 방법은 앱 전체의 다양한 상태 변화에 주의를 기울이는 것입니다. 블록은 새로운 블록 오버라이드 API를 통해 이를 수행할 수 있는 좋은 방법을 제공합니다. 이를 통해 앱의 여러 부분으로 범위를 지정하여 여러 BlocObserver 또는 EventTransformer를 구현할 수 있으므로 특정 기능 또는 전체 앱의 변경 사항을 추적할 수 있습니다.

// filename: lib/monitoring/bloc_monitor.dart 

import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart' as foundation;

/// [BlocObserver] for the application which
/// observes all state changes.
class BlocMonitor extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    foundation.debugPrint('${bloc.runtimeType} $change');
  }
}

BlocMonitoronChange를 재정의하는 사용자 정의BlocObserver입니다. 이를 통해 모든 다양한 상태 변경 사항을 추적하고 debugPrint를 통해 콘솔에 인쇄할 수 있습니다. 파운데이션에서 이 함수를 사용하면 앱이 디버그 모드로 실행된 경우에만 콘솔에 인쇄할 수 있으며 나중에 flutter logs 명령을 통해 상태 변경 사항을 사용할 수도 있습니다.

다양한 Bloc Hook도 추적할 수 있습니다.

  • onCreate: Bloc을 인스턴스화할 때마다 호출됩니다. 종종 큐빗은 느리게 인스턴스화될 수 있으며 onCreate는 큐빗 인스턴스가 생성되는 시기를 정확히 관찰할 수 있습니다.
  • onEvent: Bloc에 이벤트를 추가할 때마다 발생합니다.
  • onChange: 모든 Bloc에서 새 상태를 내보낼 때마다 호출됩니다. onChange는 블록의 상태가 업데이트되기 전에 호출됩니다.
  • onTransition: 모든 블록에서 전환이 발생할 때마다 발생합니다. 새 이벤트를 추가한 다음 해당 EventHandler에서 새 상태를 내보낼 때 전환이 발생합니다. onTransition은 Bloc의 상태가 업데이트되기 전에 호출됩니다.
  • onError: Bloc 또는 Cubit에서 에러를 발생시킬 때마다 발생합니다.
  • onClose: 블록이 닫힐 때마다 호출됩니다. Bloc이 닫히기 전에 호출되며 특정 인스턴스가 더 이상 새 상태를 생성하지 않음을 나타냅니다.
// filename: main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'app/app.dart';
import 'data.dart';
import 'monitoring/bloc_monitor.dart';

void main() {
  BlocOverrides.runZoned(
    () => runApp(
      RepositoryProvider(
        create: (context) => GameStatsRepository(GameStatsSharedPrefProvider()),
        child: const PlingoApp(),
      ),
    ),
    blocObserver: BlocMonitor(),
  );
}

이를 통해 새로운 BlocMonitor를 생성하고 영역 재정의에서 실행되는 모든 블록을 추적합니다. 이는 GameBlocStatsCubit이 모두 BlocMonitor에 변경 사항을 보고한다는 것을 의미합니다.