dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

Swift Package Managerを活用したクラシルリワードのiOSアプリ構成

はじめに

こんにちは!クラシルリワードiOSエンジニアのfunzinです。 この記事ではクラシルリワードのiOSアプリの構成について紹介していきます。

クラシルリワードについて

クラシルリワードは「日常のお買い物体験をお得に変える」アプリです。 買い物のためにお店に行く(移動する)、チラシを見る、商品を買う、レシートを受け取る......。これら日常の行動がポイントに変わり、そのポイントを使って様々な特典と交換することができます。

詳しくはこちらのnoteをご確認ください。
delyは次の領域へ。「クラシルリワード」が切り拓く、新たな買い物体験と小売業界のDX

クラシルリワードのiOSアプリについて

技術スタック

クラシルリワードは昨年から開発を着手した新規サービスのため、最新の技術を積極的に取り入れています。

  • Project Management: Swift Package Managerを利用したマルチモジュール
  • Core: SwiftUI, Swift Concurrency
  • Screen Architecture: MVVM
  • CI/CD: Xcode Cloud, GitHub Actions
  • Library Management:CocoaPods, Swift Package Manager

それぞれの詳細については、下記セクションで紹介していきます。

Project Management

クラシルリワードのプロジェクトの構成はSwift Package Managerを利用したマルチモジュール構成です。広告SDKなど一部はCocoapodsで管理していますが、それ以外は基本的にSwift Package Managerで管理しています。 Swift Package Managerを利用したプロジェクト管理方法はd_dateさんのスライドに詳しく書かれていますのでそちらをご参考ください。
Swift Package中心のプロジェクト構成とその実践

クラシルリワードでのxcworkspaceでの構成は下記のようになっています。

xcworkspace

  • swiftpm
    • DemoApp
  • Swift Package Manager
    • RetailAppPackage(ローカルのSwiftファイル管理Package)
  • xcodeproj
    • RetailApp
    • Debug
    • Staging
    • Production

アプリの都合上複数の広告SDKに依存していますが、開発時には全ての広告接続先を確認する必要がないため、接続先数を最小限にしたRetailAppを普段の開発では利用しています。 Podfile上でabstract_targetを利用することで特定のターゲットのみに広告SDKをインストールすることを実現しています。

workspace 'RetailApp.xcworkspace'
abstract_target 'Abstract' do
  use_frameworks! :linkage => :static
  pod 'AdsSDK'

  target 'RetailApp' do
    project 'RetailApp.xcodeproj'
  end

  abstract_target 'Abstract' do
    use_frameworks! :linkage => :static
    pod 'AdsAdaper1'
    pod 'AdsAdaper2'
    pod 'AdsAdaper3'
    pod 'AdsAdaper4'

    target 'Debug' do
      project 'Environment/Debug/Debug.xcodeproj'
    end
    target 'Staging' do
      project 'Environment/Staging/Staging.xcodeproj'
    end
    target 'Production' do
      project 'Environment/Production/Production.xcodeproj'
    end
  end
end

(※ライブラリ名は仮です)

ターゲット別にインストールされるCocoaPodsライブラリは下記になります。

  • RetailApp
    • AdsSDK
  • Debug, Staging, Production
    • AdsSDK
    • AdsAdaper1
    • AdsAdaper2
    • AdsAdaper3
    • AdsAdaper4

Swift Package Managerのモジュール粒度

Swift Package ManagerモジュールはApp, Feature, Coreレイヤーをベースとして分割しています。

module

  • App
    • 具体実装を各FeatureにDI
    • 画面遷移先の解決(後述)
  • Feature
    • チラシやマイページなど機能単位で分かれている
    • Featureモジュール間での依存は禁止
  • Core
    • Featureレイヤーから利用される共通のロジック群

それぞれのモジュールを細かく分けているため、1モジュールあたりに含まれるSwiftファイルは数ファイルです。 そのため各モジュールでのUnitTestの実行時間も短縮することができ、開発効率が上がっています。

Package.swiftの例

RetailAppPacakgeで管理しているPackage.swiftは下記のようになっています。

// swift-tools-version:5.6

import PackageDescription

// XCFramework
let debug = Target.binaryTarget(name: "Debug", path: "XCFrameworks/Debug.xcframework")

