Developer's Blog

なぜ iOS アプリ開発でも Redux なのか

 

こんにちは、アプリケーション共同開発部のみなみです。

初代 iPhone が発売されてから今年で10周年を迎えました。これまでに多数のアプリが開発され、傾向としては、以前と比べものにならないくらい大規模・複雑化してきています。フェンリルでも毎年多数のアプリが開発されていて、開発の日々の中で今後もその傾向は加速していくと感じます。

大規模・複雑化する開発で出てくる問題

スコープの広い状態の扱いの難しさ

画面間やモデル間で共有されるスコープの広い状態をどうするかは、アプリ開発において最も厄介な問題の一つです。

例えば・・・

開発者が頑張って小さい責務だけ持つようにした、それぞれ 200 行ぐらいのクラスを5つ作ります。突然の仕様変更でこの5つのクラスが A という状態を共有するようになりました。共有するのはたった1つの状態なのですが、これだけで全てがぶち壊しです。この5つのクラスは、1つの共有する状態を通してお互い複雑な関係を持ち始めます。最終的に A という状態は、1000 行 (5クラス * 200 行) の巨大クラスの中にある変数と変わらなくなり、見通しが大きく低下します。

これは極端な例ですが、実際の開発の中でスコープの広い状態の扱いには課題を感じることが多いです。

ビジネスロジックが書かれた ViewController

ViewController にビジネスロジックを書くことは、ユニットテストを難しくする、ViewController の肥大化につながる、見通しが悪くなるなど様々な問題を引き起こします。しかし ViewController にビジネスロジックを書く誘惑に勝つことは簡単ではありません。ViewController はとても自由度が高いです。新しく機能を追加することになった場合、まず何がビジネスロジックなのか、そのビジネスロジックをどうモデルに持たせるのか1から自分で考えて実装します。その過程でビジネスロジックが ViewController に書かれたとしても何ら不思議なことではありません。

ViewController にビジネスロジックを書きにくくするような、誰にとっても明確な制約が必要なのではないでしょうか?

見通しの悪いコード

規模が大きくなってくるとバラエティ豊かな関数・メソッドが出現します。最も理解しやすいのは純粋な関数です。純粋な関数とは、同じ引数を与えれば同じ値を返し、それ以外に余計なことをしない関数です。ただほとんどの場合、目の前の関数・メソッドを実行することの影響は詳しい実装をみないと分かりません。クラスの内部状態を変更するだけなのかもしれません。もしかするとスコープの広い状態を変更して、思わぬところに影響を与えるのかもしれません。あるいはデータベースやファイルへの読み書きを行い、ローカルの環境を大きく変えるのかもしれません。

理解しやすいものとそうでないものが明確に分離されていれば、どんなに楽だろうと感じます。

ユニットテストの不足

ユニットテスト無しでは安全なリファクタリングを行うことはできません。ただ、プロダクトコードを書くのと同じように、ユニットテストを書くにはコストが掛かるため、十分に書けないのは仕方がないことなのです。

本当にそうなのでしょうか?

ユニットテストを書いていて思うのが書きやすい(低コスト)対象と書きにくい(高コスト)対象があることです。書きやすいのは純粋な関数です。与える引数と返ってくる値についてだけ考えれば済みます。書きにくいのは内部状態を持つクラス、モックやスタブにする必要がある処理(通信処理、データベース処理、日付処理、他のクラスに依存)を持つものなどです。

問題なのはユニットテストを書く時間がないことではなく、現状よく採用されるアーキテクチャの実装が、効率良くユニットテストを書けるようになっていないことではないでしょうか?

多人数開発の難しさ

これまでに上げた問題点はソフトウェアの品質について常に気をつけていれば済む問題です。しかし多人数開発においては誰もがソフトウェアの品質について強い興味があるわけではありません。UI・UX に興味のあるエンジニア、アイディアを出すのが上手いエンジニア、UIKit に強いエンジニア・・・エンジニアのタイプは様々です。

プロダクトを成長させるためには多様なエンジニアのスキルを上手く引き出す必要があります。全てのエンジニアがソフトウェアの品質について深い造詣がなくても、それに従っていればスケールするアプリが作れる、そんな明確な指針が必要なのではないでしょうか?

われわれ開発者は、大規模・複雑化するアプリ開発にどう対応していけばいいのでしょうか?つい最近、その答えになるかもしれない Redux というものを知ったので紹介したいと思います。

Redux とは ?

