Mastering RxSwift 4강: RxSwift를 이용해 사진 필터 앱 만들기

sanghee·2021년 10월 20일
0

🧠RxSwift

목록 보기
3/3
post-thumbnail

Udemy에서 Mastering RxSwift in iOS 수업을 듣고 난 후 정리한 글입니다. 개인적으로 다른 기능들도 추가하였습니다.

Section 4: Implement Photh Filter App Using RxSwift

4-1. 소개

네비게이션 바 우측의 +버튼을 누르면 사진 화면 라이브러리를 띄운다. 사진을 선택하면 선택한 사진이 원래 화면에 보인다. 필터 적용 버튼을 누르면 필터가 적용된다.

메인 화면

필터 적용, 적용 취소

필터 종류


4-2. UI 세팅

위의 화면처럼 UI를 세팅하였다.

  • 커밋: 메인뷰 UI
  • 커밋: 사진 컬렉션뷰 구현 및 메인뷰와 연결

4-3. RxSwift 설치

# Pods for HelloRxSwift
  pod 'RxSwift'
  pod 'Snap'
  • 커밋: Podfile 수정

4-4. 사진 라이브러리 요청

info.plist

  • Privacy - Photo Library Usage Description
    • Filter Cam이 사진 라이브러리 접근을 요청합니다

요청이 허용된 후 우선 요청 결과에 관계 없이 PhotoCollectionView와 연결하였다.

4-5. 사진 데이터 가져오기

클로저에서 weak self의 사용 예제

weak reference(약한 참조)는 Reference Cycle(강력 순환 참조)를 벗어나기 위해 사용한다. 클로저 내부에 self를 사용하는 경우가 존재하는데, 특수한 상황에서는 문제가 될 소지가 있다.

class Thing {
    var disposable: Disposable? 
    var total: Int = 0 

    deinit {
        disposable?.dispose()
    }

    init(producer: SignalProducer<Int, NoError>) {
        disposable = producer.startWithNext { number in 
           self.total += number print(self.total) 
        } 
    } 
}

클로저 내부에 total를 사용하기 위해 self를 명시해 주고 있다. self는 retain count 를 증가시키게 되는데 위의 코드 역시 클로저가 self를 해제하여 retain count를 다시 낮춰준다면 문제가 없이 작동하게 된다. 그러나 closure에 대한 참조가 disposable 프로퍼티에 의해 붙잡혀 있다면 클로저는 self가 해제 될 때까지 기다리고 self는 클로저가 해제될 때까지 기다리는 strong reference cycle 상황이 발생한다. 이러한 상황을 해결하기 위해 [weak self]를 사용한다.

disposable = producer.startWithNext { [weak self] number in
    self?.total += number 
    print(self?.total)
}

출처: https://greenchobo.tistory.com/3

[weak self] 적용해 사진 요청

요청을 한 후 허용되면 사진을 가져온다.

private var photoAssets: [PHAsset] = []

func requestPhotoAuthorization() {
    PHPhotoLibrary.requestAuthorization { [weak self] status in
        if status == .authorized {
            let assets = PHAsset.fetchAssets(with: .image, options: nil)
            assets.enumerateObjects { asset, count, unSafePointer in
                self?.photoAssets.append(asset)
            }
        }
    }
}
  • 커밋: 사진 assets 가져오기

4-6. 사진 띄우기

에러발생: UICollectionView.reloadData() must be used from main thread only

컬렉션뷰에서 데이터를 로드하는 함수는 메인 스레드에서만 사용해야 한다. 따라서 main에서 실행되도록 아래의 함수를 생성하였다.

func reloadCollectionView() {
    DispatchQueue.main.async {
        self.collectionView.reloadData()
    }
}

최신 사진부터 띄우기

시간순서대로 photoAssets 폴더에 PHAsset이 저장된다. 최신 사진부터 컬렉션뷰셀에 띄우려고 한다. 배열을 뒤집는 방법도 있겠지만, 그것보다는 배열의 총 길이에서 indexPath.row에서 1을 뺀 값을 cell에 주는 방법이 더 효율적이다.

