[Flutter] Flutter Bloc 디자인 패턴 적용해서 rest api 호출하기

     

안드로이드에서 mvc를 써보고, django에서 mvt를 써보고, Flutter를 하면서 비슷한 게 있지 않을까? 생각해서 검색했더니 bloc 패턴이랑 mvvm 패턴이 가장 많이 쓰이는 것 같았다.

 

그래서 bloc을 한번 적용해보려고 하는데 생각보다 까다롭고 길다. django에서는 대충 소스 하나씩 해서 3개면 끝나는데, bloc은 파일만 몇개를 만드는지... 암튼 medium이나 블로그 찾아서 따라 하는데도 버전이 틀리거나 오타가 있어서 안 되는 부분들이 많아 따로 정리해본다.

 

 

1. 사용할 http call

 

일단 rest api중에 단건 호출인지 다건호출인지가 생각보다 중요했다. 나는 json array형태로 들어오는 api를 처리하고 싶었는데 대부분의 예제는 단건조회만 처리하게 되어있어서, 찾고 찾아서 array 형태를 파싱 하는 글을 찾았다.

 

여기서 사용할 http call은 요거다.

https://jsonplaceholder.typicode.com/posts

 

GET으로 호출하면 100개의 json 객체를 반환한다.

 

get으로 호출. 영어는 아닌듯?

 

 

2. 모델 만들기

bloc이라고 해서 뭐 b, l, o, c 각각 기능이 있는게 아니다. bloc 자체가 하나의 컴포넌트로, mvvm에서 View Model 대신 Bloc이라는 컴포넌트가 대체한다고 생각하면 된다. 따라서 model도 만들어야 한다는 점.

 

model은 어떤 디자인 패턴이나 비슷한 것 같다. 코드 스타일만 다를뿐.. userId와 id, title, body 필드를 갖는 Post 객체를 하나 만들고, post객체를 List로 갖는 Posts 객체를 하나 만든다.

 

// ~/model/model.dart
class Posts{
  late List<Post> posts;

  Posts({required this.posts});

  Posts.fromJson(List<dynamic> json){
    posts = new List<Post>.empty(growable: true);
    json.forEach((value) {
      print(value);
      posts.add(new Post.fromJson(value));
    });
  }
}

class Post{
  int? userId;
  int? id;
  String? title;
  String? body;

  Post({this.userId, this.id, this.title, this.body});

  Post.fromJson(Map<String, dynamic> json){
    userId=json['userId'];
    id=json['id'];
    title=json['title'];
    body=json['body'];
  }
}

 

해외 블로그를 따라하다보면 변수 선언에 있어서 에러가 많이 뜨는데, 플러터가 언제부터인가 null을 명시적으로 처리하도록 바뀌고 나서 초기화가 안된 변수들을 선언할 때는 ?를 항상 붙여줘야 한다. final로 선언하면 되긴 하는데 그럼 명시적인 초기화 영역이 있어야 한다. 여기서는 ?를 사용해서 널 가능성을 표시하도록 해놨다. 

 

fromJosn일 경우 내부 메소드로 새로 선언한 것인데, Json으로 객체 inner value를 세팅하기 위함이다. 상위 객체인 Posts의 경우 Json이 리스트 형태이기 때문에 foreach를 이용해 루프를 돌면서 Post 객체 값을 세팅한다. 만약 Json의 루트 형태가 List가 아니라 단일 필드와 리스트 형태의 복합 형태라면, List<dynamic>이 아니라 Map<String, dynamic> Json 형태로 받아야 한다. Map형태로 한번 받고 그 아래 단계의 필드들이 어떤 형태인지에 따라 알맞은 형태를 넣어주면 되겠다. 

 

 

 

3. API call, repository

실제로 api를 호출하는 통신 interface를 만들차례.

이 부분은 react와 코드가 별반 다를 게 없었다.

 

// ~/api.dart
class PostsApiClient {
  final _baseUrl = 'https://jsonplaceholder.typicode.com';
  final http.Client httpClient;
  PostsApiClient({
    required this.httpClient,
  });

  Future<Posts> fetchPosts() async{
    final url = '$_baseUrl/posts';
    final response = await this.httpClient.get(Uri.parse(url));
    if(response.statusCode != 200){
      throw new Exception('error getting posts');
    }
    final json = jsonDecode(response.body);
    return Posts.fromJson(json);
  }
}

 

여기서 아까 모델 만들 때 사용한 fromJson을 사용한다. json으로부터 객체 읽어오기.

 

