🐋

[iOS] RxSwiftで投稿機能を実装する

2022/02/04に公開

はじめに

弊アプリPostLikeでは、コードを整理するためにMVCからMVVMアーキテクチャに移行中です。それに伴いRxSwiftで投稿機能を実装したので紹介したいと思います。

使用ライブラリ

  • データベース -> Firestore
  • 写真ライブラリ -> DKImagePickerController
  • RxSwift
  • RxCocoa

MVVMについて

Model

  • ビジネスロジック(通信処理、DB操作など)
  • データ型の定義

View

  • MVCのView,ViewControllerが担ってた役割
  • viewModelが保持してるデータのバインド

ViewModel

  • Modelで定義した処理の呼び出し
  • データの保持

RxSwiftについて

  • Microsoftが2011年にリリースしたReactive ExtensionsのSwift版
  • メリット:値の変化の検知や非同期処理に強い
  • デメリット:学習コストが高く、導入の敷居が高い

実装

Model(Firestoreにデータを保存)

Singleとは一度だけerrorもしくは値を流すことができるObservableです。今回はBool型で定義しているのでsuccessの場合trueを流します。

PostDefaultAPI.swift
import Foundation
import Firebase
import RxSwift

protocol PostAPI {
    func post(text:String,imageArray:[UIImage]) -> Single<Bool>
}

final class PostDefaultAPI: PostAPI {
    
    func post(text:String,imageArray:[UIImage]) -> Single<Bool> {
        //Observableを作成
        return Single.create { [weak self] single in
            //Firestoreの処理
	    let dic = [
	    "userName":userName,
	    "userImage":userImage
	    "text":text,
	    "uid":uid,
	    "roomID":roomID,
	    "imageArray":imageArray
	    ]
	    Firestore.firestore().collection("hoge").document("fuga")
	    .setData(dic) { err in
	       if let err = err {
	            print("err":err)
		    single(.error(err))
	       }else{
	            single(.success(true))
	       }
	    }
            
            }
	    //disposableを作成
            return Disposables.create()
     }
     
     
}

ViewModel

ViewModelでは投稿時のバリデーションと、投稿完了通知の実装をしていきます。

PostViewMode.sift
import Foundation
import DKImagePickerController
import RxSwift
import RxCocoa

final class PostViewModel {
    
    private let disposeBag = DisposeBag()
    
    var photoArrayOutPut = BehaviorSubject<[UIImage]>.init(value: [])
     //Viewから送られてきた値を受け取る
    var photoArrayInPut:AnyObserver<[UIImage]> {
        photoArrayOutPut.asObserver()
    }
    
    var validPostSubject = BehaviorSubject<Bool>.init(value: false)
    var postCompletedSubject = BehaviorSubject<Bool>.init(value: true)
   
    var validPostDriver:Driver<Bool> = Driver.never()
    var postedDriver:Driver<Bool> = Driver.never()
    
    init(input:(postButtonTap:Signal<()>,text:Driver<String>),postAPI:PostAPI) {
        //投稿ボタンのバリデーション
        validPostDriver = validPostSubject
            .asDriver(onErrorDriveWith: Driver.empty())
        
        let validPostText = input.text
            .asObservable()
            .map { text -> Bool in
                return text != ""
            }
            
        let validPhotoArray = photoArrayOutPut
            .asObservable()
            .map { photos -> Bool in
                return photos != []
            }
        
        Observable.combineLatest(validPostText, validPhotoArray) { $0 || $1 }
            .subscribe { bool in
                self.validPostSubject.onNext(bool)
            }
            .disposed(by: disposeBag)
	    
	    
	//ボタンタップを検知してFirestoreに保存する
        postedDriver = postCompletedSubject.asDriver(onErrorJustReturn: true)
        let imageArrayObservable = photoArrayOutPut
            .asObservable()
        let textObservable = input.text.asObservable()
        let combineObservable = Observable.combineLatest(imageArrayObservable, textObservable)
        
        input.postButtonTap
            .asObservable()
            .withLatestFrom(combineObservable)
            .flatMapLatest { (imageArray,text) -> Single<Bool> in
                return postAPI.post(userName: userName, userImage: userImage, text: text, passedUid: passedUid, roomID: roomID, imageArray: imageArray)
            }
            .subscribe { [weak self] bool in
                switch bool {
                case .next(let bool):
                    self?.postCompletedSubject.onNext(bool)
                case .error(let error):
                    print(error)
                    self?.postCompletedSubject.onNext(false)
                case .completed:
                    print("completed")
                }
            }
            .disposed(by: disposeBag)
 
    }
    

PostViewModel
var photoArrayOutPut = BehaviorSubject<[UIImage]>.init(value: [])
  //observer - Viewから送られてきた値を受け取る
 var photoArrayInPut:AnyObserver<[UIImage]> {
     photoArrayOutPut.asObserver()
 }

SubjectとはObserverにもObservableにもなれるものです。その中のBehaviorSubjectとは初期値を持つSubjectです。
photoArrayOutPutは空の配列を持っている状態です。
photoArrayInPutはViewから送られてきた値を受け取るプロパティで、photoArrayOutPut.asObserver()とすることで、値が送られてきたらphotoArrayOutPutに値が代入されます。


PostViewModel
var validPostSubject = BehaviorSubject<Bool>.init(value: false)
var postCompletedSubject = BehaviorSubject<Bool>.init(value: true)

validPostSubjectは投稿できるかどうかを保持するSubjectです。
postCompletedSubjectは投稿が完了されたかどうかを保持するSubjectです。


PostViewModel
var validPostDriver:Driver<Bool> = Driver.never()
var postedDriver:Driver<Bool> = Driver.never()

Driverは

