이번 포스트에서는 BLoC 패턴을 이용해서 우주 사진/비디오 데이터를 서버로부터 가져오는 코드를 구현해보겠다
1. BLoC
BLoC 은 Business Logic Component 의 약자로서 로직과 UI 코드를 분리 (decoupling) 하는 패턴이다. 비즈니스 로직과 UI 코드를 분리하여 모듈화 함으로서 관리 및 유지보수가 용이해 지는 장점이 있다. 아래 그림에서 UI 는 플러터 위젯을 사용해서 그림을 그리는 부분을 의미하고, BloC 는 서버로 부터 데이터를 가져와서 처리하는 부분이다. 기본적인 동작은 1) UI가 BLoC에 이벤트를 전달하여 BLoC이 서버로 부터 데이터를 수신하게 하고 2) BLoC은 서버 데이터 수신 후 변경된 State를 UI에 전달하여 UI 가 갱신되게 한다.
설명이 어렵다. 일단 우주 사진을 가져오는 BLoC를 직접 구현해 보면서 이해해 보도록 하자.
2. 우주 사진/비디오 데이터 처리용 BLoC 구현
우리는 서버로 부터 우주 사진/비디오 목록을 받아서 리스트뷰로 보여주는 UI를 구현할 예정이다. 서버로 부터 모든 컨텐츠를 미리 다 받아오는 것은 로딩 시간 및 네트워크 사용량 측면에서 적절하지 않으므로, 서버에서 컨텐츠를 30개 단위로 받아오고 리스트뷰 스크롤이 하단에 도착했을 때, 추가적인 컨텐츠 30개를 리스트뷰에 추가하는 형태로 구현해 보려 한다. (Infinite List)
2.1. 필요 패키지 의존성 추가 (flutter_bloc, equatable, rxdart, http)
pubspec.yaml 을 아래와 같이 수정하자.
...
dependencies:
...
equatable: ^1.0.0 # object 비교를 편하게 하기 위한 패키지
flutter_bloc: ^5.0.0 # Bloc 라이브러리
rxdart: 0.24.1 # Dart 의 스트림의 기능 확장 (이벤트 발생 간격 조정 목적)
http: # http 라이브러리 (서버 요청용)
...
2.2. 이벤트 추가
BLoC 에 데이터 처리를 요청하기 위한 이벤트 두개를 추가해 보자.
- ApodFetched : 추가 20장 사진 데이터를 요청 이벤트
- ApodRefresh : 새로고침 이벤트. 첫 사진부터 Fetch 할 수 있도록 리셋
lib/data 폴더 생성 후, lib/data/apod_event.dart 파일을 아래와 같이 생성하면 된다.
import 'package:equatable/equatable.dart';
abstract class ApodEvent extends Equatable {
@override
List<Object> get props => [];
}
class ApodFetched extends ApodEvent {} // Fetched 이벤트
class ApodRefresh extends ApodEvent {} // Refresh 이벤트
Equeatable ?
Dart에서 '==' 오퍼레이터를 이용한 비교는 레퍼런스 비교를 의미한다. 따라서 런타임 타입과 내용이 같아도 인스턴스가 다르면 '=='의 결과가 'False' 가 된다. Equatable은 '==' 처리 시, 런타임 타입과 내부 데이터까지 모두 비교가능하도록 도와주는 패키지라고 생각하면 된다.
'name' 필드를 가진 Person이라는 클래스가 있다고 가정했을 때,
Eueatable 미 사용 시:
Person('John') == Person('John') 의 결과는 'False'
Equatable 사용 시:
Person('John') == Person('John') 의 결과는 'True'
2.3. 컨텐츠 데이터 타입 추가
BLoC이 서버를 통해 받아온 사진/비디오 데이터를 위한 타입 (클래스) 를 추가하자.
lib/data/apod.dart 파일을 아래와 같이 생성하자.
import 'package:equatable/equatable.dart';
class Apod extends Equatable {
final String description; // 사진/비디오 설명
final String title; // 사진/비디오 제목
final String url; // 사진 위치 / 비디오 썸네일 위치
final String hdUrl; // 고화질 사진 위치 / 비디오 재생 위치
final String dateStr; // 날짜 (문자열)
final DateTime date; // 날짜 (DateTime 타입)
final String copyright; // 저작권
final bool isVideo; // 사진/비디오 구분
const Apod(
{this.description,
this.title,
this.url,
this.hdUrl,
this.dateStr,
this.date,
this.copyright,
this.isVideo = false});
@override
List<Object> get props =>
[description, title, url, hdUrl, dateStr, date, copyright];
@override
String toString() =>
'APOD { title: $title, url: $url, HD url: $hdUrl, date: $dateStr, copyright: $copyright}';
}
2.4. State 추가
BLoC 이 UI에 전달할 세개의 State를 구현해 보자
- ApodInitial : 초기화된 상태 (아무 데이터도 받지 않은 상태)
- ApodFailure : 서버로 부터 데이터 수신을 실패한 상태
- ApodSuccess : 서버로 부터 데이터 수신을 성공한 상태로 현재까지 받아온 데이터를 포함한다.
lib/data/apod_state.dart 파일을 아래와 같이 생성하자.
import 'package:apod/data/apod.dart';
import 'package:equatable/equatable.dart';
abstract class ApodState extends Equatable {
const ApodState();
@override
List<Object> get props => [];
}
class ApodInitial extends ApodState {} // 초기 상태
class ApodFailure extends ApodState {} // 실패 상태
class ApodSuccess extends ApodState { // Fetch 성공 상태
final List<Apod> apods; // 현재까지 받은 사진/비디오 데이터
final int count; // 현재까지 받은 페이지 숫자
final bool hasReachedMax; // 더이상 받을 데이터가 없을 경우 True
const ApodSuccess({
this.apods,
this.count,
this.hasReachedMax,
});
ApodSuccess copyWith({
List<Apod> apods,
int count,
bool hasReachedMax,
}) {
return ApodSuccess(
apods: apods ?? this.apods,
count: count ?? this.count,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
);
}
@override
List<Object> get props => [apods, count, hasReachedMax];
@override
String toString() =>
'ApodSuccess { apods: ${apods.length}, page: $count, hasReachedMax: $hasReachedMax }';
}
2.5. BLoC 추가 - 이벤트/스테이트 처리 기본 코드
이벤트, State를 모두 추가 했으니 이제 본격적으로 BLoC 를 개발해보자.
아래 코드는 이벤트와 State 연동을 위한 mapEventToState() 함수의 구현을 통해 이벤트/스테이트 처리 기본 코드를 작성한 내용이다. 아직 서버 연동 부분인 _fetchApods 함수 구현은 비워 뒀고 다음 섹션에서 추가 하겠다.
코드 주석에 설명을 추가해 두었지만 먼저 아래 스테이트 머신을 참고하기 바란다.
- ApodRefresh 이벤트가 발생 시 ApodInitail 상태로 전이
- ApodFetched 이벤트가 발생 시 ApodSuccess 상태로 전이
- 서버로 부터 데이터를 받고 처리하는 _fetchApods 실패 시 ApodFailure 상태로 전이
lib/data/apod_bloc.dart 파일을 아래와 같이 생성하자.
import 'dart:convert';
import 'package:apod/data/apod.dart';
import 'package:apod/data/apod_event.dart';
import 'package:apod/data/apod_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:rxdart/rxdart.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
class ApodBloc extends Bloc<ApodEvent, ApodState> {
final http.Client httpClient = http.Client();
final pageSize = 30; // 한번의 Fetch 에서 받아올 사진/비디오 개수
ApodBloc() : super(ApodInitial()); // 최초 InitialState로 설정해 둔다.
// 더이상 받아올 데이터가 없을 경우를 확인하는 함수
bool _hasReachedMax(ApodState state) =>
state is ApodSuccess && state.hasReachedMax;
// 이벤트가 연속으로 빠르게 들어와도 500ms 간격으로 처리하도록 하여 불필요한 스팸 event를 skip 시키기 위한 구현
@override
Stream<Transition<ApodEvent, ApodState>> transformEvents(
Stream<ApodEvent> events, transitionFn) {
return events
.debounceTime(const Duration(milliseconds: 500))
.switchMap((transitionFn));
}
// 서버로부터 데이터를 받아오는 함수
Future<List<Apod>> _fetchApods(int count) async {
List<Apod> apods = [];
// 서버로부터 현재까지 받은 컨텐츠 개수 (count) 이후 30장 컨텐츠를 추가로 받는 구현 추가.
// 다음 섹션에서 추가 예정
return apods;
}
@override
Stream<ApodState> mapEventToState(ApodEvent event) async* {
final currentState = state;
if (event is ApodFetched && !_hasReachedMax(currentState)) {
// ApodFetched 이벤트가 발생했고 아직 서버에서 가져올 데이터가 있을 경우
try {
if (currentState is ApodInitial) {
// ApodInitial 상태에서 ApodFetched 이벤트가 발생했을 경우 최초 30장의 사진 요청 후
// 성공 시, ApodSuccess 상태로 변경
final apods = await _fetchApods(0);
yield ApodSuccess(
apods: apods, count: pageSize, hasReachedMax: false);
return;
} else if (currentState is ApodSuccess) {
// ApodSuccess 상태에서 ApodFetched 이벤트가 발생했을 경우,
// 현재까지 받은 사진 이후 사진 30장을 요청 후,
// 성공 시,
// 서버로부터 받아온 사진이 없으면 hasReachedMax 를 true 설정 후 ApodSuccess 상태로 변경
// 서버로부터 받아온 사진이 있으면 hasReachedMax 를 false로 설정 후 ApodSuccess 상태로 변경
final apods = await _fetchApods(currentState.count);
yield apods.isEmpty
? currentState.copyWith(hasReachedMax: true)
: ApodSuccess(
apods: currentState.apods + apods,
count: currentState.count + pageSize,
hasReachedMax: false);
}
} catch (e) {
// 서버 처리 예외 발생 시, ApodFailure 상태로 변경
print(e.toString());
yield ApodFailure();
}
} else if (event is ApodRefresh) {
// ApodRefresh 이벤트 방생 시, ApodInitial 상태로 변경 후, ApodFetched 이벤트 발생시킴.
yield ApodInitial();
this.add(ApodFetched());
}
}
}
2.6. BLoC 추가 - 서버 요청 코드 추가 (_fetchApods)
생성한 lib/data/apod_bloc.dart 파일에 _fetchApods 를 비롯 필요한 함수를 구현해 보자. 참고로 서버는 직접 운영중인 Node.js Express 서버이므로 주소를 공개하지는 않겠다. 서버는 'yyyy-MM-dd' 날짜에 대한 사진 정보를 Restful API 로 제공한다.
날짜로 요청할 경우 응답으로 온 JSON의 데이터 'value' 속성에 JSON 문자열 형태로 데이터를 제공한다. 매일 사진 혹은 비디오 형태의 컨텐츠 정보가 제공 된다.
예를 들어, 2020-12-16로 요청 시 아래와 같이 Video 데이터를 받게 되고
{
"date":"2020-12-16",
"explanation":"What's the matter with the Bullet Cluster? This massive cluster of galaxies (1E 0657-558) creates gravitational lens distortions of background galaxies in a way that has been interpreted as strong evidence for the leading theory: that dark matter exists within. Different analyses, though, indicate that a less popular alternative -- modifying gravity-- could explain cluster dynamics without dark matter, and provide a more likely progenitor scenario as well. Currently, the two scientific hypotheses are competing to explain the observations: it's invisible matter versus amended gravity. The duel is dramatic as a clear Bullet-proof example of dark matter would shatter the simplicity of modified gravity theories. The featured sonified image is a Hubble/Chandra/Magellan composite with red depicting the X-rays emitted by hot gas, and blue depicting the suggested separated dark matter distribution. The sonification assigns low tones to dark matter, mid-range frequencies to visible light, and high tones to X-rays. The battle over the matter in the Bullet cluster is likely to continue as more observations, computer simulations, and analyses are completed. Submitted to APOD: Notable images of the 2020 Geminids Meteor Shower",
"media_type":"video",
"service_version":"v1",
"title":"Sonified: The Matter of the Bullet Cluster",
"url":"https://www.youtube.com/embed/sau5-39wK1c?rel=0"
}
예를 들어, 2020-12-17로 요청 시 아래와 같이 Image 데이터를 받게 된다.
{
"copyright":"Stefano Pellegrini",
"date":"2020-12-17",
"explanation":"Taken over the course of an hour shortly after local midnight on December 13, 35 exposures were used to create this postcard from Earth. The composited night scene spans dark skies above the snowy Italian Dolomites during our fair planet's annual Geminid meteor shower. Sirius, alpha star of Canis Major and the brightest star in the night, is grazed by a meteor streak on the right. The Praesepe star cluster, also known as M44 or the Beehive cluster, itself contains about a thousand stars but appears as a smudge of light far above the southern alpine peaks near the top. The shower's radiant is off the top of the frame though, near Castor and Pollux the twin stars of Gemini. The radiant effect is due to perspective as the parallel meteor tracks appear to converge in the distance. As Earth sweeps through the dust trail of asteroid 3200 Phaethon, the dust that creates Gemini's meteors enters Earth's atmosphere traveling at about 22 kilometers per second.",
"hdurl":"https://apod.nasa.gov/apod/image/2012/GeminidMeteorsStePelle.jpg",
"media_type":"image",
"service_version":"v1",
"title":"Gemini's Meteors",
"url":"https://apod.nasa.gov/apod/image/2012/GeminidMeteorsStePelle1024.jpg"
}
일단, _fetchApods 에서 사용할 url 생성 함수를 만들자.
// 특정 날짜의 사진/비디오 데이터를 가져오기 위한 rest API url
String _getUrl(String date) {
var url = 'http://주소는_공개할수_없어요!/$date';
return url;
}
이어서 _fetchApods를 아래와 같이 만들자. (오늘 - count) 일 전부터 30일 전까지의까지의 데이터를 가져오는 내용이다. 데이터 저장시 중요한 포인트는
1) 비디오의 경우
- Youtube 만 지원, 그 외 서비스는 스킵
- Apod 데이터의 url 필드에 youtube 썸네일 URL 저장
- Apod 데이터의 hdUrl 필드에 youtube 재생 URL 저장
2) 이미지의 경우
- Apod 데이터의 url 필드에 일반 이미지 URL 저장
- Apod 데이터의 hdUrl 필드에 고화질 이미지 URL 저장
// 서버로 부터 데이터를 받아오는 함수
Future<List<Apod>> _fetchApods(int count) async {
List<Apod> apods = [];
// 페이지 단위 처리 (30개 데이터 fetch)
// 마지막 처리된 날짜 이후부터 하루씩 과거 데이터를 가져와서 리스트에 추가.
for (int i = 0; i < pageSize; i++) {
// 가져올 데이터 날짜 계산
var date = DateTime.now().subtract(Duration(days: (count + i)));
var dateStr = DateFormat('yyyy-MM-dd').format(date);
// 서버에 요청
final response = await httpClient.get(_getUrl(dateStr));
if (response.statusCode == 200) {
// 데이터가 정상적이지 않을 경우 Skip
if (response.body.length < 5) continue;
// 서버로 부터 받아온 JSON 형태의 데이터 파싱
var jsonobj = json.decode(response.body);
var value = json.decode(jsonobj[0]['value']);
bool isVideo;
if (value['media_type'] == 'image') {
isVideo = false;
} else if (value['media_type'] == 'video') {
isVideo = true;
} else {
continue;
}
String url;
String hdUrl;
if (isVideo) {
final videoUrl = value['url'];
// 비디오의 경우 Youtube가 아니면 Skip
if (!videoUrl.contains('youtube')) continue;
RegExp regExp = RegExp(
r'.*(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/)|(?:(?:watch)?\?v(?:i)?=|\&v(?:i)?=))([^#\&\?]*).*',
caseSensitive: false,
multiLine: false,
);
// Youtube의 경우 Thumnail 이미지를 가져와서 url에 저장하고
// 비디오 url을 hdUrl 에 저장
final match = regExp.firstMatch(videoUrl).group(1);
url = 'https://img.youtube.com/vi/$match/0.jpg';
hdUrl = value['url'];
} else {
url = value['url'];
hdUrl = value['hdurl'];
}
apods.add(Apod(
title: value['title'],
description: value['explanation'],
url: url,
hdUrl: hdUrl,
dateStr: dateStr,
date: date,
copyright: value['copyright'],
isVideo: isVideo,
));
}
}
return apods;
}
자, 이제 데이터 처리를 위한 BLoC 구현이 완료 되었다. 다음 포스트에서는 BLoC를 이용해서 UI를 그려보자.
댓글