bloc | Dart Package
flutter_bloc | Flutter Package
Count App으로 배워보는 BLoC Pattern
Count App으로 배워보는 Cubit
Equatable 라이브러리 배워보기
Equatable을 BLoC에서 왜 사용하나 ?
싱글톤(Singleton) 패턴이란 ?
이번 글에서는 BLoC Pattern에 대해서 알아보도록 하겠다.
BLoC Pattern에 대해서 배워보고 싶으시거나 아직 사용은 하고 있지만 제대로 이해하지 못하신 분들을 위해서 최대한 쉽고 이해할 수 있도록 작성하겠다.
BLoC Pattern은 너무나도 유명한 상태 관리 라이브러리여서, 이론적인 설명을 하지는 않도록 하겠다. 어떻게 사용해야 하며, 어떤 장점이 있고, 단점은 또 어떤 것이 있는지에 대해서 중점적으로 다뤄보겠다.
BLoC을 배우기 앞서 가볍게 사용할 수 있는 Cubit이 있는데, Cubit을 먼저 배워보고 BLoC을 배워보도록 하자.
먼저 BLoC을 잘 설명해주는 그림이다. 그래도 BLoC이 어떻게 작동 되는지는 대충 이해하고 있어야 하니 그림의 흐름도를 잘 기억하여야 한다.
BLoC을 설명하는 여러 이미지와 도식화를 봤지만, 제 개인적으로는 아래 블로그에서 공유한 흐름도가 가장 직관적이고 이해하기 쉽다고 생각이 든다. 물론 Provider 개발시에도 저 흐름도를 가지고 갈 수도 있다.
BLoC 패턴을 저는 주로 Clean Architecture 방법론인 DDD(Domain Driven Design) 구조로 사용하는 방법으로 주로 사용을 하였는데, DDD 구조에도 최적화된 패턴이라고 생각한다.
아래 이미지는 아마도 BLoC 패턴을 설명할 때에 주로 사용되는 이미지인데, 처음에는 아래 이미지를 보고 이해하기가 쉽지 않다.
이번 글을 끝까지 다 보고 나면 그래도 조금은 이해가 될 것 같다.
Flutter로 프로젝트를 생성하고 개발을 하다보면, 폴더 구조를 어떻게 가져가야 할지 고민이 많이 될꺼다.
저도 여러 프로젝트를 진행해 봤지만, 항상 가장 고민이 되는 부분이다. 이런 폴더 구조의 경우에는 상태 관리나 Clean Architecture 도입 유무에 따라 구조를 다르게 적용하고 있다.
우선 여기서는 폴더 구조를 4개의 폴더로 나눠서 진행할 예정이다.
model
repository
application
presentation
model 폴더에는 데이터 구조를 작성하고, repository 폴더에는 데이터를 호출하고 가져오는 부분의 작업을 담당하게 할 것인데, 모든 예제 앱에서 repository가 사용되지는 않을 것이다.
가장 중요한 application 폴더 구조에 BLoC 또는 Cubit이 사용될 것이다.
마지막으로 presentation은 UI 파일 구조를 작성할거다.
자 이제 개발을 하기 앞서 종속성을 추가해 주자. 참고로 flutter_bloc을 반드시 사용할 필요는 없고, bloc 라이브러리를 추가해도 된다.
equatable 라이브러리는 BLoC 패턴 사용시 필수 라이브러리로, 이전에 equatable에 대해서 설명한 글을 참고하여 먼저 해당 라이브러리를 왜 사용해야 하는지에 대해서 한 번 보고 오면 좋을 것 같다.
dependencies:
equatable: ^2.0.5
flutter_bloc: ^8.1.1
BLoC이나 Cubit을 배우기 앞서 가장 먼저 알아봐야할 부분이 있다.
실제 필드에서 개발을 해보신 분들은 잘 아시겠지만, 개발을 아직 배우고 계신 분들을 위해서 디버깅 환경을 먼저 만들어 보도록 하겠다.
이 부분은 불편하신 분들은 안하셔도 됩니다. 저도 사실 설정만 해놓고 잘 사용하지는 않는 부분이라 우선 공유만 하도록 하겠습니다.
만약에 개발된 앱에서 에러가 발생했거나 또는 상태가 변경되는 로그를 출력해 보고 싶을 때 어떻게 해야할까 ?
모든 상태관리 구조 파일을 찾아다니면서 로그를 다 찍고 다닐건가 ? 이렇게 하면 불편하고 시간적인 리소스 낭비이다.
BLoC에서는 이러한 부분을 전역적으로 들여다 볼수 있도록 Observer를 사용하여 관찰자를 생성해준다.
우선 아직 Cubit 사용법을 배워보지 못했으니, 우선 아래 코드를 그냥 복사하면 된다.
class TestCubit extends Cubit<TestCubitState> {
TestCubit() : super(const TestCubitState(0));
void increment() => emit(TestCubitState(state.count + 1));
}
class TestCubitState extends Equatable {
final int count;
const TestCubitState(this.count);
List<Object?> get props => [count];
}
class TestPage extends StatelessWidget {
const TestPage({super.key});
Widget build(BuildContext context) {
return BlocProvider<TestCubit>(
create: (_) => TestCubit(),
child: Scaffold(
body: BlocBuilder<TestCubit, TestCubitState>(
builder: (context, state) {
return Center(
child: GestureDetector(
onTap: () => context.read<TestCubit>().increment(),
child: Text(state.count.toString())),
);
},
),
),
);
}
}
이제 실행해서 해당 페이지로 이동해보면 카운트가 0으로 되어 있을 것이다. 이 카운트를 클릭해보면 숫자가 증가하는 것을 확인할 수 있다.
이번에는 아래 코드를 다시 추가해보자. 위에서 공유한 파일은 test 코드지만, 지금 공유하는 부분은 전역에서 적용하고 있어야 하기에 각자가 원하는 파일명으로 작성하셔도 된다.
BlocObserver를 상속 받은 기능인데, 이 부분은 아래에서 자세히 설명하도록 하겠다.
class Observer extends BlocObserver {
void onChange(BlocBase bloc, Change change) {
print(change);
super.onChange(bloc, change);
}
}
Dart 언어의 시작점인 main 함수를 찾아가자. main 함수아래에 Bloc.observer로 우리가 방금 생성한 Observer() 객체를 지정해주자.
여기서 중요한 점은 WidgetsFlutterBinding 아래에 작성되어야 한다.
main 함수에 기능을 추가할 때에 가장 중요한 점은 Flutter 위젯 바인딩 코드 보다 반드시 아래에 작성되어야 한다는 점이다.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
Bloc.observer = Observer();
runApp(const App());
}
자 이제 앱을 재시작하고 다시 카운트를 올려보자. 아래와 같이 로그가 출력되는 것을 확인할 수 있다.
출력되는 상태 변화를 확인해 보면, 현재의 상태는 TestCubitState(0)이 었지만 다음 상태로 TestCubitState(1) 변경되었다는 것을 확인할 수 있다.
Restarted application in 365ms.
flutter: Change { currentState: TestCubitState(0), nextState: TestCubitState(1) }
flutter: Change { currentState: TestCubitState(1), nextState: TestCubitState(2) }
flutter: Change { currentState: TestCubitState(2), nextState: TestCubitState(3) }
flutter: Change { currentState: TestCubitState(3), nextState: TestCubitState(4) }
flutter: Change { currentState: TestCubitState(4), nextState: TestCubitState(5) }
flutter: Change { currentState: TestCubitState(5), nextState: TestCubitState(6) }
flutter: Change { currentState: TestCubitState(6), nextState: TestCubitState(7) }
이번에는 Observer에 Bloc이 생성될 때와 Bloc이 메모리에서 제거될 때에도 출력을 해보자.
class Observer extends BlocObserver {
void onCreate(BlocBase bloc) {
print("Create :: $bloc");
super.onCreate(bloc);
}
void onClose(BlocBase bloc) {
print("Close :: $bloc");
super.onClose(bloc);
}
void onChange(BlocBase bloc, Change change) {
print(change);
super.onChange(bloc, change);
}
}
해당 기능을 사용해서 Bloc 패턴을 추적하고 디버깅하는데 유용하게 사용할 수 있다.
Observer 기능은 더 다양한 기능이 있으니, 한 번 사용해 보시길 추천한다.
개발을 해보면서 추가적인 사용 방법에 대해서도 다뤄보도록 하자.
flutter: Create :: Instance of 'TestCubit'
flutter: Change { currentState: TestCubitState(0), nextState: TestCubitState(1) }
flutter: Change { currentState: TestCubitState(1), nextState: TestCubitState(2) }
flutter: Change { currentState: TestCubitState(2), nextState: TestCubitState(3) }
flutter: Close :: Instance of 'TestCubit'
먼저 BLoC을 사용해보기 전에 Cubit을 사용해보자. Cubit은 BLoC 라이브러리에 내장되어 있는 가벼운 상태관리라고 보면된다.
사용 방법을 보다보면 Get이나 Provider와 유사하다고 생각할 수 있지만 유사하면서도 완전히 다른 방식으로 작동된다고 느낄 것이다.
가벼운 Cubit 사용 방법에 대해서는 위에 공유한 Count App으로 배워보는 Cubit 글을 참고하셔도 좋다.
첫 번째로 사용해 볼 예제는 API 호출을 하여 네트워크 이미지를 가져와 보여주고, 버튼을 클릭하여 이미지를 랜덤으로 변경해주는 기능을 Cubit으로 개발을 해보도록 하겠다.
HTTP 통신에 필요한 라이브러리 하나를 추가해보자.
dependencies:
http: ^0.13.5
기능을 개발할 때 개발하는 순서가 있는데, 꼭 정해져 있는 것은 아니지만 저는 데이터 구조를 세팅하고 API를 호출하거나 데이터를 가져오는 부분의 작업을 진행합니다.
그 후에 상태 관리 로직을 개발하고 마지막으로 UI를 작업하면서 개발의 순서를 가져가는 편입니다.
해당 예제는 간단한 구조로 데이터 구조는 사용하지 않도록 하겠습니다.
Repostiory 부분은 싱글톤 패턴을 사용해서 작성하는데, 싱글톤 패턴에 대해서 궁금하신 분들은 아래 링크를 참고하시기 바란다.
여기서 이미지 API 호출에 사용할 무료 이미지를 제공하고 있는 Lorem Picsum을 사용해서 호출을 할 예정이다.
기본 구조를 먼저 작성하도록 하자. http 라이브러리를 사용하기 위해 상단에 임포트를 미리 해두었다.
import 'dart:io';
import 'package:http/http.dart' as http;
class ChangedImageRepository {
static final ChangedImageRepository instance =
ChangedImageRepository._internal();
factory ChangedImageRepository() => instance;
ChangedImageRepository._internal();
Future<String?> fetchImage() async {
try {} on HttpException catch (error) {
return null;
}
}
}
fetchImage 부분의 코드에 API를 호출할 수 있도록 코드를 추가해주자. 우리는 이미지 Url 주소만 필요해서 Url 주소를 따로 파싱해줬다.
Future<String?> fetchImage({
required int pageNo,
}) async {
try {
http.Response _response = await http
.get(Uri.parse("https://picsum.photos/v2/list?page=$pageNo&limit=1"));
if (_response.statusCode == 200) {
List<dynamic> _data = json.decode(_response.body);
if (_data.isNotEmpty) {
String? _url = _data[0]["download_url"];
return _url;
} else {
return null;
}
}
return null;
} on HttpException catch (error) {
return null;
}
}
이번에는 상태 관리 로직을 추가해보도록 하자.
상태 관리를 사용하기 전에 어떤 상태가 필요하며, 어떤 이벤트를 사용할 것인지를 먼저 고민해봐야 한다.
상태는 해당 페이지를 진입했을 때에 초기 상태가 있을 것이며, 초기 상태에서 API를 호출하고 있는 중인 상태, 호출이 완료되서 정상적으로 데이터 세팅이 완료된 상태가 있을 것이다. 여기에 추가로 데이터를 불러오지 못했을 때에 필요한 에러 상태도 가지고 있어야 한다.
이벤트는 어떤 것들이 필요할까 ? 처음 진입시 API를 호출할 수 있는 이벤트가 필요하고, 리프레시 아이콘을 눌렀을 때에 새로운 이미지를 불러올 이벤트가 필요할 것이다.
이제 하나씩 개발을 해보도록 하자.
Cubit의 State 부분을 먼저 생성해주자. 추상 클래스로 생성한 뒤 각 상태에서 상속받아 사용할 것이다.
abstract class ChangedImageState extends Equatable {}
각각의 상태를 만들어 주었다. 초기화, 로딩 중, 로딩 완료, 에러 상태이다.
class ImageInitState extends ChangedImageState {
List<Object?> get props => [];
}
class ImageLoadingState extends ChangedImageState {
List<Object?> get props => [];
}
class ImageLoadedState extends ChangedImageState {
List<Object?> get props => [];
}
class ImageErrorState extends ChangedImageState {
List<Object?> get props => [];
}
추상 클래스에서 상태 값을 추가해 주어야 한다. 우리는 이미지를 보여줄 것이기 때문에 Nullable 타입의 문자열 imageUrl 변수와, API 조회시 필요한 정수형 pageNo를 선언해주고, 생성자에서 pageNo를 1로 초기화 하였다.
abstract class ChangedImageState extends Equatable {
final String? imageUrl;
final int pageNo;
const ChangedImageState({this.imageUrl, this.pageNo = 1});
}
먼저 로딩 완료된 상태에서의 상태 변수를 받아오도록 하자. API 호출이 완료된 상태이기 때문에 imageUrl에 받아온 이미지 주소를 넣어줘야 하고, 다음 page를 호출하여야 하기에 pageNo의 상태를 변경할 수 있도록 해주었다.
class ImageLoadedState extends ChangedImageState {
const ImageLoadedState({super.imageUrl, super.pageNo});
List<Object?> get props => [imageUrl, pageNo];
}
에러가 발생했을 때의 상태이다. 여기서 에러 메시지가 필요하기에 에러메시지를 새로 생성해 주었다.
class ImageErrorState extends ChangedImageState {
final String errorMessage;
const ImageErrorState(this.errorMessage);
List<Object?> get props => [errorMessage];
}
마지막으로 로딩 중인 상태인데, 여기서는 pageNo의 상태를 유지시켜줘야 하고, imageUrl의 상태는 다시 Null로 변경되어도 상관이 없어서 pageNo만 사용하도록 하였다.
만일 새로운 imageUrl을 받아올 동안 이미지를 계속 유지하고 싶다면 여기서도 imageUrl을 생성해서 상태를 유지시켜주면 된다.
class ImageLoadingState extends ChangedImageState {
const ImageLoadingState({super.pageNo});
List<Object?> get props => [pageNo];
}
Cubit을 생성하도록 하자. 여기서 Cubit의 상태로 위에서 생성한 상태 객체를 지정해주고, 초기화 상태로 ImageInitState를 선언해주면 된다.
class ChangedImageCubit extends Cubit<ChangedImageState> {
ChangedImageCubit() : super(ImageInitState());
}
Repostiory 부분을 가져오는 코드를 추가하고, getImage라는 이미지를 불러올 수 있는 이벤트를 추가하였다.
BLoC, Cubit에서의 상태 변경 알림은 emit 함수를 사용하는데, Provider의 Notifylistener()와 Get의 update()랑 비슷하다고 보면된다. 하지만 작동 방식은 완전히 다르다.
getImage가 호출되면 Cubit의 상태를 ImageLoadingState로 주고 pageNo의 값은 현재의 상태의 pageNo를 유지할 수 있도록 해주자.
Repository에서 fetchImage를 호출할 때에 pageNo를 넘겨주어야 해서 현재의 pageNo를 넘겨서 호출해주면 된다. 리턴 값은 Nullable 문자열이기 때문에 Null이 아닌 경우에의 상태로 ImageLoadedState를 변경해주면 된다. 여기서 imageUrl은 새로 받아온 이미지 주소로 변경해주고, pageNo는 지금 호출한 pageNo의 1을 올린 값으로 상태 변경을 해주면 된다.
Null일 경우에는 간단하게 에러 상태로 변경해주고, 필수 값으로 errorMessage를 받아오기로 했기에 단순히 Error라는 문자만 넘겨주었다.
class ChangedImageCubit extends Cubit<ChangedImageState> {
ChangedImageCubit() : super(ImageInitState());
final ChangedImageRepository _repository = ChangedImageRepository.instance;
Future<void> getImage() async {
emit(ImageLoadingState(pageNo: state.pageNo));
String? _url = await _repository.fetchImage(pageNo: state.pageNo);
if (_url != null) {
emit(ImageLoadedState(imageUrl: _url, pageNo: state.pageNo + 1));
} else {
emit(const ImageErrorState("Error"));
}
}
}
UI에 우리가 생성한 Cubit을 생성하고 상태를 전달할 수 있도록 만들어주자.
class ChangedImageCubitScreen extends StatelessWidget {
const ChangedImageCubitScreen({super.key});
Widget build(BuildContext context) {
return Scaffold();
}
}
BLoC, Cubit을 생성하는 방법이다. ChangedImageCubit이 생성됨과 동시에 getImage를 호출하도록 하였다.
build(BuildContext context) {
return BlocProvider<ChangedImageCubit>(
create: (context) => ChangedImageCubit()..getImage(),
child: Scaffold());
}
Widget
body 구조에 BlocBuilder를 사용해서 Cubit, State를 접근할 수 있도록 해주자. Builder아래에 각각의 상태에 따른 다른 UI 구조를 보여줄 수있도록 상태 분기를 해주었다.
Scaffold(
body: BlocBuilder<ChangedImageCubit, ChangedImageState>(
builder: (context, state) {
if (state is ImageLoadedState) {
// Loaded
} else if (state is ImageLoadingState) {
// Loading...
} else if (state is ImageErrorState) {
// Error..
} else {
return Container();
}
},
));
로딩이 완료된 상태의 UI 구조인데, 단순히 네트워크 이미지를 보여주고 리프레시 아이콘을 넣어 다음 이미지를 불러올 수 있도록 해주었다. 여기서 중요한 부분은 생성한 Cubit을 호출하는 방식이다.
BLoC, Cubit은 context를 통해서 접근을 하게 되는데, Flutter 구조에서 context는 매우 중요한 부분이다.
선언형 UI 구조에서 위젯 트리는 중요한 개념인데, Flutter의 위젯 각각은 개별적인 부모 context를 찾아 UI를 그리는 작업을 하는데, BLoC에서도 부모의 context 위젯 트리에 해당 BLoC이 생성되지 않으면 접근할 수 없다.
read 기능을 통해서 이미지를 다시 호출해 주었다. watch도 있는데, 이 부분은 아래에서 다시 다루겠다.
return Column(
children: [
const SizedBox(height: 100),
SizedBox(
width: MediaQueryData.fromWindow(window).size.width,
height: 100,
child: GestureDetector(
onTap: () {
HapticFeedback.mediumImpact();
context.read<ChangedImageCubit>().getImage();
},
child: const Icon(Icons.refresh_rounded)),
),
Image.network(state.imageUrl!)
],
);
로딩 중 상태에서의 UI이다. 단순히 인디케이터를 띄어주었다.
return const Center(
child: CircularProgressIndicator(
color: Colors.amber,
),
);
에러가 발생했을 때의 UI인데, 만약에 다른 상태에서 errorMessage를 접근하면 어떻게 될까 ? 아예 접근할 수 없다.
return Center(
child: Text(
state.errorMessage,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 50),
),
);
https://github.com/boglbbogl/flutter_velog_sample/tree/main/lib/management/changed_image
간단한 구조로 Cubit에 대해서 배워봤다. 좀 더 복잡한 예제로 다루고 싶은데, 블로그에 내용이 너무 길어져서 Cubit은 간단한 예제로만 작성하게 되었다.
이번에는 BLoC Pattern으로 개발을 해보자.
BLoC을 적용할 때 사용할 예제는 무한 스크롤 기능이다. 단순히 하나의 무한스크롤이 아닌 구조를 적용해서 2개의 BLoC을 다뤄보도록 하겠다.
여기서 각각의 BLoC은 다른 State 방식을 적용할 것인데, 하나는 Cubit을 배워볼 때 다뤄본 방식과 동일하고 나머지 하나는 copyWith 방식의 State 관리로 작성을 할 예정이다.
위에서 Cubit 예제를 배우면서 BlocProvider로 생성하는 방법을 배웠는데, 여기서는 MultiBlocProvider를 적용해보고, BlocBuilder 구조도 리스너 기능이 있는 빌더를 사용할 예정이다.
내용이 너무 길어져서 UI 부분은 간단하게만 살펴볼 예정이니, 필요한 UI 부분은 Git의 소스 코드를 참고하길 바란다.
먼저 어떤 예제인지 결과먼저 보도록 하자.
위의 예제 결과를 보면 수평으로 작동하는 무한 스크롤과 수직으로 작동하는 무한 스크롤 기능이 있다.
수평으로 작동되는 무한 스크롤은 최초 진입시에 아무런 데이터를 갖지 않은 상태로 있다가 다운로드 아이콘을 터치하면 그 때 데이터를 불러오는 것을 확인할 수 있다.
BLoC Pattern의 가장 강점이라고 보는 BlocListener를 사용해서 데이터를 다운받아 올 때에 에러를 강제로 발생시켜 리스너가 어떻게 작동되는지에 대해서도 이해하기 쉽게 예제를 만들었다.
무한 스크롤 기능을 사용하기 위해 앞서 사용한 무료 API를 활용할 것인데, 여기서는 데이터를 객체로 생성하도록 하겠다.
API에서 가져올 수 있는 변수들로 모델을 만들어 줬다. Json 형태의 데이터를 객체로 변환하여야 하기에 fromJson 기능을 추가하였다.
class InfinityImageModel {
final String id;
final String author;
final int width;
final int height;
final String url;
final String downloadUrl;
InfinityImageModel({
required this.id,
required this.author,
required this.width,
required this.height,
required this.url,
required this.downloadUrl,
});
factory InfinityImageModel.fromJson(Map<String, dynamic> json) {
return InfinityImageModel(
id: json["id"],
author: json["author"],
width: json["width"],
height: json["height"],
url: json["url"],
downloadUrl: json["download_url"],
);
}
}
BLoC의 copyWith 방식을 사용할 때에 필요한 열거형 타입인 enum을 사용하여 로딩, 로딩 완료, 무한스크롤로 데이터를 더 불러오는 상태 이렇게 세 가지의 타입을 생성하였다.
enum VerticalImageType {
loading,
more,
loaded,
}
API를 호출하여 데이터를 요청하는 Repository Layer 부분을 먼저 개발해 보자. 싱글톤 패턴으로 생성하여 인스턴스 생성을 제어해 줬다.
fetchHorizotalImages는 수평 무한 스크롤에 사용되는 함수이고, fetchVerticalImages는 수직 무한 스크롤에 사용하는 기능이다.
class InfinityImageRepository {
static final InfinityImageRepository instance =
InfinityImageRepository._internal();
factory InfinityImageRepository() => instance;
InfinityImageRepository._internal();
Future<List<InfinityImageModel>?> fetchHorizotalImages({
required int pageNo,
}) async {
//
}
Future<List<InfinityImageModel>?> fetchVerticalImages({
required int pageNo,
}) async {
//
}
}
수평 무한 스크롤의 부분을 먼저 작성해 보자. pageNo를 받아서 무한 스크롤에 사용할 예정이고, 데이터의 최대 limit은 10개씩 불러올 수 있도록 해줬다.
해당 함수의 리턴 타입은 위에서 생성한 모델의 리스트를 리턴할 것이고, 만일 에러가 발생하면 Null을 리턴하도록 해주었다.
Future<List<InfinityImageModel>?> fetchHorizotalImages({
required int pageNo,
}) async {
try {
http.Response _response = await http.get(
Uri.parse("https://picsum.photos/v2/list?page=$pageNo&limit=10"));
if (_response.statusCode == 200) {
List<dynamic> _fromData = json.decode(_response.body) as List<dynamic>;
List<InfinityImageModel> _data = _fromData
.map((e) => InfinityImageModel.fromJson(e as Map<String, dynamic>))
.toList();
return _data;
}
return null;
} on HttpException {
return null;
} catch (error) {
return null;
}
}
수직 무한 스크롤에 사용되는 부분이며, 기능은 동일하다.
Future<List<InfinityImageModel>?> fetchVerticalImages({
required int pageNo,
}) async {
try {
http.Response _response = await http.get(
Uri.parse("https://picsum.photos/v2/list?page=$pageNo&limit=10"));
if (_response.statusCode == 200) {
List<dynamic> _fromData = json.decode(_response.body) as List<dynamic>;
List<InfinityImageModel> _data = _fromData
.map((e) => InfinityImageModel.fromJson(e as Map<String, dynamic>))
.toList();
return _data;
}
return null;
} on HttpException {
return null;
} catch (error) {
return null;
}
}
BLoC 패턴을 사용해 보자.
수평 무한 스크롤에 사용되는 로직을 만들 것인데, 여기서의 State는 기존과 동일한 방식으로 사용하였다.
여기서 주목해야 될 점은 리스너 부분이 어떻게 사용되는지를 이해하는 것이 중요하다.
기존과 동일하게 추상 클래스로 State를 생성하였다. 여기서 API 호출에 사용되는 pageNo, 데이터를 가지고 있는 images, 에러 발생에 대한 값을 추가하였다.
pageNo는 30번으로 초기화 하여 30번의 페이지에 해당하는 데이터를 10개씩 불러오도록 하였다.
abstract class HorizontalInfinityState extends Equatable {
final int pageNo;
final List<InfinityImageModel>? images;
final bool isError;
const HorizontalInfinityState({
this.pageNo = 30,
this.images,
this.isError = false,
});
}
초기화 상태를 생성하였다. Cubit에서 사용하는 것과 동일하게 HorizontalInfinityState 객체를 상속 받으면 된다.
초기화 상태에서는 아무런 상태가 존재하지 않도록 하였다.
class HorizontalInitState extends HorizontalInfinityState {
List<Object?> get props => [];
}
다운로드의 상태이다. 해당 상태인 경우에 다운로드 버튼이 노출되고 다운로드가 가능해진 상태가 된다. 여기서는 isError의 상태를 가지고 있어야 한다.
class HorizontalDownLoadedState extends HorizontalInfinityState {
const HorizontalDownLoadedState({super.isError});
List<Object?> get props => [isError];
}
데이터를 호출하고 로드가 완료된 상태이다. 여기서는 pageNo, images의 상태가 필요하다.
class HorizontalLoadedState extends HorizontalInfinityState {
const HorizontalLoadedState({super.images, super.pageNo});
List<Object?> get props => [images, pageNo];
}
아이템을 더 불러올 때에 변경되는 상태이다. 여기서도 아무런 상태를 갖지 않아도 된다.
class HorizontalMoreState extends HorizontalInfinityState {
List<Object?> get props => [];
}
에러가 발생한 상태이다. 에러가 발생했을 때에는 images 상태와 isError 상태를 모두 가지고 있어야 한다.
class HorizontalErrorState extends HorizontalInfinityState {
const HorizontalErrorState({super.isError, super.images});
List<Object?> get props => [isError, images];
}
이제 BLoC과 Cubit의 가장 큰 차이점인 Event에 대해서 작성해보도록 하겟다.
Cubit은 이벤트가 없다. Get X, Provider에도 이벤트라는 기능은 없다. 생소할 수 있지만, 사용하다 보면 자연스럽게 Event가 분리되어 관리되는 이유를 이해할 것이다.
먼저 Event를 생성 하는데, 추상 클래스로 생성해 주었다. 여기서도 Equatable 라이브러리를 사용해서 상속을 받도록 하자.
Event도 State와 마찬가지로 Equtable을 상속 받게 되면 변경 사항이 있을 때에만 이벤트를 작동할 수 있도록 해준다.
abstract class HorizontalInfinityEvent extends Equatable {}
Horizontal 무한 스크롤에 필요한 이벤트는 2개의 이벤트만 사용할 것이다. 하나는 다운로드가 가능한 상태에서 API를 호출하여 초기 데이터를 받아오는 이벤트와 무한 스크롤로 데이터를 더 불러오는 이벤트 이렇게 2개의 이벤트만 사용하면 된다.
먼저 초기 데이터 세팅을 위해서 생성한 이벤트이다.
class HorizontalInitImagesEvent extends HorizontalInfinityEvent {
List<Object?> get props => [];
}
데이터를 더 불러오기 위한 이벤트이다.
class HorizontalMoreImageEvent extends HorizontalInfinityEvent {
List<Object?> get props => [];
}
이제 BLoC 패턴 구성을 위한 state, event 처리를 해주었다. 지금부터는 구성한 Event와 State를 BLoC에 넘겨줘서 로직을 만들어 주면 된다.
class HorizontalInfinityBloc extends Bloc<HorizontalInfinityEvent, HorizontalInfinityState> {}
무한 스크롤 사용에 필요한 Repository 레이어를 사용하기 위해 인스턴스 해주자.
class HorizontalInfinityBloc extends Bloc<HorizontalInfinityEvent, HorizontalInfinityState> {
final InfinityImageRepository _repository = InfinityImageRepository.instance;
}
이벤트를 연결해 주도록 하자. 우리가 생성한 이벤트를 넣어서 Bloc에 이벤트를 생성해주면 된다.
class HorizontalInfinityBloc extends Bloc<HorizontalInfinityEvent, HorizontalInfinityState> {
final InfinityImageRepository _repository = InfinityImageRepository.instance;
HorizontalInfinityBloc() : super(const HorizontalInitState()) {
on<HorizontalInitImagesEvent>(_initEvent);
on<HorizontalMoreImageEvent>(_moreEvent);
}
}
이벤트 생성시 HorizontalInitImagesEvent와 Emitter를 받아오게 되는데, emit이 바로 State 변경에 사용되는 스티림이다.
이벤트 작동시 1초간의 딜레이를 주었다. Emitter의 상태를 HorizontalDownLoadedState로 변경해 주면 이제 1초 후 다운로드 완료 상태로 변경이 된다.
Repository 레이어 영역에서 생성한 API를 호출하여 모델을 리턴 받도록 하자. 여기서 만약에 모델이 null이라면 에러가 발생한 상태이기 때문에 State를 HorizontalErrorState로 변경해 주었다. images는 Null 처리를 하지 않기 위해서 빈 배열 상태로 주고, isError를 true로 해주었다.
정상적으로 모델을 리턴 받으면 HorizontalLoadedState로 변경해주고 리턴 받은 모델을 images로 변경해 주고, pageNo는 현재 pageNo에 1을 증가시켜 다음 호출시 증가된 pageNo로 호출할 수 있도록 해주었다.
Future<void> _initEvent(HorizontalInitImagesEvent event,
Emitter<HorizontalInfinityState> emit) async {
await Future.delayed(const Duration(seconds: 1), () async {
emit(HorizontalDownLoadedState(isError: state.isError));
List<InfinityImageModel>? _images =
await _repository.fetchHorizotalImages(pageNo: state.pageNo);
if (_images != null) {
emit(HorizontalLoadedState(images: _images, pageNo: state.pageNo + 1));
} else {
emit(const HorizontalErrorState(isError: true, images: []));
}
});
}
이번에는 무한 스크롤로 데이터를 더 불러오는 이벤트 로직이다. 여기서는 우선 현재의 상태가 데이터를 불러와진 상태일 때에만 해당 이벤트가 실행되도록 해주었다.
실패가 되면 위와 동일하게 처리를 하고 정상적으로 데이터를 불러왔다면 현재의 images 리스트에 추가적으로 불러와진 데이터를 넣어주었다.
Future<void> _moreEvent(HorizontalMoreImageEvent event,
Emitter<HorizontalInfinityState> emit) async {
if (state is HorizontalLoadedState) {
List<InfinityImageModel>? _images =
await _repository.fetchHorizotalImages(pageNo: state.pageNo);
if (_images != null) {
emit(HorizontalLoadedState(images: [...state.images!, ..._images]));
} else {
emit(const HorizontalErrorState(isError: true, images: []));
}
}
}
이번에는 수직 무한 스크롤의 Bloc 상태 관리 코드를 살펴보도록 하자. 여기서 State 관리는 copyWith 방식을 사용하였다.
카피 기능인 copyWith 방식을 사용해보자. State를 하나의 State로 관리하되, 카피 기능을 사용해서 변경하고자 하는 상태만 변경하는 방식이다.
여기서도 Equatable 객체를 상속 받아 객체 비교를 사용하였다.
객체를 생성하는 부분은 기존 객체 생성 방법과 동일하다. 여기서 아래 부분을 보면 copyWith를 생성하여 copyWith의 파라미터의 상태가 없을 경우 기존 상태를 사용하는 것을 볼 수 있다.
lass VerticalInfinityState extends Equatable {
final List<InfinityImageModel>? images;
final VerticalImageType type;
final String? message;
final int pageNo;
const VerticalInfinityState({
this.images,
this.type = VerticalImageType.loading,
this.message,
this.pageNo = 1,
});
VerticalInfinityState copyWith({
final List<InfinityImageModel>? images,
final VerticalImageType? type,
final String? message,
final int? pageNo,
}) {
return VerticalInfinityState(
images: images ?? this.images,
type: type ?? this.type,
message: message,
pageNo: pageNo ?? this.pageNo,
);
}
List<Object?> get props => [images, type, message, pageNo];
}
Event는 페이지 진입 시 초기 데이터를 호출하는 이벤트와 Image를 추가적으로 더 불러오는 VerticalMoreImageEvent가 있다.
VerticalMoreImageEvent에서는 ScrollUpdateNotification 객체를 필수 파라미터로 받아와 스크롤을 사용한 무한 스크롤 기능을 만들어 보았다.
abstract class VerticalInfinityEvent extends Equatable {}
class VerticalInitImagesEvent extends VerticalInfinityEvent {
List<Object?> get props => [];
}
class VerticalMoreImageEvent extends VerticalInfinityEvent {
final ScrollUpdateNotification notification;
VerticalMoreImageEvent(this.notification);
List<Object?> get props => [notification];
}
Bloc을 생성하고, Repository를 선언해주자.
class VerticalInfinityBloc
extends Bloc<VerticalInfinityEvent, VerticalInfinityState> {
final InfinityImageRepository _repository = InfinityImageRepository.instance;
VerticalInfinityBloc() : super(const VerticalInfinityState()) {
on<VerticalInitImagesEvent>(_initImages);
on<VerticalMoreImageEvent>(_moerImages);
}
}
페이지 첫 진입시 API를 호출하는 이벤트인데, 여기서 중요한 점은 emit 부분이다.
state.copyWith를 사용하여 변경하고자 하는 상태만 변경해 주고 있다.
type을 로드가 완료된 상태로 변경해주고 pageNo는 현재의 pageNo에 1을 증가하여 상태를 변경해 주었다.
Future<void> _initImages(
VerticalInfinityEvent event, Emitter<VerticalInfinityState> emit) async {
List<InfinityImageModel>? _images =
await _repository.fetchVerticalImages(pageNo: state.pageNo);
if (_images != null) {
emit(state.copyWith(
images: _images,
type: VerticalImageType.loaded,
pageNo: state.pageNo + 1,
));
} else {
emit(state.copyWith(message: "Error"));
}
}
스크롤 값에 의해서 데이터를 더 불러오는 무한 스크롤 이벤트이다. 여기서 페이지의 최대 스크롤 픽셀의 90%를 넘어서면 해당 이벤트가 작동되도록 하였다.
상태를 변경해주고, 1초의 딜레이를 구현한 것을 볼 수 있는데, 잠깐의 로딩바를 띄어주기 위해서 사용하고 있다.
Future<void> _moerImages(
VerticalMoreImageEvent event, Emitter<VerticalInfinityState> emit) async {
if (event.notification.metrics.maxScrollExtent * 0.9 <
event.notification.metrics.pixels &&
state.type != VerticalImageType.more) {
emit(state.copyWith(type: VerticalImageType.more));
List<InfinityImageModel>? _images =
await _repository.fetchVerticalImages(pageNo: state.pageNo);
if (_images != null) {
emit(state.copyWith(
images: [...state.images!, ..._images], pageNo: state.pageNo + 1));
await Future.delayed(const Duration(seconds: 1)).whenComplete(
() => emit(state.copyWith(type: VerticalImageType.loaded)));
} else {
emit(state.copyWith(message: "Error"));
}
}
}
UI에 해당하는 Presentation 레이어 영역은 UI 코드가 너무 많아서 중요한 부분만 살펴보도록 하겠다.
바로 BlocListener 빌더에 대해서 살펴보도록 하자. 위에서 작성한 상태 변화에 따른 리스너 역할을 하는 곳이다.
listener는 상태 변경시 수신되는 곳인데, 우리가 작성한 BLoC의 State가 변화할 때마다 해당 리스너로 수신이 된다. 만일 특정 State의 변경 때에만 수신하고 싶다면 listenWhen을 사용하여 상태 변경을 리스너에 수신시킬 수 있다.
listenWhen은 언제 listener를 수신할 지에 대한 Boolean 타입을 리턴하게 되는데, previous는 이전 State이고, current가 변경되는 State이다.
여기서는 isError가 변경되었을 때에 리스너를 작동시켰다.
listener의 부분에서 상태 변화에 따라 에러 페이지로 라우팅을 시키고 있다.
라우터를 작동할 때에 새로 열리는 페이지에서도 BlocListener를 사용하고 싶어서 BlocProvider.value를 사용하여 상태 주입을 해주었다.
새로 오픈되는 페이지에 BlocProvider를 사용하여 Bloc을 주입하지 않으면, 새로 빌드되는 페이지의 context의 위젯 트리에서 빌더를 사용할 수 없게 된다.
BlocListener<HorizontalInfinityBloc, HorizontalInfinityState>(
listener: (context, state) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => BlocProvider.value(
value: context.read<HorizontalInfinityBloc>(),
child: const InfinityImageErrorScreen())));
},
listenWhen: (previous, current) {
if (!previous.isError) {
return current.isError;
} else {
return false;
}
},
child: body,
),
에러 페이지 코드이다. 여기서도 BlocListener를 사용하였다. 여기서는 왜 사용한 걸까 ?
바로 버튼을 눌러서 리프레시하여 에러가 발생한 이벤트를 다시 실행시켜 데이터를 정상적으로 불러오기 위해서다.
여기서 isError의 상태가 다시 변경이 되면 이번에는 Pop을 호출하여 라우터를 제거하도록 하였다.
BlocListener<HorizontalInfinityBloc, HorizontalInfinityState>(
listener: (context, state) {
if (!state.isError) {
Navigator.of(context).pop();
}
},
child: Scaffold(
appBar: appBar(title: "BLoC Error"),
body: Center(
child: IconButton(
iconSize: 100,
onPressed: () {
HapticFeedback.mediumImpact();
context
.read<HorizontalInfinityBloc>()
.add(HorizontalInitImagesEvent());
},
icon: const Icon(
Icons.refresh_rounded,
)),
),
),
);
https://github.com/boglbbogl/flutter_velog_sample/tree/main/lib/management/infinity_image
BLoC 사용 방법에 대해서 간단한 예제를 통해서 살펴보았는데, 아쉽게도 글로 작성을 하다보니 자세히 다루지 못한 부분이 있어 아쉬운 느낌이 있다.
이해가 어려우시면 직접 소스 코드를 받아 실행해 보시면서 수정해보면 이해가 훨씬 빠르게 될 것이다.
copyWith 방식과 기존의 State 방식의 장단점이 있어 무조건 어떤 방법만 써야된다는 것은 없다. 상황에 맞게 적절하게 사용하면 되는데, 리소스 측면에서 copyWith 방식의 성능이 상대적으로 좋지 않다고 생각이 되는 부분들이 있다.
간단하게 구현할 수 있는 기능은 copyWith 방식을 사용하여 좀 더 빠르게 개발이 가능하기에 서로 다른 방식을 모두 배워두는게 좋다.
마지막으로 BlocListener 부분에 대해서 다시 한번 살펴보도록 하겠다.
개인적으로는 BLoC Pattern이 가지는 가장 큰 장점인 기능이 바로 리스너 부분이라고 생각이 들고, 실제 앱 개발시에 자주 사용되는 부분이기도 해서 따로 살펴보려고 하였다.
프로젝트를 생성하여 앱을 개발하다 보면 에러 처리 / 상태 처리 등의 부분이 발생한다. API를 호출 했는데, 네트워크 에러가 발생할 수도 있고, 특정 상황에 스낵바 / 다이얼로그 등을 노출시켜 줘야 할때도 있다. 이런 상황뿐만 아니라 wifi가 연결되었을 때, 충전기가 연결되었을 때 등의 수신을 하고 있어햐 하는 기능에서도 적극 활용할 수 있는 기능이다.
Provider, Get X 등을 사용한 상태 관리를 해보면, 리스너 부분의 역활이 주로 함수의 리턴에 의존한 처리를 했을 것이다. 상태안에 함수를 선언하여 해당 함수안에서 에러를 처리하여 스낵바를 노출하던지, 함수의 리턴 값에 에러를 리턴 받아 이벤트를 발생시키는 UI 부분에서 context를 찾아 에러를 발생시켰을 것이다.
BLoC Pattern에서는 이러한 부분을 좀 더 분리되어 관리할 수 있도록 해준다.
이번에는 텍스트를 입력 받는데, 대문자 / 숫자 / 특수문자 등이 입력되었을 때에 키보드 포커싱을 해제하고 스낵바로 에러 메시지를 노출시키는 예제를 만들어 보도록 하겠다.
먼저 정확히 어떤 기능인지 예제 결과를 보도록 하자.
Application 레이어 영역의 코드를 우선 작성하도록 하자.
여기서 상태는 초기 상태 / 텍스트가 입력 중인 상태 / 특수 문자를 입력한 에러 상태 / 대문자를 입력한 에러 상태 / 숫자를 입력한 에러 상태를 생성해 주도록 하자.
먼저 상태를 만들어 주도록 하자. 상태의 에러 메시지를 노출할 필요가 있어 message를 상태 관리할 수 있도록 해주었다.
abstract class TextListenerState extends Equatable {
final String message;
const TextListenerState({this.message = ""});
}
초기 상태와 텍스트가 입력되는 상태의 코드인데, 해당 상태들은 에러가 발생한 상태가 아니기 때문에 message 상태는 가질 수 없도록 하였다.
class TextListenerInitState extends TextListenerState {
List<Object?> get props => [];
}
class TextListenerInputState extends TextListenerState {
List<Object?> get props => [];
}
에러 상태의 코드이다.
class TextListenerSpecialErrorState extends TextListenerState {
const TextListenerSpecialErrorState({super.message});
List<Object?> get props => [];
}
class TextListenerUpperErrorState extends TextListenerState {
const TextListenerUpperErrorState({super.message});
List<Object?> get props => [];
}
class TextListenerNumberErrorState extends TextListenerState {
const TextListenerNumberErrorState({super.message});
List<Object?> get props => [];
}
Event는 텍스트가 입력되는 이벤트만 필요하기 때문에 한 개의 이벤트만 생성해 주었다. TextEditingController를 받아올 수 있도록 하였다.
abstract class TextListenerEvent extends Equatable {}
class TextAddListenerEvent extends TextListenerEvent {
final TextEditingController controller;
TextAddListenerEvent(this.controller);
List<Object?> get props => [controller];
}
Bloc을 생성하여 Event, State를 상속받아 이벤트 로직을 생성하도록 하자.
class TextListenerBloc extends Bloc<TextListenerEvent, TextListenerState> {
TextListenerBloc() : super(TextListenerInitState()) {
on<TextAddListenerEvent>(_textAddListener);
}
}
해당 이벤트는 텍스트가 입력될 때마다 실행되는 이벤트로 해당 이벤트가 실행이 되면 먼저 State를 입력 중인 상태인 TextListenerInputState로 변경해 주었다.
TextEditingController로 받아온 이벤트의 가장 마지막 문자열만 확인하면 된다.
해당 문자열이 한글, 알파벳 소문자인 경우가 아닌 경우에 각 상태 에러를 변경해 주도록 하였다.
void _textAddListener(
TextAddListenerEvent event, Emitter<TextListenerState> emit) {
emit(TextListenerInputState());
String _text = event.controller.text.isNotEmpty
? event.controller.text.substring(event.controller.text.length - 1)
: "";
bool _equal = RegExp(r'''[a-zㄱ-ㅎ가-힣ㅏ-ㅣ]''').hasMatch(_text);
if (!_equal && _text.isNotEmpty) {
if (RegExp(r'''[A-Z]''').hasMatch(_text)) {
emit(TextListenerUpperErrorState(message: "대문자 '$_text' 는 입력할 수 없습니다"));
} else if (RegExp(r'''[0-9]''').hasMatch(_text)) {
emit(TextListenerNumberErrorState(message: "숫자 '$_text' 는 입력할 수 없습니다"));
} else {
emit(TextListenerSpecialErrorState(
message: "특수문자 '$_text' 는 입력할 수 없습니다"));
}
event.controller.text =
event.controller.text.substring(0, event.controller.text.length - 1);
}
}
Bloc을 생성하여 BlocListener를 사용하였다.
BlocProvider<TextListenerBloc>(
create: (context) => TextListenerBloc(),
child: BlocListener<TextListenerBloc, TextListenerState>(
listener: (context, state) {
...
}
child: _TextListenerScreen(controller: controller),
),
);
TextFormField onChanged에서 이벤트를 등록하여 텍스트의 변화가 있을 때마다 해당 이벤트를 실행할 수 있도록 해주었다.
Bloc 구조에서 onChanged를 통해서 텍스트 변화를 실행시키는 방식이 좋지 않다고 한다. 저는 간단한 예제를 만들기 위해 사용한 것이니 TextEditingController를 사용하는 경우에는 Stateful 구조에서 텍스트 변화를 수신하는 방식을 사용하는게 맞다고 한다.
TextFormField(
onChanged: (String value) => context.read<TextListenerBloc>()
.add(TextAddListenerEvent(controller)),
controller: controller,
),
해당 리스너 부분의 코드인다. 이제 해당 State가 변경되었을 때에 리스너가 작동이 되어 포커싱을 해제하고 스낵바를 노출하게 된다.
Bloc의 로직과 완전히 분리되어 상태 관리를 할 수 있게 된다.
listener: (context, state) {
if (state is TextListenerUpperErrorState ||
state is TextListenerNumberErrorState ||
state is TextListenerSpecialErrorState) {
FocusScope.of(context).unfocus();
_showSnackbar(context, state.messga);
}
},
https://github.com/boglbbogl/flutter_velog_sample/tree/main/lib/management/text_listener
위에서 Observer를 살펴보면서 상태 변화를 전역에서 수신하는 방법에 대해서 살펴보았는데, 꼭 전연에서 Observer를 등록하지 않아도 각 Bloc 내부에서 상태변화를 수신할 수 있다.
class TestBloc extends Bloc<TestEvent, TestState> {
TestBloc() : super(TestInitState()) {
}
void onChange(Change<FirebaseAuthState> change) {
super.onChange(change);
logger.e(change);
}
void onTransition(
Transition<FirebaseAuthEvent, FirebaseAuthState> transition) {
super.onTransition(transition);
logger.e(transition);
}
}
지금까지 간단한 예제를 사용해서 BLoC과 Cubit의 사용 방법을 살펴 보았는데, 글로 설명을 해야하는 부분 때문에 자세한 설명이 힘들었던 것 같다.
BLoC 패턴이 처음 사용하시는 분들에게 다소 어렵고, 이해가 잘 안될 수 있겠지만 그래도 자주 사용하다 보면 Get이나 Provider보다 오히려 유지 보수가 편해진다는 느낌을 받을 것이다.
앞으로도 다양한 예제를 만들어서 BLoC을 어떻게 사용해야 하는지에 대해 글을 작성해 보도록 하겠다.