-
Chapter 8: In Practice: Project "Collage"Raywenderlich/Combine: Asynchronous Programming 2020. 8. 11. 05:09
Version
Swift 5.3, iOS 14, Xcode 12
지난 몇 장(chapters)에서 publishers, subscribers, 모든 종류의 다양한 운영자(operators)를 사용하는 방법에 대해 많은 것을 배웠다. Swift playground에서 "안전(safety)"하게 연습했다. 그러나 이제는 실제 iOS 앱에 이러한 새로운 기술을 적용한다(get your hands dirty).
이 섹션(section)을 마무리하려면, 새롭게 학습한 Combine 지식(knowledge)을 적용할 수 있는 실제(real-life) 시나리오(scenarios)가 포함된 프로젝트(project)를 진행한다. 이 프로젝트(project)는 콜라주(Collage) 라고 하며, 사용자가 다음과 같이 사진으로 간단한 콜라주(collages)를 만들 수 있는 iOS 앱이다.
이 프로젝트(project)에서 다음과 같은 작업을 수행한다:
- UIKit 뷰 컨트롤러(view controllers)에서 Combine의 publishers를 사용한다.
- Combine으로 사용자 이벤트(events) 처리(handling)한다.
- 뷰 컨트롤러(view controllers) 사이를 탐색(navigating)하고, publishers를 사용해 데이터를 교환(exchanging)한다.
- 여러(variety) 연산자(operators)를 사용하여 앱의 논리(logic)를 구현하기 위한 다양한 구독(subscriptions)을 만든다.
- Combine 코드에서 편리하게 사용할 수 있도록, 기존 Cocoa API를 래핑(wrapping)한다.
위의 모든 내용은 많은 양의 작업이 필요하지만, 더 많은 연산자(operators)에 대해 배우기 전에 Combine에 대한 실제 경험을 얻을 수 있으며, 이론이 가득한(theory-heavy) 장(chapters)에서 벗어난 멋진 휴식(break)이다.
이 장(chapter)은 이 책에서 지금까지 다룬 내용을 자습서(tutorial) 형식(style) 기반으로 한 작업을 수행한다.
또한, 나중에 소개할 몇 가지 연산자(operators)를 사용한다.
Getting started with "Collage"
Collage를 시작하려면, 이 장(chapter)에 제공된 시작(starter) 프로젝트(project)를 열고, 프로젝트(project) 탐색기(navigator)에서 Assets/Main.storyboard를 선택한다. 앱의 구조(structure)는 다소 간단(simple)하다. 콜라주(collages)를 만들고, 미리볼(preview) 수 있는 main view controller와 사용자가 현재 콜라주(collages)에 추가할 사진을 선택하는 additional view controller가 있다:
Note: 이 장(chapter)에서는 Combine 데이터 워크 플로(workflows)와 UIKit 사용자 컨트롤(controls) 및 이벤트(events)를 통합(integrating)하는 작업을 수행한다. 이 장(chapter)의 내용을 작업하는 데 UIKit에 대한 깊은(deep) 지식(knowledge)이 필요하지는 않지만, UIKit 관련(relevant) 코드가 작동 방식이나 프로젝트(project)에 포함 된 UI 코드의 세부(details) 사항은 다루지 않는다.
현재 프로젝트(project)는 앞서 언급한(aforementioned) 논리(logic)를 구현(implement)하지 않았다. 그러나 활용할 수 있는 일부 코드가 포함되어 있어서, Combine 관련(related) 코드에만 집중할 수 있다. 먼저, 현재 콜라주(collage)에 사진을 추가하는 사용자 상호 작용(interaction)을 살펴본다.
MainViewController.swift를 열고, 파일 상단에서 Combine 프레임 워크(framework)를 가져온다(import):
import Combine
이렇게 하면이 파일에서 Combine을 사용할 수 있다. MainViewController 클래스(class)에 두 개의 private 속성(properties)을 추가하는 것으로 시작한다:
private var subscriptions = Set<AnyCancellable>() //현재 뷰 컨트롤러의 라이프 사이클에 연결된 UI 구독을 저장하는 집합 private var images = CurrentValueSubject<[UIImage], Never>([]) //PassthroughSubject 대신 CurrentValueSubject를 사용하는 것이 좋다.
subscriptions
은 현재 뷰 컨트롤러(view controller)의 라이프 사이클(lifecycle)에 연결된 UI 구독(subscriptions)을 저장(store)하는 집합(set)이다. 현재 뷰 컨트롤러(current view controller)의 수명주기(lifecycle)에 UI 컨트롤(controls)을 바인딩(bind)할 때, 일반적으로 이러한 subscriptions이 필요하다. 이렇게 하면, 뷰 컨트롤러(view controller)가 네비게이션(navigation) 스택(stack)에서 배출(popped out)되거나 해제(dismissed)되는 경우 모든 UI 구독(subscriptions)을 즉시 취소(canceled)할 수 있다.Note: 1장(chapter), "Hello, Combine!"에서 언급했듯이, subscribers는 구독(subscription)을 수동(manually)으로 취소(canceling)할 수 있도록
Cancellable
토큰(token)을 반환(return)한다.AnyCancellable
은 위의 코드에서와 같이 동일한 컬렉션(collection)에 다른 유형의 취소 가능(cancelables) 항목을 저장할 수 있도록 하는 유형 삭제형(type-erased type)이다.images
를 사용하여 현재 콜라주(collage)에 대해 사용자가 현재 선택한(selected) 사진을 내보낸다. 데이터를 UI 컨트롤(controls)에 바인딩(bind)할 때,PassthroughSubject
대신CurrentValueSubject
를 사용하는 것이 가장 적합(suitable)하다. 이는 구독(subscription)시 최소 하나의 값(value)이 전송(sent)되고, UI가 정의되지 않은(undefined) 상태(state)를 가지지 않도록 항상 보장(guarantees)한다. 즉, 잘못된(broken) 상태(state)에서도, 초기(initial) 값(value)을 기다리지 않는다.다음으로 콜라주(collage)에 이미지를 추가하고, 테스트하는 코드를
actionAdd()
에 추가한다:let newImages = images.value + [UIImage(named: "IMG_1907.jpg")!] //IMG_1907.jpg 이미지를 현재 images의 배열 값에 추가한다. images.send(newImages) //subject를 통해 모든 구독자가 이를 수신한다.
사용자가 화면의 우측 상단 모서리에 있는 + 버튼(button)을 누를 때마다, IMG_1907.jpg를 현재
images
배열(array) 값에 추가하고 해당 값을 subject으로 전송(send)하므로 모든 subscribers가 이를 수신(receive)하게 된다.프로젝트(project)의 자산 카탈로그(Asset Catalog)에서 IMG_1907.jpg를 확인할 수 있다. 몇 년 전에 바르셀로나 근처에서 찍은 멋진 사진이다.
현재 선택한 사진(selected photos)을 지우려면(clear),
actionClear()
로 이동하여 다음을 추가한다:images.send([]) //빈 배열을 보내 이미지를 지운다.
단순히
images
subject를 통해 빈(empty) 배열(array)을 전송해(sends), 모든 subscribers에게 전달한다.마지막으로 화면의 미리보기(preview)에
images
subject를 바인딩(bind)하는 코드를viewDidLoad()
끝에 다음을 추가한다:images //현재 사진 모음에 대한 구독을 시작한다. .map { photos in //단일 콜라주로 변환한다. //photos는 [UIImage]. 계속 이미지가 추가 되도, CurrentValueSubject는 배열 하나뿐이다. UIImage.collage(images: photos, size: collageSize) //UIImage+Collage.swift에 정의되어 있는 헬퍼 메서드 } .assign(to: \.image, on: imagePreview) //콜라주 이미지를 imagePreview.image로 바인딩한다. .store(in: &subscriptions) //저장 //뷰 컨트롤러의 수명주기에 연결하여 메모리관리를 할 수 있게 한다.
새로운 구독(subscription)을 확인해 본다. 앱을 빌드(build)하고 실행(run)한 후, + 버튼(button)을 몇 번 클릭한다. + 를 클릭할 때마다 동일한 사진의 사본(copy)이 하나 더 포함된 콜라주(collage)의 미리보기(preview)가 표시된다:
assign을 사용한 바인딩(binding)을 사용해, 사진 컬렉션(collection)을 가져와 콜라주(collage)로 변환(convert)하고 단일 구독(subscription)으로 이미지 뷰(image view)에 할당(assign)할 수 있다!
그러나 일반적인(typical) 시나리오(scenario)에서는 하나가 아닌 여러 개의 UI 컨트롤(control)를 업데이트(update)해야 한다. 각 바인딩(bindings)에 대해 별도의 구독(subscriptions)을 작성하는 것은 때때로 과한 것(overkill)일 수 있다. 따라서, 여러 개의 업데이트(updates)를 단일(single) 배치(batch)로 수행(perform)하는 방법을 살펴본다.
MainViewController
에는 UI 전반에 걸쳐 다양한 업데이트를 수행하는updateUI(photos:)
라는 메서드(method)가 이미 포함되어 있다. 이 메서드(method)는 현재 선택 항목(current selection)이 홀수(odd)인 경우 저장(Save) 버튼(button)을 비활성화(disables)하며, 진행중(progress)인 콜라주(collage)가 있을 때에는 지우기(Clear) 버튼(button)을 활성화(enables)한다.사용자가 콜라주(collage)에 사진을 추가할 때마다,
upateUI(photos:)
를 호출하려면handleEvents
연산자(operator)를 사용한다. 앞서 언급한 바와 같이 일부 UI 업데이트(updating), 로깅(logging) 등과 같은 부과 효과(side effects)를 수행할 때마다 사용할 수 있는 연산자(operator)이다.viewDidLoad()
로 돌아가서,map
을 사용하는 행(line) 바로 앞에 이 연산자(operator)를 삽입(insert)한다:.handleEvents(receiveOutput: { [weak self] photos in self?.updateUI(photos: photos) })
Note:
handleEvents
연산자(operator)를 사용하면 publisher가 이벤트(event)를 내보낼(emits) 때 부과 효과(side effects)를 수행(perform)할 수 있다. 이에 대한 자세한 내용은 10장, "Debugging"에서 학습한다.이렇게하면 현재 선택 항목(selection)이
map
연산자(operator) 내에서 단일(single) 콜라주(collage) 이미지로 변환되기(converted) 전에updateUI(photos:)
에 제공된다.프로젝트(project)를 다시 실행(run)하면, 미리보기(preview) 아래에 있는 두 개의 버튼(button)이 비활성화(disabled)되어 있는 것을 알 수 있다. 이는 올바른 초기(initial) 상태(state)이다:
현재 콜라주(collage)에 사진을 더 추가하면, 버튼(button) 상태(state)가 계속 변경된다. 예를 들어, 하나 또는 세 개의 사진을 선택하면 저장(Save) 버튼이 비활성화(disabled)되지만 다음과 같이 지우기(Clear)가 활성화(enabled)된다:
Talking to other view controllers
subject로 UI의 데이터를 보내고(route), 화면의 일부 컨트롤(controls)에 바인딩(bind)하는 것이 얼마나 쉬운지 확인했다. 이번에는 새로운 뷰 컨트롤러(view controller)를 표시(presenting)하고, 사용자가 이 컨트롤러 사용을 마치면 일부 데이터를 다시 가져오는 또 다른 일반적인(common) 작업을 수행한다.
두 뷰 컨트롤러(view controller) 간에 데이터를 교환(exchanging)하는 일반적인 작업은 동일한 뷰 컨트롤러(view controller)에서 subject를 구독(subscribing)하는 것과 정확히 동일하다. 결국 출력(output)을 내보내(emits)는 publisher가 있고, 내보낸(emitted) 값(values)으로 유용한 것을 만들기 위해 subscriber를 사용한다.
actionAdd()
로 다시 스크롤(scroll back)하여, 해당 메서드(method)의 기존 본문(body)을 주석(comment) 처리한 대신 다음 코드를 추가한다:let photos = storyboard!.instantiateViewController(withIdentifier: "PhotosViewController") as! PhotosViewController //스토리보드에서 PhotosViewController를 인스턴스화한다. navigationController!.pushViewController(photos, animated: true) //해당 뷰 컨트롤러를 navigation stack으로 push한다.
이 코드는 프로젝트(project)의 스토리보드(storyboard)에서
PhotosViewController
를 인스턴스화(instantiates)하여 탐색 스택(navigation stack)에 푸시(pushes)한다. 사진(photos) 라이브러리(library)에 접근(accessing)하고, 사용 가능한 사진 목록(list)을 표시하는 것은 특별히 Combine과 관련된 작업이 아니므로 해당 코드는 이미 구현(fleshed out)되어 있다.PhotosViewController.swift를 열어보면,
viewDidLoad()
에는 이미 카메라 롤(Camera Roll)에서 사진을 로드(load)하여 컬렉션 뷰(collection view)에 표시하는 코드가 포함되어 있는 것을 알 수 있다.다음 작업은 뷰 컨트롤러(view controller)에 subject를 추가하고, 사용자가 카메라 롤(Camera Roll) 목록(list)에서 탭한 이미지를 내보내(emit)는 것이다.
먼저, 이전과 마찬가지로 PhotosViewController.swift에서 Combine을 가져온다:
import Combine
구독(subscribe)하고 전송(send)하는 메인 뷰 컨트롤러(main view controller)의
images
subject와 달리, 이 뷰 컨트롤러(view controller)에서는 다른 소비자(consumers)만 구독(subscribe)할 수 있도록 허용하는 읽기 전용(read-only) publisher가 된다. 즉,PhotosViewController
내에서만 값(values)을 보낼(send) 수 있고,MainViewController
에서만 구독(subscribe)할 수 있도록 한다. 이를 위해 또 다른 일반적(common) 패턴(pattern)을 사용한다. private publisher를 추상화(abstracting)하여, publisher를 공개적으로(publicly) 노출(exposing)한다.각
MARK
주석(comments) 아래에 public 및 private 속성을 추가한다:// MARK: - Public properties var selectedPhotos: AnyPublisher<UIImage, Never> { return selectedPhotosSubject.eraseToAnyPublisher() //eraseToAnyPublisher()하면 type이 erasure되기 때문에 send를 사용할 수 없다. } // MARK: - Private properties private let selectedPhotosSubject = PassthroughSubject<UIImage, Never>()
이 코드를 사용하면 현재 유형(type)에서
selectedPhotosSubject
를 사용하여 값(values)을 보낼(send) 수 있지만, 다른 유형은 유형이 지워진(type-erased)selectedPhotos
에만 접근(access)하여 구독(subscribe)할 수 있다. 예를 들어,selectedPhotos
는 publisher를 통해 새 값을 보내는(send) 데 사용할 수 없다. 컬렉션 뷰(collection view) 델리게이트(delegate) 메서드(method)를 해당 subject에 연결해본다.아래로 스크롤(scroll down)하여,
collectionView(_:didSelectItemAt:)
까지 이동한다. 이 메서드(method)의 코드는 탭(tapped)된 컬렉션 셀(collection cell)을 깜박(flashes)인 다음, 기기(device) 라이브러리(library)에서 사진 자산(asset)을 가져온다. 마지막으로, 사진이 준비되면 subject를 사용하여 subscribers에게 이미지를 보내야 한다.// Send the selected photo
주석(comment)을 다음으로 변경한다:self.selectedPhotosSubject.send(image)
매우 간단하다. 그러나 subject를 다른 유형(types)에 노출(exposing)시키므로, 외부(external) 구독(subscriptions)을 해제(tear down)하기 위해 뷰 컨트롤러(view controller)가 해제되는(dismissed) 경우에 완료(completion) 이벤트(event)를 명시적(explicitly)으로 보낸다(send).
viewWillDisappear(animated:)
으로 스크롤(scroll up)하고 다음을 추가한다:selectedPhotosSubject.send(completion: .finished) //뷰 컨트롤러가 해제되는 경우, 완료 이벤트를 내보내 해당 subject의 구독을 해제한다.
이 코드는 해당 뷰 컨트롤러(view controller)에서 다시 이동(navigate back)할 때,
finished
이벤트(event)를 보낸다. 현재 작업을 끝내려면(wrap up) 메인 뷰 컨트롤러(main view controller)에서 선택한(selected) 사진을 구독(subscribe)해야 한다. MainViewController.swift를 열고actionAdd()
를 찾은 다음,PhotosViewController
를 표시하는 마지막 줄 앞에(before) 다음 코드를 삽입(insert)한다:let newPhotos = photos.selectedPhotos //구독한다. .map { [unowned self] newImage in return self.images.value + [newImage] //선택한 이미지의 현재 목록을 가져와서 새 이미지를 추가한다. } .assign(to: \.value, on: images) //images subject의 value에 업데이트된 이미지 배열을 할당한다. .store(in: &subscriptions) //저장 //subscriptions에 저장하면, 해당 뷰 컨트롤러를 닫을 때마다 구독이 종료된다.
이제 앱을 실행(run)하고, 새로 추가된 코드를 시험해 본다. + 버튼(button)을 탭(tap)하면, 화면에 시스템 사진 액세스 대화 상자(system Photos access dialogue) 팝업이 표시된다. 확인(OK)을 누른다:
이 앱(app)은 사용자(own) 앱(app)이므로 Allow Access to All Photos를 탭(tap)하여, Collage 앱(app)에서 시뮬레이터(Simulator)의 전체(complete) 사진(Photo) 라이브러리(library)에 접근(accessing)할 수 있도록 허용해도 안전(safe)하다.
이렇게 하면 iOS 시뮬레이터(simulator)에 포함된 기본 사진 또는 기기(device)에서 테스트하는 경우 자신의 사진(photos)으로 컬렉션 뷰(collection view)가 다시 로드(reload)된다:
그중 몇 개를 선택(tap)한다. 콜라주(collage)에 추가되었음을 나타내기 위해 깜박(flash)거릴 것이다. 그런 다음 뒤로(back) 이동하여 메인 화면으로 돌아가면 새 콜라주(collage)가 화려하게 나타난다:
Wrapping a callback function as a future
playground에서는 subjects와 publishers를 원하는 대로 모든 것을 정확하게(exactly) 설계(design)할 수 있었지만, 일상적인(in day-to-day) iOS 코드에서는 카메라 롤(Camera Roll)에 접근(accessing)하고 기기(device)의 센서(sensors)를 읽거나(reading) 일부 데이터버에스와 상호 작용(interacting)하는 등 다양한 Cocoa API를 사용(interact)하게 된다.
이 책의 뒷부분에서 사용자 정의(custom) publishers를 만드는 방법을 배울 것이다. 그러나 대부분의 경우 기존 Cocoa 클래스(class)에 subject를 추가하는 것만으로도 Combine 워크 플로우(workflow)에 그 기능을 연결(plug)하기에 충분하다.
여기서는 사용자의 콜라주(collage)를 디스크에 저장(save)할 수 있는
PhotoWriter
라는 새로운 사용자 정의(custom) 유형(type)으로 작업한다. 콜백 기반(callback-based) Photos API를 사용하여 저장(saving)하고, future를 사용해 다른 유형(types)이 작업(operation) 결과를 구독(subscribe)할 수 있도록 한다.빈(empty)
PhotoWriter
클래스(class)가 포함된 Utility/PhotoWriter.swift를 열고, 다음 정적(static) 함수(function)를 추가한다:static func save(_ image: UIImage) -> Future<String, PhotoWriter.Error> { return Future { resolve in //Future 반환 //Future는 비동기적으로 단일 결과를 반환하는 publisher. } }
이 함수는 주어진 이미지를 디스크에 비동기적(asynchronously)으로 저장(store)하여, 이 API의 소비자(consumers)가 구독(subscribe)할 future를 반환(return)한다.
클로저 기반(closure-based)의
Future
초기화(initializer)를 사용하여, 초기화(initialized)된 후 제공된 클로저(closure)에서 코드를 즉시 실행할 준비가 된(ready-to-go) Future를 반환(return)한다.클로저(closure) 내부에 다음 코드를 삽입(inserting)하여 future의 논리(logic)를 구체화(fleshing out)한다:
do { //저장 } catch { resolve(.failure(.generic(error))) //오류 래핑 }
do
내부에서 저장(saving)을 수행(perform)하고, 오류가 발생하면 future를 실패(failure)로 내보낸다.사진을 저장(saving)하는 동안 발생(thrown)할 수 있는 정확한(exact) 오류(errors)를 모르기 때문에, 발생한(thrown) 오류(error)를
PhotoWriter.Error.generic
오류(error)로 래핑(wrap)하면 된다.이제 함수(function)의 실제 작업을 위해
do
본문(body) 내부에 다음을 삽입(Insert)한다:try PHPhotoLibrary.shared().performChangesAndWait { let request = PHAssetChangeRequest.creationRequestForAsset(from: image) //PHPhotoLibrary.performChangesAndWait(_)는 포토 라이브러리에 동기적으로 접근한다. guard let savedAssetID = request.placeholderForCreatedAsset?.localIdentifier else { //생성된 이미지 asset의 식별자를 가져온다. return resolve(.failure(.couldNotSavePhoto)) //이미지 생성에 실패하여 식별자를 가져오지 못하는 경우, future는 해당 오류를 발생시킨다. } resolve(.success(savedAssetID)) //이미지 저장이 제대로 된 경우(식별자를 가져오는 경우) future는 success를 발생시킨다. }
PHPhotoLibrary.performChangesAndWait(_)
를 사용하여 사진 라이브러리(Photos library)에 동기적으로(synchronously) 접근(access)한다. future의 클로저(closure)는 그 자체로 비동기적(asynchronously)으로 실행되므로, 메인 스레드(main thread)를 차단(blocking)하는 것에 대해 걱정할 필요가 없다.콜백(callback) 함수(function)를 래핑(wrap)하고, 오류(error)가 발생하면 실패(failure)를 내보내고, 반환(return)할 결과가 있을 경우 성공(success)을 내보내는 것이 전부이다.
이제 사용자가 저장(Save)을 탭(taps)하면
PhotoWriter.save(_)
를 사용하여 현재 콜라주(collage)를 저장(save)할 수 있다. MainViewController.swift를 열고actionSave()
하단(bottom)에 다음을 추가한다:PhotoWriter.save(image) //반환값인 future를 구독한다. .sink(receiveCompletion: { [unowned self] completion in if case .failure(let error) = completion { //failure와 함께 완료되는 경우 self.showMessage("Error", description: error.localizedDescription) //오류 메시지 표시 } self.actionClear() }, receiveValue: { [unowned self] id in //저장된 이미지의 식별자를 가져올 수 있는 경우 self.showMessage("Saved with id: \(id)") //성공 메시지 표시 }) .store(in: &subscriptions) //저장
앱을 다시 실행(run)하고, 몇 장의 사진을 선택(pick)한 다음 저장(Save)을 탭(tap)한다. 이렇게 하면 새 publisher가 호출되어 콜라주(collage)를 성공적으로 저장하면 다음과 같은 알림(alert)이 표시(display)된다:
A note on memory management
Combine을 사용한 메모리(memory) 관리(management)에 대해 간단하게 설명하기 좋은 지점이다. Combine 코드는 비동기적(asynchronously)으로 실행되는(executed) 많은 클로저(closures)를 처리해야 하며, 클래스(classes)를 다룰 때 이런 클로저(closures)를 관리하기가 다소 번거롭다(cumbersome)는 것을 이미 알고 있을 것이다.
사용자 정의(custom) Combine 코드를 작성할 때, 주로(predominantly) 구조체(structs)로 처리할 수 있으므로
map
,flatMap
,filter
등과 함께 사용하는 클로저(closures)에서 캡처(capturing) semantics를 명시적(explicitly)으로 지정할 필요는 없다.그러나 UI 코드, 특히 UIKit/AppKit 관련(related) 코드를 다룰 때는 항상
UIViewController
,UICollectionController
,NSFetchedController
등과 같은 클래스(classes)로 작업해야 한다.Combine 코드를 작성할 때도 표준(standard) 규칙(rules)이 적용되므로 항상 동일한 Swift capture semantics를 사용해야한다:
- 앞서 제시된 photos view controller와 같이, 메모리에서 해제(released)될 수 있는 객체(object)를 캡처(capturing)하는 경우, 또 다른 객체(object)를 캡쳐해야 한다면 self보다는
[weak self]
또는 다른 변수(variable)를 사용해야 한다. - 해당 Collage 앱의 main view controller처럼 해제(released)할 수 없는 객체(object)를 캡처(capturing)하는 경우,
[unowned self]
를 안전하게 사용할 수 있다. 예를 들어, navigation stack에서 절대로 팝업(pop-out)하지 않고 항상 존재(present)하는 것이다.
이제 다음 작업을 계속한다. 뷰 컨트롤러(view controller)를 표시(Presenting)하고, future를 사용해 결과를 가져(fetching)온다.
Presenting a view controller as a future
이 작업은 이전에 이미 완료한(completed) 두 가지 작업을 기반으로 한다:
- UI가 없는(UI-less) 콜백(callback) 함수(function)를
Future
로 래핑(wrapped)했다. - 뷰 컨트롤러(view controller)를 수동으로(manually) 표시(presented)하고 노출된(exposed) publishers 중 하나를 구독(subscribed)했다.
이번에는 future를 사용하여 새로운 뷰 컨트롤러(view controller)를 화면에 표시(present)하고, 사용자가 작업을 마칠(done) 때까지 기다렸다가 future를 완료(complete)한다. 이 모든 것이 한 번에 이뤄진다.
ViewController
의 새 확장(extension)에서MainViewController.showMessage(title:description:)
의 논리(logic)를 Combine의future를 사용하여 재생성한다.이를 위해 UIViewController+Combine.swift를 열고 제공된 확장(extension)내에 새 메서드(method)의 초기 형태(skeleton)를 추가한다:
func alert(title: String, text: String?) -> AnyPublisher<Void, Never> { let alertVC = UIAlertController(title: title, message: text, preferredStyle: .alert) }
이 메서드(method)는
AnyPublisher<Void, Never>
를 반환(returns)한다. 값(values)을 반환(returning)하는 데 관심이 없지만, 사용자가 닫기(Close)를 탭할 때 publisher를 완료(completing)하는 것뿐이다.alertVC
라는 alert controller를 생성한다. 다음으로 화면에 표시하고 future가 완료(completes)되면 해제(dismiss)한다.let alertVC = ...
행(line) 바로 뒤에 다음을 추가한다:return Future { resolve in alertVC.addAction(UIAlertAction(title: "Close", style: .default) { _ in //close 버튼 추가 resolve(.success(())) //사용자가 탭하면 success로 종료 }) self.present(alertVC, animated: true, completion: nil) } .handleEvents(receiveCancel: { //구독이 취소되는 경우 self.dismiss(animated: true) //해제 }) .eraseToAnyPublisher()
여기에서
Future
를 생성하고 구독(subscription)시 알림(alert)에 닫기(Close) 버튼(button)을 추가하여 화면에 표시한다. 사용자가 버튼(button)을 탭(taps)하면 성공(success)으로 future를 내보낸다.구독(subscription)이 취소(canceled)되면
handleEvents(receiveCancel:)
내에서 알림(alert)을 자동으로(automatically) 해제(dismiss)한다. 이 코드는 알림(alert) 구독(subscription)을 현재 표시된(currently-presented) 뷰 컨트롤러(view controller)에 연결하고, 해당 컨트롤러(controller)가 자체적으로 해제(dismissed)되는 경우를 처리(handles)한다. 이렇게 하면 알림(the) 구독(subscription)이 취소(cancel)되고, 해당 알림(the)도 해제(dismiss)된다.이 코드를 확인하려면
MainViewController
의 기존showMessage
메서드(method)의 코드를 다음으로 바꾼다:alert(title: title, text: description) .sink(receiveValue: { _ in }) .store(in: &subscriptions) //저장
앱을 다시 빌드(build)하여 실행(run)하고 콜라주(collages)를 저장한다. 새롭게 Combine-ified 코드로 작성된 알림(alerts)이 이전과 똑같이 작동한다.
Sharing subscriptions
actionAdd()
의 코드를 다시 살펴보면, 표시된(presented)PhotosViewController
에서 사용자가 선택한(selected) 이미지로 몇 가지 작업을 더 수행할 수 있다.여기서 동일한
photos.selectedPhotos
publisher를 여러 번 구독(subscribe)해야할지, 아니면 다른 조치를 해야할지 결정하기 어려울 수 있다.동일한 publisher를 구독(subscribing)하면 원치 않는 부작용(side effects)이 발생할 수 있다. 새로운 리소스(resources)를 생성하거나 네트워크(network) 요청(requests) 등을 할 수 있기 때문이다.
동일한 publisher에 대한 여러 구독(subscriptions)을 만드는 올바른 방법은
share()
연산자(operator)를 사용해 원래(original) publisher를 공유(share)하는 것이다. 이는 publisher를 클래스(class)로 래핑(wraps)하므로, 여러 subscribers에게 안전하게 내보낼(emit) 수 있다.let newPhotos = photos.selectedPhotos
행(line)을 찾아 다음으로 변경한다:let newPhotos = photos.selectedPhotos.share()
이제 publisher가 각 구독(subscriptions)에 대해 여러 부작용(side effects)을 일으킬 염려없이,
newPhotos
에 대한 여러 구독(subscriptions)을 안전하게 만들 수 있다:명심(caveat)해야 할 점은
share()
가 공유(shared) 구독(subscription)에서 어떤 값(values)도 다시 내보내지(re-emit) 않는다는 것이다.예를 들어,
share()
에 두 개의 구독(subscriptions)이 있고 소스(source) publisher가 동기적(synchronously)으로 값을 내보내는(emits) 경우 두 번째 subscriber가 구독(subscribe)할 기회를 갖기 전에 첫 번째 subscriber에게만 초기(initial) 출력(output) 값(value)을 내보낸다 (하지만, 소스(source) publisher가 비동기적(asynchronously)으로 내보(emits)낸다면 이는 문제가 되지 않는다).이 문제에 대한 신뢰할(reliable) 수 있는 해결책(solution)은 새로운 subscriber가 구독(subscribes)할 때, 과거(past) 값(values)을 다시 내보내(re-emits)거나 재생(replays)하는 자체(own) 공유(sharing) 연산자(operator)를 만드는 것이다. 자체 연산자(operators)를 생성하는 것은 전혀 복잡하지 않다. 실제로 18장(chapter), "Custom Publishers & Handling Backpressure"에서
shareReplay()
라는 연산자를 만들어 위에서 설명한 방식으로 공유(share)를 사용할 것이다.Publishing properties with @Published
Combine 프레임 워크(framework)는 Swift 5.1에서 도입된 새로운 기능(feature)인 속성 래퍼(property wrappers)를 몇 가지 제공한다. 속성 래퍼(property wrappers)는 선언(declaration)에 구문(syntactic) 표시(marker)를 추가하여, 유형 속성(type properties)에 동작(behavior)을 추가할 수 있는 구문(syntax) 구조(constructs)이다.
Combine은
@Published
과@ObservedObject
라는 두 가지 속성 래퍼(property wrappers)를 제공한다. 이 장에서는@Published
를 사용하고,@ObservedObject
는 나중에 다시 다룰 것이다.@Published
속성 래퍼(property wrapper)를 사용하면 원래 속성(property)의 값을 변경(change)할 때마다, 새 출력(output) 값(value)을 내보내(emit) 속성을 지원(back)하는 publisher를 자동으로(automatically) 추가할 수 있다.구문(syntax)은 다음과 같다:
struct Person { @Published var age: Int = 0 }
age
속성(property)은 일반(normal) 속성(property)과 동일하게 작동(behaves)한다. 평소와 같이 명령적(imperatively)으로 값을 가져오고(get), 설정할(set) 수 있다. 그러나 컴파일러(compiler)는 동일한 접근성 수준(accessibility level, private 혹은 public)으로$age
라는 또 다른 속성(property)을 자동(automatically)으로 생성한다.$age
는 오류(error)가 발생하지 않는(never) publisher이며, 출력(output)은age
속성(property)과 동일한 유형(type)이다.age
값(value)을 수정(modify)할 때마다,$age
는 새로운 값(value)을 내보낸다(emit).Note:
@Published
에는 초기 값(initial value)이 필요하다. 원본(original) 속성(property)에 대한 기본 값(default value)을 제공하거나, 인스턴스화(instantiating)할 때 초기화(initialize)해야 한다.유형(types)에 대한 publishers 생성을 자동화(automation)하면, API 소비자(consumers)에게 실시간(real-time) 데이터 변경을 구독(subscribe)할 수 있는 기능을 매우 쉽게 제공(provide)할 수 있다.
@Published
를 사용하려면PhotosViewController
에 새 속성(property)을 추가하여, 사용자가 선택한(selected) 사진 수를 표시한다. PhotosViewController.swift를 열고selectedPhotos
아래에 새 속성(property)을 추가한다:var selectedPhotosCount = 0 //사용자가 선택한 사진 수
사용자가 사진을 탭(taps)할 때마다, 사용자가 선택한 개수를 추적(track)하기 위해 새 속성의 값(value)을 증가(increase)시킨다.
collectionView(_:didSelectItemAt:)
까지 아래로 스크롤(scroll down)하여 다음 행(line)을 찾는다:self.selectedPhotosSubject.send(image)
이 아래에 다음을 추가한다:
self.selectedPhotosCount += 1 //선택한 사진 수 증가
아직까지
selectedPhotosCount
는 평범한(vanilla)Int
속성(property)이다. 값을 얻고(get) 설정(set)할 수 있지만, 이를 구독(subscribe)할 수는 없다.속성(property) 선언(declaration)으로 돌아가서,
@Published
를 다음과 같이 추가한다:@Published var selectedPhotosCount = 0 //@Published를 추가하면 값이 변경될때 마다, 새 값을 내보내는 $selectedPhotosCount가 자동 생성된다.
이렇게 하면 컴파일러(compiler)가
$selectedPhotosCount
라는 속성(property)에 대한 publisher를 배후(behind)에서 생성한다.이제 main view controller에서 이 publisher를 구독(subscribe)하고, 기본 화면(main screen)에 선택한(selected) 사진 수에 대한 정보를 표시 할 수 있다.
구독(subscribing) 작업은 다른 publisher와 동일하며, 속성(property) 이름에
$
접두사(prefix)를 추가하는 것을 잊어선 안된다. MainViewController.swift를 열고actionAdd()
로 스크롤(scroll)한다. 여기에서phtos
을 만든 직후, 메소드(method)의 맨 위에 다음을 추가한다:photos.$selectedPhotosCount //구독 .filter { $0 > 0 } .map { "Selected \($0) photos" } .assign(to: \.title, on: self) //뷰 컨트롤러 title에 연결 .store(in: &subscriptions) //저장
photos.$selectedPhotosCount
를 구독(subscribe)하고 뷰 컨트롤러(view controller)의title
속성(property)에 바인딩(bind)한다.여기 그리고 이 책의 뒷부분에서 "바인딩(binding)"이라고 부르는 것은
assign(to:on:)
의 subscriber에서 "종료(ends)"되는 구독(subscription)이다. "binding"이라는 명사(noun)는 최소한 "assigning"보다 이러한 구독(subscription)의 특성을 매우 잘 설명한다. publisher의 출력(output)을 수신(receiving) 측의 특정(specific) 인스턴스(instance) 속성(property)에 바인딩(bind)한다.값(value)과 키 경로(key path)를
assign(to:on:)
에 제공(provide)하면, 수신(receives) 값(values)으로 해당 키 경로(key path)를 업데이트(updates)한다. 모델(model)을 뷰(views)의 속성(properties)에 바인딩(binding)하고, 네트워크(network) 요청(requests)을 뷰 모델(view models)의 입력(inputs)에 바인딩(binding)하는 등 일반적인(common) 사용 사례(use-cases)에서 실용적(practical)으로 사용할 수 있는 subscriber이다.프로젝트(project)를 다시 실행(run)하고 몇 장의 사진을 탭(tap)한다. 그런 다음 뒤로 이동(navigate bac)하여 main view controller의 제목(title)을 확인한다:
Operators in practice
몇 가지 유용한 반응(reactive) 패턴(patterns)에 대해 배웠으므로, 이전 장(chapters)에서 다룬 연산자(operators) 중 일부를 연습(practice)하고 실제 작동 방식을 살펴볼 차례이다.
Updating the UI after the publisher completes
일부 사진을 탭(tap)하면, 선택한(selected) 사진 수를 표시하도록 main view controller의 제목(title)을 변경했다. 이것은 유용하지만 콜라주(collage)에 실제로 추가된 사진의 개수를 보여주는 기본 제목(default title)을 보는 것도 편리(handy)하다.
actionAdd()
로 스크롤(scroll)하여, 기존 구독(subscription) 바로 뒤에newPhotos
의 또 다른 구독(subscription)을 추가한다:newPhotos .ignoreOutput() //방출된 값을 무시하고, 구독자에게 완료 이벤트만 제공한다. .delay(for: 2.0, scheduler: DispatchQueue.main) //주어진 시간을 기다린다. //"X photos selected"라는 기존 메시지가 나타나고, 2초 후에 실제로 선택한 총 사진의 개수를 보여준다. .sink(receiveCompletion: { [unowned self] _ in self.updateUI(photos: self.images.value) //컨트롤러의 title을 업데이트 한다. }, receiveValue: { _ in }) .store(in: &subscriptions) //저장
구독(subscription)은 2초 동안 화면에 사용자 정의(custom) 제목(title, 이전 구독(subscription)으로 표시됨)을 허용한 다음,
updateUI(photos:)
를 호출(invokes)하여 선택한 총 사진 수를 나타내는 기본값(default value)으로 제목(title)을 재설정한다.Accepting values while a condition is met
selectedPhotos
구독(subscription)을 공유(share)하는 코드를 다음으로 변경한다:let newPhotos = photos.selectedPhotos .prefix(while: { [unowned self] _ in return self.images.value.count < 6 //6개 미만이면 구독을 유지한다. }) .share() //여러 구독이 publisher를 공유해야 하는 경우, share()를 사용해 준다.
강력한 Combine 필터링(filtering) 연산자(operators) 중 하나인
prefix(while:)
에 대해 이미 배웠으며, 여기서 실제로 사용한다. 위의 코드는 선택한 총 이미지 수(count)가 6개 미만이면,selectedPhotos
에 대한 구독(subscription)을 유지(keep)한다. 즉, 사용자는 콜라주(collage) 사진을 최대 6개 까지 선택할 수 있다.share()
호출(call) 직전에prefix(while:)
를 추가하면, 하나의 구독(subscription)이 아니라 결과적으로newPhotos
의 모든 구독(subscriptions)에서 수신(incoming) 값을 필터링(filter)할 수 있다.앱을 실행(run)하고 6 장 이상의 사진을 추가해 본다. 처음 6개 이후에는 main view controller가 더 이상 수용(accept)하지 않는다는 것을 알 수 있다. 이 장(chapter)의 과제(challenges)에서는 사용자가 사진 제한 한도(limit)에 도달(reaches)하면, photos controller를 자동으로(automatically) 해제되도록(popping out) 개선한다.
Challenges
다음 장(chapter)으로 넘어가 더 많은 이론(theory)을 학습하기 전에, 몇 가지 선택적(optional) 작업을 진행해 본다.
Challenge 1: Try more operators
또 다른 필터(filter)를 추가한다. 제공된 콜라주(collaging) 함수(function) 구현은 세로(portrait) 사진을 추가 시 이를 제대로 처리(handle)하지 못한다. 따라서 newPhotos publisher의 actionAdd()에 새 필터(filter)를 추가하여 세로(portrait) 방향(orientation) 이미지를 필터링(filter)한다.
Tip: 이미지의
size
속성(property)을 확인하고,width
와height
값(values)을 비교하여 방향이 가로(landscape)인지 세로(portrait)인지 확인할 수 있다.첫 번째 작업을 마치면(finished)
photos.selectedPhotos
에 대한 새 구독(subscription)을 생성한다:filter
연산자(operator)를 사용하여images.value
에서 선택한 이미지의 현재 개수가5
인 경우에만 방출된(emitted) 값(value)을 전달(pass)한다. 이는 사용자가 이제 6번째 이미지(콜라주의 최대 사진 수)를 추가하고 있음을 의미한다.flatMap
을 사용하여 사용자에게 최대(maximum) 사진 수에 도달(reached)했음을 알리는 알림(alert)를 표시하고, 닫기(Close) 버튼(button)을 누를 때까지 기다린다.sink(receiveValue:)
를 사용하여, photos view controller를 탐색 스택(navigation stack)에서 빼낸다(pop).
이 구독(subscription)은 콜라주(collage)에 대한 최대(maximum) 사진 수를 선택하는 경우, photos view controller를 해제(pop)하고 main view controller로 돌아가도록 해야한다:
Note: 이 마지막 기능(functionality)을 확인할 때는, 선택한 세로(portrait) 사진은 최대(maximum) 개수인 6장에 포함되지 않으므로 주의해야 한다. 시스템(system) 사진(photo) 선택기(selector)가 모든 사진을 정사각형(square) 썸네일(thumbnails)로 자르기(crops) 때문에, 썸네일만 봐서는 세로(portrait) 사진인지 가로(landscape) 사진인지 알 수 없다.
Challenge 2: PHPhotoLibrary authorization publisher
Utility/PHPhotoLibrary+Combine.swift를 열고 사용자로부터 Collage 앱에 대한 사진 라이브러리(Photos library)의 권한(authorization)을 받는 코드를 확인한다. 논리(logic)가 매우 간단하고 "표준(standard)"콜백(callback) API를 기반으로 한다는 것을 확실히 알 수 있다.
이는 Cocoa API를 future로 직접 래핑(wrap)해볼 수 있는 좋은 기회를 제공한다. 이 문제를 해결하려면
isAuthorized
라는 새로운 정적(static) 속성(property)을PHPhotoLibrary
에 추가한다. 이 속성은Future<Bool, Never>
유형(type)이며, 다른 유형(types)이 사진(Photos) 라이브러리(library)의 권한(authorization) 상태(status)를 구독(subscribe)할 수 있도록 허용한다.이 장(chapter)에서 이미 이런 작업을 여러 번 수행했으며, 기존의
fetchAuthorizationStatus(callback:)
함수(function)는 사용하기 매우 간단하다. 도중에 문제가 발생하면 이 장(chapter)에서 제공하는 과제(challenge) 폴더(folder)에서, 정답(solution) 예제를 살펴볼 수 있다.마지막으로
PhotosViewController
에서 새로운isAuthorized
publisher를 사용하는 것을 잊어선 안된다. 이는 기본 대기열(main queue)에서 값(values)을 수신(receiving)하는 방법을 연습할 수 있는 좋은 기회이다.사용자가 사진에 대한 접근(access) 권한을 부여하지(grant) 않은 경우, 사용자 정의(custom) 알림(alert) publisher를 사용해 오류(error) 메시지(message)를 표시하고 닫기(Close)를 탭(tap)하면 main view controller로 돌아간다.
다른 권한(authorization) 상태(states)로 코드를 확인(test)하려면, 시뮬레이터(Simulator) 또는 장치(device)에서 설정(Settings) 앱을 열고 Privacy/Photos로 이동(navigate)한다.
Collage의 권한(authorization) 상태(status)를 "None"또는 "All Photos"로 변경하여, 코드가 해당 상태(states)에서 어떻게 작동(behaves)하는지 확인(test)해 볼 수 있다:
Key points
- 일반적으로 콜백(callback) 또는 위임 기반(delegate-based) API를 처리하는 작업이 많다. 다행히도 subject를 사용하여 futures나 publishers로 쉽게 래핑(wrapped)할 수 있다.
- 위임(delegation) 및 콜백(callbacks)과 같은 다양한 패턴(patterns)을 단일(single) Publisher/Subscriber 패턴(pattern)으로 변환하면, view controllers를 표시(presenting)하고 값(values)을 가져오는(fetching) 것과 같은 일반적인 작업이 수월(breeze)해진다.
- publisher를 여러 번 구독(subscribing)할 때, 원치 않는 부작용(side-effects)을 방지하려면
share()
연산자(operator)를 사용해 공유(shared) publisher를 사용한다.
'Raywenderlich > Combine: Asynchronous Programming' 카테고리의 다른 글
Chapter 10: Debugging (0) 2020.08.13 Chapter 9: Networking (0) 2020.08.13 Chapter 7: Sequence Operators (0) 2020.08.09 Chapter 6: Time Manipulation Operators (0) 2020.07.27 Chapter 5: Combining Operators (0) 2020.07.20