こんにちは、iOSアプリ黒帯の林 (@kazuhiro494949)です。
2019アドベントカレンダー2日目は、私がiOSアプリ開発を進めていく過程でどのようにクラス設計を行っているか紹介します。 普段の業務でなかなか理想通りに設計できないなと思っている方のお役に立てればと思います。
背景
iOSアプリ開発は、提供されているフレームワークをいかに使いこなすかという側面がとても強いです。 そのため実行結果の動きを触りながら常に試行錯誤して、心地よく動かせる実装を実現していかなければなりません。
一方、試行錯誤していく過程で記述したロジックは自然と増えていき、些細と言えないコード量になっていきます。 こういった部分ではユニットテストが効果的に品質を担保し続けてくれます。 そして、ユニットテストを書くためにはしっかりとしたクラス設計を必要とします。
- 実際の実行結果を触りながら、心地よく動くコードを試行錯誤して書く
- ユニットテストを頻繁に実行しながら、実装を固めていく
この2つは何も考えずとも同時に実現できるでしょうか? ヤフーで提供しているiOSアプリが持つコードの規模だと、残念ながらいくつもの課題が出てきてしまいます。
この記事では実際に手を動かして作る時の段取りに注目し、 実現したいものをリーズナブルかつ保守性高く作っていくクラス設計方法を考えます。
なお、Swift言語 + iOS SDKで行う開発を想定としておりそれ以外の技術は対象から外しています。
課題の定義
この記事では私自身が日々のiOSアプリ開発で抱えている以下の3つの課題に対して解決を目指します。
- 一回のユニットテスト実行が無視できないほど遅い
- アプリが動いているプラットフォームの実際の挙動を完全には把握しきれない
- 成果物の最終的な仕様は作りながら決まっていき、リリース後も変化してしまう
1. 一回のユニットテスト実行が無視できないほど遅い
Swiftで書かれたiOSアプリの実行結果を確認するには都度コンパイルが必要です。ユニットテスト側に変更が入った場合にはさらにテストターゲットのビルドが必要になるでしょう。
Swiftは実装がそのままダイレクトにビルド時間へ影響してしまいます。 例えば型推論をたくさん活用しているコードだとどうしても全体的にビルドが遅くなることが広く知られています。
そのため最初は気にならなくとも、さまざまな機能が追加されていくにつれてビルド時間もじわじわと遅くなっていきます。
また、UIを中心に開発している場合にはテストコード中にも非同期処理がどうしても多くなります。 XCTestExpectationを使うと以下のように一時的に実行完了するまで待つ必要が出てきます。 これが積み重なることで全体のテスト実行そのものも無視できないほどの時間になっていきます。
let expectation = XCTestExpectation(description: "index: 0") // <- XCTestExpectationの初期化
pagingMenuView?.scroll(index: 0, completeHandler: { [weak self] _ in
let frame = CGRect(x: 0, y: 0, width: 100, height: 44)
XCTAssertEqual(self?.pagingMenuView?.focusView.frame, frame, "success to change content layout")
expectation.fulfill() // <- 非同期処理のテスト完了フラグを立てる
})
wait(for: [expectation], timeout: 1) // <- 非同期処理が終わるのを1秒たつまで待つ
こういった問題の最も強力な解決手段がEmbeded Frameworkによるモジュール分割です。 依存関係が必要最小限になるよう強制でき、場合によっては変更時のビルド時間・テスト実行時間を劇的に短縮できます。
モジュール分割をするということは、何らかの基準でコードを分けなければなりません。 iOSアプリ開発でもレイヤードアーキテクチャーの考え方は広く浸透しており、 クラスをレイヤー構成に分割してその単位でモジュールを作ることが一般的です。
では、どのようなレイヤー構成が最適なのでしょうか。 iOSアプリ開発ではおすすめされるパターンがある程度決まっています。 MVVM, MVP, VIPER, Clean Architectureなどがあるのですが、どれを選択するかは難しい問題です。 あらゆるケースを想定すると細かくレイヤーを分ける必要があります。
多くの場合で最初の段階から複雑なレイヤー構成・モジュール分割が必要になることはなく、 オーバーエンジニアリングになってしまいます。1
また、素朴なMVCで実装された既存アプリへ特定の機能を追加する場合、 新たにレイヤードの考えを導入しても全体へ浸透させるのはなかなか骨が折れる作業です。 その結果としてモジュール分割まで到れるかというと、道半ばで心が折れてしまうことも多いのではないかと思います。
2. アプリが動いているプラットフォームの実際の挙動を完全には把握しきれない
プラットフォーム(PF)が提供するiOS SDKを使ってUI表示やアニメーションを実装する場合、最終的な表示だけ見るわけにはいきません。 その結果が得られるまでの動きも含めて、違和感ないかチェックする必要があります。
画面遷移を伴うアニメーションであれば、その画面だけで完結するわけではありません。 UIをどのタイミングでどういうアニメーション処理にすると最もなめらかになるかは、 既存の動きや変更する値などの兼ね合いでも変わってきてしまいます。
さらに、必ずしも前提としているPFが技術仕様通りに動くとは限りません。 もちろんPF側も十分な注意を払ってくれてはいますが、どうしてもドキュメントには書かれていないデータの流れを踏んでしまうこともあります。 OSSであればPFのソースコードを掘り下げていくこともできます。iOS SDKの場合は残念ながらそのようなことはできません。 そのために大枠の設計を決める時であっても、常にコードを書き、実行して実際の動きをみながら考えてアップデートしていく必要があります。
例えば単純なケースとして、以前勉強会で話したLTを紹介します。 上述した理由でリーズナブルな実装が使えなかったため、 初めに頭の中で考えて試しに実装したものと全く違うコードが最終成果物として出来上がりました。
どこまで複雑に作り込むかにもよりますが、こういったケースはiOSアプリ開発でそれほど珍しいことではないと考えています。 PFの技術仕様やそれを落とし込んだコードのイメージだけからクラス設計をするのは難しいとわかります。
3. 成果物の最終的な仕様は作りながら決まっていき、リリース後も変化してしまう
2や背景のところで書いたように、成果物の仕様はPF側の実装が実際にどのように振る舞うかにかなり依存します。 既に繰り返し同じようなものを作っていれば慣れたものかもしれません。 しかしこの分野では、初めて触る機能・使い慣れていないAPIを扱うことも多いでしょう。 iOSは今の所一年に一回大きなアップデートがあり、使っているAPIが拡張・廃止されたり急に振る舞いを変えるといったことも頻繁に起こります。 最新OSバージョンではなめらかに動くけど1つ前のバージョンでは同じコードで微妙にカクつくといったことは日常的に起きます。
さらに、書いたコードをこの先どのように変更していくのかは事業の今後の方向性に依存するでしょう。 何年か後に本人以外の人がぜんぜん違う文脈で利用していることも普通に起きます。2
設計の難しさ
このように考えていくとクラス設計は少しずつ難しい問題となっていきます。 恥ずかしながら私の場合、はじめに自分が描いたアーキテクチャ・設計通りにそのアプリがメンテナンスされ続けた経験はほとんどありません。3
これら3つの問題に対して、組織的な課題として解決するやり方もあるかと思います。外部仕様から見直すことで解決することもあります。 しかしここではいったんこの状況を受け入れて、クラス設計の段取りによって課題が致命傷とならないよう視点を変えてみます。
解決策の提案
iOSアプリ開発で新規のプロダクトや新機能を複数人にて開発する場合、私がクラス設計で意識するのは下の4つです。
- ユニットテストが現実的な時間で実装・実行可能な状態にすることを目指す
- 扱っている言語やフレームワークのアーキテクチャに合わせる
- クラス間の依存が単方向でかつできるだけ少なくなるようにする
- コーディングスタイルや実装の統一感にはこだわらない
1. ユニットテストが現実的な時間で実装・実行可能な状態にすることを目指す
iOSアプリ開発の現場では、開発を始める時にどういうクラス設計にするかという話から入るケースを目にします。 また、ある程度プロトタイプを作った上で、動くのを確認したらそれを捨てて設計から考え直すケースもよく見ます。
私はあまりそのやり方をすることはなく、基点となるクラス上に動くものを作り、 それをそのままプロダクション用のコードとしてブラッシュアップさせていくことが多いです。4
UIKitをベースとしているiOSアプリであれば、UIViewControllerを基点に作っていきます。 はじめは継承したview controller上へロジックを全てベタ書きして動くものをなんとかして作ります。 そして、出来上がった段階でその書かれたコードを元に、テストを書きながらテスト可能な実装へ少しずつ切り出していきます。
この時にさまざまなオブジェクト指向プログラミング(OOP)の原則やデザインパターンが役に立ちますが、 追求し始めると何日も手が止まってしまうのとあまり本質的ではないため無理に考えなくてもいいと思います。 シンプルなDependency Injection (DI)を駆使してテスト可能なコードを書くということへ集中していけば十分なのではないか、と考えています。
- プロパティやメソッド内の実装部であればイニシャライザで注入する
- メソッド内で使われているローカル変数であればメソッド引数で注入する
- 注入する時はProtocolを経由させて実装を切り離す
ぐらいでやっていて、DIコンテナなどは使用しません。
DIに関する概要的な説明は、以前社内LTで話をした時の資料を御覧ください。ただし、普段の開発でここまで考えながら作り込むことはありません。
2. 扱っている言語やフレームワークのアーキテクチャに合わせる
DIによってテストを実現するためにロジックやステートメントのみをクラスとして切り出していくこともあります。 しかし、特定のUIコンポーネントのみで必要なロジックならchild view controllerもしくは単なるviewとして切り出して集約させます。 これは、できるだけUIKitのアーキテクチャの延長で作っていくために効果的な方法です。
UIViewControllerやUIViewは、アプリが特定の状況になった時やユーザーが何か入力した時にフックされるメソッドを数多く持っています。 それらはUIViewControllerとUIViewで構成されたツリー構造の上を伝わって、適切なタイミングで適切な画面のイベントがフックされる想定となっています。 これは全体の画面構成がどうなっているかにも依存しており、そのタイミングを全て理解してクラス設計するのはとても難しいです。 例えばtraitCollectionDidChanged(:_)やviewWillTransition(to:with:)などはデバイスの環境が変わってもすぐにはフックされず、 画面上に現れた時に遅延して呼ばれます。
そのあたりの複雑さは以前私がQiitaに書いた画面回転系の記事を読むとイメージしやすいかもしれません。
- iOSアプリのレイアウトを回転で変更するとき注意していること
- 「UIScrollViewの上へUITableViewを並べるとiPhoneX Landscapeでレイアウトが崩れる」という題材で理解するiOS 11時代のレイアウト
UIKit全体のライフサイクルは公式ドキュメントに詳細が記載されているため、そちらを御覧ください。
さらに、画面遷移時にフックされるイベントなんかも当然あります。例えばカスタム遷移系の処理や(今やdeprecatedとなった)3D Touchなどの遷移イベントは、後からOSのバージョンアップに合わせてUIViewController上へ実装されていきました。
そういった複雑さ・不確実性に対処するためには、下手に考えずにできる限りUIKitの構造に乗っかるのが無難だと考えています。 view controller内部に実装したロジックを大元のツリー構造から大きく切り離さず、それを中心に据えたほうが実行結果が壊れにくくなります。 徐々に切り出していくという観点だけで言えば、サンプル的な例でしかないですが以前勉強会で話した時の流れがイメージとしては近いです。
ここでは例としてUIKitを出しましたが、他のフレームワークでも同様です。 土台となるものがどのように設計されているのかを理解することが重要だと思います。
3. クラス間の依存が単方向でかつできるだけ少なくなるようにする
とはいえ複雑になるにつれ、ユニットテストを回しつつ試行錯誤で動くものを作っていく時間がどんどん重たくなるのは見えています。 いずれはモジュール分割が必要です。そのために事前に考えなければならないことはなんでしょうか? 一から独自に考え始めるととても大変なので、ここでいくつかあるレイヤードアーキテクチャの力を借りましょう。
ただし、必ずしもそれらの特定の型にはめる必要はありません。 その中で活用されているアダプターパターンやDIといったデザインパターンを参考にするぐらいでも構いません。
- 他クラスへの依存をできるだけ減らしていき、インターフェース経由にする
- 循環的な依存関係をなくし、必要な場合はインターフェース経由にする
- 継承ではなく集約を使う
この時とにかくモジュール分割ができるかどうかが重要なので、 iOS SDKやSwiftのスタンダードライブラリで提供されているクラスへの依存は気にしなくてもいいでしょう。 基本的には自前で用意したクラスへの依存を頑張って減らしつつ、 サードパーティライブラリへの依存も大きなものに関しては極力やめるというのが実用的だと思います。
どのようなモジュールに分割するかは正解を出すのがなかなか難しい問題です。 また、特定の型を参考にしたとしても、実務でコードを書いているとどのクラスをどのモジュールに置くべきか迷う時が多々あります。 一度きれいに分割してもいずれは一部のモジュールのコード量が多くなりビルドが遅くなっていきます。
始めからモジュールに切り出したり全体を作り直してモジュール分割するのではなく、 DIとアダプターを使ってそろそろ分割したいなというタイミングでモジュールに切り離せるようにしていけるのがいいのではないかと考えています。
4. コーディングスタイルや実装の統一感にはこだわらない
ここまでの話はコードの構造に重点を置いており、コーディングスタイルには触れていません。 もちろんスタイルが統一されていて読みやすいことは大切なのですが、開発時にはあえて議論しすぎないようにしています。 私の場合、Lintツールなどで多少制約を入れつつ、基本的には言語やフレームワークのドキュメントに書かれたルールと実装者の考えを尊重します。5
これに関してさまざまな意見があると思います。 常にそれが正しいとは思わないものの、先に書いた課題を回避しながらスピーディーに開発していくにはそのほうがいいと考えています。
また使用する技術についてもできるだけこだわらないようにしています。 例えば、iOSアプリ分野では以前からレイアウトを組む時にどのような技術を使うべきかが盛んに議論されてきました。6 Storyboardを使うべきかコードでレイアウトを組むべきか、AutoLayoutを使うべきか否かといったものです。
私はというと、最終的に目指しているUI次第で全部使ってやることが多いです。 レイアウトのアウトラインはStoryboardでAutoLayout+UIStackViewによって組んだほうが良いと思っています。 一方で目まぐるしくレイアウトを変える必要がある場合など、Frame計算を直接行ったほうがシンプルになるケースもあります。 いずれも何らかの課題を解決する道具なので、それぞれが有効に機能する範囲は存在するでしょう。 これに関しても他の人がどうするかはその道具を使う合理的な理由があれば統一しません。
全体の流れを通して
重要なのはPFの作りとその上に直接書いた動くコードを一旦受け入れ、テストがかけるという目的に向かって少しずつ良くしていくことです。 要するにリファクタリングで、きれいに作ることを意識しすぎず最初からそれを開発の中に入れていくというやり方です。
最後に
なぜこのようなクラス設計をする必要があるかは、開発のスピードを落とさずに以下を両立していくためでした。
- 実際の実行結果を触りながら、心地よく動くコードを試行錯誤して書く
- ユニットテストを頻繁に実行しながら、実装を固めていく
背景にOOPの原則を敷いてはいるものの、この記事の内容は殆どが私自身の経験則へ依存しています。 そのため、当てはまらないケースも当然あると思います。 弊社のプロダクトでもそれぞれが抱えている課題に対して様々なアプローチが取られています。 「こんな課題を抱えていて、こう開発を進めていくことで解決しています」という話が他にもあればぜひ聞いてみたいです。
そして最初に設定した課題を同じように抱えている人たちも、広い世の中を見渡せばいるんじゃないかなと思っています。 そういった方々にとって多少なりともヒントになればうれしいです。
参考資料
- [1] Behind the Scenes of the Xcode Build Process
- [2] Building Faster in Xcode
- [3] XCTestExpectation
- [4] Embedding Frameworks In An App
- [5] Software Architecture Patterns by Mark Richards
- [6] 漸進的にViewControllerの肥大化を防ぐ - 俺コン Vol.1 / Day. 2
- [7] iOS 11以降でUIViewControllerTransitionCoordinator によるアニメーションが初回だけ効かない問題
- [8] SwiftのDI方法について最近考えてた話 - iOS LT #28
- [9] iOSアプリのレイアウトを回転で変更するとき注意していること
- [10] 「UIScrollViewの上へUITableViewを並べるとiPhoneX Landscapeでレイアウトが崩れる」という題材で理解するiOS 11時代のレイアウト
- [11] UIKit
- [12] UIViewControllerTransitioningDelegate
- [13] UIViewControllerPreviewingDelegate
- ここは開発チームの構成にも依存するでしょう。シニアの開発者を集められるのであれば、複雑な言語機能・クラス設計を扱うことは選択肢としてとても魅力的です。
- 私の能力不足もあって、設計からもとの意図がしっかり伝わることもなかなかありません。
- 自分がメンテナとして関わっている間ですらそれが起きてしまうことがあります。
- 説明のためにやや単純化していますが、実際には自分はある程度紙の上でクラス設計します。ただし絶対に必要なわけではなくあくまで動くコードを重視します。
- これはコードレビューをする時も同様で、元のコードを作ってくれた仲間に優しくあることができるやり方だと思っています。
- 背景としてAutoLayoutやStoryboardの独特さ、Swift言語との相性の悪さなどがあり、議論自体はとても生産的で面白いと思っています。
こちらの記事のご感想を聞かせください。
- 学びがある
- わかりやすい
- 新しい視点
ご感想ありがとうございました