티스토리 뷰

 그 동안 여러 예제를 작성해보면서 State를 활용하기 위해 Stateful Widget을 사용했다. 하지만 Stateful Widget을 사용하여 State를 제어하는 방식은 분명한 한계를 가지고 있는데, 그건 바로 위젯 내에서만 State 제어가 가능하다는 것이다. 

 

즉, State는 Stateful widget이 선언된 해당 페이지에서만 관리되기 때문에 그 페이지를 벗어나면 새로운 화면이 빌드되어 해당 State가 전달되지 않는다. 

 

따라서 앱 전역에 걸쳐 관리되는 State가 필요한데, 이런 경우에는 다양한 위젯에서 해당 State에 접근하게 되니 로직이 굉장히 복잡해지기 쉽다. 

 

Flutter는 이런 불필요하게 복잡한 로직과 코드를 최소화하고 일정하게 전역 State를 관리하기 위한 상태 관리 도구를 몇 가지 가지고 있다.

 

이번 포스팅에서는 그런 전역 상태 관리 기법 중 하나인 Bloc 기법에 대하여 다뤄보려고 한다.


BloC

 

BloC은 Business Login Components의 약자이다. 간단하게 말해서 일반적인 MVC 패턴의 Controller와 역할이 동일하다고 생각할 수 있다.

 

BloC 패턴의 구성 요소는  데이터 영역, 화면 영역 그리고 BloC 영역이다. 데이터 영역과 화면 영역 사이에 BloC이 존재하여 데이터를 화면에 표시할 수 있도록 해준다.

 

앱 전역에 있는 상태의 변화를 실시간으로 화면 영역이 알 수 있도록 상태 관리가 이뤄져야 하는데, BloC은 이를 위해 Stream이라는 컨셉을 활용한다. 기본적으로 상태의 변화는 이벤트가 발생시킨다. 

 

앱 어디에서나 발생할 수 있는 이벤트들은 Stream이라고 하는, 앱을 가로지르는 강을 떠다니게 된다.

 

Stream을 통해 전달된 이벤트들은 화면 영역에 상태의 변화를 알려주게 된다.

 

이를 그림으로 표현하면 다음과 같다.


바로 한번 예제와 함께 알아보자.

 

우선, Flutter는 이 BloC을 쉽게 사용할 수 있게 하기 위하여 bloc 패키지를 제공한다.

 

◎pubspec.yaml

dependencies:
  flutter:
    sdk: flutter

  bloc: ^7.0.0
  flutter_bloc: ^7.0.0

bloc은 bloc/event/state 이렇게 3가지 구성요소로 이뤄져 있다.

 

본격적으로 프로젝트를 하나 새로 생성한 뒤 blocs.counter 디렉토리를 생성하고, 아래와 같이 bloc, event 와 state를 생성해주자.

이제 차근차근 bloc의 구성요소들을 살펴보자.

 

1. state

◎counter_state.dart

part of 'counter_bloc.dart';

@immutable
class CounterState {
  final int count;

  const CounterState(this.count);

}

 

state는 Bloc이 다룰 상태에 대하여 정의한다. 실제 코드도 state에서 다룰  int 변수인 count 하나와 이를 초기화할 contructor 뿐이다.


2. event

◎counter_event.dart

part of 'counter_bloc.dart';

@immutable
abstract class CounterEvent {
  const CounterEvent();
}

class CounterIncrement extends CounterEvent {
  const CounterIncrement();

  @override
  String toString() => '[+] CounterIncrement';
}

class CounterDecrement extends CounterEvent {
  const CounterDecrement();

  @override
  String toString() => '[+] CounterDecrement';
}


event는 Bloc에 입력으로 들어갈 이벤트들을 정의하는 클래스로, 여기서 정의하는 이벤트는 실제로 동작을 수행하는 함수가 아닌, 어떤 종류의 이벤트인지 그 이름을 정의하는 것이므로 이벤트의 목록을 class로 작성해 놓으면 된다.

 

코드를 보면, 추상 클래스인 CounterEvent 내부에 Constructor 하나와 이를 상속하고 있는 Increment, decrement 클래스를 작성했으며, 내부에는 동작을 확인하기 위한  toString() 메서드를 오버라이드했다.