// SPM Library
let nuke = Target.Dependency.product(name: "Nuke", package: "Nuke")
let nukeUI = Target.Dependency.product(name: "NukeUI", package: "Nuke")
let nukeExtensions = Target.Dependency.product(name: "NukeExtensions", package: "Nuke")

// 後に説明
func targetsForDebug() -> [Target] {
    let isDebug = true
    if isDebug {
        return [debug]
    } else {
        return []
    }
}

// Core
let apiClient = Target.target(
    name: "APIClient"
)
let apiClientTests = Target.testTarget(
    name: "APIClientTests",
    dependencies: [apiClient]
)

// Feature
let leaflet = Target.target(
    name: "LeafletFeature",
    dependencies: [apiClient] + targetsForDebug()
)
let leafletTests = Target.testTarget(
    name: "LeafletFeatureTests",
    dependencies: [leaflet]
)

// App
let app = Target.target(
    name: "App",
    dependencies: [
        leaflet
    ],
    dependencyLibraries: [
        nuke,
        nukeUI
    ]
)

let package = Package.package(
    name: "RetailAppPackage",
    platforms: [
        .iOS(.v15)
    ],

    dependencies: [
        .package(url: "https://github.com/kean/Nuke", from: "12.1.0")
    ],

    targets: [
        // App
        app,

        // Feature
        leaflet,

        // Core
        apiClient,

        // Library
        debug
    ],

    testTargets: [
        leafletTests, apiClientTests
    ]
)

extension Target {
    private var dependency: Target.Dependency {
        .target(name: name, condition: nil)
    }

    fileprivate func library(targets: [Target] = []) -> Product {
        .library(name: name, targets: [name] + targets.map(\.name))
    }

    static func target(
        name: String,
        dependencies: [Target] = [],
        dependencyLibraries: [Target.Dependency] = [],
        resources: [Resource] = []
    ) -> Target {
        .target(
            name: name,
            dependencies: dependencies.map(\.dependency) + dependencyLibraries,
            resources: resources
        )
    }

    static func testTarget(name: String, dependencies: [Target]) -> Target {
        .testTarget(
            name: name,
            dependencies: dependencies.map(\.dependency)
        )
    }
}

extension Package {
    static func package(
        name: String,
        platforms: [SupportedPlatform],
        dependencies: [Dependency] = [],
        targets: [Target],
        testTargets: [Target]
    ) -> Package {
        Package(
            name: name,
            platforms: platforms,
            products: targets.map { $0.library() },
            dependencies: dependencies,
            targets: targets + testTargets
        )
    }
}

String Literalではなく変数化しておくことで、Package.swift内でも補完が効くようにしています。 また開発環境のみで利用するライブラリは、XcodeCloudのpost_clone.shのタイミングでPackage.swiftを上書きすることで本番には含めないようにしています。

sed -i "" -E "s/let isDebug = true$/let isDebug = false/g" ./RetailAppPackage/Package.swift

Screen Architecture

基本的な画面構成はSwiftUI + UIHostingControllerを採用しています。

フルSwiftUIにするかどうか議論はありますが、下記の理由でUIKit Navigation + SwiftUIを採用しました。

  • 開発着手時はiOS15でありSwiftUIのNavigationに比べてUIKitのNavigationが安定しており、従来のUIKitを利用したiOS開発に近しい状態で開発が行える
  • SwiftUI + UIHostingControllerで実現が難しいUIでも、UIViewControllerを利用したUIKitベースでの実装リプレイスが可能になる

非同期処理に関してはSwift Concurrencyをメインで利用しています。

Screen Structure

1画面は以下の構成で構築されています。 それぞれの役割をサンプルコードを交えて説明します。

screen

Builder

依存を解決してViewControllerを返却するのが責務
(※HomeTabScreenRequest.ViewControllerはUIViewController)

public struct HomeTabBuilder: FeatureBuilderProtocol {
    private let userDefaultsClient: UserDefaultsClient
    private let apiClient: APIClient
    private let routerService: RouterService

    public init(
        userDefaultsClient: UserDefaultsClient,
        apiClient: APIClient,
        routerService: RouterServiceProtocol
    ) {
        self.userDefaultsStore = userDefaultsStore
        self.apiClient = apiClient
        self.routerService = routerService
    }