  • メインスレッドで通知
  • shareReplayLatestWhileConnected を使った Cold-Hot 変換
  • エラーが発生しない

という特性を持っています。詳しくはこちらを参照してみてください。
validPostDriverは投稿できるかどうかをViewに通知するDriverです。
postedDriverは投稿が完了したかをViewに通知するDriverです。


ここではボタンタップを検知するために初期化時にSignalを登録しておきます。

PostViewModel
init(input:(postButtonTap:Signal<()>,text:Driver<String>,albumButtonTap:Signal<()>),userName:String,userImage:String,passedUid:String,roomID:String,postAPI:PostAPI) {
}

SignalとはDriverと同様

  • メインスレッドで通知
  • shareReplayLatestWhileConnected を使った Cold-Hot 変換
  • エラーが発生しない

1つ違うのがDriverは購読時replayされ、Singnalはreplayされません。
なのでDriverはテキストの購読などに優れ、Singnalはタップイベントの購読に優れています。


ここでは、validPostDriverとvalidPostSubjectを紐付け -> viewから送られてきたtextとimageArrayを監視して空でないかをチェックする -> 二つのObservableを合成してぞの結果をvalidPostSubjectに流す -> validPostDriverはvalidPostSubjectに紐付けされているのでvalidPostSubjectの値がvalidPostDriverにも流れてくる。というような実装をしています。

PostViewModel
     validPostDriver = validPostSubject
         .asDriver(onErrorDriveWith: Driver.empty()) //エラーを無視
     
 //テキストが空でないかチェック
     let validPostText = text
         .asObservable()
         .map { text -> Bool in
             return text != ""
         }
     
 //photoArrayが空でないかチェック
     let validPhotoArray = photoArrayOutPut
         .asObservable()
         .map { photos -> Bool in
             return photos != []
         }
     
 //二つのObservableを合成してtext != "" || photos != [] であればtrueを返す
     Observable.combineLatest(validPostText, validPhotoArray) { $0 || $1 }
         .subscribe { bool in
         //validPostSubjectにbool値を流す
             self.validPostSubject.onNext(bool)
         }
         .disposed(by: disposeBag)

combineLatestとは複数のObservableを1本化し、どのObservableでも発行を起こした時に、すべてのObservableの最後に発行された要素をそれぞれ持ち寄って、それらを1つの要素に合体させたものを下流へと流すというOperatorです。

これでValidation部分は完成になります。次にボタンタップを検知してFirestoreに保存する処理の説明をしていきたいと思います。


PostViewModel
//ここでもDriverとSubjectを紐付けしてあげて、エラーの場合はfalseを返すようにします。
postedDriver = postCompletedSubject.asDriver(onErrorJustReturn: false)
//photoArrayOutPutを観察
let imageArrayObservable = photoArrayOutPut.asObservable()
//textを観察
let textObservable = text.asObservable()
//二つのObservableを合成
let combinedObservable = Observable.combineLatest(imageArrayObservable, textObservable)

タップを検知した時の処理を書いていきます。

PostViewModel
//ボタンタップを検知してFirestoreに保存する
input.postButtonTap
         .asObservable()
         .withLatestFrom(combinedObservable)
         .flatMapLatest { (imageArray,text) -> Single<Bool> in
             return postAPI.post(userName: userName, userImage: userImage, text: text, passedUid: passedUid, roomID: roomID, imageArray: imageArray)
         }
         .subscribe { [weak self] bool in
             switch bool {
             case .next(let bool):
                 self?.postCompletedSubject.onNext(bool)
             case .error(let error):
                 print(error)
                 self?.postCompletedSubject.onNext(false)
             case .completed:
                 print("completed")
             }
         }
         .disposed(by: disposeBag)
     

withLatestFrom は、ある Observable にもう一方の Observabel の最新値を合成します。
ここではボタンタップ ObserVable に combinedObservableの最新値、つまりボタンタップ時の値を合成しています。
flatMapLatestとはイベントを Observable に変換し、常に新しいイベントのObservableを通知してくれるものです。ここではpostAPIのpostを呼び出して、通知されたcombineObservableをSingle<Bool>型に変換しています。flatMapLatestの他にもflatMap や flatMapFirst など似たようなオペレーターがありますが、今回の処理は flatMapでもflatMapFirstでも大丈夫です。Mapオペレーターの詳細はこちらをご覧ください。そしてSingle<Bool>を購読してpostCompletedSubjectに流してあげます。これでViewModelの処理の説明は終わりです。次はViewの説明をしていきたいと思います。

View

PostViewController
import UIKit
import DKImagePickerController
import RxSwift
import RxCocoa

final class PostViewController: UIViewController{
    @IBOutlet private weak var textView: LinkTextView!
    @IBOutlet private weak var showAlbumButton: UIButton!
    private var postViewModel:PostViewModel!
    private let disposeBag = DisposeBag()
	
