[Flutter] - Retrofit 스터디


이번 프로젝트에 써보려고 Retrofit을 정리해보았다.

Retrofit은 http 관련 코드 생성 라이브러리이다. annotation으로 쉽게 코드를 생성할 수 있다.

공식문서와 아래 영상을 참고하여 스터디 하였다.

https://www.youtube.com/watch?v=GNc20BkoA2w

0. 설치


먼저 pubspect.yaml에 라이브러리를 설치해주자.

dependencies:
  flutter:
    sdk: flutter
  retrofit: ">=3.0.0 <4.0.0"
  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^1.0.0
  retrofit_generator: ">=4.0.0 <5.0.0"
  build_runner: ^2.1.10
  json_serializable: ">4.4.0"

1. 기본 셋팅


먼저 테스트를 위해 HackerNews API를 사용하자.

https://github.com/HackerNews/API

1-1. 모델 생성


모델을 먼저 만들어보자.

import 'package:json_annotation/json_annotation.dart';

part 'news_model.g.dart';

@JsonSerializable()
class NewsModel {
  final int id;
  final String title;
  final String type;
  final String url;

  NewsModel({
    required this.id,
    required this.title,
    required this.type,
    required this.url,
  });

  factory NewsModel.fromJson(Map<String, dynamic> json) =>
      _$NewsModelFromJson(json);

  Map<String, dynamic> toJson() => _$NewsModelToJson(this);
}

위와 같이 내가 생성하려는 모델을 변수와 생성자

그리고 fromJson, toJson 함수를 작성하고 build_runner를 돌려주면 자동으로 .g.dart파일이 생성된다.

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'news_model.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

NewsModel _$NewsModelFromJson(Map<String, dynamic> json) => NewsModel(
      id: json['id'] as int,
      title: json['title'] as String,
      type: json['type'] as String,
      url: json['url'] as String,
    );

Map<String, dynamic> _$NewsModelToJson(NewsModel instance) => <String, dynamic>{
      'id': instance.id,
      'title': instance.title,
      'type': instance.type,
      'url': instance.url,
    };

위와 같이 생성 되었다.

막상 써보니 VSCode Extension에 있는 data class generator를 두고 이걸 굳이 설치해서 build_runner 시간 까지 기다려야할 이유를 모르겠다. freezed라면 몰라도… freezed는 이거 다음에 스터디해보자.

1-2. Repsotiory 생성


이제 retrofit을 이용해서 Repository를 생성해보자.

import 'package:retrofit/retrofit.dart';
import 'package:dio/dio.dart';
import 'package:retrofit_study/model/news_model.dart';

part 'hacker_news_repository.g.dart';

@RestApi(baseUrl: 'https://hacker-news.firebaseio.com/v0')
abstract class HackerNewsRepository {
  factory HackerNewsRepository(Dio dio, {String? baseUrl}) =
      _HackerNewsRepository;

  @GET('/topstories.json')
  Future<List<int>> getTopNews();

  @GET('/item/{id}.json')
  Future<NewsModel> getNewsModelDetail(@Path() int id);
}

위와 같이 작성하고 build_runner를 돌리니 코드가 생성된다.

...

@override
  Future<NewsModel> getNewsModelDetail(id) async {
    const _extra = <String, dynamic>{};
    final queryParameters = <String, dynamic>{};
    final _headers = <String, dynamic>{};
    final _data = <String, dynamic>{};
    final _result = await _dio.fetch<Map<String, dynamic>>(
        _setStreamType<NewsModel>(
            Options(method: 'GET', headers: _headers, extra: _extra)
                .compose(_dio.options, '/item/${id}.json',
                    queryParameters: queryParameters, data: _data)
                .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl)));
    final value = NewsModel.fromJson(_result.data!);
    return value;
  }

...

테스트용 화면에 연결하여 뿌려보자

2. 화면 연결


import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:retrofit_study/repository/hacker_news_repository.dart';

import '../model/news_model.dart';

class NewsListPage extends StatefulWidget {
  const NewsListPage({Key? key}) : super(key: key);

  @override
  State<NewsListPage> createState() => _NewsListPageState();
}

class _NewsListPageState extends State<NewsListPage> {
  late final HackerNewsRepository _hackerNewsRepository;

