グローバルで戦うためのiOSアプリ開発の技術選定
この記事は eureka Native Advent Calendar 2017 — Qiita の1日目の記事です。
Pairs Global事業部のiOSエンジニアのmuukiiです 🤠
エウレカの今年のアドベントカレンダーはネイティブアプリ用のカレンダーが用意されています!
面白い内容の記事でいっぱいになる予定なので毎日チェックしてもらえたらうれしいです!
さて、本題に入っていきますが、実は去年から今年にかけて、PairsのGlobal版iOSアプリをゼロから作り直しているのですが、技術的にどのような方針で作り変えたのかを話したいと思います。
作り直す前のアプリと比べて、とてもスムーズに動くので皆さんにも触っていただきたいのですが、
現在は韓国と台湾のAppStoreでしかダウンロードできないのです 😭
触ってみたい!という方は私を食事にでも誘ってもらえればと思います 🙏
AppStoreのURLはこちらです。
デモ用のデータに差し替えたアプリの動画を載せておきます。
demo.mov
アプリを作り直すきっかけ
もともとはデザインのリニューアルのみを行う予定だったのですが、既存のアプリの中身は多くのObjective-Cのコードが残っていたり、コードが複雑化してちょっと大変なことに。つまり、何かと開発しづらい状態になっていました。
「このままデザインリニューアルを頑張っても良いけれど、その後の継続的な開発が大変になりそう。」
ということでアプリをゼロから作り直しつつ、デザインリニューアルを行うことにしました。
目的を整理すると、
- ユーザーに不快感を与えないようなサクサク使えるアプリにすること
→ ユーザーに「気持ちよくアプリを利用できる」価値を届ける - 部分的に改善可能なコードベースにすること
→ 会社に「ビジネス戦略をスピーディに実現できる」価値を届ける
1は大前提ですが、2がある程度実現できなければしばらくしたらまた作り直すことになってしまいます。
期間は2016年8月から2017年6月までの約10ヶ月間。
今までより圧倒的に良くするために様々な試行錯誤がありました。
ユーザーに不快感を与えないように気持ちよく使えるアプリにするために
- UICollectionViewやUITableViewのスクロールのパフォーマンス
- 画面遷移のスピード
- 不要な表示が一瞬見えてしまうなどの表示のチラつき
Texture(旧AsyncDisplayKit)の導入を決定
UICollectionViewやUITableViewはCellを持っていますが、Cellの中身のレイアウトをAutoLayoutで行うとスクロール時にガタガタと画面が固まる現象に簡単に遭遇します。AutoLayoutによるレイアウト計算はコストの高い処理です。
極端な話、スクロールパフォーマンスを向上させるにはAutoLayoutの使用をやめるしかないのですが、様々なディスプレイサイズが存在する中でマニュアルレイアウトによる開発は辛すぎますよね😱
そんな中で、AutoLayoutに代わる別のレイアウトエンジンとなるライブラリがOSSとして公開されています。
- Texture
- LayoutKit
- PinLayout
- Yoga
- FlexLayout (内部でYogaを使用)
結論として、Textureを選択しました。
Textureは現在はPinterestのエンジニアによって開発されているライブラリです。
実際にPinterestのiOSアプリでも使用されているので、Textureを使うことでどのぐらいスムーズになるのかはPinterestを触ることで体感できるはずです。
Textureは他のレイアウトエンジンのライブラリとは異なり、レイアウトエンジンに加えて独自の方法でUIKitを高速化する機能も持っているなど、非同期処理を駆使してUIのパフォーマンスを出来る限り向上させることが目的のライブラリです。
Textureが内部で行っている処理は参考になることも多いですが、詳しい説明はここでは割愛します。
(Textureに関する記事はこちらもおすすめです。)
InterfaceBuilderの使用は一切なし
StoryboardやXIBといったInterfaceBuilderを用いたUIの実装は一切捨てて、すべてコードによるレイアウトにしています。
しかし、AutoLayoutを実装する場合、コードで NSLayoutConstraint
を作るのは結構疲れるので EasyPeasy というライブラリを使用しています。
コードでレイアウトを組むことは少し疲れますが幾つかのメリットがあると考えています。
- InterfaceBuilderに比べて自由度が高い
- InterfaceBuilderでは実現しづらいこともありますが、コードでは自由です。
- InterfaceBuilderに比べて初期化が速い
- わずかな差と言えますが、たくさんのUIコンポーネントを生成する際には差が出てきます。
- UIコンポーネントの設定がすべてコードに寄せられること
- 例えば「UIImageViewのcontentModeの設定がコードだったりInterfaceBuilderだったり」という問題が避けられます。
- InterfaceBuilder独特の挙動にハマらない
- (疲れるけど) PRでレビュー可能
- レイアウトのレビューは正直大変ではありますが、異変に気づけたことは何度かあります。
- XMLを読むよりかは僕としては楽だと感じています。
ViewModelレイヤーの導入で画面の状態を管理
画面上で不要な表示が一瞬でも行われてしまう主な原因は画面の状態管理が正しく行われていないことです。
状態の遷移に関係するフラグはどうしても増えていきがちです。これが整理できなくなるとフラグがコロコロ切り替わってしまったりして画面がチラつく原因となります。
フラグを整理して、状態の遷移を正しく、必要最低限に行うことができれば大きく改善します。
このために、MVVMアーキテクチャを参考にViewModelレイヤーを導入しました。
ViewModelレイヤーでは画面(View)にとって使いやすいデータと、画面の見た目を切り替えるフラグを提供します。
また、MVVMということでアプリ全体でRxSwiftを利用しています。
部分的に改善可能なコードベースにするために
レイヤーごとにModuleを分ける
改善が行いやすいコードベースとは依存関係が出来る限り一方通行で整理されている状態が望ましいです。
そこで、アプリ内において責務ごとにレイヤー分けを行い、それぞれをModuleとしてコンパイル可能にしています。
Moduleで分けるためにはModule間は片方からしか依存できません。相互依存になった場合はコンパイルが通せなくなります。このルールを利用して開発者が意図しない相互依存の発生を抑えてくれます。
Moduleは以下のような構成になっています。
- Pairs (Executable)
- AppDelegateぐらいしか持ってない
- App (Framework)
- 画面とUIコンポーネント
- AppService (Framework)
- Appから呼び出される
- AppRequestを使ったAPIリクエストやModelの生成・保持を行う
- AppRequest (Framework)
- AppServiceから呼び出される
- APIリクエストの定義
- パスごとのstructが定義されている
Pairs → App → AppService → AppRequestの依存関係になっています。
つまり、コンパイルはAppRequestから行われていきます。
AppService内では import AppRequest
を書くことが出来ますが、AppRequest内では import AppService
と書くことは出来ません。書くとコンパイルエラーとなります。
Compositeパターン Adapterパターンのような構造化されたデザインパターンの利用を考える
データ構造の選択はとても重要で、スケールの可能性に大きく影響し、Swiftの強力な型制約を活かすことにも貢献します。
- 配列
- 連想配列
- 線形リスト
- 木構造
- グラフ
要件に応じて「どのデータ構造が最適か」は常に考え続け、機能改善のタイミングで破綻するとわかったときは構造を見直すタイミングであり、技術的負債が溜まってしまう可能性のあるタイミングとも言えるでしょう。
また、データ構造の考え方はUIの実装にも活用することが可能です。
例えば、UIImageViewを角丸に表示するUIを作りたいとします。
アイデアの一つとして、UIImageViewのサブクラスを作ることで実現できますが、これを木構造に当てはめて考えてみます。
class RoundedCornerView : UIView {
let source: UIImageView = ... init() {
addSubview(source)
source.autoresizingMask = [.flexibleWidth, .flexibleHeight]
source.frame = bounds
} func applyCorner() { ... }
}
UIViewのサブクラスとしてRoundedCornerViewを作り、subviewとしてUIImageViewを持ちます。
次に、UIImageViewはRoundedCornerViewと同じサイズになるようにレイアウトを組みます。
RoundedCornerViewの仕事は自分自身をCornerRadiusを元にマスクするだけです。
この時点で、
- UIImageViewは画像を表示する
- RoundedCornerViewは自身に埋め込んでいるUIImageViewを丸く表示する
というふうに責務が分離されています。
さらにSwiftにはGenericsがあるので、次のように汎用化も出来ます。
class RoundedCornerView<S : UIView> : UIView { let source: S init(source: S) {
self.source = source
addSubview(source)
source.autoresizingMask = [.flexibleWidth, .flexibleHeight]
source.frame = bounds
} func applyCorner() { ... }
}
こうすることで、UIImageView以外のすべてのUIViewを角丸にすることが出来るようになりました。
利用する側は多少冗長な書き方になってしまいますが、宣言として何が起きるかが見えやすくなるので影響範囲が明確になるというメリットがあります。
このような考え方を全体的に利用し、責務ごとに分離するようにしています。
これは設計に余裕を持たせておくという意味もあり、今後どうなるかわからない場合のコンパクトな暫定対応とも言えるでしょう。
抽象から具象にするのは比較的簡単な操作なので、いつでもサブクラスにしたりGenericsをやめることは出来ます。
DBの使用を必要最低限にする
サーバーとクライアントの両方でデータを持つことは難しいことになりがちです。
データの更新頻度が低く、長く使われる場合はクライアントでDBなどを用いて永続化することはメリットにつながります。しかし、データの更新頻度が高く、使われる期間が短い場合は管理コストのほうが上回ってしまう可能性があります。
SNSのようなデータの流動性が高く、そもそもオンラインでこそ利用価値が出るプロダクトの場合において、「どのデータを永続化すべきなのか」は慎重に判断する必要があります。
Pairs Global版では現状はチャット機能にのみRealmを使用しています。
ユーザーの検索結果などは取得したJSONをstructやclassにマッピングして表示するようにしています。
UITableViewは使わずにUICollectionViewで対応する
UITableViewで出来ることの多くはUICollectionViewとUICollectionViewFlowLayoutの設定で実現可能です。
UICollectionViewで実装することにより、UIをカスタマイズするときの煩わしさから解放されます。
そのため、UITableViewでしか出来ないことが無い限りUICollectionViewを使用するようにしています。
また、UICollectionViewとUITableViewは似ていますが、UICollectionViewCellとUITableViewCellの初期化も異っているように、それぞれの内部の動き方は微妙に違います。
これらの違いがトラブルシューティングにも違いを生みます。どのような画面でもUICollectionViewとUICollectionViewCellを使用することで実装のアプローチとトラブル時の解決方法のナレッジを活かすことが出来るようになります。
標準APIへのextensionを追加は慎重に
FoundationやUIKitなどの標準で提供されているAPIにextensionを実装すると便利になりますが、その代わりに煩雑になる可能性があります。
extensionをプロジェクト全体に公開する場合は本当に多くの場所で使われるかどうかを考えてから行うようにしています。
アプリの作り直しを終えて
このリニューアル開発期間では結構多くのチャレンジングな選択をしたと思っていて、また開発が進んだ時に成功と出るのか失敗とでるのかは不安でもあり面白みでもあります。
今回紹介した開発方針も長期的な運用実績はまだないのでこれからまた全く別の方針に変わる可能性もありそうですが、リファクタリングや、アプリを作り直そうかと思っている方に参考になれば嬉しいです。
また、今回紹介した中でそれぞれを詳しく説明した記事も書こうと思っているのでよろしくお願いします!
追記
本記事に関連する記事を書きました!