Redux とは Web フロントエンド(特に SPA)で多く使われている状態管理のためのライブラリです。以前に比べ Javascript アプリケーションは複雑な状態を多く持つようになりました。Redux はその状態を管理、予測可能にするために作られたものです。Redux は Elm の影響を強く受けていて、また Flux アーキテクチャの実装の一つとも言われています。(ドキュメントでは Redux は Flux 実装とも言えるし、そうでないとも言えると述べられています。)

Redux による実装

以下 Redux による実装について、従来よく採用されていた MVC のよくある実装と比較しながら紹介します。題材としては架空のゲームを使います。ゲームは画面間やモデル間で共有される状態を容易に持つため、状態管理の大変さを想像しやすいためです。

どんなゲームか?

操作キャラクターやアイテムを使い、全ての敵の HP を 0 にしたらゲームクリアです。

  • 戦闘画面
    キャラクターを操作して敵に攻撃を当てます。
  • キャラクター設定画面
    キャラクターのステータス(HP, 攻撃力)を確認します。またアイテムを使い自身の HP を回復したり、敵の攻撃力を弱めることができます。
  • セーブデータ管理画面
    新しくゲームを始めたり、違うデータをロードできます。それに応じて全ての画面、モデルが更新されます。

状態を持つ場所は1箇所(Store)

MVC 実装

import RxSwift

/* 操作キャラクター */
struct Character {
    
    var hp: Int
    
    var attack: Int
    
}

/* 操作キャラクターの状態を持つ */
class CharacterManager {
    
    let character = Variable(nil)
    
    private let saveManager: SaveManager
    
    private let disposeBag = DisposeBag()
    
    init(saveManager: SaveManager) {
        self.saveManager = saveManager
        
        // 新しいセーブデータが読み込まれたら、それを反映する
        self.saveManager.currentData.asObservable()
            .subscribe(onNext: { [weak self] saveData in
                self?.character.value = saveData?.character
            })
            .addDisposableTo(disposeBag)
    }
    
    // 攻撃されたら HP を減らす
    func attacked(attack: Int) {
        guard let oldCharacter = character.value else { return }
        
        character.value = Character(hp: oldCharacter.hp - attack, attack: oldCharacter.attack)
    }
    
}



/* 敵 */
struct Enemy {
    
    var id: Int
    
    var hp: Int
    
    var attack: Int
    
}

/* 全ての敵の状態を持つ */
class EnemyManager {
    
    let enemies = Variable<[Enemy]>([])
    
    private let saveManager: SaveManager
    
    private let disposeBag = DisposeBag()
    
    init(saveManager: SaveManager) {
        self.saveManager = saveManager
        
        // 新しいセーブデータが読み込まれたら、それを反映する
        self.saveManager.currentData.asObservable()
            .subscribe(onNext: { [weak self] saveData in
                self?.enemies.value = saveData?.enemies ?? []
            })
            .addDisposableTo(disposeBag)
    }
    
    // 指定の ID を持つ敵の HP を減らす
    func attacked(id: Int, attack: Int) {
        guard let oldEnemy = enemies.value.filter({ $0.id == id }).first else { return }
        
        let newEnemy = Enemy(id: oldEnemy.id, hp: oldEnemy.hp - attack, attack: oldEnemy.attack)
        
        enemies.value = enemies.value.reduce([], { newEnemies, enemy in
            var mutableNewEnemies = newEnemies
            mutableNewEnemies.append(newEnemy.id == enemy.id ? newEnemy : enemy)
            return mutableNewEnemies
        })
    }
    
}



/* セーブデータ */
struct SaveData {
    
    var slot: Int
    
    var character: Character
    
    var enemies: [Enemy]
    
}

/* 現在のセーブデータの状態を持つ */
class SaveManager {
    
    let currentData = Variable(nil)
    
    func loadSlot(slot: Int) {
        //currentData = データベースからデータを読み込む処理
    }
    
}

CharacterManager, EnemyManager, SaveDataManager 。統一感を持たせるために内部状態とそれを変更するメソッドをまとめて、 ~Manager というクラス名を付けました。

ここで疑問に思うかもしれません

Manager ? 何て曖昧な名前の付け方なんだ、自分なら〜

CharacterManager・EnemyManager に SaveDataManager の参照を持たせるのではなく、SaveDataManager に CharacterManager・EnemyManager の参照を持たせるのはどうだろう ?

いや、これくらいだったら ViewController に直接 character や enemies を持たせよう

