こんにちは!クラシルリワードiOSエンジニアのfunzinです。
この記事ではクラシルリワードのiOSアプリの構成について紹介していきます。 クラシルリワードは「日常のお買い物体験をお得に変える」アプリです。
買い物のためにお店に行く(移動する)、チラシを見る、商品を買う、レシートを受け取る......。これら日常の行動がポイントに変わり、そのポイントを使って様々な特典と交換することができます。 詳しくはこちらのnoteをご確認ください。 クラシルリワードは昨年から開発を着手した新規サービスのため、最新の技術を積極的に取り入れています。 それぞれの詳細については、下記セクションで紹介していきます。 クラシルリワードのプロジェクトの構成はSwift Package Managerを利用したマルチモジュール構成です。広告SDKなど一部はCocoapodsで管理していますが、それ以外は基本的にSwift Package Managerで管理しています。
Swift Package Managerを利用したプロジェクト管理方法はd_dateさんのスライドに詳しく書かれていますのでそちらをご参考ください。 クラシルリワードでのxcworkspaceでの構成は下記のようになっています。
アプリの都合上複数の広告SDKに依存していますが、開発時には全ての広告接続先を確認する必要がないため、接続先数を最小限にしたRetailAppを普段の開発では利用しています。
Podfile上で (※ライブラリ名は仮です) ターゲット別にインストールされるCocoaPodsライブラリは下記になります。 Swift Package ManagerモジュールはApp, Feature, Coreレイヤーをベースとして分割しています。
それぞれのモジュールを細かく分けているため、1モジュールあたりに含まれるSwiftファイルは数ファイルです。
そのため各モジュールでのUnitTestの実行時間も短縮することができ、開発効率が上がっています。 RetailAppPacakgeで管理しているPackage.swiftは下記のようになっています。 String Literalではなく変数化しておくことで、Package.swift内でも補完が効くようにしています。
また開発環境のみで利用するライブラリは、XcodeCloudのpost_clone.shのタイミングでPackage.swiftを上書きすることで本番には含めないようにしています。 基本的な画面構成はSwiftUI + UIHostingControllerを採用しています。 フルSwiftUIにするかどうか議論はありますが、下記の理由でUIKit Navigation + SwiftUIを採用しました。 非同期処理に関してはSwift Concurrencyをメインで利用しています。 1画面は以下の構成で構築されています。
それぞれの役割をサンプルコードを交えて説明します。
依存を解決してViewControllerを返却するのが責務 UIHostingControllerを利用しているため、画面遷移はUIKitのNavigationを利用しています。
画面と1対1の対応関係であるStruct(XXXScreenRequest)をキーとして、画面遷移を実現しています。 e.g. AFeatureでBScreenに遷移する場合 buildViewControllerで返却する画面はAppモジュール内にあるRouterServiceが解決しています。 こちらはCookpadさんのresolverの仕組みと近しいです。 Swift Concurrencyで利用しているTask管理については、TaskManagerというクラスを用意して管理しています。
TaskManagerはdeinitされたタイミングで、保持しているtaskをキャンセルするので、RxSwiftでいうDisposable, CombineでいうAnyCancellableのような役割を果たしています。 TaskManagerの実装例 SwiftUIのViewがonAppearした時には TaskManagerの実用例 マルチモジュール化やM2チップの登場によってビルド時間は改善傾向にありますが、単体機能開発をする際にはビルド時間が長いことが開発効率を下げる要因になります。
クラシルリワードではSwift Package Managerでモジュール化していることで、各機能のDemoAppが作りやすい環境になっています。 DemoAppのために新規でxcodeprojやtargetを作成する場合、xcodeprojの差分に付き合うことになるため、それらを避けるためにPlayground(swiftpmファイル)を利用しています。 ReceiptDemoAppの例
DemoAppを他のメンバーも確認できるようにgit管理に含めていますが、PRレビューはしないことにしています。
あくまでいつでも捨てられるかつ開発用のアプリという位置付けで利用しています。 Genesis, Sourceryを利用していることで下記のフローを自動化しています。 genesisコマンドをラップしたmakeコマンドを利用することで、機能開発に必要なテンプレートを自動生成します。 機能モジュール生成物 DemoApp生成物 定型作業が多いものを自動化することで、開発者の負担を減らしています。 ブランチ戦略としてはGitHub Flowで開発しています。
新規機能開発が複数走っているため、巨大なFeatureブランチを作成すると、都度コンフリクト修正をする必要があるため、FeatureFlagをベースに機能開発しています。
FeatureFlagの仕組みはUserDefaults、FirebaseRemoteConfigを利用して実現しています。 FeatureFlagの実現例 開発版のみFeatureFlagの機能フラグを有効にできるため、本番環境には影響せずに機能を確認することができます。
QA終了後にFeatureFlagを切り替える or フラグを削除することで、本番環境に機能をリリースしています。 それぞれの機能の差分が小さい状態でmainブランチに取り組むことができるため、リリースも頻度高く行えています。(2023/5/29時点: 週平均2回リリース) クラシルリワードのiOSアプリの構成について紹介しました。 2023/1まで1人で開発していたためこの構成がうまくいくかどうかはわかりませんでしたが、チームメンバーが増えても現状は特に問題なく運用できています。
引き続き機能開発でクラシルリワードアプリを改善していきたいと思いますので、よろしくお願いします。はじめに
クラシルリワードについて
delyは次の領域へ。「クラシルリワード」が切り拓く、新たな買い物体験と小売業界のDXクラシルリワードのiOSアプリについて
技術スタック
Project Management
Swift Package中心のプロジェクト構成とその実践
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
Swift Package Managerのモジュール粒度
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
)
}
}
sed -i "" -E "s/let isDebug = true$/let isDebug = false/g" ./RetailAppPackage/Package.swift
Screen Architecture
Screen Structure
Builder
(※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)
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)
struct HomeTabScreenView: View {
@StateObject var viewModel: HomeTabScreenViewModel
var body: some View {
Text("Hello, World!")
}
}
ViewModel(ObservableObject)
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
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
import BScreenRequest
let request = BScreenRequest()
let vc = routerService.buildViewController(request: request)
present(vc, animated: true)
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)
}
}
コード生成を用いたiOSアプリマルチモジュール化のための依存解決ConcurrencyのTask管理について
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)
}
}
.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
その他の取り組み
自動生成
$ make create-feature-module FEATURE_NAME=Leaflet
LeafletFeature
├── Generated
├── Leaflet
│ ├── LeafletBuilder.swift
│ ├── LeafletScreenView.swift
│ ├── LeafletScreenViewModel.swift
│ └── LeafletViewController.swift
└── Resources
└── Localizable.strings
LeafletScreenRequests
└── LeafletScreenRequest.swift
LeafletDemoApp.swiftpm
├── Assets.xcassets
│ ├── AccentColor.colorset
│ ├── AppIcon.appiconset
│ └── Contents.json
├── Package.resolved
├── Package.swift
└── Sources
├── MyApp.swift
└── ViewControllerWrappper.swift
Development Flow
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)
最後に