3. bloc

◎counter_bloc.dart

import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';

part 'counter_event.dart';
part 'counter_state.dart';


class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(CounterState(0));

  @override
  Stream<CounterState> mapEventToState(CounterEvent event) async*{
    if(event is CounterIncrement) {
      yield CounterState(state.count  + 1);
    } else if (event is CounterDecrement) {
      yield CounterState(state.count - 1);
    }
  }
}

bloc은 앞서 정의한  state와 event를 기반으로 실제 로직을 처리하는 곳이다. bloc에서는 실제로 어떤 event에 대해 어떤 state 변화를 발생시키는지 작성하게 된다.

 

조금은 생소한 문법들이 눈에 띄는데, 다루는 김에 짚고 넘어가자.


- part / part of

 사용하는 것이 그리 권장되지는 않는 문법이긴 하나, 아예 사용을 안하는 것은 아니다. 간단하게 private member에 접근할 수 있도록 서로가 서로를 참조하도록 구성하는 방식이다. import와 export와 유사하지만 1개의 라이브러리를 쪼개놓은 것이라고 생각하면 편하다.

 

- async* / steam / yield

async는 Future를 반환하지만, async* 는 Stream 문법이라고도 하며 Stream객체를 반환한다. stream에 대해서는 직전 포스팅에서 설명을 잠깐 했다. 다시 한번 간단하게 확인해보면,

 

stream:  값, 이벤트, 객체, 컬렉션, 맵, 오류 심지어는 다른 stream에서 모든 유형의 데이터가 Stream에 의해 앱 전체적으로 퍼져 제어가 가능한 개념이다.

 

async*: 게으른 연산, 요청이 있을 때는 연산을 미루다가 함수에서 사용 시 연산을 한다.

 

yield: return 과 비슷하게 값을 반환해주는데, return함과 동시에 함수가 종료되지 않고 계속 열려있으면서 지속적으로 return 해주는 개념이다.


위에서 다룬 문법 개념을 가지고 다시 bloc을 확인해보자. 

 

◎counter_bloc.dart

import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';

part 'counter_event.dart';
part 'counter_state.dart';


class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(CounterState(0));

  @override
  Stream<CounterState> mapEventToState(CounterEvent event) async*{
    if(event is CounterIncrement) {
      yield CounterState(state.count  + 1);
    } else if (event is CounterDecrement) {
      yield CounterState(state.count - 1);
    }
  }
}

선언부를 확인해보면, Bloc객체는 제네릭으로 Event와 State를 받고, contructor는 부모 클래스의 constructor를 가져오면서 인자로 CounterState를 전달한다.

 

Stream<CounterState> mapEventToState(CounterEvent event)는 Bloc의 기본 메서드로, 정의한 CounterState를 Stream형태로 제공하기 위한 메서드이다. 그 이름에서 알 수 있듯, event가 들어왔을 때ㅔ, 그 event를 state의 변화로 바꿔주기 위한 메서드이다. 

 

그러므로 CounterEvent가 인자로 들어오며, 해당 event가 CounterIncrement인지, CounterDecrement인지 확인 후에 state를 어떻게 바꿀 지 동작을 수행한다.


 

위에서 Bloc을 모두 작성했면, 이제 작성한 Bloc을 UI와 연결시켜줘야 한다. 이를 위해서는 flutter_bloc 패키지를 사용해야 한다. flutter_bloc 패키지에서는 bloc을 제공받아 UI내 위젯을 빌드하고 bloc 객체를 사용할 수 있도록 해주는 여러 위젯이 존재한다.

- BlocProvider

먼저 앱 전역에 위에서 정의한 CounterBloc을 제공해줘야 한다. 상위 위젯에서 객체를 생성하고, 하위 위젯에서 해당 객체를 제공받아 사용하는 것을 provider 패턴이라고 한다. 

 

flutter_bloc 에는 bloc 객체를 앱 전역에 제공하기 위한 위젯인 BlocProvider가 있다. 

 

