Bloc 으로 timer 구현하기

순순·2024년 11월 26일

Flutter

목록 보기
12/16

공식 사이트 참고. bloc 을 통해 timer 를 구현해보자.

timer 는 시간이 계속 흐르고 있다. 그리고 남은 시간이 보여져야 한다. 그래서 Stream 을 사용해 흐르는 시간을 방출한다.


ticker.dart 생성


class Ticker {
  const Ticker();
  Stream<int> tick({required int ticks}) {
    return Stream.periodic(const Duration(seconds: 1), (x) => ticks - x - 1)
        .take(ticks);
  }
}

timer_state.dart


bloc 은 ‘상태관리’ 라이브러리이다. 따라서 관리해야하는 상태엔 어떤 게 있는지 파악하는 것이 중요하다. 타이머에는 어떤 상태들이 있을까? 예제에서 보여준 바로는 다음과 같다.

  • 타이머가 시작 전인 상태 (초기화 상태)
  • 진행 중인 상태
  • 일시 중지된 상태
  • 끝난 상태 (완료된 상태)

타이머의 상태에 따라 사용자가 할 수 있는 행동도 달라진다. 시작 전이라면 시작할 수 있고, 진행중이라면 일시중지 또는 완료가 가능하다. 사용자의 행동에 따라 상태가 업데이트 된다. 상태가 업데이트 된다는 것은 데이터 등에 변동 사항이 있고 그 변동 사항을 UI에 반영해야 한다는 뜻이다.

아래와 같이 TimerState 클래스를 만들어 줬다. Equtable 클래스를 확장한 형태다. Equtable 을 쓰면 props 에 담긴 변수 값이 바뀌었을 때만 앱 재구축을 트리거한다. 이로 인해 불필요하게 위젯이 재빌드 되는 경우를 방지할 수 있다. 값이 같으면 상태가 같다고 간주하는 것이다. (동등성)

그리고 주생성자로 duration 을 선언했다. 주생성자는 TimerState 또는 그 하위 클래스 생성 시 필수로 전달해야 하는 값이며, 이 값은 클래스 생성시 초기화 된다.

상태관리할 변수를 주생성자로 선언.

sealed class TimerState extends Equatable {
  // 주생성자: TimerState 생성 시 필수로 duration 값을 전달해야 함
  const TimerState(this.duration);
  
  // final 필드: 초기화 이후 값 변경 불가능
  final int duration;
  
  
  // duration 값이 변할 때만 앱 재구축
  
  List<Object?> get props => [duration];
}

그리고 아까 위에서 정의한 4가지 상태를 TimerState 의 하위 클래스로 정의한다.


final class TimerInitial extends TimerState {
  const TimerInitial(super.duration);

  
  String toString() => 'TimerInitial { duration: $duration }';
}

final class TimerRunPause extends TimerState {
  const TimerRunPause(super.duration);

  
  String toString() => 'TimerRunPause { duration: $duration }';
}

final class TimerRunInProgress extends TimerState {
  const TimerRunInProgress(super.duration);

  
  String toString() => 'TimerRunInProgress { duration: $duration }';
}

final class TimerRunComplete extends TimerState {
  const TimerRunComplete() : super(0);
}

왜 toString 을 오버라이드할까? 했더니 다음과 같은 이유가 있었다.

print로 값을 찍어볼 때, 부모 클래스의 toString()이 없다면 기본적으로 Instance of 'TimerRunComplete'와 같은 형식으로 출력된다고 한다. 그럼 어떤 값이 들어갔는지 알 수 없기 때문에… 디버깅이나 로깅을 위해 toString 메서드를 사용해주는 것이었다.

timer_event.dart


이벤트는 블록에게 “상태를 변경하라고 알리는 역할”이다. 예를 들어 사용자가 시작 버튼을 눌렀을 때, 타이머가 시작 상태로 변경되어야 한다고 블록에게 알린다거나 중단 버튼을 눌렀을 때, 일시중지 상태로 변경해야 된다고 알리는 것이다.

단순히 이벤트 발생만을 알리는 경우엔 변수가 필요없지만, 데이터를 함께 전달해야 하는 경우 아래처럼 주생성자로 변수를 받는다.

예시로 0초부터 시작할지 60초부터 시작할지? TimerStarted 에서 전달하는 변수로 초기값을 정해줄 수 있는 것이다.

초기값 설정 외에도 블록이 상태를 업데이트할 때 필요한 데이터를 전달하기 위해 생성자를 사용한다.

나는 왜 상태 관리할 변수를 state 에도 선언하고 event 에도 선언하는지 헷갈렸는데, 쉽게 state.dart 파일의 변수에 담겨있는건 현재 상태 이고, event.dart 의 변수로 넘겨주는건 앞으로 바뀔 상태라고 이해하니 구분하기 쉬웠다.

