【Swift】DDDを取り入れたiOS開発 その1 ~UseCaseとdelegate~

大島 雅人
153

こんにちは。英語サプリのiOS担当の大島です。英語サプリは10月末にリリースしたばかりのサービスで、アニメーションやBGM・効果音を取り入れたゲーム感覚の英語学習アプリです。iOS版とWeb版がリリース済みでまだサービスは始まったばかりですが、開発期間も短い中でクオリティにこだわってローンチすることが出来ました。当エントリでは、iOSアプリケーションの設計手法について紹介していきたいと思います。

DDD(ドメイン駆動設計)で複雑さと戦う

複雑なiOSアプリケーション開発をしていると以下のような問題点で悩まれているエンジニアの方も多いのではないでしょうか。

  • すぐにFatになってしまうUIViewController
  • 複数のフラグで状態を管理するUIViewController
  • Viewに依存してしまっていてユニットテストを書くのを難しい

iOS開発の場合、サーバーサイド開発におけるWebアプリケーションフレームワークのようなものがありません。UIViewControllerやUITableViewControllerのDelegate・DatasourceパターンなどもともとのCocoaのフレームワークが整備されてはいるのですが、設計指針を持たずに開発しているとすぐに上記のような問題が発生します。一人で開発している場合は特に問題はないのですが、ある程度規模が大きく複数人で開発する場合は行き当たりばったりの実装だと保守が難しくなってきます。

昨今だとこのような問題を解決するために、MVVMやFRPの概念を取り入れたSwiftらしいモダンな開発手法などもありますが、英語サプリでは古典的なDDD(ドメイン駆動設計)の概念を取り入れました。

DDD(ドメイン駆動設計)とは

DDD(ドメイン駆動設計)は業務ロジックなどドメインモデルをもとに設計する手法で、Wikipediaには以下のように記載されています。

ドメイン駆動設計(英: Domain-driven design, DDD)とはソフトウェアの設計手法であり、'複雑なドメインの設計はモデルベースで行うべきであり'、'また大半のソフトウェアプロジェクトではシステムを実装するための特定の技術ではなくドメインそのものとドメインのロジックに焦点を置くべき'とする。
wikipedia

DDDというとユビキタス言語などもっと高い視点における話も含んでいるのですが、本記事ではレイヤー化アーキテクチャの部分に着目して紹介しています。開発当初は、レイヤー化アーキテクチャを理解するのは難しかったですが、参考になる記事を同僚のkgmyshinに教えてもらい理解できるようになってきました。以下の記事はとてもおすすめです!

iOSでのDDDをもとにしたレイヤー化アーキテクチャ

ddd_layer

iOS開発におけるレイヤー化アーキテクチャを図示すると、左の図のようになります。Presentation層はユーザーからのイベントや画面の描画、カスタムビューの処理などをする部分です。UIViewControllerやCustomしたUIViewなどが該当します。

Domain層は実行したい処理を実装する場所です。例えば、音楽を再生する、音楽を停止する、音楽を頭出しするなどの処理です。UseCaseに実際のロジックを書いていき、Entityは各データ、ValueObjectはCGRectのようなユニーク性のないデータです。Swiftの場合、Structで定義されているものはValueObjectとして考えると理解しやすいかもしれません。

Infra層はAPIサーバーとの通信やJSON変換処理、CoreDataなどのローカルDBからのデータ取得等を行う層です。

全体の概要としては、UIViewControllerはUseCaseに処理を問い合わせ、UseCaseは必要であればRepositoryからデータを取得し、UIViewControllerへデータとともに通知するという流れです。各層は必ず隣り合った層としかやりとりをせず、下の層は上の層がなんなのかは知らずに済むように実装します。
ざっくりとした説明になってしまいましたが、各層の細かい説明はこちらの記事にて詳しく解説されています。

画面の操作と処理の責務を分離してテスト可能な状態にする

a1

