[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을 배워야하는 단점이 있는데 이건 직접 써봐야 할것 같다.