  @override
  void initState() {
    Dio dio = Dio();

    _hackerNewsRepository = HackerNewsRepository(dio);

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FutureBuilder(
        future: _hackerNewsRepository.getTopNews(),
        builder: (_, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }

          final ids = snapshot.data as List<int>;

          return ListView.builder(
            itemBuilder: (_, index) {
              return FutureBuilder(
                  future: _hackerNewsRepository.getNewsModelDetail(ids[index]),
                  builder: ((_, snapshot) {
                    if (snapshot.connectionState == ConnectionState.waiting) {
                      return const Center(
                        child: CircularProgressIndicator(),
                      );
                    }

                    if (snapshot.data is NewsModel) {
                      return _newsCardWidget(snapshot.data as NewsModel);
                    }

                    return Text("${snapshot.data}");
                  }));
            },
            itemCount: ids.length,
          );
        },
      ),
    );
  }

  Widget _newsCardWidget(NewsModel news) {
    return Column(children: [
      Text(news.id.toString()),
      Text(news.title),
      Text(news.url),
    ]);
  }
}

이렇게 하니 잘 나온다.

3. Annotation


기본 동작은 영상에 나온대로 따라해보았고

이제 공식 문서에 나온 Annotation을 정리해보자.

여기서 3가지가 중요하다.

  • Annotation : GET, POST, DELETE, PATCH, PUT (Rest API)
  • 반환값 T : Future
  • 파라미터 Annotation : @Path(”key”) , @Quries, @Query(“key”)

3-1. Annotation


함수위에 붙이는 Annotation으로 HTTP 방식을 설정한다.

@GET('/topstories.json')
Future<List<int>> getTopNews();

3-2. 반환값


함수의 반환값에 맞춰 코드가 생성된다.

@GET('/topstories.json')
Future<List<int>> getTopNews(); //List<int>로 설정하면
@override
  Future<List<int>> getTopNews() async {
	  ...
    final value = _result.data!.cast<int>(); //int로 형변환하여 나온다.
    return value;
  }

객체를 넣으면 아래와 같이 fromJson으로 셋팅된다.

final value = NewsModel.fromJson(_result.data!);

3-3. 파라미터 Annotation


이 부분이 숙지가 중요할것 같다.

3-3-1. @Path


url endpoint에 동적 데이터를 넣고 싶다면 Path를 통해 넣을 수 있다.

@GET('/item/{id}.json')
Future<NewsModel> getNewsModelDetail(@Path() int id); 

@GET("/NewsModels/{id}")
Future<NewsModel> getNewsModel(@Path("id") String id);
@override
  Future<NewsModel> getNewsModelDetail(id) async {
    ...
    final _result = await _dio.fetch<Map<String, dynamic>>(
        _setStreamType<NewsModel>(
            Options(method: 'GET', headers: _headers, extra: _extra)
                .compose(_dio.options, '/item/${id}.json',
                    queryParameters: queryParameters, data: _data)
                .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl)));
   ...
  }

위와 같이 url에 맞춰서 데이터가 들어간다.

3-3-2. @Query, @Queries


url에 query로 데이터를 날리는 옵션이다.

한번에 Map데이터로 날리는 방법도 있고, 하나씩 설정해주는 방법도 있따.

@GET('/demo')
  Future<String> queries(@Queries() Map<String, dynamic> queries); 

@GET("https://httpbin.org/get")
Future<String> namedExample(
    @Query("apikey") String apiKey,
    @Query("scope") String scope,
    @Query("type") String type,
    @Query("from") int from);
@override
  Future<String> queries(queries) async {
	  ...
    final queryParameters = <String, dynamic>{};
    queryParameters.addAll(queries);
		...
  }

  @override
  Future<String> namedExample(apiKey, scope, type, from) async {
		...
    final queryParameters = <String, dynamic>{
      r'apikey': apiKey,
      r'scope': scope,
      r'type': type,
      r'from': from
    };
    ...
  }

3-3-3. @Body


json으로 Post 데이터를 날리는 방식이다.

이것도 Map으로 날리거나, 객체로 날리는 2가지 방식이 존재한다.

Map으로 날리는 방식은 PATCH에 활용된다. (부분 업데이트)

@PATCH("/NewsModels/{id}")
Future<NewsModel> updateNewsModelPart(
    @Path() String id, @Body() Map<String, dynamic> map);

@PUT("/NewsModels/{id}")
Future<NewsModel> updateNewsModel(
    @Path() String id, @Body() NewsModel NewsModel);