sealed class TimerEvent {
  const TimerEvent();
}

final class TimerStarted extends TimerEvent {
  const TimerStarted({required this.duration});
  final int duration;
}

final class TimerPaused extends TimerEvent {
  const TimerPaused();
}

final class TimerResumed extends TimerEvent {
  const TimerResumed();
}

class TimerReset extends TimerEvent {
  const TimerReset();
}

class _TimerTicked extends TimerEvent {
  const _TimerTicked({required this.duration});
  final int duration;
}

timer_bloc.dart


이제 위에서 만든 state 와 event 를 관리할 bloc 을 생성해준다.

  • static

여기서는 변수를 static 으로 선언했길래 왜 그렇게 했는지 찾아보았다.

static으로 선언된 변수나 메서드는 클래스의 인스턴스가 아닌 클래스 자체에 속한다고 한다. 즉, 클래스의 인스턴스가 여러 개 생성되더라도 해당 static 변수나 메서드는 모든 인스턴스에서 동일한 값을 공유한다.

따라서, static const int _duration = 60; 라는 코드는 TimerBloc 클래스의 모든 인스턴스에서 동일한 duration 값(60)을 사용하도록 하기 위해 static으로 선언한 것이다.

쉽게 말해 TimerBloc을 여러 번 생성하거나 TimerInitial을 여러 곳에서 호출하더라도, TimerBloc 클래스 내에서 _duration의 값을 60으로 고정시킨다는 뜻이다. 이렇게 하면 타이머를 몇개를 생성하든 시작시간을 일관성있게 유지할 수 있다.

만약 타이머마다 초기 시작시간이 달라야 한다면 static 키워드를 사용하지 않고 각각 인스턴스를 생성해 관리해야 한다.

class TimerBloc extends Bloc<TimerEvent, TimerState> {
	static const int _duration 60;
	TimerBloc(): super(const TimerInitial(_duration)) {

	}
}
  • ticker

그리고 ticker 를 생성자로 받고, 스트림 데이터를 처리하는 객체를 만든다. TimerStarted 이벤트 수신 시 TimerRunInProgress 상태로 emit 한다.

아까 처음에 만들었던 ticker 클래스를 이용해서 stream 데이터 처리. 그냥 stream 구독, 구독취소 해서 흐르는 시간 체크한다고 생각하면 된다.

사실 여기서 required 에서 this 를 통한 멤버변수를 쓰지 않고 이렇게 초기화하는 건 처음봐서 조금 헤맸다. 외부에서 _ticker에 직접 접근하지 못하도록 캡슐화한 것인데, 마치 setter 처럼 ticker 를 썼다고 보면 된다. 외부에서 _ticker 를 직접 접근할 순 없지만 ticker를 이용해 초기값을 할당해준다. 코드는 아래 참고

class TimerBloc extends Bloc<TimerEvent, TimerState> {
  final Ticker _ticker;
  static const int _duration = 60;

	// 스트림의 데이터나 이벤트를 받아서 처리하는 객체
	// listen: 스트림을 구독하고, 스트림에서 발생하는 데이터에 대해 처리할 콜백을 지정
	// cancel: 스트림의 구독을 취소하여 더 이상 데이터를 받지 않도록 한다.
  StreamSubscription<int>? _tickerSubscription;

  TimerBloc({required Ticker ticker})
      : _ticker = ticker,
        super(TimerInitial(_duration)) {
    // TODO: implement event handlers
  }
}

bloc event handler 구현


아래 코드를 보면, 생성자에서 on<TimerStarted>(_onStarted); 메서드를 넘겨주고 있다. Bloc 객체 생성 시 on<TimerStarted>(_onStarted) 가 호출되며, 이벤트 핸들러가 등록되는 것이다. 뷰가 초기화 될 때 리스너 등록해주는 것과 비슷하다.

  • TimerStarted, TimerPaused, TimerResumed 등의 이벤트 발생시 어떤 행동을 실행할 지 정의해주면 된다.
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:flutter_timer/ticker.dart';

part 'timer_event.dart';
part 'timer_state.dart';

class TimerBloc extends Bloc<TimerEvent, TimerState> {
  final Ticker _ticker;
  static const int _duration = 60;

  StreamSubscription<int>? _tickerSubscription;

  TimerBloc({required Ticker ticker})
      : _ticker = ticker,
        super(TimerInitial(_duration)) {
  
    on<TimerStarted>(_onStarted);
  }

  
  Future<void> close() {
    _tickerSubscription?.cancel();
    return super.close();
  }