    public func buildViewController(request: HomeTabScreenRequest) -> HomeTabScreenRequest.ViewController {
        let viewModel = HomeTabScreenViewModel(state: .init(), dependency: .init(
            userDefaultsClient: userDefaultsClient,
            apiClient: apiClient
        ))
        let vc = HomeTabViewController(rootView: HomeTabScreenView(viewModel: viewModel), viewModel: viewModel, routerService: routerService)
        return vc
    }
}
Controller(UIHostingController)
  • 画面遷移の制御を主に行います。
  • Combineで画面遷移を実現していますが、AsyncStreamでも置き換え可能です。
final class HomeTabViewController: UIHostingController<CoinTabScreenView> {
    private var cancellables = [AnyCancellable]()
    private let viewModel: HomeTabScreenViewModel
    private let routerService: RouterServiceProtocol

    init(rootView: HomeTabScreenView, viewModel: HomeTabScreenViewModel, routerService: RouterServiceProtocol) {
        self.viewModel = viewModel
        self.routerService = routerService
        super.init(rootView: rootView)
    }
    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.output
            .receive(on: DispatchQueue.main)
            .sink(
                receiveValue: { [weak self] output in
                    guard let self else { return }
                    switch output {
                    case .coin:
                        let vc = self.routerService.buildViewController(request: CoinScreenRequest())
                        self.navigationController?.pushViewController(vc, animated: true)
                    }
                })
            .store(in: &cancellables)
    }
}
ScreenView(SwiftUI)
  • 画面の見た目を表現するためのView
  • 画面遷移はUIKitに依存しているため、SwiftUIのView上では画面遷移を行わない
  struct HomeTabScreenView: View {
    @StateObject var viewModel: HomeTabScreenViewModel
    var body: some View {
        Text("Hello, World!")
    }
  }
ViewModel(ObservableObject)
  • BaseViewModelのサブクラスとして定義(後述)
  • 画面遷移をControllerでハンドリングできるようにOutputを定義
final class HomeTabScreenViewModel: BaseViewModel<HomeTabScreenViewModel> {
    required init(state: State, dependency: Dependency) {
        super.init(state: state, dependency: dependency)
    }

   func didTapCoinButton() {
          send(.coin)
    }
}

extension HomeTabScreenViewModel {
    struct State {
        fileprivate(set) var title: String = "title"
    }

    struct Dependency: Sendable {
        let userDefaultsClient: UserDefaultsClient
        let apiClient: APIClient
    }

    enum Output {
        case coin
    }
}
BaseViewModel
  • State, Dependency, Outputを定義しているViewModelのBaseClass
  • sendメソッド経由でViewControllerにoutputを伝搬する
  • BaseViewModelを導入している意図としては、複数人で開発する上で書き方を統一したいためです
    • 現状はBaseViewModelで統一していますが、別のアーキテクチャ(e.g. TCA, Actomaton)に置き換えたいとなった時に備えて、なるべく依存度が低いように設計しています。
public protocol LogicProtocol {
    associatedtype State
    associatedtype Dependency = Void
    associatedtype Output = Void
}

public typealias BaseViewModel<Logic: LogicProtocol> = ViewModel<Logic> & LogicProtocol

@MainActor
open class ViewModel<Logic: LogicProtocol>: ObservableObject {
    public typealias Dependency = Logic.Dependency
    public typealias State = Logic.State
    public typealias Output = Logic.Output

    @Published public var state: State
    public let dependency: Dependency
    public var cancellables: Set<AnyCancellable> = []
    public let output: AnyPublisher<Output, Never>
    private let outputSubject = PassthroughSubject<Logic.Output, Never>()

    public required init(state: Logic.State, dependency: Logic.Dependency) {
        self.state = state
        self.dependency = dependency
        self.output = outputSubject.eraseToAnyPublisher()
    }

    public convenience init(state: Logic.State) where Logic.Dependency == Void {
        self.init(state: state, dependency: ())
    }

    public func send(_ output: Logic.Output) {
        outputSubject.send(output)
    }
}

Screen Navigation

