[Flutter] "Do it! 플러터 앱 프로그래밍" - 네트워크를 이용해 통신하기 | 카카오 API 이용한 책 정보 받아오기, 이미지 파일 내려받기
"조준수. (2021). Do it! 플러터 앱 프로그래밍. 이지스퍼블리싱", 7장을 실습하였다. 네트워크를 연결하여 웹에서 정보를 받아와 화면에 표시하는 기능과 파일을 다운로드하는 기능을 실습하였다. 특별히 "스크롤로 책 정보 가져오기"에서 깨알 같은 오타로 인해 발생한 에러로 크게 애를 먹었다. 콘솔창의 메시지만 진지하게 읽었더라도 덜 고생하고 더 빨리 해결할 수 있었던 에러다!
Do it! 플러터 앱 프로그래밍
플러터 기본 & 고급 위젯은 물론오픈 API와 파이어베이스를 이용한 앱 개발부터 배포까지!플러터 SDK 2.x 버전을 반영한 개정판!이 책은 플러터의 기초부터 고급 활용법까지 다루어 다양한 영역에
book.naver.com
HTTP 통신 실습
http 패키지 설치
pub.dev에서 http 외부 패키지를 찾는다(https://pub.dev/packages/http/install). http 패키지에 대한 설치 방법 및 관련 정보를 확인할 수 있다. cmd 창을 열고 http 패키지를 설치한다.
flutter pub add http
GET 방식 접속
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; // http 패키지 불러오기
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: HttpApp(),
);
}
}
class HttpApp extends StatefulWidget {
@override
State<StatefulWidget> createState() => _HttpApp();
}
class _HttpApp extends State<HttpApp> {
String result = '';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Http Example"),
),
body: Container(
child: Center(
child: Text('$result'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
var url = 'http://www.google.com';
var response = await http.get(Uri.parse(url)); // GET 방식으로 URL에 접속하는 코드
setState(() {
result = response.body;
});
},
child: Icon(Icons.file_download),
),
);
}
}
카카오 API를 이용해 책 정보 받아오기
책 정보 가져오기 / 카드 위젯 꾸미기
카카오 API 키를 이용한다(https://developers.kakao.com/). 앱 키로 REST API 키를 사용한다.
// main.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; // http 패키지 불러오기
import 'dart:convert';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
home: HttpApp(),
);
}
}
class HttpApp extends StatefulWidget {
@override
State<StatefulWidget> createState() => _HttpApp();
}
class _HttpApp extends State<HttpApp> {
String result = '';
List? data; // 사이트로 부터 받아온 데이터를 담을 data 리스트
@override
void initState() {
super.initState();
// initState에서 data 변수를 초기화해야 한다.
data = new List.empty(growable: true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Http Example"),
),
body: Container(
child: Center(
// 데이터가 0이면 '데이터가 없습니다.', 서버로 부터 데이터를 받으면 ListView.builder로 표시하는 삼항 연산자
child: data!.length == 0
? Text(
'데이터가 없습니다.',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
)
: ListView.builder(
itemBuilder: (context, index) {
return Card(
child: Container(
child: Column(
children: <Widget>[
// 카드 위젯 꾸지미기 전
// Text(data![index]['title'].toString()),
// Text(data![index]['author'].toString()),
// Text(data![index]['sale_price'].toString()),
// Text(data![index]['status'].toString()),
Image.network(
data![index]['thumbnail'],
height: 100,
width: 100,
fit: BoxFit.contain,
),
// 카드 위젯 꾸미기
Column(
children: <Widget>[
Container(
width:
// MediaQuery.of(context).size는 현 스마트폰의 화면 크기를 의미한다.
MediaQuery.of(context).size.width -
150, // overflow 방지
child: Text(
data![index]['title'].toString(),
textAlign: TextAlign.center,
),
),
Text(
'저자 : ${data![index]['authors'].toString()}'),
Text(
'가격 : ${data![index]['sale_price'].toString()}'),
Text(
'판매중 : ${data![index]['status'].toString()}'),
],
)
],
mainAxisAlignment: MainAxisAlignment.start,
),
),
);
},
itemCount: data!.length),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
getJSONData();
},
child: Icon(Icons.file_download),
),
);
}
Future<String> getJSONData() async {
// https://dapi.kakao.com/v3/search/book? // 요청할 도메인
// target=title& // 도메인에 요청할 파라미터
// query=doit // query 파라미터에 검색어 doit 전달
var url = 'https://dapi.kakao.com/v3/search/book?target=title&query=doit';
var response = await http.get(Uri.parse(url), headers: {
"Authorization": "KakaoAK ##REST API 키 넣기##"
}); // KakaoAK 대소문자 구분 잘해야 한다.
// print(response.body);
// 데이터를 List 형태의 data 변수에 넣는다.
setState(() {
var dataConvertedToJSON = json.decode(response.body);
List result = dataConvertedToJSON['documents'];
data!.addAll(result);
});
return "Successful";
}
}
검색 기능 추가하기 / 스크롤로 책 정보 가져오기
검색 기능의 추가로 플로팅 버튼을 누를 때마다 검색어가 포함된 총 10권의 책을 받아왔다. 이를 개선해 플로팅 버튼을 한 번만 누른 후 스크롤을 내릴 때마다 이어서 새로운 책을 받아서 표시하도록 했다.
// main.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http; // http 패키지 불러오기
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: HttpApp(),
);
}
}
class HttpApp extends StatefulWidget {
@override
State<StatefulWidget> createState() => _HttpApp();
}
class _HttpApp extends State<HttpApp> {
String result = '';
TextEditingController? _editingController; // 검색 기능 추가용
ScrollController? _scrollController; // 스크롤 내릴 때 마다 새로운 책을 받아서 표시하는 기능 추가용
List? data; // 사이트로 부터 받아온 데이터를 담을 data 리스트
int page = 1; // 스크롤 내릴 때 마다 새로운 책을 받아서 표시하는 기능 추가용
@override
void initState() {
super.initState();
// initState에서 data 변수를 초기화해야 한다.
data = new List.empty(growable: true);
_editingController = new TextEditingController(); // 검색 기능 추가용
// 스크롤 내릴 때 마다 새로운 책을 받아서 표시하는 기능 추가용
_scrollController = new ScrollController();
// addListener 함수를 이용해 스크롤할 때 이벤트를 받도록 처리한다.
// offset은 목록의 현재 위치를 double형 변수로 나타낸다.
// 스크롤할 때마다 offset을 검사해 maxScrollExtent보다 크거나 같고 스크롤 컨트롤러의 position에 정의된 범위를 넘어가지 않으면 목록의 마지막이라고 인식한다.
// 그러면 page를 1만큼 증가한 후 getJSONData 함수를 호출한다.
_scrollController!.addListener(() {
if (_scrollController!.offset >=
_scrollController!.position.maxScrollExtent &&
!_scrollController!.position.outOfRange) {
print('bottom'); // 리스트의 마지막일 때 실행
page++;
getJSONData();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// 앱바에 검색 기능 추가
title: TextField(
controller: _editingController,
style: TextStyle(color: Colors.white),
keyboardType: TextInputType.text,
decoration: InputDecoration(hintText: '검색어를 입력하세요'),
),
),
body: Container(
child: Center(
// 데이터가 0이면 '데이터가 없습니다.', 서버로 부터 데이터를 받으면 ListView.builder로 표시하는 삼항 연산자
child: data!.length == 0
? Text(
'데이터가 없습니다.',
style: TextStyle(fontSize: 20),
textAlign: TextAlign.center,
)
: ListView.builder(
itemBuilder: (context, index) {
print(data![index]['thumbnail']);
return Card(
child: Container(
child: Row(
children: <Widget>[
// 카드 위젯 꾸지미기 전
// Text(data![index]['title'].toString()),
// Text(data![index]['author'].toString()),
// Text(data![index]['sale_price'].toString()),
// Text(data![index]['status'].toString()),
if (data?[index]['thumbnail'] != '')
Image.network(
data![index]['thumbnail'],
height: 100,
width: 100,
fit: BoxFit.contain,
)
else
Container(
height: 100,
width: 100,
),
// 카드 위젯 꾸미기
Column(
children: <Widget>[
Container(
width:
// MediaQuery.of(context).size는 현 스마트폰의 화면 크기를 의미한다.
MediaQuery.of(context).size.width -
150, // overflow 방지
child: Text(
data![index]['title'].toString(),
textAlign: TextAlign.center,
),
),
Text(
'저자 : ${data![index]['authors'].toString()}'),
Text(
'가격 : ${data![index]['sale_price'].toString()}'),
Text(
'판매중 : ${data![index]['status'].toString()}'),
],
)
],
mainAxisAlignment: MainAxisAlignment.start,
),
),
);
},
itemCount: data!.length,
// 스크롤 내릴 때 마다 새로운 책을 받아서 표시하는 기능 추가
controller: _scrollController,
),
),
),
floatingActionButton: FloatingActionButton(
// 버튼을 누를 때마다 기존 내용을 지우고 페이지를 1로 초기화
onPressed: () {
page = 1;
data!.clear();
getJSONData();
},
child: Icon(Icons.search),
),
);
}
Future<String> getJSONData() async {
// https://dapi.kakao.com/v3/search/book? // 요청할 도메인
// target=title& // 도메인에 요청할 파라미터
// query=doit // query 파라미터에 검색어 doit 전달
// var url = 'https://dapi.kakao.com/v3/search/book?target=title&query=doit';
// 검색 기능 추가
// 검색어가 포함된 총 10권의 책을 서버로 부터 받아서 표시한다.
// var url = 'https://dapi.kakao.com/v3/search/book?'
// 'target=title&query=${_editingController!.value.text}';
// 스크롤을 내릴 때 마다 이어서 새로운 책을 받아서 표시하기 위해 page파라미터 추가
var url =
'https://dapi.kakao.com/v3/search/book?target=title&page=$page&query=${_editingController!.value.text}';
// KakaoAK 대소문자 구분 잘해야 한다.
var response = await http.get(Uri.parse(url),
headers: {"Authorization": "KakaoAK ##REST API 키 넣기##"});
print(response.body); // 검색 결과 로그창으로 확인
// 데이터를 List 형태의 data 변수에 넣는다.
setState(() {
var dataConvertedToJSON = json.decode(response.body);
List result = dataConvertedToJSON['documents'];
data!.addAll(result);
});
// return "Successful";
return response.body;
}
}
실습 중 만난 에러
[그림 4]는 실습 중 만난 에러이다. 구글링도 해보고, 저자의 깃허브 코드와 비교도 해봤지만 좀처럼 잡히지 않았다.
콘솔창의 메시지에서 힌트를 얻어 query parameter를 다시 찬찬히 확인했더니 query를 qurey라고 쳐 놓았다. 깨알 같은 오타다!
I/flutter (18383): {"errorType":"MissingParameter","message":"query parameter required"}
이미지 파일 내려받기
dio, path_provider 패키지 설치
pub.dev에서 dio, path_provider 외부 패키지를 찾아 설치한다. (https://pub.dev/packages/http/install). dio는 파일을 내려받는 데 도움을 주는 패키지이고, path_provider는 내부 저장소를 이용하는 패키지이다.
flutter pub add dio
flutter pub add path_provider
내려받기 진행 상황 표시하기
용량이 큰 파일을 내려받아야 할 때 화면에 아무런 정보가 없으면 사용자는 앱이 멈춘 것으로 착각한다(3초 이상이라면). 화면에 진행 상황을 표시하여 UX를 향상한다.
// main.dart
import 'package:flutter/material.dart';
import 'largeFileMain.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: LargeFileMain(),
);
}
}
// largeFileMain.dart
import 'package:file/memory.dart';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io'; // 파일 입출력을 돕는 io 패키지
class LargeFileMain extends StatefulWidget {
@override
State<StatefulWidget> createState() => _LargeFileMain();
}
class _LargeFileMain extends State<LargeFileMain> {
// 내려받을 이미지 주소
final imgUrl =
'https://images.pexels.com/photos/5019409/pexels-photo-5019409.jpeg'
'?auto=compress';
bool downloading = false; // 지금 내려받는 중인지 확인하는 변수
var progressString = ""; // 현재 얼마나 내려받았는지 표시하는 변수
String file = ""; // 내려받은 파일
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Large File Example'),
),
body: Center(
child: downloading
? Container(
height: 120.0,
width: 200.0,
child: Card(
color: Colors.black,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CircularProgressIndicator(),
SizedBox(
height: 20.0,
),
Text(
'Downloading File: $progressString',
style: TextStyle(
color: Colors.white,
),
),
],
),
),
)
: FutureBuilder(
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState
.none: // FutureBuilder.future가 null일 때
print('none');
return Text('데이터 없음');
case ConnectionState
.waiting: // 연결되기 전(FutureBuilder.future에서 데이터를 반환받지 않았을 때)
print('waiting');
return CircularProgressIndicator();
case ConnectionState.active: // 하나 이상의 데이터를 반환받을 때
print('active');
return CircularProgressIndicator();
case ConnectionState.done: // 모든 데이터를 받아서 연결이 끝날 때
print('done');
if (snapshot.hasData) {
return snapshot.data as Widget;
}
}
print('end process');
return Text('데이터 없음');
},
future: downloadWidget(file),
)),
floatingActionButton: FloatingActionButton(
onPressed: () {
downloadFile();
},
child: Icon(Icons.file_download),
),
);
}
// downloadWidget은 이미지 파일이 있는지 확인해서 있으면 이미지를 화면에 보여주는 위젯을 반환하고, 없으면 'No Data'라는 텍스트를 출력한다.
Future<Widget> downloadWidget(String filePath) async {
File file = File(filePath);
bool exist = await file.exists();
new FileImage(file).evict(); // 캐시 초기화하기, evict()로 캐시를 비우면 같은 이름이라도 갱신한다.
if (exist) {
return Center(
child: Column(
children: <Widget>[Image.file(File(filePath))],
),
);
} else {
return Text('No Data');
}
}
Future<void> downloadFile() async {
// dio를 선언한 후 내부 디렉터리를 가져온다.
Dio dio = Dio();
try {
// getApplicationDocumentsDirectory는 path_provider 패키지가 제공하며 플러터 앱의 내부 디렉터리를 가져오는 역할을 한다.
var dir = await getApplicationDocumentsDirectory();
// dio.download를 이용해 url에 담긴 주소의 파일 내려 받는다.
await dio.download(imgUrl, '${dir.path}/myimage.jpg',
onReceiveProgress: (rec, total) {
// onReceiveProgress함수를 실행해 진행 상황을 표시한다.
print('Rec: $rec, Total:$total');
file = '${dir.path}/myimage.jpg';
setState(() {
downloading = true; // 내려받기 시작
progressString =
((rec / total) * 100).toStringAsFixed(0) + '%'; // 얼마나 내려 받았는지 계산
});
});
} catch (e) {
print(e);
}
setState(() {
downloading = false; // 내려받기 끝
progressString = 'Completed';
});
print('Download completed');
}
}
URL을 직접 입력해 내려받기
[그림6]에서 URL 입력할 때 텍스트필드를 더블 클릭하면 복사/붙여넣기가 가능하다.
// largeFileMain.dart
import 'package:file/memory.dart';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart'; // 내부 저장소를 이용하는 패키지
import 'dart:io'; // 파일 입출력을 돕는 io 패키지
class LargeFileMain extends StatefulWidget {
@override
State<StatefulWidget> createState() => _LargeFileMain();
}
class _LargeFileMain extends State<LargeFileMain> {
// 내려받을 이미지 주소
// final imgUrl =
// 'https://images.pexels.com/photos/5019409/pexels-photo-5019409.jpeg'
// '?auto=compress';
bool downloading = false; // 지금 내려받는 중인지 확인하는 변수
var progressString = ""; // 현재 얼마나 내려받았는지 표시하는 변수
String file = ""; // 내려받은 파일]
TextEditingController? _editingController; // URL 직접 입력해 내려받기
// URL 직접 입력해 내려받기
@override
void initState() {
super.initState();
_editingController = new TextEditingController(
text:
'https://images.pexels.com/photos/5019409/pexels-photo-5019409.jpeg'
'?auto=compress');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// title: Text('Large File Example'),
// URL 직접 입력해 내려받기 TextField
title: TextField(
controller: _editingController,
style: TextStyle(color: Colors.white),
keyboardType: TextInputType.text,
decoration: InputDecoration(hintText: 'url 입력하세요'),
),
),
body: Center(
child: downloading
? Container(
height: 120.0,
width: 200.0,
child: Card(
color: Colors.black,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CircularProgressIndicator(),
SizedBox(
height: 20.0,
),
Text(
'Downloading File: $progressString',
style: TextStyle(
color: Colors.white,
),
),
],
),
),
)
: FutureBuilder(
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState
.none: // FutureBuilder.future가 null일 때
print('none');
return Text('데이터 없음');
case ConnectionState
.waiting: // 연결되기 전(FutureBuilder.future에서 데이터를 반환받지 않았을 때)
print('waiting');
return CircularProgressIndicator();
case ConnectionState.active: // 하나 이상의 데이터를 반환받을 때
print('active');
return CircularProgressIndicator();
case ConnectionState.done: // 모든 데이터를 받아서 연결이 끝날 때
print('done');
if (snapshot.hasData) {
return snapshot.data as Widget;
}
}
print('end process');
return Text('데이터 없음');
},
future: downloadWidget(file),
)),
floatingActionButton: FloatingActionButton(
onPressed: () {
downloadFile();
},
child: Icon(Icons.file_download),
),
);
}
// downloadWidget은 이미지 파일이 있는지 확인해서 있으면 이미지를 화면에 보여주는 위젯을 반환하고, 없으면 'No Data'라는 텍스트를 출력한다.
Future<Widget> downloadWidget(String filePath) async {
File file = File(filePath);
bool exist = await file.exists();
new FileImage(file).evict(); // 캐시 초기화하기, evict()로 캐시를 비우면 같은 이름이라도 갱신한다.
if (exist) {
return Center(
child: Column(
children: <Widget>[Image.file(File(filePath))],
),
);
} else {
return Text('No Data');
}
}
Future<void> downloadFile() async {
// dio를 선언한 후 내부 디렉터리를 가져온다.
Dio dio = Dio();
try {
// getApplicationDocumentsDirectory는 path_provider 패키지가 제공하며 플러터 앱의 내부 디렉터리를 가져오는 역할을 한다.
var dir = await getApplicationDocumentsDirectory();
// dio.download를 이용해 url에 담긴 주소의 파일 내려 받는다.
// dio.download() 함수의 첫 번째 인자로 사용자가 입력한 주소(_editingController)를 전달
await dio
.download(_editingController!.value.text, '${dir.path}/myimage.jpg',
onReceiveProgress: (rec, total) {
// onReceiveProgress함수를 실행해 진행 상황을 표시한다.
print('Rec: $rec, Total:$total');
file = '${dir.path}/myimage.jpg';
setState(() {
downloading = true; // 내려받기 시작
progressString =
((rec / total) * 100).toStringAsFixed(0) + '%'; // 얼마나 내려 받았는지 계산
});
});
} catch (e) {
print(e);
}
setState(() {
downloading = false; // 내려받기 끝
progressString = 'Completed';
});
print('Download completed');
}
}