꿈꾸는 시스템 디자이너

Flutter 강좌 - [Networking] 백그라운드에서 JSON 데이터 파싱하는 방법 | 정크(Jank) 방지법 본문

Development/Flutter

Flutter 강좌 - [Networking] 백그라운드에서 JSON 데이터 파싱하는 방법 | 정크(Jank) 방지법

독행소년 2019. 7. 9. 20:15

Flutter 강좌 목록 : https://here4you.tistory.com/120

 

 

지난 강좌에서 Future 클래스 사용법에 대해서 알아봤다. Http 프로토콜을 이용해서 특정 URL로 데이터를 요청하고, 그 요청을 수신하여 그 결과를 파싱해서 화면에 출력하고자 할 때 꽤 많은 시간이 소요된다. 이럴 경우 FutureBuilder 클래스를 이용해서 페이지를 구성하면 데이터의 요청/응답수신/데이터처리 과정 동안 프로그래스 인디케이터를 통해 앱이 작동중임을 사용자에게 보고할 수 있고, 모든 과정이 완료된 후 그 결과를 사용자에게 전달할 수 있었다.

이번 강좌에서는 수신한 데이터가 굉장히 큰 경우 이에 대응하는 방법에 대해서 알아본다.

우선 이번 강좌에서 개발할 앱에서 참조하는 URL(https://jsonplaceholder.typicode.com/photos)을 웹브라우저를 통해 접속해보면 다음과 같은 JSON 배열형태의 데이터가 반환된다.

[
  {
    "albumId": 1,
    "id": 1,
    "title": "accusamus beatae ad facilis cum similique qui sunt",
    "url": "https://via.placeholder.com/600/92c952",
    "thumbnailUrl": "https://via.placeholder.com/150/92c952"
  },
  {
    "albumId": 1,
    "id": 2,
    "title": "reprehenderit est deserunt velit ipsam",
    "url": "https://via.placeholder.com/600/771796",
    "thumbnailUrl": "https://via.placeholder.com/150/771796"
  },
  
  ...
  
    {
    "albumId": 100,
    "id": 4998,
    "title": "qui quo cumque distinctio aut voluptas",
    "url": "https://via.placeholder.com/600/315aa6",
    "thumbnailUrl": "https://via.placeholder.com/150/315aa6"
  },
  {
    "albumId": 100,
    "id": 4999,
    "title": "in voluptate sit officia non nesciunt quis",
    "url": "https://via.placeholder.com/600/1b9d08",
    "thumbnailUrl": "https://via.placeholder.com/150/1b9d08"
  },
  {
    "albumId": 100,
    "id": 5000,
    "title": "error quasi sunt cupiditate voluptate ea odit beatae",
    "url": "https://via.placeholder.com/600/6dd9cb",
    "thumbnailUrl": "https://via.placeholder.com/150/6dd9cb"
  }
]

잠깐 살펴보면 albumId, id, title, url, thumbnailUrl로 구성되는 JSON 객체 5000개로 이루어진 JSON 배열이다.

본 강좌에서는 위의 데이터를 수신하고 JSON 배열로 디코딩 한 후 5000개의 앨리먼트를 가지는 리스트로 변환한 후 각 thumbnailUrl에 해당하는 이미지를 그리드뷰로 만들어서 출력하는 앱을 개발한다.

기본적으로 Dart 앱은 싱글 스레드로 동작한다. 싱글 스레드 모델은 코드를 단순화 할 수 있으며, 왠만해서는 충분히 빠르게 동작한다.

그러나 매우 큰 사이즈의 JSON 문서를 파싱할 때와 같이 복잡하고 많은량의 연산을 신속하게 처리해야 할 경우도 있다. 보통 16 밀리세컨드 이상 소요되는 작업의 경우 사용자에게 불편함을 줄 수 있으며 이를 정크(Jank)라고 한다.

조금 더 설명을 하자면, 일반적인 모바일 앱은 GUI를 가지며 항상 사용자와 상호작용할 수 있어야 한다. 만약 어떠한 테스크를 처리에 16 밀리세컨트 이상 소요되어 GUI의 동작에 딜레이가 발생하거나 멈추는 현상이 발생한다면 이러한 현상을 정크라고 하며 정크가 발생하면 사용자는 불편을 느끼게 된다.

정크를 해결하는 방법은 정크가 발생할 수 있는 테스크를 GUI를 처리하는 스레드와 분리하여 별도의 스레드로 할당해 주는 것이다. 이러한 방식은 기존의 안드로이드 앱 개발에서 흔히 사용되던 방식이다. 싱글 스레드 방식인 Flutter의 경우에는 정크 테스크를 백그라운드로 처리하여 포어그라운드의 정크 현상을 예방할 수 있다.

 

1. 패키지 환경 설정

Http관련 API를 사용하기 위해 pubspec.yaml 파일을 다음과 같이 수정한다.

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2
  http: ^0.12.0+2

 

2. 라이브러리 import

소스코드에 import하는 라이브러리들은 다음과 같다.

import 'package:flutter/material.dart';

import 'dart:async'; // async / await 지원
import 'dart:convert'; // JSON 데이터 처리 지원
import 'package:flutter/foundation.dart'; // compute 함수를 제공
import 'package:http/http.dart' as http; // HTTP 프로토콜 지원

 

3. Photo 클래스 구현

서버로부터 수신한 5000개의 사진 정보는 각각 다음과 같은 Photo 클래스의 인스턴스로 저장된다.

// 사진의 정보를 저장하는 클래스
class Photo {
  final int albumId;
  final int id;
  final String title;
  final String url;
  final String thumbnailUrl;

  Photo({this.albumId, this.id, this.title, this.url, this.thumbnailUrl});

  // 사진의 정보를 포함하는 인스턴스를 생성하여 반환하는 factory 생성자
  factory Photo.fromJson(Map<String, dynamic> json) {
    return Photo(
      albumId: json['albumId'] as int,
      id: json['id'] as int,
      title: json['title'] as String,
      url: json['url'] as String,
      thumbnailUrl: json['thumbnailUrl'] as String,
    );
  }
}

 

4. Future 데이터 처리

class MyHomePage extends StatelessWidget {
  final String title;

  MyHomePage({Key key, this.title}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(title)),
      // Photo의 리스트를 처리하는 FutureBuilder 추가
      body: FutureBuilder<List<Photo>>(
        // future 항목에 fetchPhotos 함수 설정. fetchPhotos는 Future 객체를 결과값으로 반환
        future: fetchPhotos(http.Client()),
        // Future 객체를 처리할 빌더
        builder: (context, snapshot) {
          // 에러가 발생하면 에러 출력
          if (snapshot.hasError) print(snapshot.error);
          // 정상적으로 데이터가 수신된 경우
          return snapshot.hasData
              ? PhotosList(photos: snapshot.data) // PhotosList를 출력
              : Center(
                  child: CircularProgressIndicator()); // 데이터 수신 전이면 인디케이터 출력
        },
      ),
    );
  }
}