もう character も enemies も全部 AllCharacterManager に持たせよう !

その疑問は全て正しいです。どの程度の規模になるのか、どういう機能を追加していくのか、あるいは実装者の好みによって状態の持たせ方は何通りもあります

Redux 実装

import ReSwift

struct AppState {
    
    var character: Character?
    
    var enemies: [Enemy] = []
    
    var currentSaveData: SaveData?
    
}

Redux は状態を1箇所でツリー状に持ちます。ツリーの構造に関しては様々なものがありますが、状態を持たせる場所は明確で迷いがありません。そのため、エンジニアによって状態を持たせる場所がバラバラでカオスになることはありません。状態はなるべく単純なもので持ちます ( 例えるなら JSON に簡単に変換できるくらい )。

ReSwift の場合、状態を値型で持ちます。これは Swift らしい実装にする上でとても重要なポイントです。

状態を変更するには必ず Action を発行

MVC 実装

import RxSwift

/* 戦闘画面 */
class BattleViewController: UIViewController {
    
    // キャラクター設定画面、セーブデータ管理画面でも共有される
    var characterManager: CharacterManager!
    
    // キャラクター設定画面、セーブデータ管理画面でも共有される
    var enemyManager: EnemyManager!
    
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        characterManager.character.asObservable()
            .subscribe(onNext: { character in
                // 操作キャラクターの状態を表示に反映する処理
            })
            .addDisposableTo(disposeBag)
        
        enemyManager.enemies.asObservable()
            .subscribe(onNext: { enemies in
                // 敵の状態をそれぞれ表示に反映する処理
            })
            .addDisposableTo(disposeBag)
    }
    
    // 操作キャラクターが指定 ID を持つ敵に攻撃された
    func characterAttacked(enemyId: Int) {
        let enemy = enemyManager.fetchEnemy(id: enemyId)
        
        // 攻撃された操作キャラクターの状態を直接変更する
        characterManager.attacked(attack: enemy.attack)
    }
    
    // 操作キャラクターが指定 ID を持つ敵を攻撃した
    func enemyAttacked(attackedEnemyId: Int) {
        guard let character = characterManager.character.value else { return }
        
        // 攻撃された指定 ID の敵の状態を直接変更する
        enemyManager.attacked(id: attackedEnemyId, attack: character.attack)
    }

}

ViewController がモデルのメソッドを実行して、状態を(専用のレイヤーなどを介さず)直接変更します。そのメソッドを実行することによる影響はその ViewController に限定されるかもしれませんし、もっと広い範囲に影響を与えるかもしれません。広い範囲に影響を与えることであっても何の制約もなく簡単に行えてしまいます

また、ViewController が入力に対してどのモデルをどう操作するのか具体的に知っている必要があります。モデルの操作が複雑になってくると ViewController で考えることが増え、ビジネスロジックが入り込むリスクが高くなると感じます。

Redux 実装

import ReSwift

/* 敵を攻撃する Action */
struct AttackEnemy: Action {
    var targetEnemyId: Int
}

/* 操作キャラクターを攻撃する Action */
struct AttackCharacter: Action {
    var enemyId: Int
}

/* 戦闘画面 */
class BattleViewController: UIViewController, StoreSubscriber {

    typealias StoreSubscriberStateType = State
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        store.subscribe(self)
    }
    
    func newState(state: State) {
        // 状態を表示に反映する処理
    }
    
    // 操作キャラクターが指定 ID を持つ敵に攻撃された
    func characterAttacked(enemyId: Int) {
        // 対応する Action を発行するだけ、具体的なことは何も知らない
        store.dispatch(AttackCharacter(enemyId: enemyId))
    }
    
    // 操作キャラクターが指定 ID を持つ敵を攻撃した
    func enemyAttacked(attackedEnemyId: Int) {
        // 対応する Action を発行するだけ、具体的なことは何も知らない
        store.dispatch(AttackEnemy(targetEnemyId: attackedEnemyId))
    }
    
}

Action を発行することでしか状態を変更できないという強い制約があります。

また、ViewController ができるのは Action を発行することだけに限られます。どうやって、どの状態が変更されるかは別の場所(Reducer)での話で、ViewController は具体的なことを何も知る必要がありません。そのため開発者が ViewController で色々なことを考える必要がなくなり、ビジネスロジックが書かれるリスクが減るのではと感じます。

そして ReSwift では状態と同じく Action も値型で構成されます。これにより、プレゼンテーションレイヤーとそれ以外のレイヤーが自然に値型でやりとりを行うようになります。

