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で作ったアプリを
    1年間運用してみてわ
    かったこと
    株式会社カウシェ 深谷哲史
    1

    View Slide

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

    View Slide

  3. 事業紹介
    3

    View Slide

  4. 4

    View Slide

  5. カウシェの歴史
    5

    View Slide

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

    View Slide

  7. カウシェ for iOS の中身
    7

    View Slide

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

    View Slide

  9. アーキテクチャ
    9

    View Slide

  10. 10
    MVVM
    ViewModel
    View Model

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    ImageListData
    PriceData
    TitleData
    View と Data を1対1で
    対応させて更新
    DescriptionView
    GroupRequirementsView
    DescriptionData
    GroupRequirementsData

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  20. 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

    View Slide

  21. 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

    View Slide

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

    View Slide

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

    View Slide

  24. 24
    Combine
    UseCase
    ViewModel Repository

    View Slide

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

    View Slide

  26. 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

    View Slide

  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

    View Slide

  28. 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

    View Slide

  29. 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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  35. 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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  48. 最後に
    48

    View Slide

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

    View Slide