はじめに クラシルリワードについて クラシルリワードのiOSアプリについて 技術スタック Project Management Swift Package Managerのモジュール粒度 Package.swiftの例 Screen Architecture Screen Structure Builder Controller(UIHostingController) ScreenView(SwiftUI) ViewModel(ObservableObject) BaseViewModel Screen Navigation ConcurrencyのTask管理について DemoApp その他の取り組み 自動生成 Development Flow 最後に はじめに こんにちは!クラシルリワード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人で開発していたためこの構成がうまくいくかどうかはわかりませんでしたが、チームメンバーが増えても現状は特に問題なく運用できています。 引き続き機能開発でクラシルリワードアプリを改善していきたいと思いますので、よろしくお願いします。