上記のようなアニメーションや様々なボタンがついた画面を見ると、ユニットテストを書くのをは難しそうだなという感想を持たれる方もいるのではないでしょうか。この画面ではさらに、キャラクターの会話音声、会話に応じた効果音、ループで背景に環境音などが流れています。

このような状況で例えば、「ボタンを押したときに音声がなっていれば一時停止するということユニットテストを書きたい」と考えたときに、ユニットテスト内でViewControllerに関する記述や、ViewController上のボタンに対するアクセッサなどを記載していくことになってしまいます。

しかし、「音声がなっていれば一時停止するということユニットテストを書きたい」であればUIViewControllerは必要ありません。特にユニットテストでは、UIViewControllerをテスト内に持ち出すとStoryboardやViewのロード処理などでうまく扱うことは難しくなってしまいます。レイヤー化アーキテクチャによってUIViewControllerが絡まない状態のものだけをテストできるように責務を分割していくとテスト可能なアプリケーションを作ることができます。厳密には画面操作上のテストが書けている訳ではないのですが、実行したい処理自体が正しいことはテストすることが可能になります。

見た目と実行したい処理を分ける(関心ごとの分離)

先ほどの複雑そうに見える画面を、見た目の振る舞いという点を取り除いて考えると以下のような機能に分離することができます。

  • ループで環境音を再生する
  • 一人一人キャラクターごとの会話音声を再生する
  • 会話間に効果音があれば再生する
  • 音声速度を切り替えることができる
  • 音声を停止・または再生できる
  • 音声を頭出しできる

これらを先ほど説明したUseCaseとして切り出して実装していきます。このUseCase内には、画面の処理は出てきません。単に行いたい処理にのみフォーカスします。例として、会話音声を再生 or 停止する部分のメソッドのコードです。ここには、ボタンを押したら再生するや、ボタンの見た目を変えるといった実装は入ってきません。

    func playOrPause() {
        if let player = currentPhrasePlayer {
            if player.playing {
                isPlaying = false
                pausePhrase()
            } else if player.finished {
                isPlaying = !isPlaying
                (isPlaying) ?  nextPhrase() : pausePhrase()
            } else {
                isPlaying = !isPlaying
                (isPlaying) ? playPhrase() : pausePhrase()
            }
        } else {
            isPlaying = !isPlaying
            (isPlaying) ? playPhrase() : pausePhrase()
        }
    }

そして、テストはQuickを使って以下のように書くことができます。

class QuizListeningUseCaseTests: QuickSpec {
    override func spec() {
        describe("QuizListeningUseCase") {
            it("player play or pause") {
                useCase.playOrPause()
                expect(useCase.isPlaying).toEventually(beTrue(), timeout: 5, pollInterval: 1)
                useCase.playOrPause()
                expect(useCase.isPlaying).toEventually(beFalse(), timeout: 5, pollInterval: 1)
            }   
        }
    }
}

上記のコードは切り取った一部分なので分かりにくいかもしれないですが、複雑そうに見える画面でテストを書くのなんて無理・・と思わずに画面上のふるまいとやりたいこと、実行したい処理を分離することでテスト可能になるということを感じてもらえたら幸いです。

UIViewControllerとUseCaseとのやりとり

ここまでの話で、画面上の振る舞いと画面で行いたい処理を分割することができましたが、両者を結びつける方法が必要になります。例えば、上記のような画面の場合、一時停止ボタンを押したら音声を止めて一時停止ボタンを再生アイコンに変更したいと思いますよね。

すぐ思いつく実装例としては、ボタンを押したメソッドの中でボタンアイコンが一時停止アイコンだったら再生アイコンに変更するという処理です。これは一見正しそうなのですが、ViewController側にボタンの状態が再生アイコンなのか一時停止アイコンなのかという判断が生まれてきてしまいます。つまり、音声が一時停止状態かどうかということと、ボタンのアイコン画像がどうなっているかという2つの状態を持ってしまうことを意味しています。
せっかく処理を分割したのに結局ViewController側に状態を持ってしまうことになってしまいます。これでは意味がありません。

