RxSwift
4. Observables & Subjects in Practice
kangwook
2022. 12. 13. 20:47
- RxSwift의 Observable과 Subject를 배웠지만 UI를 데이터 모델에 바인딩하거나 새 컨트롤러를 제시하고 출력을 다시 가져오는 등 실제 사용은 어려울 수 있다.
- 프로젝트에 RxSwift 프레임워크를 추가하고 RxSwift의 기술을 사용하여 기능을 추가
Getting Started
Using a Subject in a View Controller
- MainViewController.swift에 다음과 같은 코드를 추가
- BehaviorRelay<[UIImage]> 는 일반 변수에 익숙한 것처럼 동작
- DisposeBag은 ViewController가 소유하므로 ViewController가 해제되는 즉시 모든 구독도 삭제
- 이를 통해 Rx 구독 메모리 관리가 매우 쉬워진다. → 구독을 DisposeBag에 넣기만 하면 ViewController의 할당 해제와 함께 처리
- 하지만 현재 상황에서는 MainViewController가 RootViewController이고 앱이 종료되기 전에 릴리즈되지 않기 때문에 적용되지 않는다.
// MainViewController.swift
private let bag = DisposeBag()
private let images = BehaviorRelay<[UIImage]>(value: [])
IBAction func actionAdd(_ sender: Any) {
let newImages = images.value + [UIImage(named: "sunset")!]
images.accept(newImages)
}
- 우측 상단의 Add 버튼을 통해 하나 이상의 이미지를 추가
- accept(_)를 사용하여 Subject를 구독하는 Observer에게 업데이트 된 이미지 세트를 보낸다.
- 이미지 Subject의 초기 값은 빈 배열이고 사용자가 Add(+) 버튼을 누를 때마다 이미지에서 생성된 Observable 시퀀스가 새 배열을 아이템으로 사용하여 .next 이벤트를 내보낸다.
IBAction func actionClear(_ sender: Any) {
images.accept([])
}
- 사용자가 현재 선택을 지우도록 하기위해 clear 버튼을 클릭한 경우 구현
Adding photos to the collage
- viewDidLoad() 에서 이미지에 대한 구독을 만든다.
- Subject임에도 불구하고 실제로 Observable의 하위 클래스이기 때문에 직접 구독할 수 있다.
override func viewDidLoad() {
//...
images
.subscribe(onNext: { [weak imageView] photos in
guard let preview = imageView else { return }
preview.image = photos.collage(size: preview.frame.size)
})
.disposed(by: disposeBag)
}
Driving a complex View Controller UI
- 현재 앱을 사용하면서 UI가 사용자 환경을 개선하기 위해 조금 더 스마트해질 수 있다는 것을 알 수 있다.
- 방금 선택한 사진이 없거나 사용자가 방금 선택을 지운 경우 지우기 버튼을 비활성화
- 마찬가지로 선택한 사진이 없는 경우 저장 버튼을 비활성화
- 홀수 사진에 대해 저장을 비활성화 하면 콜라주에 빈 공간이 남아있을 수 있다.
- 더 많은 사진이 약간 이상하게 보이기 때문에 콜라주의 사진 수를 6개로 제한할 수 있다.
- 마지막으로 ViewController의 title을 현재 선택을 반영하면 좋다.
- RxSwift를 사용하여 이미지를 한번 더 구독하면 코드의 단일 위치에서 UI를 업데이트 하기만 하면 된다.
override func viewDidLoad() {
// ...
images.asObservable()
.subscribe(onNext: { [weak self] photos in
self?.updateUI(photos: photos)
})
.disposed(by: disposeBag)
}
private func updateUI(photos: [UIImage]) {
saveButton.isEnabled = photos.count > 0 && photos.count % 2 == 0
clearButton.isEnabled = photos.count > 0
itemAdd.isEnabled = photos.count < 6
title = photos.count > 0 ? "\(photos.count) photos" : "Collage"
}
Talking to other View Controller via Subjects
- 사진을 사용자가 카메라에서 임의의 사진을 선택할 수 있도록 할 수 있다.(PhotosViewController)
- PhotosViewController를 생성하고 Add(+) 버튼을 탭 했을 때 해당 ViewController로 View를 넘길 수 있다.
IBAction func actionAdd(_ sender: Any) {
guard let photosViewController = storyboard!.instantiateViewController(withIdentifier: "PhotosViewController") as? PhotosViewController else { return }
navigationController?.pushViewController(photosViewController, animated: true)
}
- 설정된 Cocoa 패턴을 사용하여 앱을 빌드하는 경우 PhotosViewController가 MainViewController가 연결될 수 있도록 Delegate 프로토콜을 추가하는 것
- 그러나 RxSwift를 사용하면 두 클래스 간에 Observable을 통해 연결할 수 있다.
- Observable은 어떤 종류의 메시지를 하나 이상의 이해당사자인 Observer에게 전달할 수 있기 때문에 특별한 프로토콜을 정의할 필요가 없다.
Creating an Observable out of the selected photos
- PhotosViewController 상단에 다음 코드를 추가
import RxSwift
- 선택한 사진을 노출하기 위해 PublishSubject를 추가하고 싶지만 다른 클래스가 onNext(_)를 호출하고 아이템 값을 방출하도록 허용하므로 Subject는 private으로 선언
- selectedPhotos는 private으로 선언하지 않음.
- 이 속성을 구독하면 MainViewController가 사진 시퀀스를 방해하지 않고 관찰할 수 있다.
private let selectedPhotosSubject = PublishSubject<UIImage>()
var selectedPhotos: Observable<UIImage> {
return selectedPhotosSubject.asObservable()
}
- info Dictionary를 사용하여 이미지가 미리보기 이미지인지 Assets의 전체 버전인지 확인
- imageManager.requestImage(...)는 각 크기에 대해 한 번씩 해당 클로저를 호출
- 원본 크기 이미지를 받으면 onNext()를 호출하여 전체 사진을 제공
if let isThumbnail = info[PHImageResultIsDegradeKey as NSString] as? Bool, !isThumbnail {
self?.selectedPhotosSubject.onNext(image)
}
- 이것이 하나의 ViewController에서 다른 ViewController로 Observable 시퀀스를 노출하는데 필요한 전부
Observing the sequence of selected photos
- 이제 MainViewController.swift에서 스키마의 마지막 부분을 완료하는 코드를 추가
- 즉, 선택한 사진 시퀀스를 Observing 하는 것
IBAction func actionAdd(_ sender: Any) {
photosViewController.selectedPhotos // Observable 타입인 selectedPhotos 에 대한 이벤트 구독
.subscribe(
onNext: { [weak self] newImage in // 사용자가 사진을 탭했을 때
guard let images = self?.images else { return }
images.accept(images.value + [newImage])
},
onDisposed: { // 구독이 끝났을 때
print("completed photo selection")
}
)
.disposed(by: disposeBag)
}
Disposing subscriptions - review
- "completed photo selection" 이라는 문구를 Dispose 될 때 출력하도록 작성했는데 출력이 호출되지 않음.
- 즉, subscribe 이 폐기되지 않아 메모리가 해제되지 않고 있다.
- 현재의 앱에선 MainViewController가 이 DisposeBag을 갖고 있기 때문에 MainViewController가 메모리에서 해제되기 전까지는 유지된다.
- Observer에게 종료 이벤트를 주기 위해 컨트롤러가 화면에서 사라질 때, .completed 이벤트를 생성할 수 있다.
- 이것은 자동적으로 dispose 할 수 있도록 모든 observer에게 알린다.
override func viewWillDisappear(_ animated: Bool) {
selectedPhotosSubject.onCompleted()
}
Creating a custom observable
- Custom Observable을 만들고 평범한 이전 Apple API를 반응형 클래스로 전환
- 사진 프레임워크를 사용하여 사진 콜라주를 저장
- PHPhotoLibrary 자체에 반응 확장을 추가할 수 있지만 간단하게 유지하기 위해 PhotoWriter 라는 새로운 사용자 정의 클래스를 만든다.
- Observable을 생성하여 사진을 저장하는 것은 쉽다.
- 이미지가 디스크에 성공적으로 기록되면 Asset ID와 .completed 이벤트 또는 .error 이벤트가 발생
Wrapping an existing API
class PhotoWriter {
enum Errors: Error {
case couldNotSavePhoto
}
static func save(_ image: UIImage) -> Observable<String> {
return Observable.create { observer in
var savedAssetId: String?
PHPhotoLibrary.shared().performChanges({
let request = PHAssetChangeRequest.creationRequestForAsset(from: image)
savedAssetId = request.placeholderForCreatedAsset?.localIdentifier
}, completionHandler: { success, error in
DispatchQueue.main.async {
if success, let id = savedAssetId {
observer.onNext(id)
observer.onCompleted()
} else {
observer.onError(error ?? Errors.couldNotSavePhoto)
}
}
})
return Disposable.create()
}
}
}
- save(_: ) 는 Observable<String>을 반환
- 왜냐하면 사진을 저장한 후 생성된 Asset의 고유한 Local Identifier인 단일 요소를 방출하기 때문
- Observable.create()는 새로운 Observable을 생성하고 마지막 클로저 안에 모든 중요한 로직을 추가
- 첫번째 클로저 매개변수에서 제공된 이미지에서 사진 Asset을 생성
- 두번째 클로저에서 Asset ID 또는 .error 이벤트를 내보낸다.
- 마지막 단계로 해당 외부 클로저에서 Disposable을 반환해야 하므로 Observable.create({})에 return 키워드 추가
- Observable은 0개 이상의 .next 이벤트 조합을 생성할 수 있으며 .completed 또는 .error 에 의해 종료될 수 있다.
- PhotoWriter 클래스의 특별한 경우에는 저장 작업이 한번만 완료되기 때문에 하나의 이벤트에만 관심(.next + .completed 또는 .error)
- 이럴 때 Single을 사용할 수 있다.
RxSwift traits in practice
Single
- Single은 .success(value) 이벤트 또는 .error 이벤트를 한번만 내보낼 수 있는 시퀀스를 나타낸다.
- 내부적으로 .success 이벤트는 .next 이벤트와 .completed 이벤트의 쌍
- 이러한 종류의 특성은 파일 저장, 파일 다운로드, 디스크에서 데이터 로드 또는 기본적으로 값을 생성하는 모든 비동기 작업과 같은 상황에서 유용
- 시퀀스에서 단일 요소를 사용하려는 의도를 더 잘 표현하고 시퀀스가 둘 이상의 아이템을 방출하는지 확인하기 위해 subscribe 오류가 발생
- 이를 위해 Observable을 subscribe하고 .asSingle()을 사용하여 Single로 변환할 수 있다.
Maybe
- Observable이 성공적으로 완료되면 값을 방출하지 않을 수 있다는 유일한 차이점을 제외하면 Single과 매우 유사
- Custom 사진 앨범에 사진을 저장하는 경우
- 지정된 ID의 앨범이 여전히 존재하는 경우 .completed 이벤트 생성
- 사용자가 앨범을 삭제한 경우 새 앨범을 만들고 UserDefaults에서 유지할 수 있도록 새 ID로 .next 이벤트를 생성
- 문제가 발생하여 사진 라이브러리에 전혀 접근할 수 없는 경우 .error 이벤트
- Maybe는 코드를 작성할 때와 나중에 코드를 변경하는 프로그래머에게 더 많은 컨텍스트를 제공
- Single과 마찬가지로 Maybe.create({...})를 사용하여 직접 Maybe를 만들거나 .asMaybe()를 통해 Observable Sequence를 변환할 수 있다.
Completable
- 이 Observable의 변형은 subscribe이 삭제되기 전에 단일 .completed 또는 .error 이벤트만 생성하도록 허용
- ignoreElements() 연산자를 사용하여 Observable Sequence를 Completable로 변환할 수 있다.
- 이 경우 Completable에 필요한 것처럼 완료되거나 오류 이벤트만 발생하여 모든 다음 이벤트가 무시
- Completable.create {...} 를 사용하여 Observable Sequence를 생성할 수 있다.
- Completable은 비동기 작업이 성공했는지 여부만 알면 되는 다양한 곳에서 사용할 수 있다.
Subscribing to your custom observable
- 현재 PhotoWriter.save(_) observable은 한번만 방출하거나 오류가 발생하므로 Single에 적합
@IBAction func actionSave(_ sender: Any) {
guard let image = imageView.image else { return }
PhotoWriter.save(image)
.asSingle() // Single로 변환
.subscribe { [weak self] (id) in
self?.showMessage("Saved with id: \(id)")
self?.actionClear(sender)
},
onError: { [weak self] (error) in
self?.showMessage("Error")
},
onDisposed: {
print("Disposed")
}
.disposed(by: disposeBag)
}