Upgrade to Pro — share decks privately, control downloads, hide ads and more …

iOSDC21

Akifumi Fukaya
September 17, 2021

 iOSDC21

SwiftUIで作ったアプリを1年間運用してみてわかったこと

Akifumi Fukaya

September 17, 2021
Tweet

More Decks by Akifumi Fukaya

Other Decks in Technology

Transcript

  1. • 事業紹介 • アーキテクチャ • SwiftUI 活用事例 • Combine 活用事例

    • 開発中に問題となった点 • まとめ 2 アジェンダ
  2. 4

  3. 6 カウシェの歴史 2020.9 iOS版 リリース 2020.10 Apple Pay リリース 2020.11

    Android版 リリース 2020.12 購入履歴 リリース 2021.1 CRM 強化 2021.2 Instagram シェア 2021.3 商品検索 リリース 2021.4 会員機能 2021.5 アプリ内 シェア買い 2021.6 カテゴリー
  4. • iOS 13.0+ 対応 ◦ リリース当時 2020年9月 の最新OSバージョンは iOS 13

    だった ◦ iOS 13 のシェア率は90%以上 • SwiftUI, UIKit ミックス ◦ 可能な限り SwiftUI を使用 ◦ どうしても UIKit でしか実現できないところは UIKit を使用 • アーキテクチャは MVVM を使用 8 カウシェ for iOS の中身
  5. 16 商品詳細画面の構成 ProductDetailView ProductDetailViewModel ImageScrollView PriceView TitleView ︙ ImageListData PriceData

    TitleData View と Data を1対1で 対応させて更新 DescriptionView GroupRequirementsView DescriptionData GroupRequirementsData ︙
  6. 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 = "" } … 17 ProductDetailViewModel.swift
  7. 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() … 18 ProductDetailViewModel.swift
  8. 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() } … 19 ProductDetailViewModel.swift
  9. 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) } } 20 ProductDetail.swift
  10. 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) } } } } 21 ProductDetailTitleView.swift
  11. 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> } 26 ProductRepository.swift
  12. 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 } … 27 ProductRepositoryImpl.swift
  13. 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))) } } } } 28 ProductRepositoryImpl.swift
  14. 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() } } 29 ProductRepository.swift
  15. 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 } … } 30 ProductDetailUseCase.swift
  16. • NavigationLink.destination に設定した View は NavigationLink が読み込ま れると同時に初期化されてしまう • カウシェで実践しているMVVMの場合、destination

    の View が初期化されると きに同時に ViewModel も初期化される • そのため、多くの不要なインスタンスが生成されてしまう • 不要なインスタンスの初期化を防ぐために LazyView を導入している • 参考: https://gist.github.com/chriseidhof/d2fcafb53843df343fe07f3c0dac41d5 34 NavigationLink.destination に設定した View が表示前に初期化されてしまう
  17. import SwiftUI struct LazyView<Content: View>: View { let build: ()

    -> Content init(_ build: @autoclosure @escaping () -> Content) { self.build = build } var body: Content { build() } } 35 LazyView.swift 参考: https://gist.github.com/chriseidhof/d2fcafb53843df343fe07f3c0dac41d5
  18. 36 LazyView 導入 struct ContentView: View { var body: some

    View { NavigationLink( destination: ProductDetail(viewModel: .init()), label: { EmptyView() }) } } struct ContentView: View { var body: some View { NavigationLink( destination: LazyView( ProductDetail(viewModel: .init()) ), label: { EmptyView() }) } } • LazyView を導入することで、 NavigationLink.destination で設定 している View の初期化を表示時に する • View の初期化を表示時にすること で、ViewModel, UseCase, Repository, ApiClient などを必要な タイミングで初期化 • そうすることで、不要なインスタンス 生成を防ぐ
  19. • アプリ内でウェブコンテンツを表示する方法として、 SFSafariViewControler が ある • SFSafariViewController は UIViewController を継承しているクラスであるた

    め、 SwiftUI 内で使用するには UIViewControllerRepresentable でラップして 使用する必要がある • NavigationLink と SFSafariViewController を組み合わせて使用すると相性が 悪い 38 NavigationLink と SafariVC の相性が悪い
  20. 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) { } } 39 SafariView.swift
  21. import SwiftUI struct Setting: View { var body: some View

    { List { Section(header: Text("サービスについて")) { NavigationLink( destination: LazyView(SafariView(url: URL.kc.tosURL)), label: { Text("利用規約") }) NavigationLink( destination: LazyView(SafariView(url: URL.kc.privacyURL)), label: { Text("プライバシーポリシー") }) NavigationLink( destination: LazyView(SafariView(url: URL.kc.tokushohoURL)), label: { Text("特定商取引法に基づく表示") }) } } } } 40 Setting.swift
  22. • NavigationLink ではなく sheet を使用して、モーダルとして表示する • SFSafariViewController ではなく、WKWebView を使用してウェブコンテンツを 表示する

    • 遷移元の画面を UIViewController で実装し、 UIViewController.present(_:animated:completion:) で表示する 41 NavigationLink と SafariVC の相性が悪いの回避方法
  23. • NavigationLink ではなく sheet を使用して、モーダルとして表示する • SFSafariViewController ではなく、WKWebView を使用してウェブコンテンツを 表示する

    • 遷移元の画面を UIViewController で実装し、 UIViewController.present(_:animated:completion:) で表示する 42 NavigationLink と SafariVC の相性が悪いの回避方法 カウシェでは仕方なく設定画面を UIViewController で書き直しました🥲
  24. • EnvironmentObject は、親Viewで値を定義し、子Viewで値の取得や変更がで きる仕組み • カウシェでは、EnvironmentObject 使用して画面遷移やフルスクリーンポップ アップ表示しようと試みた • しかし、Viewが複数回更新されたり、意図通りではない画面遷移が発生したた

    め、導入を断念した • カウシェでは、QA中に意図しない画面遷移や画面が真っ白になる現象が発生 し、リバートして EnvironmentObject を使用しないことにしました • EnvironmentObject を活用する際は入念にQAすることをオススメします 45 EnvironmentObject で View がリロードされてしまう問題
  25. • カウシェでの、MVVM, SwiftUI, Combine の活用事例を紹介 • SwiftUI で開発したアプリを運用している中での課題を共有 ◦ NavigationLink.destination

    の View が表示前に初期化されてしまう ◦ NavigationLink と SFSafariViewController の相性が悪い ◦ EnvironmentObject で SwiftUI.View が何回も読み込まれてしまい、導入を断念 46 まとめ