Scaffold의 body에 FutureBuilder를 추가하고 future 항목에서 fetchPhotos 함수를 설정했다. 이는 fetchPhotos 함수가 Future<List<Photo>> 타입의 결과를 반환함을 의미한다. builder 항목에서는 에러가 발생할 경우 에러 내용을 출력하고, 정상 데이터를 수신한 경우 PhotosList를 출력한다. 초기에는 CircularProgressIndicator가 출력되도록 구현하고 있다.

 

5. 데이터 수신 및 파싱

FutureBuilder의 future 항목에 설정된 fetchPhotos 함수는 다음과 같이 구현된다.

// 서버로부터 데이터를 수신하여 그 결과를 List<Photo> 형태의 Future 객체로 반환하는 async 함수
Future<List<Photo>> fetchPhotos(http.Client client) async {
  // 해당 URL로 데이터를 요청하고 수신함
  final response =
      await client.get('https://jsonplaceholder.typicode.com/photos');

  // parsePhotos 함수를 백그라운도 격리 처리
  return compute(parsePhotos, response.body);
}

// 수신한 데이터를 파싱하여 List<Photo> 형태로 반환
List<Photo> parsePhotos(String responseBody) {
  // 수신 데이터를 JSON 포맷(JSON Array)으로 디코딩
  final parsed = json.decode(responseBody).cast<Map<String, dynamic>>();

  // JSON Array를 List<Photo>로 변환하여 반환
  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

특정 URL의 데이터를 Http 프로토콜의 Get 방식으로 수신한다. 데이터가 수신될때까지 대기하기 위해 async와 await로 선언되어 있는 것도 이해해야 한다.

여기서 compute 함수가 처음 등장한다. compute 함수는 아규먼트로 입력받은 함수를 백그라운드에서 실행시키고 그 결과를 반환한다. 상기 코드로 보면 parsePhotos 함수가 백그라운드로 동작한 후 그 결과를 수신하여 반환한다는 것이다.

parsePhotos 함수에서는 아규먼트로 넘겨받은 데이터를 JSON 배열로 디코딩한 후 List<Photo>로 컨버팅하여 반환한다.

 

6. 그리드 뷰 출력

FutureBuilder로 선한한 fetchPhotos로부터 정상 데이터가 반환되면 PhotoList 가 호출되어 GridView가 구성되어 출력된다.

// 수신된 그림들을 리스트뷰로 작성하여 출력하는 클래스
class PhotosList extends StatelessWidget {
  final List<Photo> photos;

  PhotosList({Key key, this.photos}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 그리드뷰를 builder를 통해 생성. builder를 이용하면 화면이 스크롤 될 때 해당 앨리먼트가 랜더링 됨
    return GridView.builder(
      gridDelegate:
          SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
      itemCount: photos.length,
      itemBuilder: (context, index) {
        var photo = photos[index];
        // 컨테이너를 생성하여 반환
        return Container(
          child: Column(
            children: <Widget>[
              // 이미지의 albumId와 ID 값을 출력
              Text("albumId: ${photo.albumId} / ID: ${photo.id.toString()}"),
              // thumbnailUrl에 해당하는 이미지를 온라인으로부터 다운로드하여 출력
              Image.network(photo.thumbnailUrl)
            ],
          ),
        );
      },
    );
  }
}

 

GridView.builder를 통해 그리드뷰를 구성하면 화면이 스크롤 되면서 동적으로 리스트뷰의 앨리먼트들이 생성된다. 이번 앱과 같이 5000개의 앨리먼트를 가질 경우 모든 앨리먼트를 생성하서 리스트뷰를 구성하는 것은 비효율적이다.

스크롤 된 앨리먼트들은 컴럼으로 생성되어 반환되며, Photo 인스턴으의 tumbnailUrl의 주소의 이미지가 동적으로 다운로드되어 그리드뷰를 구성하게 된다.

 

전체 소스코드는 다음과 같다.

import 'package:flutter/material.dart';

import 'dart:async'; // async / await 지원
import 'dart:convert'; // JSON 데이터 처리 지원
import 'package:flutter/foundation.dart'; // compute 함수를 제공
import 'package:http/http.dart' as http; // HTTP 프로토콜 지원

// 사진의 정보를 저장하는 클래스
class Photo {
  final int albumId;
  final int id;
  final String title;
  final String url;
  final String thumbnailUrl;

  Photo({this.albumId, this.id, this.title, this.url, this.thumbnailUrl});

  // 사진의 정보를 포함하는 인스턴스를 생성하여 반환하는 factory 생성자
  factory Photo.fromJson(Map<String, dynamic> json) {
    return Photo(
      albumId: json['albumId'] as int,
      id: json['id'] as int,
      title: json['title'] as String,
      url: json['url'] as String,
      thumbnailUrl: json['thumbnailUrl'] as String,
    );
  }
}

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final appTitle = 'Isolate Demo';

    return MaterialApp(
      title: appTitle,
      home: MyHomePage(title: appTitle),
    );
  }
}

