SwiftUIを使ったアプリを1年運用してみてわかったこと
(本記事は、2021年9月にiOSDC Japan 2021で発表した「SwiftUIで使ったアプリを1年運用してみてわかったこと」のトークセッションを文字起こししたものです。実際のセッション動画はこちら)
こんにちは!カウシェの@akifumiです。
SwiftUIが発表され早2年が経ちました。
そんなSwiftUIですが、本番アプリで採用されているケースはまだまだ少ないかと思います。
そんな中、積極的に新しい技術を取り入れようと、私たちが2020年9月にリリースした、シェア買いアプリ「カウシェ」は、大部分をSwiftUI, Combineを活用して作っています。
今日は、リリースから1年運用した今、
SwiftUI, Combineを活用し、どのようなアーキテクチャで開発・運用しているのか
SwiftUIで開発中に問題となった点
運用している中でわかった良かった点・悪かった点
などを紹介したいと思います。
なお、カウシェは、昨年2020年9月にMVPとしてiOS版のアプリをリリースし、その後、細かく改善を繰り返して行きながら、毎月新しい機能をリリースしています。
カウシェ for iOS の中身
カウシェiOS版アプリの中身は、iOS 13以上に対応しています。リリース当時の2020年9月の最新OSバージョンがiOS 13であったことと、市場のシェアが90%以上であったことから、iOS 13をサポートしています。
そして技術ですが、SwiftUIとUIKitをミックスして使用しています。可能な限りSwiftUIを使用していますが、どうしてもUIKitでしか実現できないところがあるため、そういったところはUIKitを使用しています。
アーキテクチャ
アーキテクチャはMVVMを使用しています。
MVVMでは、ViewとModelの間にViewModelという層を作成します。Viewの表示ロジックをViewModelに委譲して、Viewは表示するオブジェクトだけを記述します。そうすることでViewのコードをより完結でシンプルにすることができます。
また、ViewModelに対して単体テストが書きやすいこともメリットの一つです。カウシェではMVVMアーキテクチャを導入して開発しています。
具体例をあげてみましょう。
例えば、商品詳細画面の例です。ProductDetailViewというSwiftUI.Viewのクラスがあって、ProductDetailというModelのクラスがあります。その間にProductDetailViewModelというクラスを作成しています。ProductDetailViewModelに表示するロジックを記述して、ViewModelに対してテストを書くことで品質を高めています。
カウシェでのSwiftUIの活用事例
先ほどの例と同様に、商品詳細画面です。
これ自体がProductDetailViewというSwiftUI.Viewになっています。
その中に子Viewとして、画像表示部分のImageScrollView、価格表示部分のPriceView、それから商品名や事業者名みたいなところでTitleViewを表示しています。カウシェの場合、複数人で同じ商品を買うグループを作成するのですが、それに必要な条件であるGroupRequirementsViewや購入のフッターであるPurchaseFooterという子Viewを用意したりしています。
先ほど説明したProductDetailViewの中に子Viewのクラスをそれぞれ作成しています。そしてViewModel側には、Viewに表示する内容のDataクラスを作成して、一対一で対応させています。
Dataが更新されると、SwiftUIのViewが自動的に更新されるという設計にしています。
final class ProductDetailViewModel: ObservableObject {
// MARK: - Data
final class ImageListData: ObservableObject {
var index: Int = 0
fileprivate(set) var images: [ProductImage] = []
}
final class PriceData: ObservableObject {
var price: String = ""
var referencePrice: String = ""
var discountRate: String?
}
final class TitleData: ObservableObject {
var title: String = ""
var vendor: String = ""
}
final class DescriptionData: ObservableObject {
var description: String = ""
}
final class GroupRequirementsData: ObservableObject {
var lowerLimitUserCountText: String = ""
}
…
}
こちらがProductDetailViewModelの例です。
ViewModel自体はObservableObjectを適用することで、SwiftUIのViewバインドするようにしています。また子Viewに対応した各Dataを宣言しています。
それぞれのData自身はViewで表示する内容を定義しています。さらに、それらがすべてObservableObjectを適用してViewに対して一対一でバインドするという形です。
final class ProductDetailViewModel: ObservableObject {
…
// MARK: - Outputs
let imageListData: ImageListData = ImageListData()
let priceData: PriceData = PriceData()
let titleData: TitleData = TitleData()
let descriptionData: DescriptionData = DescriptionData()
let groupRequirementsData: GroupRequirementsData = GroupRequirementsData()
@Published private(set) var isFooterPresented: Bool = true
let footerData: PurchaseFooterData = PurchaseFooterData()
…
}
こちらはViewModelからSwiftUI.ViewへのOutputの定義です。
先ほど各Dataの定義がありましたが、それをインスタンス化しています。
final class ProductDetailViewModel: ObservableObject {
…
// MARK: - Inputs
private(set) lazy var onAppear: () -> Void = { [weak self] in
guard let self = self else { return }
// 商品詳細情報を取得
self.reload()
}
private(set) lazy var onPurchaseButtonTap: () -> Void = { [weak self] in
guard let self = self else { return }
// 購入処理を開始
self.purchase()
}
…
}
こちらはInputの部分です。
SwiftUI.ViewからViewModelに対して何かしらのInputを行う入り口を宣言しています。例えば「画面が表示されました」や「購入ボタンが押されました」といった宣言をしています。
struct ProductDetailView<ViewModel: ProductDetailViewModel>: View {
@ObservedObject var viewModel: ViewModel
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
ImageScrollView(data: viewModel.imageListData, geometry: geometry)
.frame(width: geometry.size.width, height: geometry.size.width)
ProductDetail.PriceView(data: viewModel.priceData)
ProductDetail.TitleView(data: viewModel.titleData)
ProductDetail.GroupRequirementsView(data: viewModel.groupRequirementsData)
Divider()
.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
ProductDetail.DescriptionView(data: viewModel.descriptionData)
}
}
if viewModel.isFooterPresented {
PurchaseFooter(data: viewModel.footerData, onButtonTap: viewModel.onPurchaseButtonTap)
.frame(height: Layout.Footer.height)
}
}
}
.onAppear(perform: viewModel.onAppear)
}
}
こちらはProductDetailViewというSwiftUI.Viewに対してObservedObjectでViewModelを宣言しています。その中で子Viewたちが定義されていて、それに対してViewModel側で定義した各Dataを一対一で送る形にしています。
また画面が表示されたところで onAppear だったり、購入ボタンがタップされたところで onPurchaseButtonTap をinputとしてViewModelにも渡すようにしています。
extension ProductDetailView {
struct TitleView<TitleData: ProductDetailViewModel.TitleData>: View {
@ObservedObject var data: TitleData
var body: some View {
ProductDetailTitleView(data: data)
}
}
}
private struct ProductDetailTitleView<TitleData: ProductDetailViewModel.TitleData>: View {
@ObservedObject var data: TitleData
var body: some View {
VStack {
HStack {
Text(data.title)
.font(.headline)
.fontWeight(.bold)
Spacer(minLength: 0)
}
HStack {
Text(data.vendor)
.font(.caption)
Spacer(minLength: 0)
}
}
}
}
こちらは商品詳細のタイトルのビューの例になっています。
ProductDetailViewに対して全ての子ビューを定義していくことも可能ですが、そうすると一つのSwiftUIのViewのファイルがかなり大きくなってしまう問題があります。
SwiftUIのViewが大きくなった時にはファイルを分割して小さくできるようにしています。
こちらの例で言いますと、商品名と事業者名の部分をProductDetailTitleViewというprivateのstructを用意しています。
その中で商品名だったり、事業者名が表示されるというViewを用意していてProductDetailViewのextensionとしてTitleViewを宣言することによって、ProductDetailViewからはTitleViewだけが見える、かつファイルを分割して、小さくしていける工夫をしています。
カウシェでの Combine 活用事例
活用事例として、主にAPI通信でデータを取得する箇所で使用しています。
ViewModel <-> UseCase <-> Repository 間のやりとりでCombineを活用しています。
例ですが、ViewModelからはUseCaseとやりとりを行い、UseCaseからは複数のRepositoryとやりとりをする構成にしています。
商品詳細の例ですが、ProductDetailViewModelからProductDetailUseCaseとやりとりを行い、ProductDetailUseCaseはProductRepository・GroupRepositoryとやりとりを行い必要な情報を取得したり、使いやすいように加工したりしてViewModelに返すという構成にしています。
import Combine
protocol ProductRepository: AnyObject {
typealias GetProductResponse = ProductRepositoryImpl.GetProductResponse
typealias Error = ProductRepositoryImpl.ProductRepositoryError
func getProduct(productId: String, completion: @escaping (Result<GetProductResponse, Error>) -> Void)
func getProduct(productId: String) -> AnyPublisher<GetProductResponse, Error>
}
こちらは、ProductRepositoryの例になります。
Repository自体はプロトコルで定義をしています。getProductという関数で商品情報を取得するのですが、一つcompletionのclosureで関数を定義していて、こちらが実際にAPIコールをして商品情報を取得してくる形です。
closureで定義することによってモックなどもしやすくなり、テストがより書きやすい形にしています。もう一つの関数としてはAnyPublisherを返すものになっていて、こちらがCombineの例になっています。
実際のProductRepositoryの実装部分はProductRepositoryプロトコルを適用しています。
import Combine
final class ProductRepositoryImpl: ProductRepository, Instantiatable {
// MARK: - Instantiatable
struct Arguments {
let apiClient: ApiClientType
init(apiClient: ApiClientType) {
self.apiClient = apiClient
}
}
private let apiClient: ApiClientType
init(with arguments: Arguments) {
self.apiClient = arguments.apiClient
}
…
}
例えばこのクラスはAPI通信を行って商品情報を取得するので、DependencyInjectionでApiClientを受け取るようにしています。
import Combine
final class ProductRepositoryImpl: ProductRepository, Instantiatable {
…
// MARK: - ProductRepository
enum ProductRepositoryError: Error {
case getProductFailed(Error)
}
func getProduct(productId: String, completion: @escaping (Result<GetProductResponse, ProductRepositoryError>) -> Void) {
let request = GetProductRequest(productId: productId)
apiClient.getProduct(request: request) { result in
switch result {
case .success(let response):
completion(.success(response))
case .failure(let error):
completion(.failure(.getProductFailed(error)))
}
}
}
}
APIを通して商品情報を取得し、completionのclosureの方で商品のオブジェクトを返す実装にしています。
extension ProductRepository {
func getProduct(productId: String) -> AnyPublisher<GetProductResponse, Error> {
return Future { [weak self] promise in
self?.getProduct(productId: productId) { result in
switch result {
case .success(let response):
promise(.success(response))
case .failure(let error):
promise(.failure(error))
}
}
}.eraseToAnyPublisher()
}
}
さらにProductRepositoryに対してのextensionとしてAnyPublisherの関数を定義しています。こちらはFeatureで先ほど作成したgetProductのcompletionのclosureを中でコールしPromiseでresponseを返すという形にしています。
そうすることで、extensionとして定義しているので、毎回同じ実装を書かなくても、AnyPublisherとして返せるようにしています。先ほどcompletionの方でAPI通信を行っていたのですが、モックの方はダミーのResponseを返すことでテストが容易に書けるようにしています。
import Combine
final class ProductDetailUseCase: Instantiatable {
// MARK: - Instantiatable
struct Arguments {
let productRepository: ProductRepository
let groupRepository: GroupRepository
}
private let productRepository: ProductRepository
private let groupRepository: GroupRepository
init(with arguments: Arguments) {
self.productRepository = arguments.productRepository
self.groupRepository = arguments.groupRepository
}
…
}
最後はProductDetailUseCaseの例ですが、こちらは複数のRepositoryを受け取るようになっています。例えば直列でグループ情報と商品情報を取得したり、それぞれ並列でリクエストした後に中身を合わせてマージして返すために、UseCaseが間にいるという構成にしています。
SwiftUI アプリの開発中に問題となった点
ここからはSwiftUIのアプリの開発をしていて課題になった点をご紹介したいと思っています。今日は三点ご共有します。
1. NavigationLink.destination に設定した View が表示前に初期化されてしまう
NavigationLink.destinationですが、設定したViewは、NavigationLinkが読み込まれると同時に初期化されてしまうという仕様があります。カウシェで実践しているMVVMの場合、destinationのViewが初期化されてしまうと、同時にViewModelも初期化されてしまいます。さらに先ほど説明したUseCase、その奥のRepository、そしてさらにApiClientまで初期化されてしまい、多くの不要なインスタンスが生成されてしまうという問題がありました。
不要なインスタンスの初期化を防ぐために、Chris EidhofさんがGistで公開していたLazyViewを導入しています。
LazyViewの実装ですがBodyのところでbuild関数が実行されるようになっているので、実際に表示されたタイミングでViewが初期化されるようになっています。
struct ContentView: View {
var body: some View {
NavigationLink(
destination: LazyView(
ProductDetail(viewModel: .init())
),
label: {
EmptyView()
})
}
}
例ですが、商品詳細のProductDetailをNavigationLinkのdestinationにそのまま入れるのではなく、LazyViewかますことで初期化を遅らせています。
そうすることで、Viewの初期化を表示タイミングにすることができるので、ViewModel・UseCase・Repository・ApiClientなど関係するインスタンスも必要なタイミング(表示されたタイミング)で初期化されるという形になっています。そうすることで、不要なインスタンス生成を防いでいます。
2. NavigationLink と SFSafariViewController の相性が悪い
アプリの中でウェブコンテンツを表示する方法としてSFSafariViewControllerというものがあると思います。SFSafariViewControllerは、UIViewControllerを継承しているクラスであるので、SwiftUI内で使用するにはUIViewControllerRepresentableをラップして使用する必要があります。NavigationLinkとSFSafariViewControllerの組み合わせが悪かったのでご紹介します。
import SwiftUI
import SafariServices
struct SafariView: UIViewControllerRepresentable {
typealias UIViewControllerType = SFSafariViewController
var url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
return SFSafariViewController(url: url)
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
}
}
その前に少し説明を挟みますが、SwiftUIでSFSafariViewControllerを使用する場合の例です。SafariViewというものを定義してUIViewControllerRepresentableを適応してmakeUIViewControllerでSFSafariViewControllerのInstanceを生成して返すというものになります。
設定画面のマイページの例ですが、NavigationLinkでdestinationにSafariViewを設定して返すようにしていました。
そうすると画像の右側に表示しているのですが、NavigationがBarが二重で表示されてしまう問題がありました。
こちらの資料を作成している中で調査していて分かったのですが、UINavigationControllerにSFSafariViewControllerをプッシュ遷移させても同様の現象になったので、もともとSFSafariViewControllerとナビゲーションのプッシュ遷移の相性が悪いという形でした。
こちらの回避方法ですが、NavigationLinkではなくシートを使ってモーダルとして下からせり上がっていく形のものを表示するっていうのも一つありますし、SFSafariViewControllerをやめて、WKWebViewでウェブコンテンツを表示するという方法もあるんじゃないかなと思います。
もう一つがUIViewControllerで遷移元を実装し、UIViewController.present(_:animated:completion:) で表示する方法です。
リリース直前だったっていうこともあったり、あまり調査する時間がなかったので、一つ前の画面をUIViewControllerで書き直しました。
そうすることで前のページのUIViewControllerに対してUIViewController.present(_:animated:completion:) でSFSafariViewControllerを表示すると、NavigationBarが二重で表示されるという問題は回避することができました。
3. EnvironmentObject で View が何回も読み込まれてしまう
EnvironmentObjectは親Viewで値を定義して子Viewで値の取得や編集・変更ができるという仕組みになっています。カウシェではEnvironmentObjectを使用して画面遷移だったり、フルスクリーンポップアップ表示をしようと試みました。
しかし、Viewが複数回更新されたり、意図通りではない画面遷移が発生し、画面が真っ白になる問題が発生し、リバートしてEnvironmentObjectを使用しない形に変更しました。
EnvironmentObjectを活用する際は、入念にQAすることをオススメします。
カウシェの場合はiOS 13以上で使おうとして問題に直面したのですが、iOS 14以上ではStateObjectが出てきたので、そちらと組み合わせるとまた違った挙動になるかなと思っているので、こちらはトライしたいと思っています。
まとめ
今回は、MVVM, SwiftUI, Combine の活用事例をご紹介させていただきました。
1年間、2020年の9月から2021年の9月までSwiftUIを活用したアプリケーションの開発・運用をしている中で、直面した課題の共有を3点させていただきました。
本記事と合わせて、iOSDC2021のセッションから半年後のカウシェについては以下の記事をぜひご一読いただけると嬉しいです。
カウシェは「世界一楽しいショッピング体験をつくる」をビジョンに、カウシェの開発・運用をしています。
少しでも気になった方は、ぜひ下記よりエントリーいただければと思います。
この記事が気に入ったらサポートをしてみませんか?