はじめに
こんにちは。BASE株式会社 Pay IDでiOSアプリエンジニアをしているkakkkiです。iOS版のPay ID ショッピングアプリの開発を担当しています。
iOS版Pay IDアプリ(以下Pay IDアプリ)はリリースされてから9年以上、新機能開発を行いながら継続的に運用されています。普段の業務では機能開発を進める一方で、マルチモジュール化やStrict Concurrency対応、SwiftUIへの移行など継続的に技術的な改善に取り組んでいます。
Pay IDアプリ内に古くから存在する画面の多くはUIKitベースで実装されていますが、最近はSwiftUIで実装された画面が増えていくようになりました。そこで本記事では、SwiftUIを導入する過程で直面した課題や取り組みについて、約2年前から今日に至るまでの画面実装方針の変遷を3つのフェーズに分けながら紹介していきます。
特に長期間運用されているUIKitベースのアプリにSwiftUIを導入したいと考えているiOSエンジニアの皆様にとって、少しでも参考になれば幸いです。
フェーズ1 (2022年10月頃): Compositional Layouts × Diffable Data Source
この時点では、複雑なレイアウトやデータ管理を効率化するためにiOS13から導入されたCompositional LayoutsとDiffable Data Sourceを採用していました。Compositional Layoutsにより、複雑なレイアウトでもセクションごとに設定できるようになりレイアウト実装の柔軟性が高まりました。また、Diffable Data Sourceによりデータの変更を即座にUIに反映できるようになり、データとUIの統一的な管理が可能となりました。
しかし、この時点ではSwiftUIは全く導入しておらずUIKitのみでの開発でした。UIKitは今でもバリバリ現役ですが、SwiftUIと比べてUI実装の速度が劣るケースもあり、後々開発効率などの観点からSwiftUIを導入していきたいという気持ちが強くなっていきました。
フェーズ2 (2023年10月頃): Compositional Layouts × Diffable Data Source × SwiftUI(UIHostingConfiguration)
次のステップとして、SwiftUIを小さくコンポーネント実装に留める形で導入していくことを試みました。
この時点では以下の図のようなイメージです。
具体的には、UICollectionViewCell
内でSwiftUIを利用する方法です。以下のようなUICollectionViewCell
の共通実装を用意して、iOS16以上ではUIHostingConfiguration
、iOS15ではUIHostingController
経由でセル内のUIをSwiftUIで実装していくようになりました。
/// HostingCellの中身となるViewが準拠するプロトコル public protocol HostingCellContent: View { associatedtype Dependency init(_ dependency: Dependency) } open class HostingCell<Content: HostingCellContent>: UICollectionViewCell { // TODO: iOS15サポートを切ったら削除する private let hostingController = UIHostingController<Content?>(rootView: nil) override public init(frame: CGRect) { super.init(frame: frame) // TODO: iOS15サポートを切ったら削除する hostingController.view.backgroundColor = .clear hostingController.view.translatesAutoresizingMaskIntoConstraints = false } @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func configure(_ dependency: Content.Dependency, parent: UIViewController? = nil) { if #available(iOS 16.0, *) { configureWithHostingConfiguration(dependency) } else { configureWithHostingVC(dependency, parent: parent) } } /// iOS16以上ではUIHostingConfigurationを使う func configureWithHostingConfiguration(_ dependency: Content.Dependency) { if #available(iOS 16.0, *) { contentConfiguration = UIHostingConfiguration { Content(dependency) } // デフォルトでマージンが設定されているので0にする .margins(.all, 0) } } // TODO: iOS15サポートを切ったら削除する /// iOS15以下ではUIHostingControllerを使う func configureWithHostingVC(_ dependency: Content.Dependency, parent: UIViewController?) { guard let parent else { return } hostingController.rootView = Content(dependency) hostingController.view.invalidateIntrinsicContentSize() guard hostingController.parent == nil else { return } parent.addChild(hostingController) contentView.addSubview(hostingController.view) NSLayoutConstraint.activate([ hostingController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), hostingController.view.topAnchor.constraint(equalTo: contentView.topAnchor), hostingController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), hostingController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) hostingController.didMove(toParent: parent) } }
セルの呼び出し側はセルの中身がどのように実装されてるかを知る必要はなく、従来のUICollectionViewCell
を用いた開発と同様に実装することができました。この取り組みにより、セル内のUIをSwiftUIで宣言的に記述できるようになり、UI構築の開発速度が向上しました。
フェーズ2のSwiftUI導入時に直面した課題
しかし、フェーズ2のUICollectionViewCell
内にSwiftUIを埋め込む実装の方法だと、いくつかの課題に直面しました。 特に以下の要件を満たすために、実装が複雑化しやすいことが分かりました。
- お気に入り状態を更新する際に、APIの結果を待たずに一時的にUIに反映したい
- ユーザーによるデータ更新操作およびUIへの反映処理が頻繁にある
これらの要件を実現する過程で、次のような問題が浮かび上がりました。
@State
による状態管理が可能なケースが限定される
UIHostingConfiguration
内でSwiftUIビューを使用する際、@State
プロパティを外部から初期化すると問題が発生していました。具体的には、外部から渡された値で@State
を初期化すると、セルの再利用時に状態がリセットされず、データの不整合が発生することがありました。
以下は簡略化したコード例です。
struct ItemView: View { @State private var isFavorite: Bool init(_ isFavorite: Bool) { // 外部から渡された値で@Stateを初期化 _isFavorite = State(initialValue: isFavorite) } var body: some View { // ... } }
UICollectionViewCell
内でこのItemView
を使用すると、セルの再利用時にItemView
のinit
が再度呼ばれますが、@State
の値は初期化されず以前の状態を保持します。そのため、表示とデータの不整合が生じ、意図しないUIの挙動になります。
また、以下のようにSwiftUI内部でisFavorite
を更新した際も、同様にUIに正しいデータが反映されないことがありました。
struct ItemView: View { @State private var isFavorite: Bool init(_ isFavorite: Bool) { // 外部から渡された値で@Stateを初期化 _isFavorite = State(initialValue: isFavorite) } var body: some View { Button(action: { // お気に入り更新APIを呼ぶ前に、スムーズにUIが更新できるように一時的に更新する isFavorite.toggle() // お気に入り更新APIをコール }) { // ... } } }
これらの問題の原因は、@State
がSwiftUIのビューのライフサイクルに基づいて状態を管理するため、外部からの初期化と相性が悪いことにあります。WWDC22にあるUse SwiftUI with UIKit のセッションでは、以下のようにSwiftUIの外で保持されるデータを使用する際は @State
, @StateObject
といったプロパティラッパーは使うべきでないという言及がされています。
To store data that is created and owned by a SwiftUI view, SwiftUI provides the @State and @StateObject property wrappers. Since we're focused on data owned outside of SwiftUI, these property wrappers aren't the right choice.
(値を利用する箇所がSwiftUIの中に閉じているケースでは、UIHostingConfiguration
の中のSwiftUIで @State を利用しても期待通り動きます。)
当時、Pay IDアプリ内の多くの画面はMVPアーキテクチャで構成されていました。Presenterを知っているのはUIViewController
のみです。つまりSwiftUIの外でデータを保持する構成にしていた以上、大半のケースでは@State
を使用することはできませんでした。
SwiftUIビュー内で発生したイベント伝搬とデータ更新伝達の複雑化
セル内のSwiftUIビューでユーザーアクションが発生すると、それをPresenterに伝える必要があります。するとコールバックやデリゲートなど、なんらかの形でUIViewControllerを経由しないといけません。
// イメージ図(諸々簡略化して書いています) アプリユーザー ↓ ユーザーアクション(お気に入りボタンをタップなど) SwiftUI.View (in UICollectionViewCell) ↓ ユーザーアクションの伝達 UIViewController ↓ ユーザーアクションの伝達 Presenter ↓ APIリクエストなどのハンドリング
さらに、Presenterの処理の結果何かしらSwiftUI.Viewの中の表示を変える場合、Presenterの結果をまたSwiftUIビューに伝えないといけません。当然Presenterは直接SwiftUIビューを知ることはありません。そこで以下のようなフローでデータ更新を伝搬することになります。
// イメージ図 Presenter ↓ データ更新の伝達 UIViewController ↓ データ更新の伝達 SwiftUI.View (in UICollectionViewCell) ↓ UIを更新
つなげるとこのようなフローになります。
// イメージ図 アプリユーザー ↓ ユーザーアクション(お気に入りボタンをタップなど) SwiftUI.View (in UICollectionViewCell) ↓ ユーザーアクションの伝達 UIViewController ↓ ユーザーアクションの伝達 Presenter ↓ APIリクエストなどのハンドリング ↓ データ更新の伝達 UIViewController ↓ データ更新の伝達 SwiftUI.View (in UICollectionViewCell) ↓ UIを更新
もちろんこのフローでもアプリを要件通り実装することは可能です。ですが、SwiftUIでUIを書いているとSwiftUIで実現しやすいデータバインディングの機構を取り入れて、上記のようなフローをよりシンプルにしたいと思うようになっていきました。
フェーズ3 (2024年7月頃): SwiftUI × Observationフレームワーク
フェーズ2のUICollectionViewCell
内に埋め込む形でSwiftUIを導入した際に出てきた課題を解決するため、よりSwiftUIベースな実装方針を目指すことを決めました。ここでの構成が、現在新しい画面を実装する時に積極的に取り入れている形です。
SwiftUIとUIKit間の責務分離と整理
まず、状態管理とビジネスロジックをSwiftUI内部で完結させる実装方針に転換しました。また、レイアウトも今まではUIKit(Compositional Layouts)で実装していましたが、この部分もSwiftUIで実装するように変更しました。
UIKitは主に画面遷移を担当します。ナビゲーションバーなど一部UIを担当することもありますが、API通信などのデータ取得もSwiftUI内部(ViewもしくはViewが保持するViewModel)からRepositoryを参照するようになり、SwiftUIとUIKit間のデリゲート処理もかなり減りました。それによりビュー内で発生したイベント伝搬・データ更新の複雑だったフローの見通しも改善されました。
View+コンポーネントは、言わずもがなSwiftUIです。View+画面遷移の部分は、引き続きUIKit(UINavigationController)で行います。View+レイアウトについては、UIHostingControllerを利用してUIViewControllerにSwiftUIで書いたレイアウト実装を埋め込んでいます。
そしてView+ロジック・状態管理については、Swift5.9, iOS17以上で利用できるObservationフレームワークを導入することにしました。
PerceptionによるObservationフレームワークの導入
しかし、Pay IDアプリはまだiOS16.2以上をサポートしています。そこでPoint-Freeが公開してくれている、Observation相当のバックポート実装を提供してくれるswift-perceptionを利用します。swift-perception(以下Perception)ライブラリはiOS17以上であればObservationフレームワークのAPI(withObservationTracking)を呼び出し、iOS17未満ならObservationフレームワークの挙動を模倣した実装が呼ばれます。
SwiftUI上の状態管理において、Perception経由でObservationを導入するか、Combine製のObservableObjectプロトコルを利用するか悩みましたが、ObservableObjectよりもObservationの方が以下の点から使い勝手が良いと判断しました。
- 子ビューの差分更新において不要に親ビューの差分更新が走らない
- 値(クラス)を入れ子にした場合、ネストしたクラスの値の変更を検知できる
Perceptionを利用する場合、追跡したい値を保持するオブジェクトを@Perceptible
クラスとして宣言します。そして@Perceptible
クラスへのアクセスを持つViewをWithPerceptionTracking
Viewでラップします。
import Perception struct HogeScreen: View { let viewModel = HogeViewModel() var body: some View { // WithPerceptionTrackingで囲む必要がある // TODO: iOS16切ったらWithPerceptionTrackingラップを外すだけでいい WithPerceptionTracking { VStack { Text(viewModel.count) Button("Increment") { viewModel.increment() } // ... } } } } @Perceptible // TODO: iOS16切ったら@Observableに変更する @MainActor final class HogeViewModel { var count = 0 func increment() { count += 1 } // ... } // UIHostingControllerでSwiftUI.ViewをUIViewControllerの中身として埋め込む final class HogeViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let vc = UIHostingController(rootView: HogeScreen()) self.addChild(vc) self.view.addSubview(vc.view) vc.didMove(toParent: self) vc.view.translatesAutoresizingMaskIntoConstraints = false vc.view.topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true vc.view.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 0).isActive = true vc.view.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0).isActive = true vc.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0).isActive = true // ... } }
このようにSwiftUI上のデータバインディングが可能になったおかげで、データの変更が発生した際に@Perceptible
クラスの値を更新するだけで済み、UIへの反映がとても楽になりました。
Perception利用時の注意点
Perceptionを使う上で特に重要な注意点の一つとして、ForEach
などViewを返すクロージャーの中で@Perceptible
な値を参照する場合は、クロージャーの中でも別途WithPerceptionTracking
で囲む必要があります。囲っていないと、値の更新があってもクロージャー内のViewには反映されません。
struct HogeScreen: View { let viewModel = HogeViewModel() var body: some View { WithPerceptionTracking { ForEach(Array(hogeViewModel.items.enumerated()), id: \\.offset) { index, item in // WithPerceptionTracking { // ❌ hogeViewModel.itemsの一部のnameが更新されても変更を追跡できていない Text(item.name) // } } } } } @Perceptible final class HogeViewModel { /* ... */ }
その理由は、ForEach
のクロージャーがViewのbody本体と同じタイミングで呼び出されるわけでなく、Viewのbody本体が呼び出された後にForEach
クロージャーが呼ばれることにあります。そのためForEach
自体を囲ったWithPerceptionTracking
の中にはForEach
クロージャーの中身のViewは含まれていないので、@Perceptible
のプロパティを追跡するためのViewの登録処理が動いていないのです。
以下のように、ForEachクロージャーの中もWithPerceptionTracking囲っていれば追跡可能です。
struct HogeScreen: View { let viewModel = HogeViewModel() var body: some View { WithPerceptionTracking { ForEach(Array(hogeViewModel.items.enumerated()), id: \\.offset) { index, item in // ForEachのクロージャーの中もWithPerceptionTrackingで囲ってるので、 // 値の変更を追跡できてる // ⭕️ WithPerceptionTracking { Text(item.name) } } } } } @Perceptible final class HogeViewModel { /* ... */ }
また、自作のViewにViewBuilderを渡す際も注意が必要です。
以下のコードだと、CustomContainerに渡すViewBuilderの中身がWithPerceptionTracking
で囲まれていません。body本体が呼び出されるタイミングではViewBuilderの中はまだ呼び出されてないので、Text(hogeViewModel.count)
の箇所はbody直下のWithPerceptionTracking
の範囲外になってしまいます。
// ViewBuilderを渡す側 struct ContentView: View { let hogeViewModel = HogeViewModel() var body: some View { WithPerceptionTracking { CustomContainer(title: "Custom Container") { // ViewBuilderの中身 Text(hogeViewModel.count) } } } } // ViewBuilderを受け取る側 struct CustomContainer<Content: View>: View { let title: String let content: () -> Content init(title: String, @ViewBuilder content: @escaping () -> Content) { self.title = title self.content = content } var body: some View { VStack() { Text(title) content() .padding() } } } @Perceptible final class HogeViewModel { /* ... */ }
なので先ほどと同様に、ViewBuilderの中身もWithPerceptionTrackingで囲む必要があります。
// ViewBuilderを渡す親View struct ContentView: View { let hogeViewModel: HogeViewModel var body: some View { WithPerceptionTracking { CustomContainer(title: "Custom Container") { // ViewBuilderの中身をWithPerceptionTrackingで囲む WithPerceptionTracking { Text(hogeVieModel.count) } } } } }
おわりに
本記事では、古くからあるUIKitベースのiOSアプリにおいてSwiftUIを導入する過程で直面した課題や取り組みについて紹介させていただきました。
前述した通り現在のPay IDアプリのサポートバージョンはiOS 16.2以上であり、次回のサポートバージョン整理のタイミングでiOS17以上のサポートとする可能性があります。その時はPerceptionから純粋なObservationへの移行もあるので、その取り組みの中で新たな気づきがあればまたどこかで紹介させていただければと思います。
また、新規実装はもちろんのこと、古いUIKitベースの画面に対してSwiftUIベースな実装方針に基づいてリアーキテクチャなどもチームで進めていきたいと思います。
最後になりますが、Pay IDでは随時メンバーを募集しています。ご興味のある方は気軽にご応募ください!