iOS版『NAVITIME』をリアーキテクチャした話
こんにちは、s-pongです。ナビタイムジャパンでiOS版『NAVITIME』の開発を担当しています。
iOS版『NAVITIME』は、2022年7月に全面リニューアルしました。iOS版『NAVITIME』をリニューアルした話は、すでに別の記事にてお話ししていますが、この記事ではもう少し開発サイドにフォーカスを当てて、リアーキテクチャしたことについてお話ししていきたいと思います。
背景
『NAVITIME』は2010年にスマートフォン向けのサービスを提供開始しており、今日までたくさんの機能追加や改善を重ねてきました。
今では多くのユーザーの方に利用していただき、開発者としてとても嬉しく思っています。
一方その裏側で、『NAVITIME』は当社アプリの中でもトップクラスの複雑さを持ったアプリへと成長を遂げました。
別の記事での「技術面での課題」でも触れていますが、
今となっては利用されているかどうか判断の難しい処理や、Objective-Cで書かれた古くからの処理がいたるところに残ってしまっていた
これまでも部分的なリファクタリングを行ってデッドコードの削減やSwift化を進めてきたが、そういった技術的な負債を一掃することはできていなかった
当社のiOSアプリエンジニアのなかでもObjective-Cの経験者が少なくなってきていることもあり、Objective-Cのソースコードを読み解く必要がある『NAVITIME』はメンテナンスコストがかかってしまっていた
などの多くの問題を抱えている状況でした。
これらの問題は、開発のスピード低下や不具合を生み出す原因となっていたので、アプリをリニューアルするタイミングで技術的な負債を一掃しようと決意し、リアーキテクチャを実施することとなりました。
リアーキテクチャの方針
リアーキテクチャを進めるにあたって、以下の方針を定めました。
リアーキテクチャ前の作りでは、複数のアーキテクチャを抱えている状態だったり、Objective-cとSwiftが混在していることによる可読性の低下が起こっていたりすることで、既存仕様を把握するのに非常に時間がかかっていました。
また、Apple社は毎年何かしら新しい技術を発表しており、その技術を使って新しい価値をユーザーに届けることも重要です。
リアーキテクチャで可読性の高いコードに整えることで、ビジネスサイドの要求も答えつつ、技術的な改善や機能拡張しやすいプロダクトにしておくことを目指しました。
言語
言語はSwiftUIを採用しました。
SwiftUI
SwiftUIとはWWDC2019でAppleが発表した、UIをシンプルなswiftコードで簡単に構築できるフレームワークです。
SwiftUIについての詳細は、すでにいろいろなサイトで大変わかりやすく解説されていますので、ここでは省略させていただきますが、従来のUIKitでの開発に比べて、
コード量が少なく表現できる
Storyboard等のInterface Builderのコンフリクトに悩まされない
画面の見通しがしやすい
など様々なメリットがありました。
一方で、SwiftUIもまだ不安定な部分があり、
OSバージョン間の動作が異なっている場合がある
現時点ではUIKitより機能が少ない
不具合の特定がしづらい
などのデメリットもありました。
開発初期の頃はUIKitでの開発との違いに戸惑いも感じましたが、今ではSwiftUIのメリットの方が大きいと感じており、UIKitでの開発に戻れなくなるほど採用してよかったと感じています。
SwiftUIを別プロダクトで導入した話やアプリのリニューアルを通して分かったSwiftUIとの付き合い方も別記事にて紹介していますので、こちらも是非ご覧ください。
アーキテクチャ
SwiftUIを採用するにあたって、アーキテクチャ選定はとても重要でした。
iOSを構成するアーキテクチャは現在いろいろあり、
MVC、 MVVM、 MVP、 VIPER、 Redux・・・等々、選択肢としては多くありました。
アーキテクチャの詳細な説明はここでは省かせていただきますが、それぞれにメリット・デメリットがあり、UIとロジックを疎結合にできることを前提に優先度をつけると、VIPER、Redux > MVVM,、MVP > MVCということになりました。
最終的にVIPER or Reduxということになったのですが、地図やナビゲーションなど、インタラクティブな機能を有する『NAVITIME』をSwiftUI + Reduxで実装できるイメージがいまいち湧きませんでした。
その結果、SwiftUIと相性がいいもの、かつリアーキテクチャの方針を満たせそうなものとしてはVIPER + Layered Architectureがいいのではと思い、採用しました。
VIPER
VIPERとはClean ArchitectureをiOS向けにしたアーキテクチャのことで、View、 Interactor、 Presenter、 Entity、 Routerで構成され、それぞれの頭文字を取ってVIPERと呼ばれています。
従来のMVCに比べて1つあたりのファイルサイズの肥大化を防ぎつつ、責務分離することで、役割をより明確にできるメリットがあります。
反面、デメリットとして、一つのモジュールに対して4つのファイルが1セットになるので、全体のファイル数が多くなる傾向にあります。
Layered Architecture
Layered Architectureとは、文字通り多層アーキテクチャのことで、アプリケーションを複数の層に分割し、それらを独立したモジュールとして疎結合にすることで、モジュールごとの開発がしやすかったり入れ替えが容易になるような特徴のあるものです。
VIPER + Layered Architecture
アーキテクチャとしてこの二つを採用するにあたり、VIPERのEntity部分をLayered Architectureの考え方を適用することで、より疎結合になるように工夫しています。
具体的には、InteractorとEntityの間にRepositoryを噛ませることで、InteractorがEntityを取得する際にローカルデータを取ってくるのかAPIを通して取ってくるのかを意識しなくてよくなっています。
また、それぞれの役割を独立させることができ、ソースコードがより疎結合になることでテストコードが書きやすくなりました。
SwiftUIと組み合わせた方法としては、ViewをSwiftUIのViewに置き換え、PresenterをObservableObjectに準拠させることで、値の受け渡しをすることができます。
VIPERではRouterが画面遷移の責務を負っていますので、Routerを通して各モジュールのViewを取得するようにし、それをView内のNavigationLinkに渡して画面遷移するようにしています。
下記にRouter、Interactor、 Presenter、 View、Repositoryのコード例を示しておきます。
protocol TestRouterProtocol: AnyObject {
static func makeModule() -> AnyView
}
// Router
final class TestRouter: TestRouterProtocol {
// Router間同士はこのメソッドでモジュールを呼び出すことが可能
static func makeModule() -> AnyView {
let interactor = TestInteractor()
let router = TestRouter()
// Router <-- Presenter
// Interactor <-- Presenter
let presenter = TestPresenter(router: router, interactor: interactor)
// View <--> Presenter
let view = TestView(presenter: presenter)
return AnyView(view)
}
}
protocol TestInteractorProtocol: AnyObject {
}
// Interactor
final class TestInteractor: TestInteractorProtocol {
// Interactorは必要なRepositoryを保持
var repository: TestRepositoryProtocol
init(repository: TestRepositoryProtocol = TestRepository()) {
self.repository = repository
}
}
// Presenter
final class TestPresenter: ObservableObject, AnyObject {
// PresenterはRouter,Interactorを保持
var interactor: TestInteractorProtocol
var router: TestRouterProtocol
init(router: TestRouterProtocol, interactor: TestInteractorProtocol) {
self.router = router
self.interactor = interactor
}
}
// View
struct TestView: View {
// Viewはpresenterを保持
@StateObject var presenter: TestPresenter
var body: some View {
Text("Hello, World!")
}
}
protocol TestRepositoryProtocol: AnyObject {
}
// Repository
final class TestRepository: TestRepositoryProtocol {
init() {
}
}
余談ですが、ここ数年の間にSwiftUIと相性がいいアーキテクチャについて様々な議論が飛び交っており、選択肢としてのアーキテクチャが増えてきました。
技術の発展の著しさや、開発トレンドの変化の速さを実感しているところです。
当社のケースではSwiftUI + VIPER + Layered Architectureを採用しましたが、アプリの規模感などによって適切なアーキテクチャは異なるかと思いますので、ぜひベストなアーキテクチャを探してみてください。
実際どうだったか
実際にリアーキテクチャを進めるにあたっては、仕様を整理しつつSwift化されていないObjective-cのファイル(数千行はザラにありました)を理解しながら、SwiftUIやVIPERに当てはめていくなど、いくつも高い障壁があったことを覚えています。
手を動かす前に工数を算出してみた時も、とんでもない数字が出てきて、始める前からげんなりしましたが、そこは
Mr.Children「終わりなき旅」の歌詞「高ければ高い壁の方が登った時気持ちいいもんな」というワードを頭の中で無限ループさせて努力と根性で乗り切りました。
SwiftUIではStoryboard等のInterface Builderに比べて画面が爆速で実装できます。そのおかげでロジックに時間を費やすことができ、画面ごとにVIPERでの各責務を考えながらロジックを当てはめていく実装が可能になるので、慣れてくると開発スピードも自然に上がっていたような気がします。
最後に
今回のリアーキテクチャで、UIとロジックを疎結合にすることができ、今後の機能拡張や運用での開発のスピードを高められる作りにすることができたと感じています。また複数のアーキテクチャを統一したことや、フルSwift化やデッドコードの削除により、メンテナンスしやすいコードに生まれ変わり、ユーザーからのご要望や機能追加が素早く対応できるようになりました。
今後も引き続き機能拡充や改善を行いながら、より多くのユーザーに安心安全で快適な移動体験を提供できるサービスの実現を目指していきます。