新しいサービスを作るときにたいがい必要になる「ユーザー登録」のフローを、流行り?のCoordinatorとReSwiftで作ってみる試みです。
当方iOSエンジニアですが、フロントエンド、Redux経験ゼロです。
Qiita投稿も初めてなので分かりにくい点など多々あるかと思いますが、ビシバシご指摘いただければと思います。また「こういうやり方もあるよ、こっちのほうがいいよ」といったご指摘も大歓迎です。
この記事は、以下のCoordinatorとReSwiftについての事前知識があると、より深く理解できると思います。
なぜCoordinatorとReSwift?
iOSアプリの開発はView controllerを軸に組み立てていくことが多いと思います。基本的にはView controllerは自分の管理するViewの描画やインタラクションを担当するものと位置付けられているのですが(参考:MVC)、実際には画面遷移や遷移先の画面の初期化など、自分のView以外についても知らなくてはいけない場面にしばしば出くわします。MVCのModelの管理も含めView controllerの役割が多岐にわたるためどんどん肥大化し、ロジックの複雑化、テストが難しくなる、コードの属人化などの問題がでてくることがあります。iOSにおける設計の一般的な問題についてはこちらの記事が参考になります。
Coordinator
このような問題を解決する方法の一つとしてCoordinatorがあります 参考Qiita記事1。
CoordinatorはView controller同士のつながりの管理を行うオブジェクトです。View controllerの役割をviewの描画とインタラクションのみに限定することで、それぞれのView controllerが独立して存在でき、肥大化して再利用不能・理解不能になる状況を防ぐことが期待できます。
参考: https://will.townsend.io/2016/an-ios-coordinator-pattern
ReSwift
ReSwiftはReduxのSwiftによる実装です。公式ドキュメントによると、Reduxで書くアプリのメリットは
- 一貫した振る舞い
- クライアント・サーバ・ネイティブ環境で動く
- テストが簡単
- 開発が楽しくなる!
です。アプリの状態を変更するにはActionを通じてのみ可能なため処理の流れを追跡しやすく、複数人の開発でも実装のばらつきがでにくいなどのメリットもあります。
公式: http://reswift.github.io/ReSwift/master/index.html
参考: https://speakerdeck.com/ninjinkun/reactive-swift-meetup
Coordinator & ReSwift
組み合わせると(いい意味で)マジやべぇ、という記事。
https://willowtreeapps.com/ideas/app-coordinators-and-redux-on-ios/
アプリの構成
メイン画面を表示する前に登録が必要なアプリを想定します。
以下の5つの画面で構成します。
1. ようこそ画面
初めて起動すると、まずこの画面に来る。
インタラクションは「スタート」ボタンのみ。
スタートボタンを押すと、認証画面に移る。
ようこそ画面を一定時間表示してから自動的に認証画面に移るパターンも想定。
2. 認証画面
サービスへの登録またはログインを選択する画面。
今回は登録のみ扱います。
3. 登録画面
サービスへの登録を行う画面。
Eメール、パスワードを入力して「登録」ボタンを押すと非同期にネットワーク通信を行い、登録に成功したら「登録成功画面」に移る。
4. 登録成功画面
登録に成功した旨を表示する画面。「スタート」ボタンを押すと、メイン画面に移動する。
この画面を省略して、登録成功後にメイン画面に移動するパターンもありうるが、登録フローを複数に分けた例を示したかったので入れてみた。
5. メイン画面
アプリのメイン画面。
今回はとりあえず何かUILabelを表示するだけ。
Coordinator関連の登場人物
Coordinator
- Coordinator ・・・すべてのCoordinatorが従うprotocol。start()メソッドのみ実装が必要。
protocol Coordinator {
func start()
}
// 後ほど登場
protocol NavigationCoordinator: Coordinator {
var navigationController: UINavigationController {get set}
init(navigationController: UINavigationController)
}
- AppCoordinator ・・・アプリ開始時点で作られる。ようこそ画面を表示。
- AuthenticationCoordinator ・・・認証画面用Coordinator
- RegisterCoordinator ・・・登録画面用Coordinator
- MainCoordinator ・・・メイン画面用Coordinator
ViewController
- WelcomeViewController ・・・ようこそ画面
- AuthenticationViewController ・・・認証画面
- RegisterViewController ・・・登録画面
- RegisterSuccessViewController ・・・登録成功画面
- MainViewController ・・・メイン画面
処理の流れ
それでは具体的にどのような手順で処理が行われるか見てみます。
まずAppDelegate.swiftのグローバル領域でReducerを登録します。
import UIKit
import ReSwift
let mainStore = Store<State>(reducer: appReducer, state: State())
次は起動時の処理です。UIWindowを生成し、最初のCoordinatorであるAppCoordinatorに渡して初期化し、start()で実行を開始します。しょっぱなで let _ = mainStore.state とmainStoreにアクセスすることで、その後確実にmainStoreのインスタンスが存在することが保証されます。
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var appCoordinator: AppCoordinator!
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// インスタンス生成
let _ = mainStore.state
window = UIWindow()
appCoordinator = AppCoordinator(window: window!)
appCoordinator.start()
window?.makeKeyAndVisible()
return true
}
}
AppCoordinatorはこのようになっています。
class AppCoordinator : Coordinator
{
private let window: UIWindow
init(window: UIWindow) {
self.window = window
}
func start() {
if isAuthenticated() {
showMainView() // 認証済みならメイン画面に直行
} else {
showWelcome() // ようこそ画面を表示
}
}
}
extension AppCoordinator {
func isAuthenticated() -> Bool {
return false; // ダミー
}
func showWelcome() {
guard let welcomeViewController = WelcomeViewController.storyboardInstance() else {
return
}
// ようこそ画面の"Start"ボタンタップ時によばれるclosureをセット
welcomeViewController.startButtonPressed = {
self.showAuthentication()
}
// 表示
window.rootViewController = welcomeViewController
}
func showAuthentication() {
let authCoordinator = AuthenticationCoordinator(window: window)
authCoordinator.authenticated = {
self.authenticated()
}
authCoordinator.start()
}
func authenticated() {
// 認証済みならメイン画面を表示
showMainView()
}
func showMainView() {
let mainCoordinator = MainCoordinator(window: window)
mainCoordinator.start()
}
}
今回は決め打ちでようこそ画面を出しています。AppCoordinatorのshowWelcome()では、WelcomeViewControllerの生成、Startボタンタップ時によばれるclosureのセット、画面の表示が行われます。ここでは、CoordinatorがViewControllerのライフサイクルや遷移に責任を持つというコンセプトが示されています。
ここでのポイントは、ボタンタップ時の動作をCoordinatorがセットしている下記の部分です。
welcomeViewController.startButtonPressed = {
self.showAuthentication()
}
このようにクロージャを渡してやることにより、WelcomeViewControllerは単に自分のメンバであるstartButtonPressed()を呼び出すだけよく、中身の処理が何であるかを知る必要がなくなります。このような構造はdelegateを使うことでも実現できますし、そのようなサンプルも多いですが、個人的にはクロージャ渡しのほうがシンプルかなと思いました。
WelcomeViewControllerの中身はこんな感じです。
import UIKit
typealias ViewControllerFinished = () -> ()
class WelcomeViewController: UIViewController {
var startButtonPressed: ViewControllerFinished!
static func storyboardInstance() -> WelcomeViewController? {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
return storyboard.instantiateViewController(withIdentifier: "WelcomeViewControllerIdentifier") as? WelcomeViewController
}
@IBAction func pressedStartButton(_ sender: Any) {
startButtonPressed()
}
}
メンバ変数 startButtonPressed に対してtypealiasで定義したclosureであるViewControllerFinished を割り当てています。ここには上記で見たように、WelcomeViewController生成後にAppCoordinatorによって実体がセットされます。
さていよいよユーザーインタラクションです。
スタートボタンをタップすると、startButtonPressed()が実行されますが、その実体はAppCoordinatorにあります。さかのぼって確認してみると、以下のような処理でした。
welcomeViewController.startButtonPressed = {
self.showAuthentication()
}
func showAuthentication() {
let authCoordinator = AuthenticationCoordinator(window: window)
authCoordinator.authenticated = {
self.authenticated()
}
authCoordinator.start()
}
showAuthentication()でも同様に、新たなCoordinator (AuthenticationCoordinator)を作り、closureをセットし、start()を実行しているのがわかります。
AuthenticationCoordinatorを見てみましょう。
import Foundation
import UIKit
enum AuthenticationState: String {
case NotRegistered // 新規ユーザー
case Authenticated // 登録成功またはログイン中
}
typealias AuthState = (_ authState: AuthenticationState) -> ()
class AuthenticationCoordinator : Coordinator
{
var window: UIWindow
var authenticated: CoordinatorFinished!
required init(window: UIWindow) {
self.window = window
}
func start() {
guard let authenticationViewController = AuthenticationViewController.storyboardInstance() else {
return
}
authenticationViewController.authenticate = { authType in
switch authType {
case .login:
// ログイン画面表示
case .register:
self.showRegisteration()
}
}
window.rootViewController = authenticationViewController
}
}
extension AuthenticationCoordinator {
func showRegisteration() {
let navigationController = UINavigationController()
let registrationCoordinator = RegisterCoordinator(navigationController: navigationController)
registrationCoordinator.authenticationState = { authState in
self.validateAuthState(authState)
}
registrationCoordinator.cancelled = {
self.window.rootViewController?.dismiss(animated: true, completion: nil)
}
registrationCoordinator.start()
window.rootViewController?.present(navigationController, animated: true, completion: nil)
}
func validateAuthState(_ state: AuthenticationState) {
switch state {
case .NotRegistered:
print("Not registered yet")
case .Authenticated:
// メインビューを表示
self.authenticated()
}
}
}
authenticationViewController.authenticateに渡しているclosureは、画面で登録ボタンが押された時に実行されます。今回は簡単のため、ログインと登録の2つの場合を示しました。AuthenticationTypeはAuthenticationViewController内に定義されています。
import UIKit
enum AuthenticationType {
case login
case register
}
typealias AuthenticationMethod = (_ authType: AuthenticationType) -> ()
class AuthenticationViewController: UIViewController {
var authenticate: AuthenticationMethod!
static func storyboardInstance() -> AuthenticationViewController? {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
return storyboard.instantiateViewController(withIdentifier: "AuthenticationViewControllerIdentifier") as? AuthenticationViewController
}
@IBAction func login(_ sender: Any) {
authenticate(.login)
}
@IBAction func signUp(_ sender: Any) {
authenticate(.register)
}
}
さて登録ボタンが押されてsignUp()が呼ばれた後の処理を見てみます。authenticate(.register)の実体はAuthenticationCoordinatorのshowRegisteration()に行き着きます。
func showRegisteration() {
let navigationController = UINavigationController()
let registrationCoordinator = RegisterCoordinator(navigationController: navigationController)
registrationCoordinator.authenticationState = { authState in
self.validateAuthState(authState)
}
registrationCoordinator.cancelled = {
self.window.rootViewController?.dismiss(animated: true, completion: nil)
}
registrationCoordinator.start()
window.rootViewController?.present(navigationController, animated: true, completion: nil)
}
ここでも新たなCoordinator (RegisterCoordinator) を作っていますが、これまでとの違いは、RegisterCoordinatorの初期化でUINavigationControllerが渡っているところです。これにより、今後はこのNavigationControllerにViewControllerをpushすることで処理を進めていくことができます。
RegisterCoordinatorはNavigationCoordinatorプロトコルに準拠します。
protocol NavigationCoordinator: Coordinator {
var navigationController: UINavigationController {get set}
init(navigationController: UINavigationController)
}
import Foundation
import UIKit
typealias CoordinatorFinished = () -> ()
class RegisterCoordinator : NavigationCoordinator
{
var navigationController: UINavigationController
var authenticationState: AuthState!
var cancelled: CoordinatorFinished!
required init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
guard let registerViewController = RegisterViewController.storyboardInstance() else {
return
}
registerViewController.registerSuccess = {
self.showRegisterSuccess()
}
registerViewController.cancelled = {
self.cancel()
}
navigationController.setViewControllers([registerViewController], animated: true)
}
}
extension RegisterCoordinator {
func showRegisterSuccess() {
guard let registerSuccessViewController = RegisterSuccessViewController.storyboardInstance() else {
return
}
registerSuccessViewController.start = {
self.registrationSuccess()
}
navigationController.pushViewController(registerSuccessViewController, animated: true)
}
func registrationSuccess() {
authenticationState(.Authenticated)
}
func cancel() {
cancelled()
}
}
ようやく登録画面にたどり着きました。
ここで、ReSwiftの出番です。
ReSwift関連の登場人物
State
- State ・・・ReSwiftに定義されているStateType protocolに従うstruct
struct State: StateType {
var registerState = RegisterState()
}
- RegisterState ・・・登録に必要な状態を持つstruct。StateのサブStateとして位置付けている。今後の開発で画面が増えてきたら、同様にサブStateを追加すればよい。
struct RegisterState {
var isLoading: Bool = false
var isRegistered: Bool = false
var error: Dictionary<String, Any>?
var email:String = ""
var password: String = ""
}
Action
- RegistrationStartAction・・・登録ボタンが押されたときのAction
- registerAction・・・実際にネットワーク通信をして登録を行うAction。AsyncActionCreator を使い非同期処理を行う。
- RegisterSuccessAction・・・登録が成功したときのAction
- RegisterErrorAction・・・登録が失敗したときのAction
- ServerErrorAction・・・サーバエラー時のAction
import Foundation
import ReSwift
import Alamofire
extension RegisterState {
struct RegisterStartAction: Action {
var email: String
var password: String
}
struct RegisterSuccessAction: Action {}
struct RegisterErrorAction: Action {
var error: Dictionary<String, Any>?
}
struct ServerErrorAction: Action {
var error: Error
}
static func registerAction(email: String, password: String) -> Store<State>.AsyncActionCreator {
return { (state, store, callback) in
var params: [String:Any]
params = [
"email": email,
"password": password,
]
let register: URLRequest = {登録用のURLRequest}
Alamofire.request(register).validate().responseJSON { response in
if let json = response.result.value, let jsonDict = json as? Dictionary<String, Any> {
switch response.result {
case .success:
if let error = jsonDict["error"] as? Dictionary<String, Any> {
callback {_, _ in RegisterErrorAction(error: error) }
break
}
// true success
callback {_, _ in RegisterSuccessAction() }
case .failure(let error):
callback {_, _ in ServerErrorAction(error: error) }
}
}
}
}
}
}
Reducer
- appReducer・・・アプリ全体のreducerをまとめたもの
- registerReducer・・・登録処理のためのreducer
さて、RegisterViewControllerではEメール、パスワードを入力し、登録ボタンを押すことで登録処理が走ります。
import UIKit
import ReSwift
class RegisterViewController: UIViewController {
var cancelled: ViewControllerFinished!
var registerSuccess: ViewControllerFinished!
@IBOutlet weak var emailTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var signupButton: UIButton!
static func storyboardInstance() -> RegisterViewController? {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
return storyboard.instantiateViewController(withIdentifier: "RegisterViewControllerIdentifier") as? RegisterViewController
}
override func viewWillAppear(_ animated: Bool) {
mainStore.subscribe(self) { subscription in
subscription.select { state in state.registerState }
}
}
override func viewWillDisappear(_ animated: Bool) {
mainStore.unsubscribe(self)
}
@IBAction func signup(_ sender: Any) {
let email = emailTextField.text!.lowercased()
let password = passwordTextField.text!
mainStore.dispatch(RegisterState.RegisterStartAction(email: email, password: password))
}
@IBAction func cancel(_ sender: Any) {
view.endEditing(true)
cancelled()
}
}
extension RegisterViewController: StoreSubscriber {
func newState(state: RegisterState) {
if state.email == "" {
return
}
if state.isLoading {
signupButton.isEnabled = false
// validate email & call register API
validateEmail()
return
}
if state.isRegistered {
registerSuccess()
} else {
print("registeration error")
}
}
func validateEmail() {
// vaildate email and password
// ...
// call register API
mainStore.dispatch(RegisterState.registerAction(email: email, password: password))
}
}
viewWillAppearでStoreのsubscribeを行い、Stateの変化を監視します。上記のように、State全体でなくサブStateだけをsubscribeすることが可能です。
viewWillDisappearでunsubscribeすることにより、画面が表示されていないところでの予期せぬ副作用を防ぐことができます。
登録ボタンが押されるとsignup()が実行され、mainStore.dispatch()によりRegisterStartActionが発行されます。このActionはemail, passowrdをメンバーに持ち、RegisterStateに変更を加えます。
Reduxの掟では、Stateを変更できるのはReducerだけと決まっていますから、実際の処理registerReducerを見てみましょう。
import Foundation
import ReSwift
func registerReducer(state: RegisterState?, action: Action) -> RegisterState {
let state = state ?? RegisterState()
var newState = state
switch action {
case let action as RegisterState.RegisterStartAction:
newState.isLoading = true
newState.email = action.email
newState.password = action.password
case _ as RegisterState.RegisterSuccessAction:
newState.isLoading = false
newState.isRegistered = true
case let action as RegisterState.RegisterErrorAction:
newState.isLoading = false
newState.isRegistered = false
newState.error = action.error
default: break
}
return newState
}
isLoadingをtrueに変更し、 email, passwordをセットしているだけですね。
新しいStateができたところで、次の処理はRegisterViewController.newState(state: RegisterState)に移ります。
extension RegisterViewController: StoreSubscriber {
func newState(state: RegisterState) {
if state.email == "" {
return
}
if state.isLoading {
signupButton.isEnabled = false
// emailを検証し、登録APIを呼び出す
validateEmail()
return
}
if state.isRegistered {
registerSuccess()
} else {
print("registeration error")
}
}
func validateEmail() {
// email, passwordの検証
// ...
// 登録APIの呼び出し
mainStore.dispatch(RegisterState.registerAction(email: email, password: password))
}
}
新しいStateでは、 email != ""
, isLoading == true
ですから、validateEmail()が実行されることになります(実際のemail, passwordの検証は省略してます)。正しいemail, passwordが渡ってきたと仮定すると、次にregisterActionがdispatchされます。
ここでネットワークを介した登録APIを呼び出すことになります。具体的な通信方法についてはケースに応じて様々かとは思いますが、非同期であるという点に着目してください。ここではAlamofireを例に使っています。
ReSwiftで非同期処理を行う方法はいくつかあるようですが、ここではAsyncActionCreatorを使い、結果に応じて新たなActionをdispatchするという方法を取ってみます。
extension RegisterState {
///...
static func registerAction(email: String, password: String) -> Store<State>.AsyncActionCreator {
return { (state, store, callback) in
var params: [String:Any]
params = [
"email": email,
"password": password,
]
let register: URLRequest = {登録用のURLRequest}
Alamofire.request(register).validate().responseJSON { response in
if let json = response.result.value, let jsonDict = json as? Dictionary<String, Any> {
switch response.result {
case .success:
if let error = jsonDict["error"] as? Dictionary<String, Any> {
callback {_, _ in RegisterErrorAction(error: error) }
break
}
// true success
callback {_, _ in RegisterSuccessAction() }
case .failure(let error):
callback {_, _ in ServerErrorAction(error: error) }
}
}
}
}
}
}
めでたく登録が成功すると、RegisterSuccessActionがcallbackされ、RegisterStateのisRegisteredがtrueに変更されます。ここで再度RegisterViewController.newState(state: RegisterState)が呼ばれ、結果的にregisterSuccess()が呼ばれます。この実体はRegisterCoordinatorのshowRegisterSuccess()になります。
extension RegisterCoordinator {
func showRegisterSuccess() {
guard let registerSuccessViewController = RegisterSuccessViewController.storyboardInstance() else {
return
}
registerSuccessViewController.pressedStartButton = {
self.registrationSuccess()
}
navigationController.pushViewController(registerSuccessViewController, animated: true)
}
func registrationSuccess() {
authenticationState(.Authenticated)
}
登録成功画面がpushされ、画面には「スタート」ボタンが現れます。これを押すと
- RegisterCoordinator.registrationSuccess()
- RegisterCoordinator.authenticationState(.Authenticated)
- AuthenticationCoordinator.validateAuthState(.Authenticated)
- AuthenticationCoordinator.authenticated()
- AppCoordinator.authenticated()
- AppCoordinator.showMainView()
という順番で処理が呼ばれ、最終的にメイン画面が表示されます。
showMainView(), MainCoordinator, MainViewControllerはそれぞれ以下のようになります。
func showMainView() {
let mainCoordinator = MainCoordinator(window: window)
mainCoordinator.start()
}
import Foundation
import UIKit
class MainCoordinator: Coordinator {
var window: UIWindow
required init(window: UIWindow) {
self.window = window
}
func start() {
guard let mainViewController = MainViewController.storyboardInstance() else {
return
}
window.rootViewController = mainViewController
}
}
import UIKit
class MainViewController: UIViewController {
@IBOutlet weak var label: UILabel!
static func storyboardInstance() -> MainViewController? {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
return storyboard.instantiateViewController(withIdentifier: "MainViewControllerIdentifier") as? MainViewController
}
}
お疲れ様でした!
雑感&ご意見求む
- closureで処理を渡している分、処理の実体を探し当てるのが若干面倒。何か工夫してシンプルさと分かりやすさを両立できないものか。
- Storeをsubscribeした時にnewState()が呼ばれるので、間違えて変な処理が走らないように注意が必要。RegistrationViewController.newState()では以下の部分がそれをはじくコードになってますが、なんか美しくない。
if state.email == "" {
return
}
- すべてのインタラクションをReSwiftのActionにするべきなのかが疑問。たとえば新たなCoordinatorの生成やViewControllerの表示もActionによってするべきかどうか。今回の例のように、画面遷移を伴うインタラクションについてはCoordinatorに任せ、画面内の処理についてはActionを使用するというポリシーは悪くないかも?
- エラー処理のベストプラクティスを調査中。今回は使ってませんが、StoreのMiddlewareの仕組みを使ってグローバルなエラー処理をするのがよさげ。
- 動くコードがあればよいのですが、間に合ってません。要望があれば、まとめてみようと思います。
まとめ
今回の記事では、以下の手法についてユーザー登録の具体例を使って解説を試みました。
- Coordinatorを使ってView controllerを管理する方法
- ReSwiftを使ってSwiftでReduxライクに状態管理する方法
- AsyncActionCreatorを使ってネットワーク経由で受け取った結果に応じてActionを切り替える方法
5つの画面を管理するのにはやや大げさなアーキテクチャかもしれませんが、画面数が増えても同じやり方で管理できるのが強みかと思います。
また、一度作っておけば同様のサービスを立ち上げる際にも再利用できるので、お得感があるのもいいですね。
それでは、Happy coding!