状態の変更は Reducer (純粋な関数)で行う

MVC 実装

/* 操作キャラクターの状態を持つ */
class CharacterManager {
    
    let character = Variable(nil)
    
	/* 省略 */

    // 攻撃されたら HP を減らす
    func attacked(attack: Int) {
        /* character.value の状態を変更する処理 */
    }
    
}

/* 全ての敵の状態を持つ */
class EnemyManager {
    
    let enemies = Variable<[Enemy]>([])
    
    /* 省略 */
    
    // 指定の ID を持つ敵の HP を減らす
    func attacked(id: Int, attack: Int) {
        /* enemies.value の状態を変更する処理 */
	}
    
}

状態と、それに対する操作がセットになっています。

Redux 実装

/* 操作キャラクターの状態を担当する Reducer */
func characterReducer(state: State?, action: Action) -> Character? {
    let state = state ?? State()
    let characterOldState = state.character
    var characterNewState = characterOldState
    
    switch action {
    // 操作キャラクターの HP を減らす
    case let action as AttackCharacter:
        guard let character = characterOldState, let enemy = state.enemies.filter({ $0.id == action.enemyId }).first else { break }
        
        characterNewState = Character(hp: character.hp - enemy.attack, attack: character.attack)

    // 新しいセーブデータを反映する
    case let action as SetSaveData:
        characterNewState = action.saveData.character

    default:
        break
    }
    
    return characterNewState
}

/* 全ての敵の状態を担当する Reducer */
func enemiesReducer(state: State?, action: Action) -> [Enemy] {
    let state = state ?? State()
    let enemiesOldState = state.enemies
    var enemiesNewState = enemiesOldState
    
    switch action {
    // 指定の ID を持つ敵の HP を減らす
    case let action as AttackEnemy:
        guard let character = state.character, let targetEnemy = enemiesOldState.filter({ $0.id == action.targetEnemyId }).first else { break }
        
        let newEnemy = Enemy(id: targetEnemy.id, hp: targetEnemy.hp - character.attack, attack: targetEnemy.attack)
        
        enemiesNewState = enemiesOldState.reduce([], { enemiesNewState, enemy in
            var mutableenEmiesNewState = enemiesNewState
            mutableenEmiesNewState.append(newEnemy.id == enemy.id ? newEnemy : enemy)
            return mutableenEmiesNewState
        })

    // 新しいセーブデータを反映する
    case let action as SetSaveData:
        enemiesNewState = action.saveData.enemies

    default:
        break
    }
    
    return enemiesNewState
}

状態と、状態に対する操作(新たな状態の生成)は別々になっています。状態は先述した Store に持ち、状態に対する操作は Reducer という関数で行います。Reducer は引数として現在の状態と Action を受け取り、常に新しい状態を返す関数です。Reducer は同じ引数を与えられれば、同じ値を返す必要があます。また Reducer の中ではグローバル変数を更新、通信処理、ログ出力などの副作用のある処理を書くことはできません。

この強い制約のおかげで Reducer は驚くほど見通しが良くなります。いきなり NSUserDefaults を読み書きすることもありませんし、いきなりシングルトンにアクセスすることもありません。与えた引数と返ってきた値が全てです。モックが不要なのでユニットテストもサクサク書けます。

純粋な関数にできない、副作用のあるものは Middleware (もしくは ActionCreator) が担当します。例えば ReSwift の場合、 ActionCreator でデータベース処理が必要なセーブデータの読み込みを行い、その結果を再び Action として発行します。

struct SetSaveData: Action {
    var saveData: SaveData
}

func LoadSaveData(slot: Int) -> Store.ActionCreator {
    return { state, store in
        let saveData = /* データベースから指定のセーブデータを読み込み */
        
        return SetSaveData(saveData: saveData)
    }
}

Reducer を純粋な関数とすることで、純粋な関数とそうでないもの(理解しやすいものとそうでないもの)を明確に分離できるのです。

Redux は・・・

スケールしやすい

スピード重視や何らかの事情で無理な実装を重ねます。最初は驚くほど速いスピードでコードを追加できます。しかし、時間が経つにつれそのスピードは少しずつ落ちていきます。最初は本当に気づかないくらいゆっくりと。さらに時間が経つと、たった数行のコードの追加にとても時間がかかるようになります。最初の頃ならすぐにでも追加できたようなコードです。