UIHostingControllerを利用しているため、画面遷移はUIKitのNavigationを利用しています。 画面と1対1の対応関係であるStruct(XXXScreenRequest)をキーとして、画面遷移を実現しています。

e.g. AFeatureでBScreenに遷移する場合

import BScreenRequest

let request = BScreenRequest()
let vc = routerService.buildViewController(request: request)
present(vc, animated: true)

buildViewControllerで返却する画面はAppモジュール内にあるRouterServiceが解決しています。

extension RouterService {
    public func buildViewController<ScreenRequest>(request: ScreenRequest) -> ScreenRequest.ViewController where ScreenRequest: ScreenRequestProtocol {
        switch request {
        case let request as AScreenRequest:
            return build(request) as! ScreenRequest.ViewController
        case let request as BScreenRequest:
            return build(request) as! ScreenRequest.ViewController
        default:
            fatalError("should not reach here")
        }
    }
}

extension RouterService {
    func build(_ request: AScreenRequest) -> AScreenRequest.ViewController {
        let builder = AScreenBuilder()
        return builder.buildViewController(request: request)
    }

    func build(_ request: BScreenRequest) -> BScreenRequest.ViewController {
        let builder = BScreenBuilder()
        return builder.buildViewController(request: request)
    }
}

こちらはCookpadさんのresolverの仕組みと近しいです。
コード生成を用いたiOSアプリマルチモジュール化のための依存解決

ConcurrencyのTask管理について

Swift Concurrencyで利用しているTask管理については、TaskManagerというクラスを用意して管理しています。 TaskManagerはdeinitされたタイミングで、保持しているtaskをキャンセルするので、RxSwiftでいうDisposable, CombineでいうAnyCancellableのような役割を果たしています。

TaskManagerの実装例

public typealias TaskID = AnyHashable

public protocol TaskIDProtocol: Hashable & Sendable {
    var identifier: String { get }
}

extension TaskIDProtocol {
    public static func createDefaultIdentifier() -> String {
        String(describing: type(of: Self.self))
    }
}

public struct DefaultTaskID: TaskIDProtocol {
    public let identifier: String = createDefaultIdentifier()
    public init() {}
}

public final class TaskManager {
    private(set) var taskDict: [TaskID: [AnyCancellable]] = [:]
    public init() {}

    public func addTask(
        priority: TaskPriority? = nil,
        operation: @Sendable @escaping () async -> Void
    ) {
        _addTask(id: DefaultTaskID(), task: Task(priority: priority, operation: operation))
    }

    public func addTask(
        id: some TaskIDProtocol,
        priority: TaskPriority? = nil,
        operation: @Sendable @escaping () async -> Void
    ) {
        _addTask(id: id, task: Task(priority: priority, operation: operation))
    }

    public func cancelTask(id: some TaskIDProtocol) {
        taskDict[id] = nil
    }

    public func cancelAndThenAddTask(
        id: some TaskIDProtocol,
        priority: TaskPriority? = nil,
        operation: @Sendable @escaping () async -> Void
    ) {
        cancelTask(id: id)
        _addTask(id: id, task: Task(priority: priority, operation: operation))
    }

    public func cancelAll() {
        taskDict = [:]
    }

    private func _addTask(
        id: some TaskIDProtocol,
        task: Task<Void, Never>
    ) {
        let cancellable = AnyCancellable {
            task.cancel()
        }
        taskDict[id, default: []].append(cancellable)
    }
}

SwiftUIのViewがonAppearした時には.taskは使わずに、TaskManagerに責務を集約しています。 理由としては下記です。

  • ボタンを押した時にもAPI通信などの非同期処理が発生するため、Taskを一元管理したいため
  • 前回実行中の同一IDのTaskをキャンセルしたい場合があるため

TaskManagerの実用例

struct HomeTabView: View {
    @StateObject var viewModel: HomeTabViewModel
    @State private var taskManager = TaskManager()

    var body: some View {
        VStack {
            Button("Button") {
             // ExampleButtonTaskIDと同一IDの実行中Taskをキャンセルして、Taskを実行する
                taskManager.cancelAndThenAddTask(id: ExampleButtonTaskID()) { [weak viewModel] in
                    await viewModel?.didTapButton()
                }
            }
        }
        .onAppear {
            taskManager.addTask { [weak viewModel] in
                await viewModel?.onAppear()
            }
        }
    }
}


