はじめに
この記事はand factory.inc Advent Calendar 2023 12日目の記事です。
昨日は @doihei さんの「iOSアプリ開発でDanger+SwiftLintをサクッと導入する」でした。
Swift Packageを用いたマルチモジュール構成で開発を進める際に、Package.swiftの記述が手間だと感じたことはありませんか?
Package.swiftを少し編集するたびにXCodeがResolveを開始したり、typoがあるとエラーになるので地味に辛いです。
少しでも楽に書けるように自分なりに試してみた基本方針を実例とともに紹介できればと思います。
Package.swift
今回は、1画面=1Targetとして全画面の実装を1つのPresentation Packageにまとめた場合のPackage.swiftを例としています。
※紹介のため記述内容は簡易化しています。
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
// MARK: Pacakge
let package = Package(
name: "Presentation",
platforms: [.iOS(.v15)],
products: Products.allValues,
dependencies: Dependencies.allValues,
targets: Targets.allValues
)
// MARK: Helper
extension String {
func initialLetterUppercased() -> Self {
prefix(1).uppercased() + dropFirst()
}
}
protocol PackageAtom {
associatedtype ValueType
var name: String { get }
var value: ValueType { get }
}
extension PackageAtom where Self: RawRepresentable, Self.RawValue == String {
var name: String {
rawValue.initialLetterUppercased()
}
}
extension PackageAtom where Self: CaseIterable {
static var allValues: [ValueType] {
Self.allCases.map(\.value)
}
}
// MARK: Settings
let swiftFlagsForDebug = [
"-strict-concurrency=targeted",
"-enable-actor-data-race-checks",
]
let swiftSettingsForDebug: [PackageDescription.SwiftSetting] = [
.unsafeFlags(swiftFlagsForDebug, .when(configuration: .debug)),
]
// MARK: - Products
enum Products: String, CaseIterable, PackageAtom {
case rootScreen
case homeScreen
case searchScreen
case myPageScreen
case tutorialScreen
case splashScreen
var targets: [String] {
Targets.targets(for: self)
}
var value: Product {
Product.library(
name: name,
targets: targets
)
}
}
// MARK: - Dependencies
enum Dependencies: String, CaseIterable, PackageAtom {
case logger
case nuke
case sFSafeSymbols
var value: Package.Dependency {
switch self {
case .logger:
return .package(
path: "../../Support/Logger"
)
case .sFSafeSymbols:
return .package(
url: "https://github.com/SFSafeSymbols/SFSafeSymbols.git", .upToNextMajor(from: "4.1.1")
)
case .nuke:
return .package(
url: "git@github.com:kean/Nuke.git",
from: "12.1.2"
)
}
}
func asDependency(productName: ProductName) -> Target.Dependency {
switch productName {
case let .specified(moduleName):
return .product(name: moduleName, package: name)
case .usePackageName:
return .product(name: name, package: name)
}
}
enum ProductName {
case specified(name: String)
case usePackageName
}
}
// MARK: - Targets
enum Targets: String, CaseIterable, PackageAtom {
case shared
case rootScreen
case homeScreen
case searchScreen
case myPageScreen
case tutorialScreen
case splashScreen
case presentationTests
var pathName: String {
switch self {
case .presentationTests:
return name
default:
return "Screen/\(name)"
}
}
static let commonDependencies: [Target.Dependency] = [
Targets.shared.asDependency,
Dependencies.sFSafeSymbols.asDependency(productName: .usePackageName),
Dependencies.logger.asDependency(productName: .usePackageName),
]
var dependencies: [Target.Dependency] {
switch self {
case .shared:
return []
case .rootScreen:
return Self.commonDependencies + [
Targets.homeScreen.asDependency,
Targets.myPageScreen.asDependency,
]
case .homeScreen:
return Self.commonDependencies + [
Dependencies.nuke.asDependency(productName: .specified(name: "NukeUI")),
]
default:
return Self.commonDependencies
}
}
var isTestTarget: Bool {
self == .presentationTests
}
var value: Target {
return isTestTarget ?
.testTarget(
name: name,
dependencies: dependencies,
path: "./Tests/\(pathName)",
swiftSettings: swiftSettingsForDebug
) :
.target(
name: name,
dependencies: dependencies,
path: "./Sources/\(pathName)",
swiftSettings: swiftSettingsForDebug
)
}
var asDependency: Target.Dependency {
.target(name: value.name)
}
static func targets(for product: Products) -> [String] {
Targets.allCases.map(\.name).filter { $0 == product.name }
}
}
基本方針としてPackageの構成要素をEnumで記述することで、依存追加やTargetの新規追加の際になるべくtype safeに書けるようにしています。
少し長いので、MARKで区切ったSection毎に説明していきます。
Section毎に紹介
Package
// MARK: Pacakge
let package = Package(
name: "Presentation",
platforms: [.iOS(.v15)],
products: Products.allValues,
dependencies: Dependencies.allValues,
targets: Targets.allValues
)
- Packageの構成要素であるproducts, dependencies, targetsをそれぞれEnumで管理することで、記述を簡便化しています。
Helper
// MARK: Helper
extension String {
func initialLetterUppercased() -> Self {
prefix(1).uppercased() + dropFirst()
}
}
protocol PackageAtom {
associatedtype ValueType
var name: String { get }
var value: ValueType { get }
}
extension PackageAtom where Self: RawRepresentable, Self.RawValue == String {
var name: String {
rawValue.initialLetterUppercased()
}
}
extension PackageAtom where Self: CaseIterable {
static var allValues: [ValueType] {
Self.allCases.map(\.value)
}
}
- 文字列の1文字目のみを大文字に変換する関数をString型に追加しています。
- Product名やTarget名を指定する際に、enumのcase名の1文字目を大文字に変換すれば良い運用にしているためです。
- PackageAtom Protocol
- Products,Dependencies,TargetsをEnumで管理した際の共通項をPackageAtom Protocolとして抽出しています。
Settings
// MARK: Settings
let swiftFlagsForDebug = [
"-strict-concurrency=targeted",
"-enable-actor-data-race-checks",
]
let swiftSettingsForDebug: [PackageDescription.SwiftSetting] = [
.unsafeFlags(swiftFlagsForDebug, .when(configuration: .debug)),
]
- Targetに指定するSwiftSettingについて記述しています。
Products
// MARK: - Products
enum Products: String, CaseIterable, PackageAtom {
case rootScreen
case homeScreen
case searchScreen
case myPageScreen
case tutorialScreen
case splashScreen
var targets: [String] {
Targets.targets(for: self)
}
var value: Product {
Product.library(
name: name,
targets: targets
)
}
}
- ProductごとにEnumで管理しています。
- 新規に画面追加する際は、Productsに関してはcase追加のみで済むようになっています。
Dependencies
// MARK: - Dependencies
enum Dependencies: String, CaseIterable, PackageAtom {
case logger
case nuke
case sFSafeSymbols
var value: Package.Dependency {
switch self {
case .logger:
return .package(
path: "../../Support/Logger"
)
case .sFSafeSymbols:
return .package(
url: "https://github.com/SFSafeSymbols/SFSafeSymbols.git", .upToNextMajor(from: "4.1.1")
)
case .nuke:
return .package(
url: "git@github.com:kean/Nuke.git",
from: "12.1.2"
)
}
}
func asDependency(productName: ProductName) -> Target.Dependency {
switch productName {
case let .specified(moduleName):
return .product(name: moduleName, package: name)
case .usePackageName:
return .product(name: name, package: name)
}
}
enum ProductName {
case specified(name: String)
case usePackageName
}
}
- 外部ライブラリや、SwiftPacakageでモジュール分解しているローカルパッケージ(logger)をEnumで管理しています。
- 依存追加する際は後述のTargets内部でDependenciesの値を参照することになるのですが、
func asDependencies
を用意することで、参照したいProduct名とPackage名が違うケースに対応しています。
Targets
// MARK: - Targets
enum Targets: String, CaseIterable, PackageAtom {
case shared
case rootScreen
case homeScreen
case searchScreen
case myPageScreen
case tutorialScreen
case splashScreen
case presentationTests
var pathName: String {
switch self {
case .presentationTests:
return name
default:
return "Screen/\(name)"
}
}
static let commonDependencies: [Target.Dependency] = [
Targets.shared.asDependency,
Dependencies.sFSafeSymbols.asDependency(productName: .usePackageName),
Dependencies.logger.asDependency(productName: .usePackageName),
]
var dependencies: [Target.Dependency] {
switch self {
case .shared:
return []
case .rootScreen:
return Self.commonDependencies + [
Targets.homeScreen.asDependency,
Targets.myPageScreen.asDependency,
]
case .homeScreen:
return Self.commonDependencies + [
Dependencies.nuke.asDependency(productName: .specified(name: "NukeUI")),
]
default:
return Self.commonDependencies
}
}
var isTestTarget: Bool {
self == .presentationTests
}
var value: Target {
return isTestTarget ?
.testTarget(
name: name,
dependencies: dependencies,
path: "./Tests/\(pathName)",
swiftSettings: swiftSettingsForDebug
) :
.target(
name: name,
dependencies: dependencies,
path: "./Sources/\(pathName)",
swiftSettings: swiftSettingsForDebug
)
}
var asDependency: Target.Dependency {
.target(name: value.name)
}
static func targets(for product: Products) -> [String] {
Targets.allCases.map(\.name).filter { $0 == product.name }
}
}
- TargetごとにEnumで管理しています。
- 各Targetは、別のTargetもしくはDependenciesで定義したいずれかのproductに依存しますが、いずれの場合も極力type safeに記載できるようにしています。
- Targetに依存する場合の例:
Targets.homeScreen.asDependency
- ライブラリに依存する場合かつライブラリの名称とProduct名が同様の場合の例:
Dependencies.logger.asDependency(productName: .usePackageName)
- Product名が異なる場合は、別途Product名を指定する形式になります:
Dependencies.nuke.asDependency(productName: .specified(name: "NukeUI"))
- Targetに依存する場合の例:
- 全画面が依存するDependencyは
commonDependencies
にまとめることで簡略化しています。
おわりに
各要素をEnum管理するといえばシンプルではありますが、
実際に成立させるために記載した補助関数などが参考になれば幸いです。
明日のAdvent Calenderもお楽しみに!