では状態管理を一箇所に集めるために、UseCase側にViewControllerの参照を持つのはどうでしょうか。しかし、これもせっかく分離した処理を持つUseCaseとViewControlellerが密結合になってしまい、ViewControllerがないとユニットテストが書けない状態に逆戻りになってしまいます。また、UseCaseを他の画面から使いたい場合にもこの方法だと、使うことができません。
レイヤー化アーキテクチャの原則に従うなら、下の層、つまりUseCaseは、上の層であるViewControllerのことを知っていてはいけないのです。

delegateで疎結合にする

このようなViewControllerとオブジェクト間で密結合になってしまう問題にはCocoaでは一般的にdelegateで対応します。UseCase側ではdelegateを持ち、処理が終わったらdelegate先へ通知するということのみを行います。UseCase側から見れば、通知をするのみでその通知先がどんな処理をするかは関与しません。ViewController側から見れば、UseCaseから来た通知に対して画面上の処理を実装するのみです。レイヤー化アーキテクチャのところで説明した以下のような流れを実現することができます。

  1. ViewControllerはUseCaseに対して問い合わせを行う
  2. UseCaseは問い合わせに対した処理を行い、結果をViewControllerに通知する
  3. ViewControllerは通知を受け取ったら画面上の表示を変更する

上記のような書き方をすると、ViewController側には状態を持たずに、ただ単にUseCaseの処理結果(イベント)に対してViewの表示を更新するということだけに徹することができます。一時停止ボタンの例で行くと、下記のようになります。

  1. ViewControllerはUseCaseに対して再生or停止メソッドを呼ぶ
  2. UseCaseは再生中なら停止、停止中なら再生を行い、結果をViewControllerに通知する
  3. ViewControllerは受け取った通知にもとづいて再生アイコンと一時停止アイコンを切り替える

このようにUseCaseに問い合わせを行い、delegateで通知を受けっとって描画を更新するという書き方をすることで、viewDidLoadviewDidAppearなど既存のUIViewControllerと同じようにイベントを受け取ったら表示を更新するという書き方が出来て、状態を意識せず一貫性を持って書くことが出来たかなと思います。

SwiftにおけるdelegateとOptionalの関係

一方で、delegateパターンを採用することにしたものの、Swiftではdelegateパターンはちょっとやっかいな面もあります。それは、OptionalなdelegateメソッドがPureSwiftだと実現できない点です。

同僚のyutuが書いたこちらのSwift におけるオプショナルなメソッドについて真面目に考えるでも述べられていますが、Optionalなdelegateを実装するためにはprotocol@objc をつける必要があります。せっかくPureSwiftで書いているのに、このせいでobjcが出てきてしまうのはもったいないです。

Swiftでの解決方法としては、現状 @objcをつけるしかなく、delegateではなくclosureで実装するべきだったかなとも思います。ただ、その場合実装必須なprotocolのような強制をさせることができないので一長一短ではあります。英語サプリでは、delegateメソッドとしてoptionalな必要な場面があまりなかったので、protocolでrequiredなdelegateメソッドとして実装して対応しています。

まとめ

今回は、ViewControllerとUseCaseの部分にフォーカスして紹介してきました。他にもiOSにおけるDDDのレイヤー化アーキテクチャとして、UseCaseとRepositoryのやりとりの部分や、AlamofireやSwiftyJSONなどを使ったRepository側の処理とユニットテストの書き方なども紹介していきたいと思っています。

最後に、英語サプリはWeb版も先月末にリリースしました。Web版といいつつAngularJs+TypeScriptでSPAとして開発していて、アニメーションやインタラクションにこだわったゲームらしいものになっています。こちらも是非使ってみてください!

web