flutter에서 특이한 점은 repository라는 컴포넌트를 만든다는 점인데, 뭔가 추상적인 메소드를 만드는 느낌이다. 실제 PostsApiClient가 http 콜 로직을 담당한다면, repository는 추상화한 메소드를 제공하는 너낌이랄까?

 

// ~/api.dart   
class PostsRepository{
  final PostsApiClient postsApiClient;
  PostsRepository({required this.postsApiClient});
  Future<Posts> fetchPosts() async{
    return await postsApiClient.fetchPosts();
  }
}

repository도 별도의 디렉터리를 만들지만, 나는 그냥 api.dart에 넣어버렸다. 하는 일은 postsApiClient를 호출해주는 일. 지금은 fetchPosts인 전체 조회만 가지고 있지만, 만약 다른 것들이 추가되면 PostRepository에서 여러 가지 호출이 가능하도록 만들어질 것이다. 즉 View(Bloc)단에서는 HttpApi에 대한 소스를 쳐다보지도 않아도 된다는 장점이 있다. 

 

 

4. Bloc

 

대망의 Bloc 부분이다. bloc을 만들기 전에 event와 state를 만들어야 한다.

 

state는 데이터의 상태를 관리하는 react의 redux와 비슷한 개념이다. event는 말 그대로 특정 이벤트가 발생했을 때 state를 업데이트해주기 위한 컴포넌트이다.

// ~/bloc/Posts_event.dart
import 'package:equatable/equatable.dart';

abstract class PostsEvent extends Equatable{
  const PostsEvent();
}

class FetchPosts extends PostsEvent{
  const FetchPosts();

  @override
  List<Object> get props => [];
}

이번 예제에서는 앱이 실행되자마자 호출하는 것 말고는 따로 이벤트가 없기 때문에 특별한 건 없다.

 

state에서는 상태를 관리하는데, flutter에서는 Empty, Loading, Loaded, Error 4개의 상태로 구분지어서 관리한다. 각 상태에 맞는 데이터셋을 가지고 있도록 정의해 있다.

 

// ~/bloc/Posts_state.dart
import 'package:equatable/equatable.dart';
import 'package:jin99tutorial/model/model.dart';

abstract class PostsState extends Equatable{
  const PostsState();
  @override
  List<Object> get props => [];
}

class PostsEmpty extends PostsState {}

class PostsLoading extends PostsState {}

class PostsLoaded extends PostsState {
  final Posts posts;
  const PostsLoaded({required this.posts});
  @override
  List<Object> get props => [posts];
}

class PostsError extends PostsState {
  final String message;
  const PostsError(this.message);
  @override
  List<Object> get props => [message];
}

 

 

위 event와 state를 걸고 bloc소스를 만들 수 있다.

 

// ~/bloc/bloc.dart
import 'package:jin99tutorial/api.dart';
import 'package:bloc/bloc.dart';

import 'package:jin99tutorial/bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class PostsBloc extends Bloc<PostsEvent, PostsState>{
  final PostsRepository repository;
  
  PostsBloc({required this.repository}) : super(PostsEmpty());

  PostsState get initialState => PostsEmpty();

  
  @override
  Stream<PostsState> mapEventToState(PostsEvent event) async*{
    if (event is FetchPosts){
      yield PostsLoading();
      try{
        final posts = await repository.fetchPosts();
        print(posts);
        yield PostsLoaded(posts: posts);
      }catch(_){
        yield PostsError("데이터 읽다가 뻑남");
      }
    }
  }
}

 

bloc에 mapEventToState를 보면 특정 이벤트가 일어났을 때 어떻게 하는지 정의가 되어있다. FetchPosts 이벤트가 발생하면 (event is FetchPosts) State를 Loading -> Loaded or Error로 yield 해주는 것을 알 수 있다.

 

즉 뭔지는 모르겠지만 이 Bloc 클래스를 사용하면 Event가 발생하면 State를 위 순서로 바꿔준다는 것이다.

 

 

 

5. 표현 main.dart

 

이제 bloc을 어떻게 사용하는지 main.dart를 보면 된다.

 

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'api.dart';
import 'model/model.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jin99tutorial/bloc/bloc.dart';

class SimpleBlocObserver extends BlocObserver {

  @override
  void onEvent(Bloc bloc, Object? event) {
    super.onEvent(bloc, event);
    print('onEvent : $event');
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    super.onError(bloc, error, stackTrace);
    print('error : $error');
    print('stackTrace : $stackTrace');
  }

  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    print('transition : $transition');
  }
}

