[iOS] UIKitからSwiftUIへ段階的に移行するための"共存アーキテクチャ"を組むには?
初めまして、auコマース&ライフのアプリチームの坂井です。
今年度(2024年度)で新卒入社3年目になり、主に「au PAY マーケット」のiOSアプリの開発やアプリ配信の運用を担っています。
au PAY マーケットアプリの歴史と「混沌」
本題を入る前に、まずは弊社の「au PAY マーケット」のアプリとその開発実態についてご紹介します。
弊社が運営している「au PAY マーケット」は、Webサイトおよびネイティブアプリ(iOS/Android)で利用できるサービスです。
au IDをお持ちのお客さまであれば、Pontaポイントをためて、使うことに加えて、キャンペーンのエントリー、クーポンの取得などを行うことができます。
「au PAY マーケット」は、今年(2024年)で名称変更をして4周年を迎えましたが、以前は「Wowma!」という名称で2017年よりサービスを提供していました。
この旧Wowma!の時代から、弊社のプロダクトとしてWebサイトとネイティブアプリの両方が展開されており、サービス名称変更を経てさらなる新機能の追加や改修、リニューアルを施しながら今の「au PAY マーケット」に至ります。
このような歴史を持つ弊社のモバイルアプリは、旧Wowma!の時代に構築されたアーキテクチャと実装のまま現在も「au PAY マーケット」アプリとして開発と運用が続けられています。
つまり、旧Wowma!時代のままのアーキテクチャが老朽化しつつあるということです!
iOSアプリでは、開発言語はSwift、UIフレームワークにはUIKitを用いています。
そしてコードのアーキテクチャはMVVMをベースとした形で構築されています。(これはAndroid(Kotlin)でも同様です)
このMVVMベースのアーキテクチャを下支えするために、RxSwift / RxKotlinが導入されています。
多くのデータ管理やデータフローがこのRxフレームワークの仕組みによって実現されており、各種操作やAPIレスポンスなどのハンドリングもあらゆる画面でRxフレームワークを用いて実装されています。
しかし、旧Wowma!の時代から、様々なアプリエンジニアが行ってきた追加実装や改修が積み上げられている現在のソースコードは複雑化を極めており、現在のアプリエンジニアでは把握しきれていないほどの「仕様」が詰め込まれています。
その「仕様」は時として「バグ」へと姿を変えることがあります。
新たに実装しようとしている処理が、その「仕様」と喧嘩を始めることもあります。
そして、多くの場合これらにはRxフレームワークが絡んでいるため、さらに厄介なことが起きます。
これまで積み上げるように追加実装されてきた機能もRxフレームワークを使って記述されているものが多く、どの画面(ViewController)のソースコードを見に行ってもRxのコードを見かけないことはほぼありません。
Rxフレームワークはデザインパターンの一つの「Observer」パターンで成り立っていることから、Rxを用いて追加実装を行うと、たいてい「イベントを発火する側」と「それを監視する側」の二つの存在を追加することになります。
度重なる追加実装の結果、この二つの存在がありとあらゆる場所に乱立され、「そのイベントを誰に向かって発信しているのか?」「誰からそのイベントを受け取っているのか?」がかなり追いづらい状況になっています。
さらに、イベントを受け取った時に別のファイルのRxコードに別のイベントを飛ばすという連鎖反応を起こしているケースも存在します。
すなわち、誰とやりとりしているのかがよくわからない大量のRxコード達が不特定多数と通信を行っているという混沌がアプリ内部で発生しているのです。
このようにRxフレームワークが絡んでいるアーキテクチャと肥大化した追加実装は、いずれ内部処理ロジックの大規模な破綻を招く「時限爆弾」となっている可能性も十分に考えられるため、早期の対処が必要になっています。
(このRx問題の詳細についてはまた別の機会に紹介するかもしれません)
本記事では、運営年数を重ねてあらゆるものが積み上がった実装による弊害から脱却し、アプリのアーキテクチャをモダン化することを目的に推し進めている我々アプリチームの取り組みについて紹介していきます。
※ これから記す取り組みはiOSのSwiftUIに着目して紹介していますが、これらは全てAndroidアプリに対しても実施しています。
AndroidではSwiftUIと同じ宣言型UIフレームワークのCompose UIを導入しています。
※ 以下に掲載している図やコードは全てイメージであり、実際のコードや構造を厳密に再現したものではありません。また、技術的な正確さを保証するものではありません。
アーキテクチャの再建
Appleが新たな宣言型UIフレームワークであるSwiftUIを発表して以来、はや5年が経過しました。
昨今のアプリ業界ではSwiftUIでの開発や導入がすでに浸透していますが、「au PAY マーケット」アプリも時代と業界に合わせてアーキテクチャをモダン化するために、iOSアプリ開発チームでは現在、このSwiftUIへの切り替えと、より簡潔なアーキテクチャへの刷新を計画し、段階的に推し進めています。
とはいえ、すでに多量の画面、機能、データ構造があるアプリをアーキテクチャという土台からUIまでフルリニューアルするのは至難の業です。
現在の進め方では、先ほど「段階的に」と記した通り、大枠の画面(ViewController)上にある細かな部品(UIView)をSwiftUIで再実装していき、完成したものから順次リリースしていく形をとっています。
しかし、SwiftUIへの移行はただUI実装を作り替えるだけで成り立つものではありません。
段階的に進める上でも、先にやらなければならないことが2つあります。
1つ目は、UIView上にSwiftUIViewを表示する土台を用意すること。
2つ目は、SwiftUIViewが自分用のデータを監視できるようにしたデータ管理構造を用意することです。
どちらも肝心になるのは、既存実装と新規実装を共存させることです。
SwiftUIViewを表示する土台の準備
現在の「au PAY マーケット」のホームタブの「おすすめ」画面はTopViewControllerの画面となっており、簡潔にすると下図のような構造になっています。(その親元にUITabBarControllerなどがありますが、本取り組みにおいて現時点ではまだ取り扱いませんので省略します)
TopViewControllerにUIScrollViewとUIStackViewを置き、その上に「画像バナー」や「あなたへのおすすめ」といった各種コンテンツを表示する個別の小さなView(以下、コンテンツView)を縦に並べています。
このUIStackViewはそのままに、これらの個別のコンテンツViewのみをSwiftUIViewに置き換えることで段階的にSwiftUIへ移行するという形をとります。
ただし、UIKitのViewにSwiftUIを載せることになるので、UIViewがSwiftUIViewを取り扱えるようにするUIHostingControllerを間に挟む必要があります。
これにより、それぞれのSwiftUIのコンテンツViewがUIHostingControllerを仲介してUIStackViewに並ぶ構図が出来上がります。
図に表すと下図のようになります。
本来であればUIScrollViewとUIStackViewそのものをSwiftUIのScrollViewとVStackに置き換えることでSwiftUIViewの縦積みは実現できますが、TopViewControllerにはこのUIStackView内のコンテンツViewの並び順や出し分けの制御をUIKitの仕組みで行うロジックが含まれており、これはTopViewControllerの基幹部分にあたるため、この基幹部分もSwiftUIに対応させる改修をしてしまうと必然的に全てのコンテンツViewをSwiftUI化しなければならなくなるので、今回の「段階的な移行」ではこの改修は行わないこととしました。
すなわち、画面上にはSwiftUIViewを置きながらも、内部処理上ではUIViewとして取り扱う形にしています。
こうすることで、UIKitのViewとSwiftUIViewを同一のUIStackViewに共存させ、SwiftUI化が完了したコンテンツViewから徐々に載せ替えていきながら順次リリースしていくという運用が可能になります。
SwiftUIのためのデータ管理構造の準備
ご存知の通り、SwiftUIには参照しているデータを監視してUIに反映するObserverパターンな仕組みが組み込まれています。
つまり、アプリのアーキテクチャ自体もこの仕組みに対応させる必要があります。
前述の通り、現在のアーキテクチャはObserverパターンのRxフレームワークによって構成されています。
MVVMをベースとしたアーキテクチャであるため、データ管理やデータフロー周りはViewModelに集約されています。
これをSwiftUIの仕組みに置き換えるやり方としては様々な考え方があると思いますが、我々のアプリチームがとった手法は、各SwiftUIViewで使用するデータオブジェクトに@Publishedを付け、「UIState」と名付けたクラスに格納して一元管理するというものです。
このUIStateの内容はこのようになっています。
(○○ResponseクラスはAPIのインターフェイスクラスです)
class UIState: ObservableObject {
@Published var contentAlpha: ContentAlphaState = .init()
@Published var contentBeta: ContentBetaState = .init()
@Published var contentTheta: ContentThetaState = .init()
...
class ContentAlphaState: ObservableObject {
@Published var value: ContentAlphaResponse = .init()
}
class ContentBetaState: ObservableObject {
@Published var value: ContentBetaResponse = .init()
}
class ContentThetaState: ObservableObject {
@Published var value: ContentThetaResponse = .init()
}
...
}
@PublishedをつけることでSwiftUIViewが値の更新を監視できるクラスとしてレスポンスデータを格納しておくことができます。
class TopViewModel {
...
var uiState = UIState()
...
}
このUIStateクラスのオブジェクトをViewModelが管轄するデータとして持たせます。
APIから受け取ったレスポンスデータをViewModelまで運ぶ既存の処理はRxを用いて実装されており、この仕組み自体に改修は加えません。
そこに、そのレスポンスデータがSwiftUI製のコンテンツView向けのものであれば、UIStateオブジェクト内の対象のvalueにそのデータを流します。
一方でSwiftUIに置き換えていないコンテンツView向けのデータはそのまま(各コンテンツViewのクラスに直接渡す)とするので、従来のUIKitのコンテンツViewとSwiftUIのコンテンツViewの両方にデータを流すことができる仕組みが出来上がります。
これで従来のMVVMアーキテクチャを変更することなく、その中にSwiftUIのための新たなデータ構造とデータフローを用意することで、新旧両方のアーキテクチャを共存させることができました。
最終的な目標
もちろん、この取り組みの最終的な目標は主にViewControllerとViewModelからなるMVVMアーキテクチャから「SwiftUIアーキテクチャ」への完全移行です。
現在の段階的な移行の取り組みをまとめると以下のような構図になります。
class UIState: ObservableObject {
@Published var contentAlpha: ContentAlphaState = .init()
@Published var contentBeta: ContentBetaState = .init()
@Published var contentTheta: ContentThetaState = .init()
...
class ContentAlphaState: ObservableObject {
@Published var value: ContentAlphaResponse = .init()
}
class ContentBetaState: ObservableObject {
@Published var value: ContentBetaResponse = .init()
}
class ContentThetaState: ObservableObject {
@Published var value: ContentThetaResponse = .init()
}
...
}
class TopViewModel {
...
var uiState = UIState()
...
private func handleResponseData(data: ResponseData) {
switch data.type {
...
case .contentAlpha:
uiState.contentAlpha = data.value // UIState内の値を更新する
...
}
}
...
}
class TopViewController: UIViewController {
...
let viewModel = TopViewModel()
...
let contentAlphaView: ContentAlphaView // SwiftUI
let contentDeltaView: ContentDeltaUIView // UIKit
...
private func setupContentView(dataList: [ResponseData]) {
for data in dataList {
switch data.type {
...
case .contentAlpha:
contentAlphaView.state = viewModel.uiState.contentAlpha // UIState内の@Published付きプロパティを参照させる
...
case .contentDelta:
contentDeltaView.configure(data: data.value) // 従来通りの値の受け渡しを行う
...
}
}
}
...
}
class ContentAlphaView: View {
@observedObject var state: UIState.ContentAlphaState // 参照先を受け取り次第bodyが更新される
var body: some View {
...
}
}
既存実装の土台と基盤であるUIKitとViewModelはそのまま維持しつつ、UIKitのViewController上には個別のSwiftUIViewを載せて、ViewModel上にはそのSwiftUIViewのためのデータクラスのUIStateを載せることで共存状態をとっています。
ただし、これは「段階的な移行」の一段階に過ぎず、将来の「完全な移行」にも対応できる形となっていなければ効率の悪いやり方になってしまいます。
では、実際はどうなのかというと、このやり方でも将来の完全移行に「完全対応」していると考えています。
なぜなら、各SwiftUIViewやUIStateは既存実装に何ら依存しておらず、然るべき場所であればどこにでも置いて使える状態であるためです。
つまり、現在はUIStackView上のUIViewにUIHostingControllerを挟んで置かれているSwiftUIViewも、別のSwiftUIのVStackやScrollView内に置いてしまえば普通に動作しますし、UIStateも全ての親となるような何らかのSwiftUIViewやSwiftUIのための新たなViewModel的存在に移譲して各データの参照関係をそれに合わせるだけで済むわけです。
対する既存実装(UIKitとViewModel)はRxによる依存が根強いため、仮に同様のことをやろうとするとかなり厄介なことになるでしょう。
現在の「段階的な移行」で作っているSwiftUI関連のモジュールは全てRxに一切頼らず独立したものであるため、ほぼ足枷なくスムーズに完全移行にも繋げられることが期待できます。
ズバリ、それにより達成される我々の野望(最終目標)はこれです。
今まで作ってきたSwiftUI製コンテンツViewを親SwiftUIViewのScrollViewに並べて、データ管理とデータフローは既存のViewModelから独立したUIStateが担うという構図です。
SwiftUIを中心とした新たなアーキテクチャでアプリをモダン化するとともに、Rxフレームワークからも脱却することでコードの可読性を向上させます。
これにより、今後の更なる追加実装の際にも参照関係が明瞭な新アーキテクチャなら「混沌化」を十分予防することができるので、将来的な保守性の心配もありません。
これであらゆる混沌を克服した「au PAY マーケット」アプリの新生ホーム画面が完成するというわけです。
そもそもなぜ段階的なのか?
そもそも、なぜ段階的に移行するという判断が為されたのかということについてですが、まず、前述の通りすでに多量の画面、機能、データ構造があるこのアプリをアーキテクチャという土台からUIまでフルリニューアルするのは難しいというのが一つ挙げられます。
そして、我々アプリチームでは「au PAY マーケット」の新たなキャンペーンや新機能に対応するための開発も行っています。
もちろん、これを放っておいてSwiftUI移行を進める訳にはいきませんので、並行して進める形をとっています。
そのため、基盤部分を置き換えてしまうと新機能の開発にも大きく影響してしまうという点も一つ挙げられます。
これらの要因から、新規開発の進行に影響を及ぼさないようにしつつ小さな部分からSwiftUI化していくことで、フルリニューアルとまでは行かずとも段階的には移行を進められるというのがこの判断の大筋になります。
これを以て、段階的に進める上でも必要となる新たなアーキテクチャを上記の通りに実装したという経緯になっています。
いずれは基盤(TopViewControllerなど)そのものをSwiftUIで再実装して完全移行の作業に差し掛かることになります。
その作業に着手できるようになるまでは中途段階にはなりますが、それでも混沌と化した既存実装から簡潔でホワイトボックス化したSwiftUI実装へ部分的にでも移行したことによる可読性や保守性といった面の効果は十分に期待できるものと考えています。
余談ですが、
上記の課題の根幹にあるのは、令和の流行語の一つである「人手不足」です。
社員と外部協力者含めて我々のアプリチーム内のiOSエンジニアは4名、Androidエンジニアは3名となり、新機能開発も担いながら新たなアーキテクチャへの移行も推し進めているためベロシティが出しにくい状況であるのが実情です。
ここまで当記事を読んでいただき、
「au PAY マーケット」アプリのSwiftUI / Compose UIへの移行と新たなアーキテクチャの構築に興味を持っていただけた方は、
ぜひ我々のアプリチームへの参画をご検討いただければと思いますーー。
ここまでお付き合いいただきありがとうございました。
いずれリリースされるであろう、SwiftUI / Compose UIと全く新しいアーキテクチャで構築された新生「au PAY マーケット」アプリにご期待ください!