struct ExampleButtonTaskID: TaskIDProtocol {
  let identifier: String = createDefaultIdentifier()
}

DemoApp

マルチモジュール化やM2チップの登場によってビルド時間は改善傾向にありますが、単体機能開発をする際にはビルド時間が長いことが開発効率を下げる要因になります。 クラシルリワードではSwift Package Managerでモジュール化していることで、各機能のDemoAppが作りやすい環境になっています。

DemoAppのために新規でxcodeprojやtargetを作成する場合、xcodeprojの差分に付き合うことになるため、それらを避けるためにPlayground(swiftpmファイル)を利用しています。

ReceiptDemoAppの例

ReceiptDemoApp
demo

DemoAppを他のメンバーも確認できるようにgit管理に含めていますが、PRレビューはしないことにしています。 あくまでいつでも捨てられるかつ開発用のアプリという位置付けで利用しています。

その他の取り組み

自動生成

Genesis, Sourceryを利用していることで下記のフローを自動化しています。

  • 機能モジュールの作成
  • DemoAppの作成
  • 画面遷移のType解決

genesisコマンドをラップしたmakeコマンドを利用することで、機能開発に必要なテンプレートを自動生成します。

$ make create-feature-module FEATURE_NAME=Leaflet

機能モジュール生成物

LeafletFeature
├── Generated
├── Leaflet
│   ├── LeafletBuilder.swift
│   ├── LeafletScreenView.swift
│   ├── LeafletScreenViewModel.swift
│   └── LeafletViewController.swift
└── Resources
    └── Localizable.strings

LeafletScreenRequests
└── LeafletScreenRequest.swift

DemoApp生成物

LeafletDemoApp.swiftpm
├── Assets.xcassets
│   ├── AccentColor.colorset
│   ├── AppIcon.appiconset
│   └── Contents.json
├── Package.resolved
├── Package.swift
└── Sources
    ├── MyApp.swift
    └── ViewControllerWrappper.swift

定型作業が多いものを自動化することで、開発者の負担を減らしています。

Development Flow

ブランチ戦略としてはGitHub Flowで開発しています。 新規機能開発が複数走っているため、巨大なFeatureブランチを作成すると、都度コンフリクト修正をする必要があるため、FeatureFlagをベースに機能開発しています。 FeatureFlagの仕組みはUserDefaults、FirebaseRemoteConfigを利用して実現しています。

FeatureFlagの実現例

public struct FeatureFlagManager: Sendable {
    public enum BoolKey {
        case isNewDesign
        case isHeaderHidden
    }

    public var getBoolValue: @Sendable (BoolKey) -> Bool
}

extension FeatureFlagManager {
    public static func live(userDefaultsClient: UserDefaultsClient, remoteConfig: RemoteConfig) -> FeatureFlagManager {
        .init(
            getBoolValue: { key in
                switch key {
                case .isNewDesign:
                    // UserDefaultsClientはUserDefaultsのラッパーなのでここでは説明を割愛
                    return userDefaultsClient.isNewDesign
                case .isHeaderHidden:
                 return remoteConfig.bool("isHeaderHidden").boolValue
                }
            }
        )
    }
}
// 利用例
let isNewDesign = featureFlagManager.getBoolValue(.isNewDesign)
let isHeaderHidden = featureFlagManager.getBoolValue(.isHeaderHidden)

開発版のみFeatureFlagの機能フラグを有効にできるため、本番環境には影響せずに機能を確認することができます。 QA終了後にFeatureFlagを切り替える or フラグを削除することで、本番環境に機能をリリースしています。

それぞれの機能の差分が小さい状態でmainブランチに取り組むことができるため、リリースも頻度高く行えています。(2023/5/29時点: 週平均2回リリース)

最後に

クラシルリワードのiOSアプリの構成について紹介しました。

2023/1まで1人で開発していたためこの構成がうまくいくかどうかはわかりませんでしたが、チームメンバーが増えても現状は特に問題なく運用できています。 引き続き機能開発でクラシルリワードアプリを改善していきたいと思いますので、よろしくお願いします。