class MyHomePage extends StatelessWidget {
  final String title;

  MyHomePage({Key key, this.title}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(title)),
      // Photo의 리스트를 처리하는 FutureBuilder 추가
      body: FutureBuilder<List<Photo>>(
        // future 항목에 fetchPhotos 함수 설정. fetchPhotos는 Future 객체를 결과값으로 반환
        future: fetchPhotos(http.Client()),
        // Future 객체를 처리할 빌더
        builder: (context, snapshot) {
          // 에러가 발생하면 에러 출력
          if (snapshot.hasError) print(snapshot.error);
          // 정상적으로 데이터가 수신된 경우
          return snapshot.hasData
              ? PhotosList(photos: snapshot.data) // PhotosList를 출력
              : Center(
                  child: CircularProgressIndicator()); // 데이터 수신 전이면 인디케이터 출력
        },
      ),
    );
  }
}

// 서버로부터 데이터를 수신하여 그 결과를 List<Photo> 형태의 Future 객체로 반환하는 async 함수
Future<List<Photo>> fetchPhotos(http.Client client) async {
  // 해당 URL로 데이터를 요청하고 수신함
  final response =
      await client.get('https://jsonplaceholder.typicode.com/photos');

  // parsePhotos 함수를 백그라운도 격리 처리
  return compute(parsePhotos, response.body);
}

