Dandy Now!
  • [Flutter] "Do it! 플러터 앱 프로그래밍" - 네트워크를 이용해 통신하기 | 카카오 API 이용한 책 정보 받아오기, 이미지 파일 내려받기
    2022년 02월 14일 12시 28분 22초에 업로드 된 글입니다.
    작성자: DandyNow
    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
    반응형
    댓글