void main() {
  Bloc.observer = SimpleBlocObserver();

  final PostsRepository repository = PostsRepository(
    postsApiClient: PostsApiClient(
      httpClient: http.Client(),
    ),
  );
  runApp(MyApp(
    repository: repository,
  ));
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  final PostsRepository repository;

  MyApp({Key? key, required this.repository});

  @override
  Widget build(BuildContext context) {
    String title = 'title입니다.';

    return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.lightGreen,
        ),
        home: Scaffold(
          appBar: AppBar(
            title: Text(title),
          ),
          body: ListView(
            children: [
              BlocProvider(
                  create: (context) => PostsBloc(repository: repository),
                  child: HomePage()
              )
            ],
          ),
          floatingActionButtonLocation: FloatingActionButtonLocation
              .endFloat, // This trailing comma makes auto-formatting nicer for build methods.
        )
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<PostsBloc, PostsState>(
      builder: (context, state) {
        if (state is PostsEmpty) {
          BlocProvider.of<PostsBloc>(context).add(FetchPosts());
        }
        if (state is PostsError) {
          return Center(
            child: Text('failed to fetch Posts'),
          );
        }
        if (state is PostsLoaded) {
          return _buildPost(context, state.posts);
        }
        return Center(
          child: CircularProgressIndicator(),
        );
      },
    );
  }

  Widget _buildPost(BuildContext context, Posts model){
    return ListView.builder(
      scrollDirection: Axis.vertical,
      shrinkWrap: true,
      itemCount: model.posts.length,
      itemBuilder: (context, index) {
        return Container(
          margin: EdgeInsets.all(8.0),
          child: Card(
            child: Container(
              margin: EdgeInsets.all(8.0),
              child: Column(
                children: <Widget>[
                  Text("userid : ${model.posts[index].userId}"),
                  Text("id : ${model.posts[index].id}"),
                  Text("title : ${model.posts[index].title}"),
                  Text("body : ${model.posts[index].body}")
                ],
              ),
            ),
          ),
        );
      },
    );

  }
}

 

 

가장 먼저 BlocObserver라는 것을 사용한다. 이게 옛날 소스에는 BlocObserver가 없고 BlocDelegate를 사용하는데, 최신 flutter에서는 BlocObserver로 대체되었다. 요 Observer를 사용하면 Bloc의 event가 언제 발생하고 state가 어떻게 바뀌는지 모니터링할 수 있다.

 

void main() {
  Bloc.observer = SimpleBlocObserver();
  final PostsRepository repository = PostsRepository(
    postsApiClient: PostsApiClient(
      httpClient: http.Client(),
    ),
  );
  runApp(MyApp(
    repository: repository,
  ));
}

 

생성한 Observer는 main에서 Bloc.observer로 선언하면 된다. 이것 역시 옛날에는 BlocSuperviser라는 클래스가 담당했던 건데 Bloc.observer로 변경되었다.

 

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<PostsBloc, PostsState>(
      builder: (context, state) {
        if (state is PostsEmpty) {
          BlocProvider.of<PostsBloc>(context).add(FetchPosts());
        }
        if (state is PostsError) {
          return Center(
            child: Text('failed to fetch Posts'),
          );
        }
        if (state is PostsLoaded) {
          return _buildPost(context, state.posts);
        }
        return Center(
          child: CircularProgressIndicator(),
        );
      },
    );
  }

 

실제로 state를 보고 Bloc이 실행되는 부분은 BlocBuilder이다. 여기서 state에 따라서 if문 안에 있는 코드가 실행된다. 비어있을 때(PostsEmpty) BlocProvider를 통해서 FetchPosts()를 실행하게 된다.

 

이제 실행해보면 다음과 같이 json결과가 리스트로 쭈우욱 나오게 된다.

 

 

 

 

프로그램이 어떻게 실행되는지 보기 위해서는 디버깅을 하면 정말 편함 

 

 

요렇게 브레이크 포인트를 잡고 한 스텝 한 스텝씩 넘기다 보면 State와 Event에 값이 어떻게 변하는지 트래킹 할 수 있다. 

 

앱일 실행되었을 때 실행되는 거는 했는데 외부에서 데이터가 변했을 때 어떻게 적용하는지 한번 찾아봐야겠다.

반응형

'Study > FrontEnd' 카테고리의 다른 글

[flutter] 플러터 http api 호출해보기  (3) 2022.06.26
[Flutter] Retrofit 적용해서 api 호출하기  (0) 2021.08.26
[Flutter] #3 http interface  (0) 2021.07.27
[Flutter] #2 stateful widget  (0) 2021.07.22
[Flutter] #1 flutter layout  (0) 2021.07.18

댓글

Designed by JB FACTORY