[iOS] RxSwiftで投稿機能を実装する
はじめに
弊アプリ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を流します。
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では投稿時のバリデーションと、投稿完了通知の実装をしていきます。
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)
}
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に値が代入されます。
var validPostSubject = BehaviorSubject<Bool>.init(value: false)
var postCompletedSubject = BehaviorSubject<Bool>.init(value: true)
validPostSubjectは投稿できるかどうかを保持するSubjectです。
postCompletedSubjectは投稿が完了されたかどうかを保持するSubjectです。
var validPostDriver:Driver<Bool> = Driver.never()
var postedDriver:Driver<Bool> = Driver.never()
Driverは
- メインスレッドで通知
- shareReplayLatestWhileConnected を使った Cold-Hot 変換
- エラーが発生しない
という特性を持っています。詳しくはこちらを参照してみてください。
validPostDriverは投稿できるかどうかをViewに通知するDriverです。
postedDriverは投稿が完了したかをViewに通知するDriverです。
ここではボタンタップを検知するために初期化時にSignalを登録しておきます。
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にも流れてくる。というような実装をしています。
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に保存する処理の説明をしていきたいと思います。
//ここでも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)
タップを検知した時の処理を書いていきます。
//ボタンタップを検知して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
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にイベント送るため初期化時にタップイベントとテキストイベントを登録しておきます。
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に流してあげます。
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だと簡潔なコードで検知することができます。
//albumButtonをタップ
showAlbumButton.rx.tap.subscribe { [weak self] _ in
self?.showAlbum()
}
.disposed(by: disposeBag)
ここではPostViewModelのバリデーションの結果と投稿完了通知を受け取っています。
//投稿文または写真があれば投稿できるようにする
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