13
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

and factory.incAdvent Calendar 2023

Day 12

Package.swiftを楽に書きたい

Last updated at Posted at 2023-12-11

はじめに

この記事は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"))
  • 全画面が依存するDependencyは commonDependencies にまとめることで簡略化しています。

おわりに

各要素をEnum管理するといえばシンプルではありますが、
実際に成立させるために記載した補助関数などが参考になれば幸いです。

明日のAdvent Calenderもお楽しみに!

13
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?