理由は”コードを追加した時にどういう影響があるかわからない“ただそれだけです。

状態管理が上手くできていない、ユニットテストがない、View との結合度が高い・・・そこには様々な原因があります。いずれにせよ、そこでそのプロダクトの成長は止まります。どんなに素晴らしい機能を思いついても、どんなに素晴らしいデザインを閃いても、それはもうただの夢物語でしかありません

Redux にはなるべくそうならないようにするための明確な指針があります。Redux の制約は一見すると強すぎるようにみえますが、それは開発者に無理な実装をさせず、自然にスケールするような実装に導こうとしているからだと思います。

おもしろい

初めて Redux を知った時は衝撃的でした。それまでは状態とそれに対する操作、それ以外の処理(通信処理、データベース処理、ログ出力など)やその処理を行うオブジェクトの参照などを当然のようにひとまとめにしていて、それが全てだと思っていました。

Redux では状態は Store、状態への操作(新しい状態の生成)は Reducer、副作用は Middleware が担当します。今までひとまとめにしていたものが見事にバラバラになっています。これまで想像もしなかった書き方でとても興味深いです。

また Redux にはおもしろい未来が広がっています。

例えば redux-saga です。Redux では Middleware が非同期処理などの副作用のある処理を一手に引き受けます。そのため Middleware は相当複雑になってしまうのですが、redux-saga ではそれをわかりやく表現できます。非同期処理をわかりやすく表現できるものとしてリアクティブライブラリもありますが、redux-saga にはテストコードが書きやすいという特徴があります。

そして、これは予想なのですが redux-saga では、非同期処理を直接行う処理は Promise を返せばいいだけなので、リアクティブライブラリと同等の表現力でありながら、リアクティブライブラリのデメリットであるライブラリ依存度の強さという課題をクリアできるのではと考えています。(例えば ReactiveCocoa から RxSwift にライブラリを変更した場合、Signal や SignalProducer などそのライブラリ固有のオブジェクトを返していた場合、ユニットテストでその影響をもろに受けますが、redux-saga は Promise を返すだけなので影響を受けにくいのではないのでしょうか。もちろん状態フローの部分に関しては redux-saga でも同じようなことがあれば大きな影響を受けます。)
他にも Redux にはおもしろそうな Middleware がごろごろ転がっていて、日々新しい発想をもとにしたものが開発されています。

美しい

美しいことは最も重要です。ユーザーの目に見えるデザインがいくら美しくても、内部の実装が美しくなければ、それは張りぼてでしかありません。

Redux (ReSwift) の中心にあるものはとてもシンプルです。値型と純粋な関数、ただそれだけです。何も複雑なものは存在しません。副作用まみれのフロントエンドで、燦然と輝く値型と純粋な関数の集まりはとても美しくみえます。

まとめ

個人で開発していた時からずっと疑問に思っていたことがありました。それはスコープの広い状態をどう扱うかです。

答えを求めて様々な記事を読みましたが、”なるべくグローバル変数やシングルトンは使わないようにしましょう”と書いてあるぐらいで、明確な答えを得ることはできませんでした。そんな時、ふとしたきっかけで Redux を知りました。それまで曖昧にされてきた、スコープの広い状態の扱い方についての Redux の明確な考えはとても画期的でした。さらに Redux について調べる中で、Redux は状態管理だけではなく、他にも様々な可能性があると感じるようになりました。

Redux は決して万能ではありません。iOS では差分更新を自前でする必要がありますし、純粋な関数の集まりを作るのとトレードオフで、 Middleware は副作用のある処理を一手に引き受けるため相当複雑になることが予想されます。

それでも Redux には可能性を感じずにはいられません。Redux には何か今までにはない、異質なものを感じます。

Swift は最も美しい言語です。なぜこんなにも美しいのでしょうか?それは Swift がマルチパラダイム言語と呼ばれるように、今までとは異なるパラダイムのものを取り入れているからかもしれません。例えば Optional や map、flatMap、reduce などの高階関数があります。これらの機能はとても強力で、美しいコードを書くために大きく貢献してくれました。

さあ!今度は言語機能と同じように、実装の全体的な構造部分にも異質なパラダイムの影響を受けたものを取り入れ、最も美しい実装を目指しましょう!!!

参考

 

フェンリルのオフィシャル Twitter アカウントでは、フェンリルプロダクトの最新情報などをつぶやいています。よろしければフォローしてください!

 

Copyright © 2019 Fenrir Inc. All rights reserved.