  void _onStarted(TimerStarted event, Emitter<TimerState> emit) {
    // TimerBloc이 TimerStarted 이벤트를 수신하면, 시작 기간과 함께 TimerRunInProgress 상태를 푸시 
    emit(TimerRunInProgress(event.duration));
    // 이미 열려 있는 _tickerSubscription이 있는 경우 메모리 할당을 해제하기 위해 취소
    _tickerSubscription?.cancel();
    _tickerSubscription = _ticker
        .tick(ticks: event.duration)
        // _ticker.tick 스트림을 듣고 모든 틱에 남은 기간으로 _TimerTicked 이벤트를 추가
        .listen((duration) => add(_TimerTicked(duration: duration)));
  }
}

add 와 emit 의 차의점


add
이벤트를 Bloc 에 전달하는 역할. Bloc 에 상태를 변경하라는 신호를 보내는 것일 뿐 직접 상태를 변경하진 않음

emit
이벤트 로직 처리 결과. 이벤트 처리 후에 새로운 상태로 변경하기 위해 사용. 직접 상태를 변경함.

add는 “이 일이 발생했으니 처리해줘!“라고 이벤트를 보내는 거고,
emit은 “이제 상태가 이렇게 바뀌었어!“라고 상태를 내보내는 것


Ticker event handler 구현

class TimerBloc extends Bloc<TimerEvent, TimerState> {
  final Ticker _ticker;
  static const int _duration = 60;
  StreamSubscription<int>? _tickerSubscription;
	
  TimerBloc({required Ticker ticker})
      : _ticker = ticker,
        super(TimerInitial(_duration)) {
    on<TimerStarted>(_onStarted);
    on<TimerPaused>(_onPaused);
    on<TimerResumed>(_onResumed);
    on<_TimerTicked>(_onTicked); // 추가
  }
  
  
  ... // 생략
  
  void _onTicked(_TimerTicked event, Emitter<TimerState> emit) {
    emit(
      event.duration > 0
          ? TimerRunInProgress(event.duration)
          : TimerRunComplete(),
    );
  }
}

상태를 UI에 반영하기

상위 위젯에서 BlocProvider TimeBloc을 생성하여 제공해준다.

class TimerPage extends StatelessWidget {
  const TimerPage({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => TimerBloc(ticker: Ticker()),
      child: const TimerView(),
    );
  }
}

BlocBuilder 로 UI를 그린다. BlocProvider 로 전달한 TimeBloc 인스턴스에 접근해서 상태에 따라 반영한다.

  • buildWhen : 이전 상태(prev)와 새로운 상태(state)를 비교하여 빌드 여부를 결정한다. 상태의 타입이 변경될 때만 UI를 다시 빌드하도록 설정 가능.

  • switch : 상태에 따라 UI를 분기 처리

  • context.read<TimerBloc>().add(const TimerPaused()) -> add 를 통해 상태 변경 요청

class Actions extends StatelessWidget {
  const Actions({super.key});

  
  Widget build(BuildContext context) {
    return BlocBuilder<TimerBloc, TimerState>(
      buildWhen: (prev, state) => prev.runtimeType != state.runtimeType,
      builder: (context, state) {
        return Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            ...switch (state) {
              TimerInitial() => [
                  FloatingActionButton(
                    child: const Icon(Icons.play_arrow),
                    onPressed: () => context
                        .read<TimerBloc>()
                        .add(TimerStarted(duration: state.duration)),
                  ),
                ],
              TimerRunInProgress() => [
                  FloatingActionButton(
                    child: const Icon(Icons.pause),
                    onPressed: () =>
                        context.read<TimerBloc>().add(const TimerPaused()),
                  ),
                  FloatingActionButton(
                    child: const Icon(Icons.replay),
                    onPressed: () =>
                        context.read<TimerBloc>().add(const TimerReset()),
                  ),
                ],
              TimerRunPause() => [
                  FloatingActionButton(
                    child: const Icon(Icons.play_arrow),
                    onPressed: () =>
                        context.read<TimerBloc>().add(const TimerResumed()),
                  ),
                  FloatingActionButton(
                    child: const Icon(Icons.replay),
                    onPressed: () =>
                        context.read<TimerBloc>().add(const TimerReset()),
                  ),
                ],
              TimerRunComplete() => [
                  FloatingActionButton(
                    child: const Icon(Icons.replay),
                    onPressed: () =>
                        context.read<TimerBloc>().add(const TimerReset()),
                  ),
                ]
            }
          ],
        );
      },
    );
  }
}

튜토리얼인데 생각보다 복잡하고 어려운 면이 있었지만 블록이 어떤 방식으로 상태를 변경하는지에 대해 약간은 감을 잡을 수 있었던 시간이었다. 다만 아직 모자라다는 생각이 든다. 틈틈이 다른 예제들도 구현해보면서 블록에 익숙해질 필요가 있을 것 같다.
profile
플러터와 안드로이드를 공부합니다

0개의 댓글