◎main.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:state_ex/screen/counter_screen.dart';
import 'package:state_ex/screen/home_screen.dart';

import 'bloc/counter/counter_bloc.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => CounterBloc(),
      child: MaterialApp(
        title: 'Flutter State BloC',
        routes: {
          '/': (context) => HomeScreen(),
          '/counter': (context) => CounterScreen()
        },
        initialRoute: '/',
      )
    );
  }
}

위 코드를 확인해보면, main에서 BlocProvider를 사용하여 create: 와 함께 CounterBloc을 선언하고, 하위 위젯인 HomeScreen과 CounterScreen에서 이를 사용할 수 있도록 하였다.


- BlocBuilder

이제 제공 받은 Bloc 객체를 기반으로 UI를 빌드하기 위한 위젯인 BlocBuilder에 대하여 알아보자. 

 

◎home_screen.dart


import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../bloc/counter/counter_bloc.dart';

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: Text('Home Screen')
        ),
        body: BlocBuilder<CounterBloc, CounterState> (
            buildWhen: (previous, current) => previous.count != current.count,
            builder: (context, state) {
              return Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Text('Count: ' + state.count.toString()),
                      TextButton(
                          onPressed: () {
                            Navigator.of(context).pushNamed('/counter');
                          },
                          child: Text('Go to CounterScreen')
                      )
                    ],
                  )
              );
            }
        )
    );
  }
}

BlocBuilder는 제네릭으로 Bloc과 State를 받으며, buildWhen 속성을 이용하여 언제 빌드할 지 설정할 수 있다. 기본적으로 상태의 변화가 발생하면 빌드하지만, 만약 특수한 경우가 있거나 빌드를 하고 싶지 않을 경우 false를 설정하여 막을 수 있다.

 

상태와 관련된 값을 사용하기 위해서는 builder의 state를 사용하여 state.count 와 같이 사용하면 된다.

 

◎counter_screen.dart


import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:state_ex/bloc/counter/counter_bloc.dart';

class CounterScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Counter Screen')
      ),
      body: BlocBuilder<CounterBloc, CounterState> (
        builder: (countext, state) {
          return Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('Counter: ' + state.count.toString()),
                TextButton(
                  onPressed: () {
                    BlocProvider.of<CounterBloc>(context).add(CounterIncrement());
                  },
                  child: Text('[+] Increment')
                ),
                TextButton(
                    onPressed: () {
                      BlocProvider.of<CounterBloc>(context).add(CounterDecrement());
                    },
                    child: Text('[-] Decrement')
                )
              ],
            )
          );
        },
      )
    );
  }

}

또한 위 CounetScreen과 같이 state를 변경할 이벤트를 발생시키기 위해서는 state가 아닌, Bloc 객체에 접근할 수 있어야 하는데, 이를 위해 BlocProvider.of<CounterBloc>(context) 문법을 사용할 수 있다.


내용이 정말 많았는데, 이제 마무리로 구성한 Bloc이 정말 상태를 잘 제어하는 지 확인해보자.

즉, 정리해보면 아래와 같은 로직으로 State가 변화한다.

 

1. BlocProvider.of(context).add(Event)로 이벤트가 전달된다.

2. Bloc에서는 해당 Event가 어떤 이벤트인지 확인한다.

3. Bloc에서는 들어온 Event의 타입에 따라 상태의 변화를 발생시킨다.

4. 상태의 변화는 Stream 형태로 앱 전역에 제공된다.

5. UI 영역에서는 BlocBuilder를 통해 상태 변화를 계속 관찰하고 있으며, Stream을 통해 상태의 변화가 전달되었으므로 UI를 다시 빌드한다.

6. 이를 통해 사용자는 값이 변화한 화면을 다시 확인할 수 있다.

 

끝!!!

반응형

'Mobile > Flutter' 카테고리의 다른 글

[Flutter] 전역 상태 관리 기법: Provider  (0) 2022.07.26
[Flutter] API 연동  (0) 2022.07.24
[Flutter] State  (0) 2022.07.11
[Flutter] 화면 전환  (0) 2022.07.09
[Flutter] Buttons  (0) 2022.07.09
Comments