private var photoAssets: [PHAsset] = []

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    guard let cell = collectionView.dequeueReusableCell...
    
    let asset = photoAssets[photoAssets.count - indexPath.row - 1]
    let width = ...
    let size = ...
    
    PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFit, options: nil) { image, _ in
        cell.photo = image
    }
    
    return cell
}

  • 커밋: 사진 가져온 후 컬렉션뷰셀을 통해 최신 사진부터 띄우기

4-7. 선택한 사진 보내기

PublishSubject를 만든 후 selectedPhoto 관찰

private let selectedPhotoSubject = PublishSubject<UIImage>()
var selectedPhoto: Observable<UIImage> {
    return selectedPhotoSubject.asObservable()
}

Subject를 만들고 selectedPhoto 추가

private let selectedPhotoSubject = PublishSubject<UIImage>()
private var selectedPhoto: Observable<UIImage> {
    return selectedPhotoSubject.asObservable()
}

선택한 사진 이벤트 추가

  1. 클릭한 셀 인덱스를 통해 해당 photoAsset에 접근
  2. 해당 photoAsset으로 이미지 요청한다.
  3. 클로저 내부 image, info가 존재하고, 이미지가 저하되지 않았을 경우에만 다음을 실행한다.
  4. 생성한 subject에 image를 추가한다.
  5. 메인 뷰로 넘어간다.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    let selectedAsset = photoAssets[indexPath.row]
    let width = (collectionView.frame.width - ((column - 1) * padding)) / column
    let size = CGSize(width: width, height: width)
    
    PHImageManager.default().requestImage(for: selectedAsset, targetSize: size, contentMode: .aspectFit, options: nil) { [weak self] image, info in
        
        guard let image = image, let info = info, let isDegradedImage = info["PHImageResultIsDegradedKey"] as? Bool else { return }

        if !isDegradedImage {
            self?.selectedPhotoSubject.onNext(image)
            self?.navigationController?.popViewController(animated: true)
        }
    }
}
  • 커밋: Subject 만든 후 선택한 사진 추가, 메인 뷰로 이동

4-8. 선택한 사진 구독, 띄우기

신문, 신문기사, 구독자

Subject가 신문이라면 Observable는 관찰할 수 있는 신문기사이다. 그리고 그 기사를 구독하는 구독자가 필요하다.

Subject라는 신문을 만들고, selectedPhoto라는 변수의 이벤트가 있다. 그러나 현재는 구독자가 없다.

여기에서 구독자는 메인뷰이다(코드 참조, 포토뷰에서 이미지를 선택하면 메인 뷰로 다시 이동해 해당 이미지를 띄운다). 포토뷰의 selectedPhoto의 변화 이벤트를 관찰한다.

코드 설명

아래는 메인뷰의 + 버튼이며 이 버튼을 누르면 포토뷰로 이동한다. 포토뷰의 selectedPhoto를 구독하고 포토뷰로 이동한다.

포토뷰에서 selectedPhoto값이 바뀌면, 그 이벤트를 관찰해 자신의 이미지뷰에 띄운다.

@objc func addBtnTapped() {
    let photoVC = PhotoCollectionViewController()
    
    photoVC.selectedPhoto.subscribe(onNext: { [weak self] image in
        self?.photoImageView.image = image
    }).disposed(by: disposeBag)
    
    navigationController?.pushViewController(photoVC, animated: true)
}

  • 커밋: 메인뷰에서 포토뷰의 선택한 사진을 subscribe함, 이미지 화질 수정

4-9. 이미지에 필더 적용

사진 추가시에만 필터 버튼 보여주기

처음에 필터버튼을 정의할 때 isHidden을 true로 지정한 후, 위에서 photo를 이미지뷰에 띄운 이후에 필터버튼의 isHidden을 false로 바꾼다.

private let filterBtn: UIButton = {
    let btn = UIButton(type: .system)
    btn.setTitle("Filter", for: .normal)
    ...
        
    btn.isHidden = true
    return btn
}()
...

