はじめに
@kotetu こと栗山です。今年の 4 月に STORES に入社しました。今回が、入社して初めての担当記事となります。
今回は、筆者が開発を担当している STORES 決済 の iOS アプリ (以後、 "決済アプリ" と記載) の開発チームで現在進行形で実施している取り組みについて、筆者が対応したとある案件で実際に経験したことや入社 2 ヶ月半の立場からの所感をベースに紹介します。
とある画面のアーキテクチャ変更対応
決済アプリでは、2025 年から SwiftUI の導入を徐々に進めており、一部画面については画面実装が SwiftUI ベースとなっています。
SwiftUI 対応と併せて、SwiftUI の利用に適したアーキテクチャへの変更も順次行なっています。
先月、筆者はとある画面のアーキテクチャ変更を担当しました。単に案件をこなすだけでなく、既存アーキテクチャのキャッチアップやどう変更されるのかについて理解を深めることができたので、筆者にとっては単に案件をこなす以上に有意義なものでした。
決済アプリのソフトウェア設計 (これまで)
決済アプリのリポジトリは、5年以上の長期に渡り機能追加やメンテナンスが行われてきました。決済アプリのリポジトリは、その初期から "Clean Swift" という設計パターンを用いて開発が進められてきました。
Clean Swift アーキテクチャ
Clean Swift は、Uncle Bob こと Robert Cecil Martin 氏が考案した Clean Architecture 1という設計パターンを iOS アプリ開発に応用した設計パターンの一つです。
Clean Swift は MVC パターンで起こりがちな "Massive View Controller" (View Controller 層の肥大化) 問題に対する解決策として考案されたものです。
Clean Swift では、1 つの画面を実装するにあたり、大きく分けて下記 3 つの主要クラス (上記記事中では "コンポーネント" と呼んでいます) に責務分割します。
- ViewController
- Presenter
- Interactor
ViewController は View の制御に専念し、他のクラスからは View を直接操作しないようにします。また、 Interactor はドメインロジックを、 Presenter は表示ロジックを主に扱います。
さらに、上記主要コンポーネントの他に、下記 3 つのコンポーネントも使用します。
- Router
- Worker
- Configurator
Router は画面遷移に関する処理を扱い、 Worker は他の画面で扱うために共通化されたドメイン処理を扱います。 Configurator はコンポーネント間の接続を担っています。
Configurator を除いた 5 コンポーネントの依存関係 2 は下記の図のようになります。
classDiagram direction LR class Router class Worker class ViewController { - interactor: Interactor - router: Router } class Presenter { - viewController: ViewController } class Interactor { - presenter: Presenter - worker: Worker } ViewController --> Interactor ViewController --> Router Presenter --> ViewController Interactor --> Presenter Interactor --> Worker
主要コンポーネント間は単方向のデータフローとなっていることがわかります。
決済アプリのフォルダ構造、実装
決済アプリでは、 Clean Swift アーキテクチャをベースに細部については独自のアレンジを施した上で活用しています。
主要な画面は Clean Swift アーキテクチャに基づいて実装されており、かつ整理されたフォルダ構成となっているため、筆者のような加入して日が浅い開発者にとっても実装の全体像が理解しやすい構成になっていると感じました。
ここでは、そんな決済アプリの実装について簡単に紹介します。
フォルダ構造
決済アプリのフォルダ構造は下記のとおりです。
- Scenes
- 画面ごとにフォルダが切られており(
News
やLogin
など)、フォルダ内には View Controller / Presenter / Interactor / Router といった各クラスが格納されています。
- 画面ごとにフォルダが切られており(
- Models
- ドメインモデルや API レスポンスモデルなどが格納されています。
- Views
- 画面間で共通して使用する UI 実装が格納されています。
- Workers
- 決済処理や API 処理が格納されています。
- Services
- API リクエスト処理や外部デバイスとの接続処理が格納されています。
- Util
- Extension などのユーティリティ処理が格納されています。
実装
決済アプリでは、 Configurator を除いた Clean Swift の各コンポーネントに責務分割を行なって実装しています。ここでは、各コンポーネント実装について簡略化したサンプル実装を元にご紹介します。
Scenes/UserList/UserListViewController.swift
標準的な ViewController 実装です。初期化タイミングで Interactor の処理を呼び出してデータ取得を開始するほか、UI 操作をトリガーにして Router を使って別な画面へ遷移させます。
struct UserDisplayModel { let id: String let name: String } @MainActor protocol UserListDisplayLogic: AnyObject { func display(userList: [UserDisplayModel]) } final class UserListViewController: UIViewController { var interactor: (any UserListBusinessLogic)? var router: (any NSObjectProtocol & UserListRoutingLogic)? required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setup() } override func viewDidLoad() { super.viewDidLoad() setupViews() loadUserList() } private func setup() { let viewController = self let interactor = UserListInteractor() let presenter = UserListPresenter() let router = UserListRouter() viewController.interactor = interactor viewController.router = router interactor.presenter = presenter presenter.viewController = viewController } private func setupViews() { /* View 初期化処理 */ } private func loadUserList() { interactor?.loadUserList() } private func tap(user: UserDisplayModel) { router?.routeToUserDetail( from: self, id: user.id ) } }
Scenes/UserList/UserListInteractor.swift
API からのデータ取得といったドメインロジックに近い処理を行います。取得したデータを Presenter へ渡します。
@MainActor protocol UserListBusinessLogic { func loadUserList() } final class UserListInteractor: UserListBusinessLogic { var presenter: (any UserListPresentationLogic)? private let userWorker = UserWorker() func loadUserList() { userWorker.fetchUserList { [weak self] users in Task { @MainActor in self?.presenter?.present(userList: users) } } } }
Scenes/UserList/UserListPresenter.swift
Interactor から受け取ったデータを表示用のデータへ加工します。加工が終わったデータを ViewController へ渡して UI へ反映します。
@MainActor protocol UserListPresentationLogic { func present(userList: [User]) } final class UserListPresenter: UserListPresentationLogic { weak var viewController: (any UserListDisplayLogic)? func present(userList: [User]) { viewController?.display(userList: userList.map{ .init( id: $0.id, name: $0.name )}) } }
Scenes/UserList/UserListRouter.swift
主に画面遷移処理を行います。
protocol UserListRoutingLogic { func routeToUserDetail( from viewController: UIViewController, id: String ) } final class UserListRouter: NSObject, UserListRoutingLogic { func routeToUserDetail( from viewController: UIViewController, id: String ) { /* 画面遷移処理 */ } }
Models/User.swift
API レスポンスモデルです。
struct User { let id: String let name: String let description: String }
Workers/UserList/UserWorker.swift
API からのデータ取得処理を行います。
final class UserWorker { func fetchUserList(completion: @escaping ([User]) -> Void) { /* API からのデータ取得 */ } }
コンポーネント間の依存関係は下記の図のようになります。
classDiagram direction LR namespace Scenes { class UserListRouter class UserListViewController { - interactor: UserListInteractor - router: UserListRouter } class UserListPresenter { - viewController: UserListViewController } class UserListInteractor { - presenter: Presenter - worker: Worker } } namespace Models { class User { - id : String - name : String - description: String } } namespace Workers { class UserWorker } UserListViewController --> UserListInteractor UserListViewController --> UserListRouter UserListPresenter --> UserListViewController UserListInteractor --> UserListPresenter UserListInteractor --> UserWorker
SwiftUI の導入
前述のとおり、決済アプリでは SwiftUI を使用して画面実装を行う取り組みを始めました。新規画面実装だけでなく、既存画面の SwiftUI 化についても徐々に対応を進めています。
制約や特性を踏まえたアーキテクチャの変更
現状、決済アプリの iOS Deployment Target (Minimum Deployments) は 15 となっています。iOS 15 では SwiftUI に Pull to Refresh (refreshable モディファイア) や フォーカス状態の管理 (FocusState) といった機能追加が行われました。 UI 実装としてできることは確実に増えましたが、一方で Navigation 周りについては NavigationStack がまだ使えない (サポートバージョンは iOS 16+
) こともあり、画面遷移を含めた全ての実装を SwiftUI で行うにはまだ課題がありました。
そのため、決済アプリでは SwiftUI で実装された View を UIHostingController で Wrap し、画面遷移は UIKit (UIViewController / UINavigationController) で実装する形に落ち着きました3。
上記のような制約や、SwiftUI を使って実装する場合に ObservableObject や @State / @Binding を使用してデータバインディングするケースが多いことを考慮し、決済アプリではデータバインディングと親和性の高い MVVM パターンをベースとした設計パターンへ変更することにしました。
決済アプリへの導入
それでは、サンプル実装を見ながら元の Clean Swift アーキテクチャからどのように変更したのかを見ていきましょう。
Scenes/UserList/UserListViewController.swift
元の実装から UIHostingController を継承したクラスへ変更した上で、SwiftUI で作られた View (UserListView) をセットするだけのシンプルな作りに変更しました。
final class UserListViewController: UIHostingController<UserListView> { override func viewDidLoad() { super.viewDidLoad() } } extension UserListViewController { static func instantiate() -> UserListViewController { let provider = ViewControllerProvider() let view = UserListView( viewModel: UserListViewModel(), provider: provider, router: UserListRouter() ) let viewController = UserListViewController( rootView: view ) provider.viewController = viewController return viewController } }
Scenes/UserList/UserListView.swift
元の UserListViewController で行なっていた表示処理や UI 操作時の処理は UserListView で行います。
また、 router インスタンスについても UserListView で保持し、画面遷移はこの router インスタンスを使用して行います。
struct UserDisplayModel: Identifiable, Equatable { let id: String let name: String } struct UserListView: View { @ObservedObject var viewModel: UserListViewModel @ObservedObject var provider: ViewControllerProvider var router: any UserListRoutingLogic var body: some View { ZStack { List(viewModel.users) { user in Button { guard let viewController = provider.viewController else { return } router.routeToUserDetail( from: viewController, id: user.id ) } label: { Text(user.name) } } .listStyle(.plain) } .onAppear { viewModel.fetchUserList() } } }
Scenes/UserList/UserListViewModel.swift
UserListInteractor および UserListPresenter の処理をひとまとめにしたようなクラスです。Clean Swift 版では Presenter で ViewController のインスタンスを保持した上で ViewController の更新メソッドを呼ぶ必要がありましたが、 MVVM 版の実装では users が @Published オブジェクトで UserListView 側の UI にバインディングされるため、users の内容が更新されるとバインディングした View へ変更が即座に反映されます。
ViewModel への置き換えに伴い、これまで使用していた UserListInteractor と UserListPresenter は削除しました。
@MainActor final class UserListViewModel: ObservableObject { @Published private(set) var users: [UserDisplayModel] = [] private let userWorker = UserWorker() func fetchUserList() { userWorker.fetchUserList { [weak self] users in self?.users = users.map { UserDisplayModel(id: $0.id, name: $0.name) } } } }
なお、SwiftUI 導入前から使用していた UserListRouter (画面遷移) と UserWorker / User (API 呼び出し) については変更せずそのまま再利用しています。
コンポーネント間の依存関係は下記の図のようになります。
classDiagram direction LR namespace Scenes { class UserListRouter class UserListViewController { - rootView: UserListView } class UserListView { - router: UserListRouter - viewModel: UserListViewModel } class UserListViewModel { - users: [UserDisplayModel] - worker: UserWorker } } namespace Models { class User { - id : String - name : String - description: String } } namespace Workers { class UserWorker } UserListViewController --> UserListView UserListView --> UserListRouter UserListView --> UserListViewModel UserListViewModel --> UserListView UserListViewModel --> UserWorker
まとめと今後の展望
本稿では、現在決済チームで対応を進めている SwiftUI 対応について、Clean Swift から MVVM へのアーキテクチャ変更の取り組みを中心にご紹介しました。
置き換えを実施した印象としては、適切な粒度での責務分割を維持しつつも Interactor と Presenter が ViewModel に置き換わったことで、構成がシンプルになったように感じました。
一方で、複雑な UI の画面に対して置き換えを行う場合は ViewModel が肥大化するリスクがあるため、何らかの肥大化を抑えるための工夫が必要に感じました。ただ、決済アプリは比較的シンプルな画面が多いため、トータルで考えると MVVM へ置き換える効果は大きいように感じました。
SwiftUI および MVVM パターンへの置き換えはまだ始まったばかりです。適宜チーム内で見直しを行いながら決済アプリにより最適化した設計にしていければと考えています。
- https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html↩
- Configurator は決済 iOS アプリでは利用していないため、省略しました。↩
- 将来的に最低サポートバージョンが上がった場合は再度方針変更が行われる可能性がありますが、 UIKit で実装された既存実装を考慮すると、この状態はしばらく続くのではないかと思います。↩