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

iOSDC21

Ee4f2266abb0268305431dbbcade59d2?s=47 Akifumi Fukaya
September 17, 2021

 iOSDC21

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

Ee4f2266abb0268305431dbbcade59d2?s=128

Akifumi Fukaya

September 17, 2021
Tweet

Transcript

  1. SwiftUIで作ったアプリを 1年間運用してみてわ かったこと 株式会社カウシェ 深谷哲史 1

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

    • 開発中に問題となった点 • まとめ 2 アジェンダ
  3. 事業紹介 3

  4. 4

  5. カウシェの歴史 5

  6. 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 カテゴリー
  7. カウシェ for iOS の中身 7

  8. • iOS 13.0+ 対応 ◦ リリース当時 2020年9月 の最新OSバージョンは iOS 13

    だった ◦ iOS 13 のシェア率は90%以上 • SwiftUI, UIKit ミックス ◦ 可能な限り SwiftUI を使用 ◦ どうしても UIKit でしか実現できないところは UIKit を使用 • アーキテクチャは MVVM を使用 8 カウシェ for iOS の中身
  9. アーキテクチャ 9

  10. 10 MVVM ViewModel View Model

  11. 11 MVVM > 商品詳細画面 ProductDetail ViewModel ProductDetail View ProductDetail View

    ViewModel Model
  12. カウシェでの SwiftUI 活用事例 12

  13. 13 商品詳細画面の構成

  14. 14 商品詳細画面の構成 ProductDetailView

  15. 15 商品詳細画面の構成 ImageScrollView PriceView TitleView GroupRequirementsView PurchaseFooter

  16. 16 商品詳細画面の構成 ProductDetailView ProductDetailViewModel ImageScrollView PriceView TitleView ︙ ImageListData PriceData

    TitleData View と Data を1対1で 対応させて更新 DescriptionView GroupRequirementsView DescriptionData GroupRequirementsData ︙
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. カウシェでの Combine 活用事例 22

  23. • 主にAPI通信でデータを取得する箇所で使用 • ViewModel <-> UseCase <-> Repository 間のやりとりで Combine

    を活用 23 Combine の活用事例
  24. 24 Combine UseCase ViewModel Repository

  25. 25 Combine ProductDetail UseCase ProductDetail ViewModel Product Repository Group Repository

  26. 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. SwiftUI アプリの開発中に 問題となった点 31

  32. • NavigationLink.destination に設定した View が表示前に初期化されてしまう • NavigationLink と SFSafariViewController の相性が悪い

    • EnvironmentObject で View が何回も読み込まれてしまう 32 課題点
  33. • NavigationLink.destination に設定した View が表示前に初期化されてしまう • NavigationLink と SFSafariViewController の相性が悪い

    • EnvironmentObject で View が何回も読み込まれてしまう 33 課題点
  34. • NavigationLink.destination に設定した View は NavigationLink が読み込ま れると同時に初期化されてしまう • カウシェで実践しているMVVMの場合、destination

    の View が初期化されると きに同時に ViewModel も初期化される • そのため、多くの不要なインスタンスが生成されてしまう • 不要なインスタンスの初期化を防ぐために LazyView を導入している • 参考: https://gist.github.com/chriseidhof/d2fcafb53843df343fe07f3c0dac41d5 34 NavigationLink.destination に設定した View が表示前に初期化されてしまう
  35. 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
  36. 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 などを必要な タイミングで初期化 • そうすることで、不要なインスタンス 生成を防ぐ
  37. • NavigationLink.destination に設定した View が表示前に初期化されてしまう • NavigationLink と SFSafariViewController の相性が悪い

    • EnvironmentObject で View が何回も読み込まれてしまう 37 課題点
  38. • アプリ内でウェブコンテンツを表示する方法として、 SFSafariViewControler が ある • SFSafariViewController は UIViewController を継承しているクラスであるた

    め、 SwiftUI 内で使用するには UIViewControllerRepresentable でラップして 使用する必要がある • NavigationLink と SFSafariViewController を組み合わせて使用すると相性が 悪い 38 NavigationLink と SafariVC の相性が悪い
  39. 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
  40. 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
  41. • NavigationLink ではなく sheet を使用して、モーダルとして表示する • SFSafariViewController ではなく、WKWebView を使用してウェブコンテンツを 表示する

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

    • 遷移元の画面を UIViewController で実装し、 UIViewController.present(_:animated:completion:) で表示する 42 NavigationLink と SafariVC の相性が悪いの回避方法 カウシェでは仕方なく設定画面を UIViewController で書き直しました🥲
  43. 43 NavigationLink と SafariVC の相性が悪いの回避方法

  44. • NavigationLink.destination に設定した View が表示前に初期化されてしまう • NavigationLink と SFSafariViewController の相性が悪い

    • EnvironmentObject で View が何回も読み込まれてしまう 44 課題点
  45. • EnvironmentObject は、親Viewで値を定義し、子Viewで値の取得や変更がで きる仕組み • カウシェでは、EnvironmentObject 使用して画面遷移やフルスクリーンポップ アップ表示しようと試みた • しかし、Viewが複数回更新されたり、意図通りではない画面遷移が発生したた

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

    の View が表示前に初期化されてしまう ◦ NavigationLink と SFSafariViewController の相性が悪い ◦ EnvironmentObject で SwiftUI.View が何回も読み込まれてしまい、導入を断念 46 まとめ
  47. 47 世界一楽しい ショッピング体験を つくる

  48. 最後に 48

  49. • 「世界一楽しいショッピング体験をつくる」というビジョンの実現を一緒目指してく れる仲間を募集しています • 正社員、副業、インターンなど、様々なポジションがありますので、興味のある方 はご連絡ください • ホームページ https://about.kauche.com/#recruit •

    Wantedly https://www.wantedly.com/companies/kauche 最後に 49