@override
  Future<NewsModel> updateNewsModelPart(id, map) async {
    ...
    final _data = <String, dynamic>{};
    _data.addAll(map);
		...
  }

  @override
  Future<NewsModel> updateNewsModel(id, NewsModel) async {
		...
    final _data = <String, dynamic>{};
    _data.addAll(NewsModel.toJson());
		...
  }

3-3-4. @Part


MultipartFile로 파일을 보낼때 사용하는 Annotation이다.

@POST("http://httpbin.org/post")
Future<void> createNewNewsModelFromFile(@Part() File file);
@override
  Future<void> createNewNewsModelFromFile(file) async {
		 ...
    final _data = FormData();
    _data.files.add(MapEntry(
        'file',
        MultipartFile.fromFileSync(file.path,
            filename: file.path.split(Platform.pathSeparator).last)));
   ...
  }

3-3-5. @Field


FormField를 보낼때 쓰는 Annotation으로 추가적인 Annotation이 필요하다.

@POST("http://httpbin.org/post")
@FormUrlEncoded() //추가적인 Annotation
Future<String> postUrlEncodedFormData(@Field() String hello); //FormField이다.
@override
Future<String> postUrlEncodedFormData(hello) async {
  ...
  final _data = {'hello': hello};
  final _result = await _dio.fetch<String>(_setStreamType<String>(Options(
          method: 'POST',
          headers: _headers,
          extra: _extra,
          contentType: 'application/x-www-form-urlencoded')
      .compose(_dio.options, 'http://httpbin.org/post',
          queryParameters: queryParameters, data: _data)
      .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl)))
	...
}

4. 추가 동작


4-1. HttpResponse로 받기

만약 HttpResponse 객체를 직접 받고 싶다면 아래와 같이 작성하면 받을 수 있다.

@GET("/tasks")
Future<HttpResponse<List<Task>>> getTasks();

4-2. Header 설정

//파라미터에서 설정
@GET("/tasks")
Future<Task> getTasks(@Header("Content-Type") String contentType );

//상단 어노테이션에서 설정
@GET("/tasks")
@Headers(<String, dynamic>{
    "Content-Type" : "application/json",
    "Custom-Header" : "Your header"
})
Future<Task> getTasks();

4-3. 에러 처리

catchError를 통해 dioError 객체를 받아올 수 있다.

client.getTask("2").then((it) {
  logger.i(it);
}).catchError((Object obj) {
  // non-200 error goes here.
  switch (obj.runtimeType) {
    case DioError:
      // Here's the sample to get the failed response error code and message
      final res = (obj as DioError).response;
      logger.e("Got error : ${res.statusCode} -> ${res.statusMessage}");
      break;
    default:
      break;
  }
});

4-4. 멀티 쓰레딩

flutter에서만 사용가능하며 model parse하는 부분을 isolate로 처리하는것 같다.

정말 수많은 데이터를 가져와 파싱하는 구조가 아니면 굳이 필요없어 보인다.

@RestApi(
  baseUrl: "https://5d42a6e2bc64f90014a56ca0.mockapi.io/api/v1/",
  parser: Parser.FlutterCompute,
)
abstract class RestClient {
  factory RestClient(Dio dio, {String baseUrl}) = _RestClient;

  @GET("/task")
  Future<Task> getTask();
  @GET("/tasks")
  Future<List<Task>> getTasks();

  @POST("/task")
  Future<void> updateTasks(Task task);
  @POST("/tasks")
  Future<void> updateTasks(List<Task> tasks);
}
//아래와 같이 deserialize와 serialize를 짝으로 맞춰주면 된다. Top-level인것을 주의하자.
Task deserializeTask(Map<String, dynamic> json) => Task.fromJson(json);
List<Task> deserializeTaskList(List<Map<String, dynamic>> json) =>
    json.map((e) => Task.fromJson(e)).toList();
Map<String, dynamic> serializeTask(Task object) => object.toJson();
List<Map<String, dynamic>> serializeTaskList(List<Task> objects) =>
    objects.map((e) => e.toJson()).toList();

결론


아직 아주 얕은 수준으로 써봤지만 확실히 보일러플레이트 코드를 칠 이유가 없어서 편한것 같다.

그래도 Annotation을 배워야하는 단점이 있는데 이건 직접 써봐야 할것 같다.