언어·프레임워크/Flutter

[Flutter] "Do it! 플러터 앱 프로그래밍" - 네트워크를 이용해 통신하기 | 카카오 API 이용한 책 정보 받아오기, 이미지 파일 내려받기

DandyNow 2022. 2. 14. 12:28
728x90
반응형

"조준수. (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),
      ),
    );
  }
}

 

[그림 1] http 패키지 GET 방식 www.google.com 접속

 


 

카카오 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";
  }
}

 

[그림 2]

 

검색 기능 추가하기 / 스크롤로 책 정보 가져오기

검색 기능의 추가로 플로팅 버튼을 누를 때마다 검색어가 포함된 총 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;
  }
}

 

[그림 3] 검색, 스크롤 기능 추가

 

실습 중 만난 에러

[그림 4]는 실습 중 만난 에러이다. 구글링도 해보고, 저자의 깃허브 코드와 비교도 해봤지만 좀처럼 잡히지 않았다.

[그림 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');
  }
}

 

[그림 5] 내려받기 진행 상황 표시

 

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');
  }
}

 

[그림 6] URL을 직접 입력해 내려받기

 

728x90
반응형