    override func viewDidLoad() {
        super.viewDidLoad()
	self.postViewModel = PostViewModel(input: (postButtonTap: postButton.rx.tap.asSignal(), text: textView.rx.text.orEmpty.asDriver()),postAPI: PostDefaultAPI())
	
	//albumButtonをタップ
	showAlbumButton.rx.tap.subscribe { [weak self] _ in
            self?.showAlbum()
        }
        .disposed(by: disposeBag)
	
	//投稿文または写真があれば投稿できるようにする
	postViewModel.validPostDriver
            .drive { [weak self] bool in
                self?.postButton.isEnabled = bool
                self?.postButton.backgroundColor = bool ? .red : .systemGray4
            }
            .disposed(by: disposeBag)
	
	//投稿完了通知
        postViewModel.postedDriver
            .drive { [weak self] bool in
                switch bool {
                case true:
                    self?.dismiss(animated: true, completion: nil)
                case false:
                    print("false!!!!!!!!!!!!!")
                }
                
            }
            .disposed(by: disposeBag)
    }


    private func showAlbum() {
	let pickerController = DKImagePickerController()
        pickerController.maxSelectableCount = 2
        pickerController.sourceType = .photo
        pickerController.assetType = .allPhotos
        pickerController.allowSelectAll = true
        pickerController.showsCancelButton = true
        pickerController.didSelectAssets = {(assets: [DKAsset]) in
            for asset in assets {
                asset.fetchFullScreenImage { image, info in
                    if var item = try? self.postViewModel.photoArrayOutPut.value() {
                        item.append(image!)
                        self.postViewModel.photoArrayInPut.onNext(item)
                    }
                }
            }
        }
        pickerController.modalPresentationStyle = .fullScreen
        pickerController.UIDelegate = CustomUIDelegate()
        self.present(pickerController, animated: true, completion: nil)
    }
	
	
}

ViewModelにイベント送るため初期化時にタップイベントとテキストイベントを登録しておきます。

PostViewController
self.postViewModel = PostViewModel(input: (postButtonTap: postButton.rx.tap.asSignal(),text: textView.rx.text.orEmpty.asDriver()),postAPI: PostDefaultAPI())

これでタップもしくはテキストを入力するたびにViewModelにイベントが送られるようになりました。


ViewModelにはUIImage型で送りたいので asset をfetchFullScreenImageでUIImage型に変換して、ViewModelのphotoArrayInPutに流してあげます。

PostViewController
 pickerController.didSelectAssets = {(assets: [DKAsset]) in
            for asset in assets {
                asset.fetchFullScreenImage { image, info in
                    if var item = try? self.postViewModel.photoArrayOutPut.value() {
                        item.append(image!)
                        self.postViewModel.photoArrayInPut.onNext(item)
                    }
                }
            }
        }

ここではタップイベントを検知して showAlbum メソッドを呼び出しています。通常だと@IBActionやaddTargetで検知しないといけないところをRxSwiftだと簡潔なコードで検知することができます。

PostViewController
   //albumButtonをタップ
   showAlbumButton.rx.tap.subscribe { [weak self] _ in
        self?.showAlbum()
   }
   .disposed(by: disposeBag)
	

ここではPostViewModelのバリデーションの結果と投稿完了通知を受け取っています。

PostViewController
//投稿文または写真があれば投稿できるようにする
postViewModel.validPostDriver
            .drive { [weak self] bool in
                self?.postButton.isEnabled = bool
                self?.postButton.backgroundColor = bool ? .red : .systemGray4
            }
            .disposed(by: disposeBag)
	
//投稿完了通知
postViewModel.postedDriver.drive { [weak self] bool in
	switch bool {
                case true:
                    self?.dismiss(animated: true, completion: nil)
                case false:
                    print("false!!!!!!!!!!!!!")
                }
            }
            .disposed(by: disposeBag)

まとめ

RxSwiftを使うとIBActionやdelegate処理が簡略化できたり、ロジック部分のテストがしやすくなったりと開発に大きなメリットをもたらしてくれることがわかりました。今回の記事で間違っているとこがありましたらご指摘の方をよろしくお願いします。

Discussion