- 投稿日:2019-06-27T22:40:31+09:00
Firebase Realtime Database を用いてチャットアプリを爆速コーディングしてみた。
はじめに
本記事で分かること
- Firebaseで新規プロジェクトの作成〜アプリにFirebaseを組み込むまで
- Firebase Realtime Databaseを用いた超簡易的なチャットアプリ
本記事で分からないこと
- Firebaseのアカウント作成
- いい感じのUIのアプリ
完成形
#picplaypost https://t.co/ZXHerxwHcP
— tetsukick (@TcarpentersR) 2019年6月27日1. Firebaseで新規プロジェクトの作成
まず、Firebase へアクセスして、コンソールへ移動。
下記の赤枠箇所より、新規プロジェクトを追加。
下記のように、プロジェクト名、アナリティクスの地域を選択してプロジェクトを作成。
これで作成完了。2. アプリにFirebaseを導入
CocoapodsでFirebaseを導入します。
詳細は、公式ドキュメントを参照してください。コマンド実行後は、拡張子が
.xcworkspaceとなっている方からプロジェクトを開くようにしてください。
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
2. Firebaseコンソール画面でiOSアプリの作成
ここからはFirebase公式の手順に従ってください。
先程作成したプロジェクトのページから、以下の赤枠をクリック
後はFirebaseの画面上のステップ通りに進めてください。
こちらについては、
今回導入するのは、Firebase/core と Firebase/Database になります。以下コマンドでpodfileの作成。
shellpod initpodfileの内容は下記になります。プロジェクト名は各々変更してください。
podfilesource 'https://github.com/CocoaPods/Specs.git' platform :ios, '10.0' use_frameworks! target 'firebase-chatting' do pod 'Firebase/Core' pod 'Firebase/Database' endそしたら、以下のコマンドを実行しましょう。
shellpod installはい!これでFirebaseの導入は完了です!!
ここまでだいたい10秒ですかね、、www笑3. RealTimeDatabaseのルールを変更
{ "rules": { ".read": true, ".write": true } }ルールを上記のように設定して、読み込み権限、書き込み権限を与えます。
設定する場所はこちら↓↓↓
4. UI画面の作成
Xcodeの右上の赤枠の箇所をクリックして、UI部品の選択画面を表示する。
UITextViewにConstraints(制約)を付与
今回はSafeAreaに対して、余白15の制約をつけます。
UIViewの制約をSafeAreaに対して、左0,右0,下0で付与する
UIViewの中に入力用のUITextFieldを配置(2つ)
5.配置したUI部品とコードの紐づけ
左側がstoryboard。右側が
ViewControlle.swiftが開かれている状態にします。
左のstoryBoardから、controlを押しながらドラッグ&ドロップします。
名前は適当につけてください。
UITextViewとUITextFieldを紐づけてください。
最後に、画面下部のView.bottomとSafeArea.bottomにつけられている制約も同じくドラッグ&ドロップで紐づけます。
6. データの読み込み処理
初期設定
Database.database().reference()でインスタンスを取得します。ViewController.swiftvar databaseRef: DatabaseReference! override func viewDidLoad() { super.viewDidLoad() databaseRef = Database.database().reference()データの読み込み
observeでイベントの監視を行います。
.childAddedを指定することで、子要素が追加されたときにwithで与えた処理が実行されます。
データに追加があると自動で呼ばれるので、更新処理を実装する必要はありません。ViewController.swiftoverride func viewDidLoad() { super.viewDidLoad() databaseRef = Database.database().reference() databaseRef.observe(.childAdded, with: { snapshot in if let obj = snapshot.value as? [String : AnyObject], let name = obj["name"] as? String, let message = obj["message"] { let currentText = self.textView.text self.textView.text = (currentText ?? "") + "\n\(name) : \(message)" } }) }7. データの書き込み
ボタンを押した時に、UITextField2つに入力されている値を送信します。
ViewController.swift@IBAction func tappedSendButton(_ sender: Any) { view.endEditing(true) if let name = nameInputView.text, let message = messageInputView.text { let messageData = ["name": name, "message": message] databaseRef.childByAutoId().setValue(messageData) messageInputView.text = "" } }ここまでで、一旦動かしてみてください。
入力時に入力域がキーボードで隠れてしまいますよね、、
なので、ここからはおまけ的にキーボードに入力域が隠れないようにする方法を紹介します。8. 入力域がキーボードで隠れないように動的に入力域の位置を変更
ViewDidload内に以下を追記。
こちらは、キーボードが表示されるタイミングと非表示になるタイミングを監視してくれるものになります。ViewController.swiftNotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)そしたら、上記によって呼び出されるメソッドにて入力域の末端の制約を動的に変更させます。
ViewController.swift@objc func keyboardWillShow(_ notification: NSNotification){ if let userInfo = notification.userInfo, let keyboardFrameInfo = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { inputViewBottomMargin.constant = keyboardFrameInfo.cgRectValue.height } } @objc func keyboardWillHide(_ notification: NSNotification){ inputViewBottomMargin.constant = 0 }9. 完成
これで完成です!!
最後にViewControllerの全貌を貼っておきます。
ViewController.swiftimport UIKit import FirebaseDatabase class ViewController: UIViewController { @IBOutlet weak var textView: UITextView! @IBOutlet weak var nameInputView: UITextField! @IBOutlet weak var messageInputView: UITextField! @IBOutlet weak var inputViewBottomMargin: NSLayoutConstraint! var databaseRef: DatabaseReference! override func viewDidLoad() { super.viewDidLoad() databaseRef = Database.database().reference() databaseRef.observe(.childAdded, with: { snapshot in dump(snapshot) if let obj = snapshot.value as? [String : AnyObject], let name = obj["name"] as? String, let message = obj["message"] { let currentText = self.textView.text self.textView.text = (currentText ?? "") + "\n\(name) : \(message)" } }) NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil) } @IBAction func tappedSendButton(_ sender: Any) { view.endEditing(true) if let name = nameInputView.text, let message = messageInputView.text { let messageData = ["name": name, "message": message] databaseRef.childByAutoId().setValue(messageData) messageInputView.text = "" } } @objc func keyboardWillShow(_ notification: NSNotification){ if let userInfo = notification.userInfo, let keyboardFrameInfo = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { inputViewBottomMargin.constant = keyboardFrameInfo.cgRectValue.height } } @objc func keyboardWillHide(_ notification: NSNotification){ inputViewBottomMargin.constant = 0 } }最後に
実際にだいたい1時間くらいで実装できてしまったので、ほんとに驚きです。
今後Firebaseの諸々を調査していく予定です。よかったらいいねください!!
参考記事
Firebase を使って30分でiOSのチャットアプリを作ってみる(新SDK対応版)
【Swift】リアルタイムチャットを実現するFirebaseでCRUD(データ作成、読み込み、更新、削除)をやってみる
- 投稿日:2019-06-27T22:40:31+09:00
【Swift】Firebase Realtime Database を用いてチャットアプリを爆速コーディングしてみた。
はじめに
本記事で分かること
- Firebaseで新規プロジェクトの作成〜アプリにFirebaseを組み込むまで
- Firebase Realtime Databaseを用いた超簡易的なチャットアプリ
本記事で分からないこと
- Firebaseのアカウント作成
- いい感じのUIのアプリ
完成形
#picplaypost https://t.co/ZXHerxwHcP
— tetsukick (@TcarpentersR) 2019年6月27日1. Firebaseで新規プロジェクトの作成
まず、Firebase へアクセスして、コンソールへ移動。
下記の赤枠箇所より、新規プロジェクトを追加。
下記のように、プロジェクト名、アナリティクスの地域を選択してプロジェクトを作成。
これで作成完了。2. アプリにFirebaseを導入
CocoapodsでFirebaseを導入します。
詳細は、公式ドキュメントを参照してください。コマンド実行後は、拡張子が
.xcworkspaceとなっている方からプロジェクトを開くようにしてください。
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
2. Firebaseコンソール画面でiOSアプリの作成
ここからはFirebase公式の手順に従ってください。
先程作成したプロジェクトのページから、以下の赤枠をクリック
後はFirebaseの画面上のステップ通りに進めてください。
こちらについては、
今回導入するのは、Firebase/core と Firebase/Database になります。以下コマンドでpodfileの作成。
shellpod initpodfileの内容は下記になります。プロジェクト名は各々変更してください。
podfilesource 'https://github.com/CocoaPods/Specs.git' platform :ios, '10.0' use_frameworks! target 'firebase-chatting' do pod 'Firebase/Core' pod 'Firebase/Database' endそしたら、以下のコマンドを実行しましょう。
shellpod installはい!これでFirebaseの導入は完了です!!
ここまでだいたい10秒ですかね、、www笑3. RealTimeDatabaseのルールを変更
{ "rules": { ".read": true, ".write": true } }ルールを上記のように設定して、読み込み権限、書き込み権限を与えます。
設定する場所はこちら↓↓↓
4. UI画面の作成
Xcodeの右上の赤枠の箇所をクリックして、UI部品の選択画面を表示する。
UITextViewにConstraints(制約)を付与
今回はSafeAreaに対して、余白15の制約をつけます。
UIViewの制約をSafeAreaに対して、左0,右0,下0で付与する
UIViewの中に入力用のUITextFieldを配置(2つ)
5.配置したUI部品とコードの紐づけ
左側がstoryboard。右側が
ViewControlle.swiftが開かれている状態にします。
左のstoryBoardから、controlを押しながらドラッグ&ドロップします。
名前は適当につけてください。
UITextViewとUITextFieldを紐づけてください。
最後に、画面下部のView.bottomとSafeArea.bottomにつけられている制約も同じくドラッグ&ドロップで紐づけます。
6. データの読み込み処理
初期設定
Database.database().reference()でインスタンスを取得します。ViewController.swiftvar databaseRef: DatabaseReference! override func viewDidLoad() { super.viewDidLoad() databaseRef = Database.database().reference()データの読み込み
observeでイベントの監視を行います。
.childAddedを指定することで、子要素が追加されたときにwithで与えた処理が実行されます。
データに追加があると自動で呼ばれるので、更新処理を実装する必要はありません。ViewController.swiftoverride func viewDidLoad() { super.viewDidLoad() databaseRef = Database.database().reference() databaseRef.observe(.childAdded, with: { snapshot in if let obj = snapshot.value as? [String : AnyObject], let name = obj["name"] as? String, let message = obj["message"] { let currentText = self.textView.text self.textView.text = (currentText ?? "") + "\n\(name) : \(message)" } }) }7. データの書き込み
ボタンを押した時に、UITextField2つに入力されている値を送信します。
ViewController.swift@IBAction func tappedSendButton(_ sender: Any) { view.endEditing(true) if let name = nameInputView.text, let message = messageInputView.text { let messageData = ["name": name, "message": message] databaseRef.childByAutoId().setValue(messageData) messageInputView.text = "" } }ここまでで、一旦動かしてみてください。
入力時に入力域がキーボードで隠れてしまいますよね、、
なので、ここからはおまけ的にキーボードに入力域が隠れないようにする方法を紹介します。8. 入力域がキーボードで隠れないように動的に入力域の位置を変更
ViewDidload内に以下を追記。
こちらは、キーボードが表示されるタイミングと非表示になるタイミングを監視してくれるものになります。ViewController.swiftNotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)そしたら、上記によって呼び出されるメソッドにて入力域の末端の制約を動的に変更させます。
ViewController.swift@objc func keyboardWillShow(_ notification: NSNotification){ if let userInfo = notification.userInfo, let keyboardFrameInfo = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { inputViewBottomMargin.constant = keyboardFrameInfo.cgRectValue.height } } @objc func keyboardWillHide(_ notification: NSNotification){ inputViewBottomMargin.constant = 0 }9. 完成
これで完成です!!
最後にViewControllerの全貌を貼っておきます。
ViewController.swiftimport UIKit import FirebaseDatabase class ViewController: UIViewController { @IBOutlet weak var textView: UITextView! @IBOutlet weak var nameInputView: UITextField! @IBOutlet weak var messageInputView: UITextField! @IBOutlet weak var inputViewBottomMargin: NSLayoutConstraint! var databaseRef: DatabaseReference! override func viewDidLoad() { super.viewDidLoad() databaseRef = Database.database().reference() databaseRef.observe(.childAdded, with: { snapshot in dump(snapshot) if let obj = snapshot.value as? [String : AnyObject], let name = obj["name"] as? String, let message = obj["message"] { let currentText = self.textView.text self.textView.text = (currentText ?? "") + "\n\(name) : \(message)" } }) NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(ViewController.keyboardWillHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil) } @IBAction func tappedSendButton(_ sender: Any) { view.endEditing(true) if let name = nameInputView.text, let message = messageInputView.text { let messageData = ["name": name, "message": message] databaseRef.childByAutoId().setValue(messageData) messageInputView.text = "" } } @objc func keyboardWillShow(_ notification: NSNotification){ if let userInfo = notification.userInfo, let keyboardFrameInfo = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { inputViewBottomMargin.constant = keyboardFrameInfo.cgRectValue.height } } @objc func keyboardWillHide(_ notification: NSNotification){ inputViewBottomMargin.constant = 0 } }最後に
実際にだいたい1時間くらいで実装できてしまったので、ほんとに驚きです。
今後Firebaseの諸々を調査していく予定です。よかったらいいねください!!
参考記事
Firebase を使って30分でiOSのチャットアプリを作ってみる(新SDK対応版)
【Swift】リアルタイムチャットを実現するFirebaseでCRUD(データ作成、読み込み、更新、削除)をやってみるGitHubはこちら
- 投稿日:2019-06-27T20:06:10+09:00
【swift】UIButtonのタイトルラベルに下線を付けた上で色を調整する方法
実現したいこと
UIButtonのタイトルラベルに下線を付けた上で色を調整する
経緯
下線を付ける方法を調べていたら
let AttributedString = NSAttributedString(string: "text", attributes: [.underlineStyle: NSUnderlineStyle.single.rawValue
と書けば下線を弾けることが分かった。しかし、色が青色になってしまう。
button.setTitleColor(ColorManager().white(), for: .normal)と書いても色を変更できないため、ハマった。
結論
let AttributedString = NSAttributedString(string: "text", attributes: [.underlineStyle: NSUnderlineStyle.single.rawValue, NSAttributedString.Key.foregroundColor : UIColor.white])
button.setAttributedTitle(AttributedString, for: .normal)と書けば良い。
- 投稿日:2019-06-27T16:16:24+09:00
App Extension使用時に発生したエラー「embedded binary is not signed with the same certificate as the parent app. Verify the embedded binary target's code sign settings match the parent app's.」の解決法
[前提]iOSの開発でApp Extension (https://qiita.com/motokiee/items/1f9147e6eb18f51937af)
を利用していた。
アプリ配布のためApp本体のProvisioning Profileなどを整えた所、タイトルのエラーが発生した。[原因]App本体とApp ExtensionでProvisioning Profileや開発チームが異なっていた事(App本体のProvisioning Profileと開発チームだけ手動で設定したのに、App Extension側は自動設定のままで放置していた事)が原因だった。
[解決法]App Extension用にProvisioning Profileを作成し、Xcodeの TARGETS > (Extensionの名前) > Signing でAutomatically manage signingを外し(手動設定にし)、同メニューのSigning(Release)> Provisioning Profile からProvisioning Profileを設定した。
同時に、TARGETS > (Extensionの名前) >Build Settings > Signing > Code Signing Identity > Release より、開発チームを親Appの開発チームと合わせた。
- 投稿日:2019-06-27T13:30:34+09:00
【Swift】鉄道指向プログラミング(Railway Oriented Programming)でResultの使い方を学ぶ
少し前に「これは何だろう?」と思ったことについて調べてみました。
SwiftのResultとは?
SuccessとFailureの2つのケースを持ったenumです。多くの方が自前で今まで実装してきましたが
Swif5.0で標準ライブラリに導入されました。https://github.com/apple/swift/blob/master/stdlib/public/core/Result.swift
鉄道指向プログラミング(Railway Oriented Programming)とは?
2014年にScott Wlaschinさんが提唱された
関数型プログラミングを行っていくなかで
エラーハンドリングをどう扱っていくかに主に焦点を当てたプログラミング手法です。https://fsharpforfunandprofit.com/rop/
Many examples in functional programming assume
that you are always on the “happy path”.
But to create a robust real world application
you must deal with validation, logging, network
and service errors, and other annoyances.So, how do you handle all this in a clean functional way?
This talk will provide a brief introduction to this topic,
using a fun and easy-to-understand railway analogy.-
多くの関数型プログラミングの例は
いつも「ハッピーパス(いわゆる正常系のこと)」を通っていることを想定しているが
現実にがバリデーションやログ、ネットワーク通信やサービスのエラー
その他の腹立たしいことを扱わなければならない。で、それをこれらをどうやってクリーンに関数型の手法で対処できるだろうか?
今回のトークでは、楽しく、わかりやすい鉄道との共通点を使ってこの問題について
簡単な導入部分を紹介する。とあり
抽象的な概念というよりは
より具体的な問題に焦点を当てており
すぐに実践に応用できるようになっています。Resultと鉄道指向プログラミング
鉄道指向プログラミングでは
Resultを用いたエラーハンドリングを行います。
Resultを用いることでそのままの型を用いる以上の
多くのメリットを得ることができます。今回は
鉄道指向プログラミングはどういうものなのかが気になったのと
Resultがどのように機能するのかを理解するために
記録として残してみました。※
記事の中で出てくる図はScottさんの講演スライドから拝借しています。
ブログの中で「自由にして良い」という記載がございましたので
活用させていただきました。The powerpoint slides are also available from Github. Feel free to borrow from them!
今回の例
以下の処理を行います。
- リクエストを受け取る
- バリデーションチェックをする
- リクエストをDBに保存(更新)する
- 認証済みのメールに送信する
- ユーザに結果を返す
命令型プログラミングで書いた例
下記の例を見てみます
命令型プログラミングの実装
struct DB { func updateDb(from request: Request) throws -> Bool{ return true } } struct SmtpServer { func sendEmail(to request: Request) throws -> Bool { return true } } func validateRequest(_ request: Request) -> Bool { return true } func executeUseCase(request: Request, db: DB, stmpServer: SmtpServer) -> String { if !validateRequest(request) { return "validation error" } do { let result = try db.updateDb(from: request) if !result { return "Record not found" } if !stmpServer.sendEmail(to: request) { return "Fail to send mail" } } catch { return "Fail to update" } return "OK" }これはいわゆる命令型と呼ばれるような形で書かれています。
特に問題はないのですが
こうすると
ifで判定をしたり
do catch文が途中で入ってくるため
本来のやりたいことが見えづらくなってしまいます。では
このようなエラーハンドリングを
どうやって関数型プログラミングを使って
綺麗な形で処理できるでしょうか?結果のパターンを考えてみる
上記の例の処理の流れを考えてみます。
Requestを受け取り
処理が成功した場合は次の処理へ
エラーの場合はエラーになった時点で
レスポンスを返しています。次に関数型プログラミングの形で考えてみます。
関数型では処理を上から下へ向かう
データの流れとして捉えます。ここで上記の図のように処理の結果は
4つのパターンが考えられます。このようなレスポンスをどうやって表現することができるでしょうか?
enum Result { case Success case ValidationError case UpdateError case SendMailError }パターンということで
enumとして捉えました。これですべてのケースを網羅できていますが
他の処理でも同じように使えるようにしたいですね。それがSwiftの
Resultです。public enum Result<Success, Failure: Error> { case success(Success) case failure(Failure) }※ F#にもResultはありますがSwiftのFailureはErrorプロトコルに適合する必要があります。
Resultを使うと処理の流れは下記のようになります。こうすることで各関数が同じレスポンスを返すようになります。
下記にResultを返す関数を提示してみます。
Resultを返す関数
struct ValidationError: LocalizedError { let field: String let value: Any let reason: String var localizedDescription: String { return "\(field) \(value) is not valid because \(reason)" } } enum UseCaseError: LocalizedError { case validation(ValidationError) case update(Error) case sendMail(Error) var localizedDescription: String { switch self { case .validation(let error): return error.localizedDescription case .update(let error): return "update error \(error)" case .sendMail(let error): return "sendMail error \(error)" } } } struct DB { func updateDb(from request: Request) -> Result<Request, UseCaseError> { return Result { try updateDb(request) }.mapError(UseCaseError.update) } private func updateDb(_ request: Request) throws -> Request { return request } } struct SmtpServer { func sendEmail(to request: Request) -> Result<Request, UseCaseError> { return Result { try sendEmail(request.email) return request }.mapError(UseCaseError.sendMail) } private func sendEmail(_ email: String) throws -> Void { return } } func validateRequest(_ request: Request) -> Result<Request, UseCaseError> { if request.name.isEmpty { let error = ValidationError(field: "name", value: request.name, reason: "name should not be empty") return .failure(.validation(error)) } return .success(request) }同じレスポンスを返すということは
各関数を一つのワークフローとして組み合わせて
全体の処理を構成できそうですね。※
値はなんでも良いsuccess
またはUseCaseErrorを持ったfailure
を返すしかし
それぞれの型を見ると(Request) -> Result<Request, UseCaseError>ということで型が合いません。
どうやって各処理を連結できるようになるでしょうか?
鉄道の車線をイメージしてみる(鉄道指向プログラミング)
下記の図を見てください。
一つのインプットを与えると一つのアウトプットを出力する関数を
鉄道の車線に例えています。次の図を見てください。
もう一つ車線が出てきました。
左の関数のアウトプットと右の関数のインプットが一致しているため
この二つの車線を繋げることができます。このような場合は
シンプルですぐに理解できると思います。
Resultの場合はアウトプットが2つの可能性があります。
これを表現するためには車線の分岐が必要になります。
Success車線とFailure車線ができます。このような分岐を生じる関数を
スイッチ関数
と呼びます。ではスイッチ関数を連結した場合の動きはどうなるでしょうか?
上記の例で説明すると
Validate関数が
成功した場合 ->Success車線を通りUpdateDbを実行する
失敗した場合 ->Failure車線を通りUpdateDbは実行せずにFailure車線を通り続けるとなります。
言い換えると
処理が成功している場合のみ処理を継続し
エラーが発生した場合は以降の処理を行わずに最終的なアウトプットまで進むことになります。
なんとなくイメージはできたでしょうか?
では
問題のResultの連結方法について車線で考えてみます。上記で示した1つの車線の関数の連結はシンプルでした。
同様にインプットとアウトプットが2車線同士の関数の場合もシンプルです。
インプットとアウトプットが一致すればそのまま繋げることができます。
しかし
Resultを返すような関数は通常の値をインプットとして受け取るため
インプットとアウトプットが一致するため連結できません。ではどうすれば良いのか?
車線の数を合わせれば良い
のです。
1車線インプット、2車線アウトプットの関数から
2車線インプット、2車線アウトプットの関数へ変換する
ことで2車線関数同士の関数を繋ぎ合わせることと同じになります。それを実現するのがflatMapです。
Scottさんの講演ではアダプターブロックと読んでいました。
flatMapの実装
public enum Result<Success, Failure: Error> { case success(Success) case failure(Failure) public func flatMap<NewSuccess>( _ transform: (Success) -> Result<NewSuccess, Failure> ) -> Result<NewSuccess, Failure> { switch self { case let .success(success): return transform(success) case let .failure(failure): return .failure(failure) } } }https://github.com/apple/swift/blob/master/stdlib/public/core/Result.swift#L96
引数に
ResultのSuccess型を引数にして
変換してResult<NewSuccess, Error>を返す関数を受け取り
Result<NewSuccess, Error>を返します。この
transformの形に注目すると(Success) -> Result<NewSuccess, Failure>これはまさにスイッチ関数と同じ形です。
Scottさんの講演では下記のようなbind関数を定義しています。
func bind<A,B>(_ switchFuntion: @escaping (A) -> Result<B, Error>) -> (Result<A, Error>) -> Result<B, Error> { return { (a: Result<A, Error>) in switch a { case .success(let x): return switchFuntion(x) case .failure(let error): return .failure(error) } } }これを活用することもできますが
Swiftの標準で使われているメソッドで考えていきたいと思います。Resultを用いた処理の例
最初の方で命令型で書いた例を
Resultを使った形で考えてみます。
Resultを使った実装例
... struct DB { func updateDb(from request: Request) -> Result<Request, UseCaseError> { return Result { try updateDb(request) }.mapError(UseCaseError.update) } func updateDb(_ request: Request) throws -> Request { return request } } struct SmtpServer { func sendEmail(to request: Request) -> Result<Request, UseCaseError> { return Result { try sendEmail(request.email) return request }.mapError(UseCaseError.sendMail) } func sendEmail(_ email: String) throws -> Void { return } } func validateRequest(_ request: Request) -> Result<Request, UseCaseError> { if request.name.isEmpty { let error = ValidationError(field: "name", value: request.name, reason: "name should not be empty") return .failure(.validation(error)) } return .success(request) } func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String { switch validateRequest(request) .flatMap(db.updateDb) .flatMap(stmpServer.sendEmail(to:)) { case .success: return "OK" case .failure(let error): return error.localizedDescription } }
flatMapを使うことで連結ができるようになりました。それでは動作を確認してみます。
let request = Request(userId: 1, name: "hoge", email: "hoge@hoge.com") let result = executeUseCase(request: request, db: DB(), stmpServer: SmtpServer()) print(result) // success("OK")値がきちんと設定されている場合は
successになります。では
nameを空文字にしてみたいと思います。let request = Request(userId: 1, name: "", email: "hoge@hoge.com") let result = executeUseCase(request: request, db: DB(), stmpServer: SmtpServer()) print(result) // failure(UseCaseError.validation(ValidationError(field: "name", value: "", reason: "name should not be empty")))
UseCaseError.validationが出力されました。想定だとそれ以降のメソッドは呼ばれていないはずですので確認をします。
struct DB { func updateDb(from request: Request) -> Result<Request, UseCaseError> { print("updateDb") return Result { try updateDb(request) }.mapError(UseCaseError.update) } } struct SmtpServer { func sendEmail(to request: Request) -> Result<Request, UseCaseError> { print("sendEmail") return Result { try sendEmail(request.email) return request }.mapError(UseCaseError.sendMail) } }この状態でもう一度
successを出力すると// updateDb // sendEmail // success("OK")と出ますが
nameを空文字すると// failure(UseCaseError.validation(ValidationError(field: "name", value: "", reason: "name should not be empty")))とエラーのみ出力され
その先のメソッドが呼ばれていないことが確認できました。他のメソッドと組み合わせるには?
下記のメソッドを追加したいとします。
func canonicalizeEmail(_ request: Request) -> Request { let canonicalized = request.email.trimmingCharacters(in: .whitespaces).lowercased() return Request(userId: request.userId, name: request.name, email: canonicalized) }これは
Resultは登場しない1車線関数です。これも連結して処理できるようにしたいですが
1車線関数のアプトプットと2車線関数のインプットをそのまま繋げることはできません。
同様に2車線関数のインプットと1車線関数のアウトプットをそのまま繋げることはできません。ではどうするか?
これも
flatMapの時と同じように考えます。つまり1車線関数を2車線関数に変換するようにします。
これを実現するのはmapです。
Mapの実装
public enum Result<Success, Failure: Error> { public func map<NewSuccess>( _ transform: (Success) -> NewSuccess ) -> Result<NewSuccess, Failure> { switch self { case let .success(success): return .success(transform(success)) case let .failure(failure): return .failure(failure) } } }https://github.com/apple/swift/blob/master/stdlib/public/core/Result.swift#L41
transformを見てみると1車線関数を渡してResult型に変換して返します。これを使用すると
func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String { switch validateRequest(request) .map(canonicalizeEmail) // ← ここ .flatMap(db.updateDb) .flatMap(stmpServer.sendEmail(to:)) { case .success: return "OK" case .failure(let error): return error.localizedDescription }と上記のように連結することができました。
次に下記のメソッドを考えてみたいと思います。
struct DB { func updateDbVoid(_ request: Request) throws -> Void { return } }これはまず1車線関数のため連結できません。
さらにアウトプットがないためmapを使っても連結ができません。※こういう関数はデッドエンド関数と呼ばれているそうです。
ではどうするのか?
インプットで受け取った値を内部で関数を実行した後に
そのままインプットの値を返すようにします。今回はメソッドを一つ追加します。
extension Result { static func tee(_ f: @escaping (Success) -> ()) -> (Success) -> Result<Success, Failure> { return { a in f(a) return .success(a) } } static func tee(_ f: @escaping (Success) throws -> ()) -> (Success) -> Result<Success, Error> { return { a in do { try f(a) return .success(a) } catch { return .failure(error) } } } }これを先ほどの
updateDbVoidに適用します。struct DB { func updateDb(_ request: Request) -> Result<Request, UseCaseError> { return Result<Request, UseCaseError> .tee(self.updateDbVoid)(request).mapError(UseCaseError.update) } func updateDbVoid(_ request: Request) throws -> Void { return } }こうすると今までと同じように扱うことができます。
func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String { switch validateRequest(request) .map(canonicalizeEmail) .flatMap(db.updateDb) .flatMap(stmpServer.sendEmail(to:)) { case .success: return "OK" case .failure(let error): return error.localizedDescription } }Resultを使うことで得られること
これまで見てきたように
Resultを使うことで
全体で統一的にエラーハンドリングを行えるようになりました。さらに各メソッドの型を見てみると
executeUserCase: (Request, DB, SmtpServer) -> String validateRequest: (Request) -> Result<Request, UseCaseError> canonicalizeEmail: (Request) -> Request updateDb: (Request) -> Result<Request, UseCaseError> sendEmail(Request) -> Result<Request, UseCaseError>となっています。
Resultを使ったことで
メソッドが失敗するかもしれないということを
目で見てわかるようになりました。具体的なエラーの内容は
enumで確認できます。enum UseCaseError { case validation(ValidationError) case update(Error) case sendMail(Error) } ... 一部省略こうすることで
他の人が見てもどういう処理をしているのかを型で伝えやすくなり
いわゆる自己文書化(Self-documenting)につながります。※ 自己文書化については下記のサイトなどに詳しく書かれています
https://www.webprofessional.jp/self-documenting-javascript/その他の鉄道指向プログラミング
この他にも色々な場合に関しての
鉄道指向プログラミングのアプローチが紹介されています。いくつか挙げたいと思います。
複数のエラーが欲しい場合は?
これまで見てきたケースですと
エラーは一つしか扱うことができません。例えばバリデーションチェックのエラーは
発生した全てのエラーが欲しい場合があるかもしれません。そのような場合
講演の中では詳細には触れていませんが
それぞれの処理をPairとして組み合わせていけば
いくつでも組み合わせることができる
といったことをおっしゃっています。講演で紹介されていたブログの内容はこちら↓
https://fsharpforfunandprofit.com/posts/monoids-without-tears/SwiftではiOS13よりCombineというフレームワークが増え
その中でZipが定義されています。
https://developer.apple.com/documentation/combine/publishers/zip
※2019/6/22現在のベータ版の情報です。また
zipを定義しているライブラリがあります。例えば下記のライブラリでは
zipというメソッドで複数の処理結果をペアの組み合わせにしています。
https://github.com/pointfreeco/swift-validated※ちなみに
zipはHaskellなどの関数型プログラミングでも定義されており
同様の使われ方をされています。RxSwiftでも使用されています。
https://github.com/ReactiveX/RxSwift/blob/master/RxSwift/Observables/Zip.swift
https://github.com/ReactiveX/RxSwift/blob/master/RxSwift/Observables/Zip+arity.swift#L23ドメインイベントなどのメッセージを渡したい場合
処理結果以外に他の機能にメッセージを送りたい場合もあるかもしれません。
(講演ではメールの送信に成功したことをCRMに伝えるなど)このような場合は
successの場合にメッセージをリストとして持ち
処理結果とメッセージのリストの組み合わせを伝達するようにします。
Githubを参考にSwiftでも実装してみました(結構無理あり
)
struct Request { let userId: Int let name: String let email: String } struct ValidationError: LocalizedError { let field: String let value: Any let reason: String var localizedDescription: String { return "\(field): \(value) is not valid because \(reason)" } } enum UseCaseMessage: LocalizedError { case validation(ValidationError) case update(Error) case sendMail(Error) case UpdateSuccess case SendMailSuccess var localizedDescription: String { switch self { case .validation(let error): return error.localizedDescription case .update(let error): return "update error \(error)" case .sendMail(let error): return "sendMail error \(error)" case .UpdateSuccess: return "Update Success" case .SendMailSuccess: return "Send Mail Success" } } } extension Array: Error where Element: Error {} enum ROPResult<Success, Message> { case success(Success, [Message]) case failure([Message]) func map<NewSuccess>(_ f: (Success) -> NewSuccess) -> ROPResult<NewSuccess, Message> { switch self { case .success(let x, let msgs): return .success(f(x), msgs) case .failure(let errors): return .failure(errors) } } func flatMap<NewSuccess>( _ transform: (Success) -> ROPResult<NewSuccess, Message> ) -> ROPResult<NewSuccess, Message> { switch self { case .success(let x, let msgs): do { let result = try transform(x).get() return .success(result.0, result.1 + msgs) } catch let errors as [Error] { return .failure(errors as! [Message]) } catch { return .failure([error] as! [Message]) } case .failure(let errors): return .failure(errors) } } func get() throws -> (Success, [Message]) { switch self { case .success(let x, let msgs): return (x, msgs) case .failure(let errors): throw errors as! [Error] } } func mapError( _ transform: (Message) -> Message ) -> ROPResult<Success, Message> { switch self { case .success(let x, let msgs): return .success(x, msgs) case .failure(let errors): let newErrors = errors.map(transform) return .failure(newErrors) } } static func tee(_ f: @escaping (Success) throws -> (), msgs: [Message] = []) -> (Success) -> ROPResult { return { a in do { try f(a) return .success(a, msgs) } catch { return .failure([error] as! [Message]) } } } } extension ROPResult where Message == Swift.Error { init(catching body: () throws -> (Success, Message)) { do { let result = try body() self = .success(result.0, [result.1]) } catch { self = .failure([error]) } } } struct DB { func updateDb(_ request: Request) -> ROPResult<Request, UseCaseMessage> { return ROPResult<Request, UseCaseMessage> .tee(self.updateDbVoid, msgs: [UseCaseMessage.UpdateSuccess])(request) .mapError(UseCaseMessage.update) } func updateDbVoid(_ request: Request) throws -> Void { return } } struct SmtpServer { func sendEmail(to request: Request) -> ROPResult<Request, UseCaseMessage> { do { try sendEmail(request.email) return .success(request, [UseCaseMessage.SendMailSuccess]) } catch { return .failure([UseCaseMessage.sendMail(error)]) } } func sendEmail(_ email: String) throws -> Void { return } } func validateRequest(_ request: Request) -> ROPResult<Request, UseCaseMessage> { if request.name.isEmpty { let error = ValidationError(field: "name", value: request.name, reason: "name should not be empty") return .failure([.validation(error)]) } return .success(request, []) } func canonicalizeEmail(_ request: Request) -> Request { let canonicalized = request.email.trimmingCharacters(in: .whitespaces).lowercased() return Request(userId: request.userId, name: request.name, email: canonicalized) } func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String { switch validateRequest(request) .map(canonicalizeEmail) .flatMap(db.updateDb) .flatMap(stmpServer.sendEmail(to:)) { case .success(let x, let messages): // Request(userId: 1, name: "hoge", email: "hoge@hoge.com") print("\(x)") // Messages are [UseCaseMessage.SendMailSuccess, UseCaseMessage.UpdateSuccess] print("Messages are \(messages)") return "OK" case .failure(let errors): return errors.reduce("", { total, error in return total + error.localizedDescription }) } } let request = Request(userId: 1, name: "hoge", email: "hoge@hoge.com") let result = executeUserCase(request: request, db: DB(), stmpServer: SmtpServer()) print("result is \(result)") // result is OK let errorRequest = Request(userId: 1, name: "", email: "hoge@hoge.com") let errorResult = executeUserCase(request: errorRequest, db: DB(), stmpServer: SmtpServer()) print("errorResult is \(errorResult)") // errorResult is name: is not valid because name should not be empty※ 講演の中では一覧ができるから見やすいとのことで
エラーとドメインのメッセージを一緒に扱っていますが
エラーとメッセージは別に違う型でも
良いのではないかなと個人的には思っています。
そもそもResultで分岐しているので分かるから良いのかもしれませんが。
Failureを分けてみた例(これも結構無理あり
)
struct Request { let userId: Int let name: String let email: String } struct ValidationError: LocalizedError { let field: String let value: Any let reason: String var localizedDescription: String { return "\(field): \(value) is not valid because \(reason)" } } enum UseCaseError: LocalizedError { case validation(ValidationError) case update(Error) case sendMail(Error) var localizedDescription: String { switch self { case .validation(let error): return error.localizedDescription case .update(let error): return "update error \(error)" case .sendMail(let error): return "sendMail error \(error)" } } } enum UseCaseMessage { case UpdateSuccess case SendMailSuccess } extension Array: Error where Element: Error {} enum ROPResult<Success, Failure: Error, Message> { case success(Success, [Message]) case failure([Failure]) func map<NewSuccess>(_ f: (Success) -> NewSuccess) -> ROPResult<NewSuccess, Failure, Message> { switch self { case .success(let x, let msgs): return .success(f(x), msgs) case .failure(let errors): return .failure(errors) } } func flatMap<NewSuccess>( _ transform: (Success) -> ROPResult<NewSuccess, Failure, Message> ) -> ROPResult<NewSuccess, Failure, Message> { switch self { case .success(let x, let msgs): do { let result = try transform(x).get() return .success(result.0, result.1 + msgs) } catch let errors as [Error] { return .failure(errors as! [Failure]) } catch { return .failure([error] as! [Failure]) } case .failure(let errors): return .failure(errors) } } func get() throws -> (Success, [Message]) { switch self { case .success(let x, let msgs): return (x, msgs) case .failure(let errors): throw errors } } func mapError<NewFailure>( _ transform: (Failure) -> NewFailure ) -> ROPResult<Success, NewFailure, Message> { switch self { case .success(let x, let msgs): return .success(x, msgs) case .failure(let errors): let newErrors = errors.map(transform) return .failure(newErrors) } } static func tee(_ f: @escaping (Success) throws -> (), msgs: [Message] = []) -> (Success) -> ROPResult { return { a in do { try f(a) return .success(a, msgs) } catch { return .failure([error] as! [Failure]) } } } } extension ROPResult where Failure == Swift.Error { init(catching body: () throws -> (Success, Message)) { do { let result = try body() self = .success(result.0, [result.1]) } catch { self = .failure([error]) } } } struct DB { func updateDb(_ request: Request) -> ROPResult<Request, UseCaseError, UseCaseMessage> { return ROPResult<Request, UseCaseError, UseCaseMessage> .tee(self.updateDbVoid, msgs: [UseCaseMessage.UpdateSuccess])(request) .mapError(UseCaseError.update) } func updateDbVoid(_ request: Request) throws -> Void { return } } struct SmtpServer { func sendEmail(to request: Request) -> ROPResult<Request, UseCaseError, UseCaseMessage> { do { try sendEmail(request.email) return .success(request, [UseCaseMessage.SendMailSuccess]) } catch { return .failure([UseCaseError.sendMail(error)]) } } func sendEmail(_ email: String) throws -> Void { return } } func validateRequest(_ request: Request) -> ROPResult<Request, UseCaseError, UseCaseMessage> { if request.name.isEmpty { let error = ValidationError(field: "name", value: request.name, reason: "name should not be empty") return .failure([.validation(error)]) } return .success(request, []) } func canonicalizeEmail(_ request: Request) -> Request { let canonicalized = request.email.trimmingCharacters(in: .whitespaces).lowercased() return Request(userId: request.userId, name: request.name, email: canonicalized) } func executeUserCase(request: Request, db: DB, stmpServer: SmtpServer) -> String { switch validateRequest(request) .map(canonicalizeEmail) .flatMap(db.updateDb) .flatMap(stmpServer.sendEmail(to:)) { case .success(let x, let messages): print("\(x)") print("Messages are \(messages)") return "OK" case .failure(let errors): return errors.reduce("", { total, error in return total + error.localizedDescription }) } }非同期処理
講演内では具体的に扱っていませんでしたが
鉄道指向プログラミングは
全ての処理をシーケンシャルに扱うという訳ではなく
インプットとアウトプットをどうやって繋げていくのかということを示しています。なのでレールの途中は並列に処理を行うこともあります。
F#では
asyncのような
非同期を同期的に扱う仕組みがあるので
それを活用することで変わらず形でコードを書くことができます。より複雑な処理の場合は
メッセージを送ることで他のワークフローに任せてしまうなどを挙げていました。SwiftでもCombineフレームワークの中で
Futureが定義されています。
https://developer.apple.com/documentation/combine/publishers/futureまた
Futureの中で
非同期処理のコールバックでPromiseという型を受け取っていますが
これは(Result<Output, Failure>) -> Voidのtypealiasです。
https://developer.apple.com/documentation/combine/publishers/future/promise※2019/6/22現在のベータ版の情報です。
他にも非同期を同期的に扱うライブラリが活用できます。
ライブラリの参考例
https://github.com/malcommac/Hydraまた将来的には標準として採用される予定の
async/awaitなどが活用できる可能性があります。これらの他にもログや処理失敗時DBのロールバックなどについても少し言及されていたので
ご興味のある方はスライドや動画をご参照ください。補足: EitherやMonadとの違い
Scottさんもサイトで言及されていましたが
鉄道指向プログラミングは
下記のような理由でHaskellの用語を用いていないと言っています。より具体的な形で多くの人に理解して欲しい
これは特定のエラーハンドリングの問題を解決するためのものであり
モナドを知らない人にも
まずはより目に見える具体的な形で見てもらいたかった。具体的なものから抽象概念を理解する方が理解の進むが早いと強く思っている。
正確にモナド則に従っているわけではない
flatMapはmonadに必ずしも従っているわけではなく
モナドの方がもっと複雑。Eitherは抽象的すぎる
道具ではなくレシピを提示したかった。
パンを作るためのレシピが欲しいのに
「小麦粉とオーブンを使え」とだけ言うのが役に立たないのと同様に
エラーハンドリングのためのレシピが欲しいのに
「Eitherとbind(flatMap)を使え」
とだけ言うのは役に立たない。なので具体的なカスタムオペレーターや
map、teeなどの数多くの状況に使えるけれども
書き方は一つに限定されるようなテンプレートを提供したかった。こうすることで後々誰がメンテナンスしても全体像が理解しやすくなって楽になる。
最後に
鉄道指向プログラミングの概要と
Resultの動きを見てみました。鉄道指向プログラミングでは
型を合わせていくことに焦点を当てており
型を通して処理を考えることの大切さや効果といったことを学べました。またF#というあまり触れる機会がない言語に触れ
普段とは違う考え方やコードの書き方を知り
すごい勉強になりました。今回は出てきませんが
鉄道指向プログラミングは
ドメイン駆動設計などの話にも繋がっており
Scottさんもドメインモデルについての本や講演もされています。https://fsharpforfunandprofit.com/books/
https://www.youtube.com/watch?v=Up7LcbGZFuoまだまだ私が理解できていない部分も多々あると思いますので
引き続き学んでみたいと思います。間違いなどございましたらご指摘して頂けますとうれしいです
- 投稿日:2019-06-27T10:30:41+09:00
現場で選ばれているiOSアーキテクチャ
はじめに
ナビタイムジャパンでは、アプリの継続的な開発・運用をスムーズに行うために「設計」を重視しています。本記事では、その「設計」を実現するアーキテクチャをいくつか紹介し、実際に現場で使われているアーキテクチャについて考察していきたいと思います。
アーキテクチャとは?
アーキテクチャとは、アプリを綺麗に開発・継続的に運用していくための設計方法です。この設計方法を考えずに開発すると以下の問題点が発生してしまいます。
- クラスが肥大化し、コードを追いにくくなる
- ロジックの煩雑化、再利用性の低下
- チーム開発における役割分担がしにくい
- 属人化が進み、引き継ぎにくい
- テストがしにくい
- 機能の追加、改修が困難
- etc…
チームでのアプリ開発はもちろん、個人でアプリを開発する時も、開発するアプリに規模や特性に合わせて適切にアーキテクチャを選ぶことが、アプリを継続的に開発・運用していく上で非常に重要です。ただし、アーキテクチャは手段に過ぎず、固執することなく常に設計について考えることが大切です。
アーキテクチャのご紹介
どのようなアーキテクチャがあって、どのように選べばいいのかをご紹介していきます。
MVC (Model View Controller)
iOS開発において、
MVCというとAppleが推奨しているCocoa MVCが一般的です。
Cocoa MVCではViewがユーザーの入力を受け付け、Controllerにアクションを渡します。そしてControllerがModelにデータの更新を依頼します。最後にModelがControllerに処理の完了を通知し、ControllerがViewを更新します。
View:表示、入出力Controller:入力にもとづくModelとViewの制御Model:データの保持、通信、ビジネスロジック
Cocoa MVCはUIKitをそのまま素直に使えば簡単に実装できますが、ControllerがViewと密結合しており、Viewのライフサイクルにも影響されるため、テストは容易ではありません。また、ControllerはViewとModelの仲介を行うので、処理が集中しがちで肥大化しやすいです。このためMassive View controllerと言われることもあります。ところで、
Cocoa MVCはAppleによって改良されたもので、原初のMVCとは構造が異なります。原初MVCは元々SmallTalkという元祖オブジェクト指向のプログラミング言語の開発環境のために考案されたデザインパターンです。
Cocoa MVCではControllerがViewとModelを分離し仲介を行いますが、原初MVCではModelがViewに通知し、ViewがModelの更新された状態を取得して表示を更新するというオブザーバパターンを採用しています。このためViewとModelの間に依存関係が生じてしまい、Viewで表示用にデータを加工することも必要になります。MVP (Model View Presenter)
MVPはViewとModel、そして両者を仲介し、プレゼンテーションロジック(UIのビジネスロジック)を担当するPresenterで構成されるアーキテクチャです。MVCとは異なり、入力を受け付けるのがControllerではなくViewになり、MVPではUIViewControllerはViewに分類されます。このためPresenterはViewのライフサイクルやレイアウトに依存せずに、プレゼンテーションロジックのみ担当し、import UIKitが必要ありません。また、ViewとPresenterはデリゲートやインターフェイスを通してやり取りを行うため、Viewのモックがしやすく、Presenterのテストが容易になります。
MVPにはSupervising ControllerとPassive Viewの2種類があり、前者のSupervising ControllerではデータバインディングによりViewがModelを監視し、データ更新があれば表示も更新します(上図のViewとModelにデータバインディングの関係が追加されます)。ViewがModelを監視することで、データを加工せずにそのまま表示するシーンでは、Presneterの負担を軽減できますが、ModelがViewのレイアウトに依存してしまいます。一方、後者のPassive Viewでは、ViewとModelのやり取りは全てPresenterを介しますので、ViewとModelが完全に分離されますが、その分Presenterの負担が増えてしまいます(これはCocoa MVCでのControllerに似ています)。MVVM (Model View ViewModel)
MVVMは元々MicrosoftのWPF (Windows Presentation Foundation)およびSilverlightアーキテクチャです。MVPと似ていますが、MVPではデリゲートやインターフェイスで処理を移譲・更新処理を行うのに対して、MVVMではデータバインディングでViewとViewModelを紐付けます。データバインディングでは、イベントを発行・購読することで、直接的な依存関係がなくても、アクションに対応した処理を行って表示を更新することができます。データバインディングは
MVP (Supervising Controller)での監視よりもさらに密な考え方で、UIとデータオブジェクトを紐付けて、同一のデータをやり取りできるようにしています。双方向データバインディングでは、UIを変更すればデータも書き換えられ、データが更新されればUIも変更されます。iOSでは、データバインディングを実現するために、リアクティブプログラミング(
RP)を実装したフレームワークであるRxSwiftやReactiveSwiftなどのライブラリが使われますが、RPの習得コストが高く、個人開発者はともかくチームで開発する場合は、全員がRPについて理解していることが必要のため、簡単に導入できないというデメリットがあります。Clean Architecture
ここまで紹介してきた
MVC,MVP,MVVMはGUIアーキテクチャと呼ばれるもので、UIに関するロジック(プレゼンテーション)とシステムに関するビジネスロジック(ドメイン)を分離する※のが目的です。しかし、プレゼンテーション層から分離されたロジックに関するルールはないため、サーバ通信やデータの永続化などのロジックは全てModelに詰め込まれ肥大化してしまいます。
※Presentation Domain Separation(PDS)一方、
Clean Architectureはシステムアーキテクチャと呼ばれ、UIだけに止まらず、システム全体の構造を示すものです。Clean Architectureではドメイン駆動開発(DDD)やユースケース駆動開発(UCDD)を意識して、ビジネスロジックをUIやフレームワークから引き離して、それぞれの層ごとに役割と責任を分離しています。
※ 画像はThe Clean Code Blogを参考
Clean Architectureは以下の層で構成され、中心には変化の少ないドメインを、外側には変化の激しい層を配置し、中心に向かって一方向の依存性しか持ちません。
- フレームワーク・ドライバ
- DB、UI、デバイス、Web
- インターフェイスアダプター
- Gateway、Presenter、Controller
- アプリ固有のデータ・ロジック
- Use Case
- アプリ非依存のデータ・ロジック
- Entity
Clean Architectureはシステム全体の構造を、各層に役割と責任を持たせて整理していることから、特定のフレームワークやライブラリに依存しません。また、各層に分割したことでUIやデータの保存処理が頻繁に変化してもビジネスロジックには影響しなく、全ての層でテストを導入することもコストが低いです。このように、
Clean Architectureは変更に強く、再利用可能で、テストもできるため、「Clean」なアーキテクチャです。しかし、層が多くなるため必然的にコード量が多くなってしまい、プロトタイプや小規模なアプリケーション開発にはあまり向いていませんが、一定規模以上のプロダクトで効力を発揮します。VIPER
※ 画像はiOS Project Architecture: Using VIPERより引用
VIPERは上で述べたClean ArchitectureをiOS向けに再設計したアーキテクチャです。もちろんClean Architectureがもとになっているので、システムアーキテクチャであり、単一責任の原則にもとづいて各層を分割し責任を持たせています。View, Interactor, Presenter, Entity, Routerの頭文字を取ってVIPERと呼びます。また、VIPERでは各層でプロトコルを定義し、それに準拠して実装することでテストも行いやすいです。
Router
- 「画面遷移」と「依存関係の解決」を担当
Viewが画面遷移まで担当する必要がなくなるView
UIView&UIViewController- 「画面の更新」と「
Presenterへのイベント通知」を担当Presenter
Viewから受け取ったイベントをもとに「プレゼンテーションロジック」を担当Viewに画面の更新を依頼Interactorにデータの取得を依頼Routerに画面遷移を依頼import UIKit禁止Interactor
- 「データに関するロジック」を担当
- データ取得が完了したらデリゲート経由で
Presenterに返すimport UIKit禁止Entity
- 「データ構造の定義」を担当
structでデータを定義Interactorのみアクセス可import UIKit禁止
VIPERでは役割を細かく分割するため、ファイル構成もやや複雑になってしまうが、Generambaというコード生成ツールを活用すれば必要なファイルを適切な構成で作成できます。現場で選ばれているアーキテクチャ
ここまでiOSの代表的なアーキテクチャについて紹介してきましたが、では実際の現場ではどのアーキテクチャが選ばれているのか、社内のアプリを対象にアンケートを取りました。以下がその結果です。
MVCとMVPがそれぞれ約3割ずつを占めており、MVVMが約2割弱、少数派としてVIPERやその他のアーキテクチャがありました。実際、数年前に開発が始まったアプリではMVCが多く、3年以内の新規アプリやリニューアルしたアプリではMVPやMVVM、VIPERなどのアーキテクチャが採用されています。その背景として、アジャイル開発やテスト自動化の重視が考えられ、よりテストがしやすく、しっかりと役割分担ができ、継続的な開発を支えるために適切なアーキテクチャが選ばれています。
MVVMよりもMVPが選ばれているのは、上でも述べた通り、データバインディングの学習コストが高いため、比較的導入がしやすく、かつテストも行いやすいからです。また、MVP+Clean ArchitectureやMVP+Coordinatorなど、MVPに+αしてGUIアーキテクチャの弱点を補うような使い方もあります。また、MVVMではRxSwiftなどのデータバインディングライブラリを使うと、ライブラリへの依存性が生じてしまうので、ステータス監視でノーバインディングな使い方もあります。
VIPERを選ぶアプリもありますが、厳密な分割が必要で、学習コストもやや高いため、実際にはMVPやMVVMが多く選ばれている傾向があります。まとめ
本記事では、iOS開発でよく話に上がるアーキテクチャを紹介し、実際の現場で選ばれているアーキテクチャについて考察をしてきました。近年のアジャイル開発やテスト自動化の潮流に合わせて、選べれるアーキテクチャに変化が見られます。しかし、導入にともなう学習コストやアプリの規模との相性も関係し、「これを選べば正解」なアーキテクチャはなく、プロジェクトのメンバーやプロダクトの規模に合わせて、適切なアーキテクチャを選ぶべきと言えます。
また、冒頭でアーキテクチャは「アプリを綺麗に開発・継続的に運用していくための設計方法」だと述べましたが、アーキテクチャはあくまで手段に過ぎず、使い方を間違えても、それに囚われすぎてもダメで、常に「設計」を意識しながら、適切にアーキテクチャを活用していくことが重要です。
参考
- Model-View-Controller
- 【完全保存版!】iOSエンジニアとして働く上で知っておきたい14のこと
- iOSアプリ設計大全集 2016
- iOSをMVC,MVP,MVVM,Clean Architectureで実装してみた
- Webアプリケーション開発者から見た、MVCとMVP、そしてMVVMの違い
- StackOverFlowの「MVPとMVCの違い」についての回答を読んでみた
- 怖くないiOSでのMVVM
- まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて - Qiita
- iOS Clean Architecture - 騒音のない世界 BLOG
- iOS Project Architecture : Using VIPER 和訳 - Qiita
- VIPERアーキテクチャ まとめ - Qiita
- 投稿日:2019-06-27T02:17:17+09:00
[iOS]UIStackView+ContainerView+UIScrollViewで複雑な画面を設計する
今更なネタですが。
アプリのコンテンツ量が多くなってくると、いろんなコンテンツが混在した画面というものが登場します。最たる例はトップ画面です。
こんな雰囲気。
単純な場合はどのように組んでも問題は生じないのですが、次の場合頭を抱えることになります。
- 横スクロールが伴う
- 一部のコンテンツが、状況によって出たり出なかったりする
- 出たり出なかったりするのがAPIのレスポンス依存(後から決まる)
- APIが別れている、複数ある
- 高さがコンテンツの量に応じて変わる
- 高さがアニメーションにより増減する
- タッチイベントが競合する
- Viewが重い
- 一番下だけ無限スクロールでページングする
- スイッチが有り、コンテンツが切り替わる
こういった画面に遭遇した時、UITableView、UICollectionView、UIStackViewのどれかを使うことを検討すると思います。
そのUIStackViewでどう組むかという話です。対象
UIStackViewがある程度分かる人
UITableVIewとUICollectionViewではダメなのか?
ダメではないと思います。
しかしこれらはハマりポイントが多数存在しがちです。
- 高さの変更でハマる(特にアニメーション)
- 非同期でAPI取得後、表示・非表示を制御する時にハマる
- Cellの場合は表示直前に生成するため、Cell内のViewが多いとスクロールする際にカクつくことがある(例えばCellの中にUICollectionViewが含まれている場合など)
- Cell内にUICollectionViewがあったりすると、設計に悩むしライフサイクルでも悩む
それでせっかく作ってもあーだこーだ調整に時間が掛かってしまうというのが過去数件ありました。
UIStackViewがこの全てを解決するわけではありませんが、仕様によってはUIStackViewを使うことでスッキリ書けることがあります。UIStackViewが向かないもの
同じようなViewが連続する場合はもちろんCellを使ったほうがよいでしょう。
また、コンテンツ量が非常に多い場合なども向きません(といってもこれは本当に多いケースです)
ヘビーな画面を作る際は、画面表示時に1回だけ重いか、Cell表示時に毎回重いかどちらかを選択することになると思います。ContainerViewでどのように高さを変更するか問題
これが本題です。
UIStackViewの中にContainerViewを含める場合、高さの制御が問題になります。
なぜか内部のUIViewContollerで自身のViewの高さをAutoLayoutで指定してもUIStackViewの高さに反映されてくれないのです。
できればUITableViewAutomaticDimensionのように、内部側から高さを決めたいですよね。それを実現するためには、内部のUIViewControllerでこうします。
override func loadView() { super.loadView() self.view.translatesAutoresizingMaskIntoConstraints = false }これで解決します(理由は察せると思うので割愛。何だそんなことかー、と思いました)
サンプル
https://github.com/osanaikoutarou/EasyStackViewControllers
こういう状態で、ContainerViewをUIStackViewに入れます。
画面A
UITextViewのScrollableはオフにしています
このように、内部の高さに応じてUIStackView側の高さも変わっています。
一番上のViewControllerの中身はこの状態です↓
空っぽ!鬼門:UIScrollView
何度やってもハマるUIScrollViewのAutoLayout
参考にどうぞUIScrollView -> 画面view 上下左右
ContentView -> UIScrollView 上下左右+EqualWidth
StackView -> ContentView 上下左右
このままだとエラーが出るので
UIView -> StackView 高さ固定
高さ固定を使わないなら、Remove at build timeにチェックこのViewを使わないなら、viewDidLoadあたりでStackViewの中身を空に
(ここらへんもっとスマートな方法無いんですかね?AutoLayoutのエラーを放置する手もありますが)UIStackView in UIStackViewは可能か?
上のサンプルの画面Cで既に使っています
大丈夫です高さアニメーションしたら、見た目が少し変
こちら
[iOS]UIStackViewのアニメーションが変!Viewにするか、ViewControllerにするか迷う
別解
ライブラリーを使うてもあるかも知れません(学習コストが高そうで、まだ使ったことありません)
StackViewController
https://github.com/seedco/StackViewController
AloeStackView
https://github.com/airbnb/AloeStackView積み残した話題
一番下のコンテンツが無限スクロール(ページング)の場合はどうする??
CellとStackViewのコラボレーションは少しハマりどころがあるのでまた別の機会に追記
このトピック無いなと思って書いたんですが、ありました。
巨大なビューをStoryboardだけで表現するContainerView周りの話題は認識しておいたほうが良いですね。


































