// 수신한 데이터를 파싱하여 List<Photo> 형태로 반환
List<Photo> parsePhotos(String responseBody) {
  // 수신 데이터를 JSON 포맷(JSON Array)으로 디코딩
  final parsed = json.decode(responseBody).cast<Map<String, dynamic>>();

  // JSON Array를 List<Photo>로 변환하여 반환
  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

// 수신된 그림들을 리스트뷰로 작성하여 출력하는 클래스
class PhotosList extends StatelessWidget {
  final List<Photo> photos;

  PhotosList({Key key, this.photos}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 그리드뷰를 builder를 통해 생성. builder를 이용하면 화면이 스크롤 될 때 해당 앨리먼트가 랜더링 됨
    return GridView.builder(
      gridDelegate:
          SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
      itemCount: photos.length,
      itemBuilder: (context, index) {
        var photo = photos[index];
        // 컨테이너를 생성하여 반환
        return Container(
          child: Column(
            children: <Widget>[
              // 이미지의 albumId와 ID 값을 출력
              Text("albumId: ${photo.albumId} / ID: ${photo.id.toString()}"),
              // thumbnailUrl에 해당하는 이미지를 온라인으로부터 다운로드하여 출력
              Image.network(photo.thumbnailUrl)
            ],
          ),
        );
      },
    );
  }
}

 

실행화면은 다음과 같다.

 

 

본 강좌는 Flutter 공식 사이트의 문서를 참고하여 작성되었습니다.

https://flutter.dev/docs/cookbook/networking/background-parsing

 

Parse JSON in the background

By default, Dart apps do all of their work on a single thread. In many cases,this model simplifies coding and is fast enough that it does not result inpoor app performance or stuttering animations, often called jank.However, you might need to perform an ex

flutter.dev

 


Flutter Code Examples 강좌를 추천합니다.

  • 제 블로그에서 Flutter Code Examples 프로젝트를 시작합니다.
  • Flutter의 다양한 예제를 소스코드와 실행화면으로 제공합니다.
  • 또한 모든 예제는 Flutter Code Examples 앱을 통해 테스트 가능합니다.

Flutter Code Examples 강좌로 메뉴로 이동

Flutter Code Examples 강좌 목록 페이지로 이동

Flutter Code Examples 앱 설치 | Google Play Store로 이동

 

Flutter Code Examples - Google Play 앱

Are you a beginner at Flutter? Check out the various features of Flutter through the demo. Source code for all demos is also provided.

play.google.com

Comments