STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

STORES 予約 における予約者さま向けアプリリニューアルの裏側 iOSアプリ編

はじめに

STORES 予約 モバイルエンジニアの neko です。

この記事は「STORES 予約 における予約者向けアプリリニューアルの裏側 iOS編」です。

今回のリニューアルついては、この記事のほか、PM&デザイナーとAndroidエンジニアの記事も公開されています。

そちらの記事もぜひご覧ください。

product.st.inc

product.st.inc

なお、記事中のソースコードについては、サンプル用として書き直したもので、実際のプロダクトコードとは異なります。

全体の構成

まず、アプリ全体の構成について触れておきます。

予約者さま向けアプリ「 Coubic by STORES 予約 」iOS版のアーキテクチャやモジュール構成は、先行してリニューアルしたオーナーさま向けアプリ「 STORES 予約 」iOS版に準じています。

オーナーさま向けアプリについてはこちらの記事で軽く触れていますので、よろしければこちらもご覧ください。

ディレクトリ構成のイメージは下記のようになります。

├── Data
│   └── Repository
│       ├── xxxRepository.swift
│       ├── ......
├── Model
│   ├── xxxModel.swift
│   ├── ......
│   ├── Mock
│   │   ├── ......
└── Screen
    ├── xxxPage
    │   ├── xxxEnvironment.swift
    │   ├── xxxListView.swift
    │   ├── xxxListViewModel.swift
    │   ├── ......
    │   ├── Component
    │   │   ├── xxxListItem.swift
    │   │   ├── ......
    │   └── Domain
    │   │   ├── xxxUseCase.swift
    │   │   ├── ......

大きくは「Data」「Model」「Screen」の3レイヤー構成になっており、全体はシングルモジュールです。

「Screen」レイヤーがUseCaseまでを持ち、ViewはViewModelを通してUseCaseを操作し、サーバーからのデータを取得します。

次の章から、それぞれのレイヤーについて個別に説明していきます。

Dataレイヤー

Dataレイヤーは、サーバー側と通信するためのAPIクライアントを操作する機能を持ち、外部にインタフェースを公開しています。

APIクライアントは openapi-generator を利用した自動生成の仕組みを利用し、Repositoryはasync/awaitを使って実装しています。

この部分の詳細についてはアドベンドカレンダーの記事に書いていますので、そちらを参考にしてください。

// APIクライアントの実装例
open class func session(sessionParams: SessionParams? = nil) async throws -> SessionToken {
    var requestTask: RequestTask?
    return try await withTaskCancellationHandler {
        try Task.checkCancellation()
        return try await withCheckedThrowingContinuation { continuation in
            guard !Task.isCancelled else {
                continuation.resume(throwing: CancellationError())
                return
            }

            requestTask = sessionWithRequestBuilder(sessionParams: sessionParams).execute { result in
                switch result {
                case let .success(response):
                    continuation.resume(returning: response.body)
                case let .failure(error):
                    continuation.resume(throwing: error)
                }
            }
        }
    } onCancel: { [requestTask] in
        requestTask?.cancel()
    }
}
// Repositoryの実装例
protocol SessionRepositoryProtocol {
  func session(params: String) async throws -> SessionToken
}

class SessionRepository: SessionRepositoryProtocol {
  func session(params: String) async throws -> SessionToken {
    try await API.post(sessionParams: SessionParams(params: params)
  }
}

Modelレイヤー

ModelレイヤーはUIが扱うデータをStructとして持っています。

また、Repository経由で取得するAPIのレスポンスデータを、View側で利用するためのModelに変換するTranslatorも持っています。

現在はアプリ内部にデータベースは持っておらず、いわゆるEntityにあたる役割は存在しません。

// Modelの実装例
struct UserModel {
  let id: Int
  let name: String

  init() {
    id = 0
    name = ""
  }

  init(
    id: Int,
    name: String,
  ) {
    self.id = id
    self.name = email
  }
}

extension UserModel {
  static func translate(source: Response) -> UserModel {
    .init(
      id: source.id,
      name: source.name
    )
  }
}

Screenレイヤー

ScreenレイヤーはSwiftUIで実装されたViewと、ビジネスロジックを管理するViewModel、Repositoryを管理するUseCaseを持っています。

ViewModelはView側のアクションを受けてロジックを実行し、必要があればUseCaseに操作を要求します。

UseCaseはRepositoryを複数持ち、UseCaseからの要求に応じてRepositoryに操作を要求し、Repository経由で取得したデータをModelに変換してViewModelに返します。

UseCaseについては、個人的な経験としては別階層にDomainレイヤーとして配置することが多かったです。

しかし、実際のところUseCaseはViewModelに対して1対1の関係で、依存性が強いことから、今回はScreenの中に置くことにしました。

この点について、こうした配置にしたことで、ViewModelとUseCaseの使い分けが曖昧になってしまい、今後この2つはマージしていっても良さそうに感じています。

// UseCaseの実装例
protocol LoginUseCaseProtocol {
  func session(params: String) async throws -> String?
}

actor LoginUseCase: LoginUseCaseProtocol {
  private let repository: SessionRepositoryProtocol

  init(repository: SessionRepositoryProtocol) {
    self.repository = repository
  }

  func session(params: String) async throws -> String? {
    try await repository.session(params: String).token
  }
}
// ViewModelの実装例
@MainActor
class LoginViewModel: ObservableObject {
  @Published var ......
  @Published var ......

  private let useCase: LoginUseCaseProtocol

  init(useCase: LoginUseCaseProtocol) {
    self.useCase = useCase
  }

  func onAppear() {
    ......
  }

  private func basicLogin() async {
    do {
      let token = try await useCase.session(params: String)
      if let token = token {
        debugPrint("token \(token)")
      } else {
        debugPrint("token is empty")
      }
    } catch {
      debugPrint("request failed : \(error)")
    }
}

画面遷移については、遷移の実行自体はViewで行いますが、Viewのインスタンス生成やViewに関する依存性の注入はRouter機能として分離しました。

この部分はもう少し改善を加え、また別のブログで詳細を書きたいと思っています。

最後に

今回のリニューアルは、全体として大きなボトルネックが発生することなく、比較的スピーディーに進めることができました。

しかし、例えばViewModelとUseCaseの整理や、画面遷移を管理するナビゲーション周りの実装などで、改善し切れなかった点も残りました。

STORES 予約 では、それぞれのアプリについて利用状況を分析しつつ、定期的に改善を続けています。

機能面の充実に伴い、実装面でのリファクタリングや改良も引き続き並行して行い、より良いアプリを提供できる体制を目指していきます。

今後も成果について定期的に発信していきたいと考えていますので、よろしくお願いします。