Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

● 事業紹介 ● アーキテクチャ ● SwiftUI 活用事例 ● Combine 活用事例 ● 開発中に問題となった点 ● まとめ 2 アジェンダ

Slide 3

Slide 3 text

事業紹介 3

Slide 4

Slide 4 text

4

Slide 5

Slide 5 text

カウシェの歴史 5

Slide 6

Slide 6 text

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 カテゴリー

Slide 7

Slide 7 text

カウシェ for iOS の中身 7

Slide 8

Slide 8 text

● iOS 13.0+ 対応 ○ リリース当時 2020年9月 の最新OSバージョンは iOS 13 だった ○ iOS 13 のシェア率は90%以上 ● SwiftUI, UIKit ミックス ○ 可能な限り SwiftUI を使用 ○ どうしても UIKit でしか実現できないところは UIKit を使用 ● アーキテクチャは MVVM を使用 8 カウシェ for iOS の中身

Slide 9

Slide 9 text

アーキテクチャ 9

Slide 10

Slide 10 text

10 MVVM ViewModel View Model

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

13 商品詳細画面の構成

Slide 14

Slide 14 text

14 商品詳細画面の構成 ProductDetailView

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

16 商品詳細画面の構成 ProductDetailView ProductDetailViewModel ImageScrollView PriceView TitleView ︙ ImageListData PriceData TitleData View と Data を1対1で 対応させて更新 DescriptionView GroupRequirementsView DescriptionData GroupRequirementsData ︙

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

struct ProductDetailView: 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

Slide 21

Slide 21 text

extension ProductDetailView { struct TitleView: View { @ObservedObject var data: TitleData var body: some View { ProductDetailTitleView(data: data) } } } private struct ProductDetailTitleView: 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

Slide 22

Slide 22 text

カウシェでの Combine 活用事例 22

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

24 Combine UseCase ViewModel Repository

Slide 25

Slide 25 text

25 Combine ProductDetail UseCase ProductDetail ViewModel Product Repository Group Repository

Slide 26

Slide 26 text

import Combine protocol ProductRepository: AnyObject { typealias GetProductResponse = ProductRepositoryImpl.GetProductResponse typealias Error = ProductRepositoryImpl.ProductRepositoryError func getProduct(productId: String, completion: @escaping (Result) -> Void) func getProduct(productId: String) -> AnyPublisher } 26 ProductRepository.swift

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

import Combine final class ProductRepositoryImpl: ProductRepository, Instantiatable { … // MARK: - ProductRepository enum ProductRepositoryError: Error { case getProductFailed(Error) } func getProduct(productId: String, completion: @escaping (Result) -> 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

Slide 29

Slide 29 text

extension ProductRepository { func getProduct(productId: String) -> AnyPublisher { 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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

SwiftUI アプリの開発中に 問題となった点 31

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

● NavigationLink.destination に設定した View は NavigationLink が読み込ま れると同時に初期化されてしまう ● カウシェで実践しているMVVMの場合、destination の View が初期化されると きに同時に ViewModel も初期化される ● そのため、多くの不要なインスタンスが生成されてしまう ● 不要なインスタンスの初期化を防ぐために LazyView を導入している ● 参考: https://gist.github.com/chriseidhof/d2fcafb53843df343fe07f3c0dac41d5 34 NavigationLink.destination に設定した View が表示前に初期化されてしまう

Slide 35

Slide 35 text

import SwiftUI struct LazyView: 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

Slide 36

Slide 36 text

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 などを必要な タイミングで初期化 ● そうすることで、不要なインスタンス 生成を防ぐ

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

● アプリ内でウェブコンテンツを表示する方法として、 SFSafariViewControler が ある ● SFSafariViewController は UIViewController を継承しているクラスであるた め、 SwiftUI 内で使用するには UIViewControllerRepresentable でラップして 使用する必要がある ● NavigationLink と SFSafariViewController を組み合わせて使用すると相性が 悪い 38 NavigationLink と SafariVC の相性が悪い

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

43 NavigationLink と SafariVC の相性が悪いの回避方法

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

● EnvironmentObject は、親Viewで値を定義し、子Viewで値の取得や変更がで きる仕組み ● カウシェでは、EnvironmentObject 使用して画面遷移やフルスクリーンポップ アップ表示しようと試みた ● しかし、Viewが複数回更新されたり、意図通りではない画面遷移が発生したた め、導入を断念した ● カウシェでは、QA中に意図しない画面遷移や画面が真っ白になる現象が発生 し、リバートして EnvironmentObject を使用しないことにしました ● EnvironmentObject を活用する際は入念にQAすることをオススメします 45 EnvironmentObject で View がリロードされてしまう問題

Slide 46

Slide 46 text

● カウシェでの、MVVM, SwiftUI, Combine の活用事例を紹介 ● SwiftUI で開発したアプリを運用している中での課題を共有 ○ NavigationLink.destination の View が表示前に初期化されてしまう ○ NavigationLink と SFSafariViewController の相性が悪い ○ EnvironmentObject で SwiftUI.View が何回も読み込まれてしまい、導入を断念 46 まとめ

Slide 47

Slide 47 text

47 世界一楽しい ショッピング体験を つくる

Slide 48

Slide 48 text

最後に 48

Slide 49

Slide 49 text

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