@objc func addBtnTapped() {
    let photoVC = PhotoCollectionViewController()
    
    photoVC.selectedPhoto.subscribe(onNext: { [weak self] image in
        self?.photoImageView.image = image
        self?.filterBtn.isHidden = false
    }).disposed(by: disposeBag)
    
    navigationController?.pushViewController(photoVC, animated: true)
}

필터 적용하기

FilterService라는 클래스를 따로 만들어 필터 관련 기능들을 관리한다. 이 클래스에 applyFilter함수를 추가하였다. 이 함수는 이미지를 받아 필터를 적용하는 함수이다.

func applyFilter(to inputImage: UIImage, completion: @escaping ((UIImage) -> ())) {
    guard let filter = CIFilter(name: PhotoFilter.shared.halftone) else { return }
    filter.setValue(5.0, forKey: kCIInputWidthKey)
    
    let sourceImage = CIImage(image: inputImage)
    filter.setValue(sourceImage, forKey: kCIInputImageKey)
    
    guard let outputImage = filter.outputImage else { return }
    let formRect = outputImage.extent
    
    guard let cgImage = context.createCGImage(outputImage, from: formRect) else { return }
    let processedImage = UIImage(cgImage: cgImage, scale: inputImage.scale, orientation: inputImage.imageOrientation)
    
    completion(processedImage)
}

필터 종류는 아래에서 확인할 수 있다.

Core Image Filter Reference

  • 커밋: 사진 추가시 필터 버튼 보여주기, 코드 정리
  • 커밋: 필터 버튼 클릭시 이미지에 필터 적용
  • 커밋: 필터 모델 생성, 필터 변경 및 필터 적용 취소 기능 추가
  • 커밋: 필터 변경시 적용 버튼 텍스트 적용으로 수정

4-10. 필터를 Observable로 변경

FilterService 수정

기존 코드

  • applyFilter 함수는 이미지와 필터키를 받아서, 필터처리가 된 이미지(processedImage)를 클로져로 반환하였다.

수정 코드

  • 클로져로 반환된 filteredImage 이벤트를 옵져버에 추가한다.
  • Disposables.create()를 반환한다.
// 기존 코드
final class FilterService {
    ...
    
    func applyFilter(to inputImage: UIImage, filterKey: String, completion: @escaping ((UIImage) -> ())) {
        ...
        
        completion(processedImage)
    }
}
// 수정 코드
import RxSwift

final class FilterService {
    ...

    func applyFilter(to inputImage: UIImage, filterKey: String) -> Observable<UIImage> {
        return Observable<UIImage>.create { observer in
            self.applyFilterToImage(to: inputImage, filterKey: filterKey) { filteredImage in
                observer.onNext(filteredImage)
            }
            
            return Disposables.create()
        }
    }
    
    private func applyFilterToImage(to inputImage: UIImage, filterKey: String, completion: @escaping ((UIImage) -> ())) {
        ...
        
        completion(processedImage)
    }
}

메인뷰 수정

기존 코드

  • 선택된 이미지를 applyFilter 함수에 필터키와 함께 넣어 필터처리된 이미지를 얻었다.
  • 얻은 이후 메인뷰의 이미지를 해당 이미지로 수정하였다.

수정 코드

  • filteredImage를 구독한다. 값이 세팅되고 변경될 때 메인뷰의 이미지를 해당 이미지로 변경한다.
  • disposeBag에 의해 dispose 처리한다.
// 기존 코드
func applyFilter() {
    guard let selectedImage = selectedImage else { return }

    FilterService.shared.applyFilter(to: selectedImage, filterKey: filter.CIKey) { [weak self] filteredImage in
        self?.photoImageView.image = filteredImage
    }
}
// 수정 코드
func applyFilter() {
    guard let selectedImage = selectedImage else { return }
    
    FilterService.shared.applyFilter(to: selectedImage, filterKey: filter.CIKey)
        .subscribe(onNext: { [weak self] filteredImage in
            self?.photoImageView.image = filteredImage
        }).disposed(by: disposeBag)
}
  • 커밋: 필터처리된 이미지를 Observable로 변경

전체 코드는 아래의 깃허브 저장소에서 확인해주세요.

https://github.com/sanghee-dev/Filter-Cam

profile
👩‍💻

0개의 댓글