Tuist 를 이용하여 모듈 설계를 단계적으로 작성해보려고 한다.
목표
- Tuist 를 이용하여 요구조건에 맞는 멀티 모듈 프로젝트를 구성한다.
요구사항
- Tuist 4.x 버전을 사용한다.
- Feature는 UI, Presenter, UITest, PresenterTest 로 나눈다.
- 프로젝트는 App (앱 모듈), Service(앱 모듈 이외의 모듈 모음) 로 구성한다.
- swift-composable-architecture 라이브러리를 추가하여 UI, Presenter 에서 사용할 수 있도록 제공한다.
- Shared(KMP) 는 우선 제외한다.
전체 구조
// (P): Project
// (F): Folder
// (T): Target
├── (P) App
├── (P) Service
│ ├── (F)Feature/
│ │ ├── (F)Messenger/
│ │ │ ├── (T)MessengerPresenter
│ │ │ ├── (T)MessengerUI
│ │ │ ├── (T)MessengerPresenterTests
│ │ │ └── (T)MessengerUITests
│ │ ├── ...
│ ├── (T)CommonUI/
│ ├── (T)CommonPresenter/
├── ...
├── (P)Shared/ (KMP)
│ ├── MessengerDomain
│ ├── ...
├── (F)ThirdParty
│ ├── swift-composable-architecture
│ ├── ...
수행
Tuist Project Helper 구현하기
- 크게 AppTarget 과 FrameworkTarget 으로 구분한다.
AppTarget
// Tuist > ProjectDescriptionHelpers > Project+Target.swift
public static func makeAppTargets(
rootPath: String = ".",
name: String,
destinations: ProjectDescription.Destinations = [.iPhone, .iPad],
dependencies: [TargetDependency] = [],
baseBundleID: String
) -> [Target] {
let mainTarget: Target = .target(
name: name,
destinations: destinations,
product: .app,
bundleId: "${BUNDLE_IDENTIFIER}",
deploymentTargets: .iOS(minimumOSVersion),
infoPlist: .extendingDefault(with: Project.infoPlist()),
sources: ["\(rootPath)/\(name)/Sources/**"],
resources: ["\(rootPath)/\(name)/Resources/**"],
dependencies: dependencies,
settings: .settings(
base: Project.baseSettings(),
configurations: [
.debug(
name: "Debug",
settings: [
"BUNDLE_IDENTIFIER": "\(baseBundleID)",
"DISPLAY_NAME": "\(name)-dev"
]
),
.release(
name: "Release",
settings: [
"BUNDLE_IDENTIFIER": "\(baseBundleID)",
"DISPLAY_NAME": "\(name)"
]
)
]
)
)
return [mainTarget]
}
FrameworkTarget
- 조건에 따라 framework와 대응되는 test target 을 구성하도록 하였다.
// Tuist > ProjectDescriptionHelpers > Project+Target.swift public static func makeFrameworkTargets( rootPath: String = ".", name: String, destinations: ProjectDescription.Destinations = [.iPhone, .iPad], dependencies: [TargetDependency], testDependencies: [TargetDependency] = [], baseBundleID: String, useTestTarget: Bool = true, useResources: Bool = false ) -> [Target] { let sources: Target = .target( name: name, destinations: destinations, product: .framework, bundleId: "\(baseBundleID).\(name)", deploymentTargets: .iOS(minimumOSVersion), infoPlist: .default, sources: ["\(rootPath)/\(name)/Sources/**"], resources: useResources ? ["\(rootPath)/\(name)/Resources/**"] : [], dependencies: dependencies, settings: .settings( base: Project.baseSettings(), configurations: [ .debug( name: "Debug", settings: [ "BUNDLE_IDENTIFIER": "\(baseBundleID).\(name)" ] ), .release( name: "Release", settings: [ "BUNDLE_IDENTIFIER": "\(baseBundleID).\(name)" ] ) ] ) ) let tests: Target = .target( name: "\(name)Tests", destinations: destinations, product: .unitTests, bundleId: "\(baseBundleID).\(name)Tests", deploymentTargets: .iOS(minimumOSVersion), infoPlist: .default, sources: ["\(rootPath)/\(name)/Tests/**"], resources: [], dependencies: [.target(name: name)] + testDependencies, settings: .settings( base: Project.baseSettings(), configurations: [ .debug( name: "Debug", settings: [ "BUNDLE_IDENTIFIER": "\(baseBundleID).\(name)Tests" ] ), .release( name: "Release", settings: [ "BUNDLE_IDENTIFIER": "\(baseBundleID).\(name)Tests" ] ) ] ) ) return useTestTarget ? [sources, tests] : [sources] }
Third Party 패키지 구성하기
service package
// Tuist > ProjectDescriptionHelpers > Project+Package.swift
public static let servicePackages: [ProjectDescription.Package] = [
.remote(url: "https://github.com/pointfreeco/swift-composable-architecture.git", requirement: .exact("1.17.1")),
...
]
test package
// Tuist > ProjectDescriptionHelpers > Project+Package.swift
public static let testPackages: [ProjectDescription.Package] = [
.remote(url: "https://github.com/Quick/Nimble.git", requirement: .exact("13.7.1")),
.remote(url: "https://github.com/Quick/Quick.git", requirement: .exact("7.6.2"))
]
모듈 의존성 구성하기
CommonFramework
- FeatureUI, FeaturePresenter 에서 사용할 공통 모듈을 구성한다.
// Tuist > ProjectDescriptionHelpers > Project+Framework.swift
public enum CommonFramework: String, CaseIterable {
case commonPresenter = "CommonPresenter"
case commonUI = "CommonUI"
public var dependcies: [TargetDependency] {
switch self {
case .commonPresenter:
return [
.package(product: "ComposableArchitecture")
]
case .commonUI:
return [...]
}
}
}
FeatureFramework
- MessengerUI, MessengerPresenter 는 CommonPreseter 를 추가하여 TCA 를 사용할 수 있도록 하였다.
// Tuist > ProjectDescriptionHelpers > Project+Framework.swift
public enum FeatureFramework: String, CaseIterable {
case messengerPresenter = "MessengerPresenter"
case messengerUI = "MessengerUI"
public var dependcies: [TargetDependency] {
switch self {
case .messengerPresenter:
return [
.target(name: CommonFramework.commonPresenter.rawValue)
]
case .messengerUI:
return [
.target(name: Self.messengerPresenter.rawValue),
.target(name: CommonFramework.commonUI.rawValue),
.target(name: CommonFramework.commonPresenter.rawValue)
]
}
}
}
다중 프로젝트 워크스페이스 구성하기
Workspace
- appclication 과 service 프로젝트를 포함한 workspace 를 구성한다.
// workspace.swift
let workspace = Workspace(
name: "HOGWON",
projects: [
"application",
"service"
]
)
application
// application > Project.swift
let project: Project = Project(
name: name,
organizationName: organization,
targets: [
Project.makeAppTargets(
name: Framework.app.rawValue,
dependencies: Framework.app.dependcies,
baseBundleID: baseBundleID
),
].flatMap { $0 }
)
service
- 기존에 구성한 CommonFramework 와 FeatureFramework enum 값을 이용하여 target 을 구성한다.
// service > Project.swift
let project = Project(
name: name,
organizationName: organization,
packages: Project.servicePackages + Project.testPackages,
targets: [
Project.CommonFramework.allCases
.flatMap { framework in
Project.makeFrameworkTargets(
name: framework.rawValue,
dependencies: framework.dependcies,
baseBundleID: baseBundleID,
useTestTarget: false
)
},
Project.FeatureFramework.allCases
.flatMap { framework in
Project.makeFrameworkTargets(
name: framework.rawValue,
dependencies: framework.dependcies,
testDependencies: Project.testDependencies,
baseBundleID: baseBundleID,
useTestTarget: true
)
}
].flatMap { $0 }
)
개선점
- TCA Wrapper Framework 구성하기
- “Package.swift” 파일 이용하여 Third Party 패키지 추가하기
1. TCA Wrapper Framework 구성하기
- 현재 TCA 는 Static Library 형태로 제공되고 있다.
- 처음에는 FeatureUI, FeaturePresenter 에 각각 의존성을 추가하였으나,
Package 'ComposableArchitecture' has been linked from target 'CalendarPresenter' and target 'CalendarUI', it is a static product so may introduce unwanted side effects.
오류 가 발생됨 - 결국 “Do Not Embed” 형태로 CommonPresenter 를 통하여 TCA 의존성 제공
- 하지만 CommonPresenter 는 점차 규모가 커질 수 있으므로 TCA 의 독립성이 떨어질 수 있음. (가령 TCA 만 의존하고 싶거나 혹은 TCA 를 제외한 CommonPresenter 만 의존하고 싶거나 …)
- CommonPresenter 대신에 TCAFramework 와 같은 TCA 를 wrapping 하는 역할의 framework 를 사용해볼 수 있을 것 같다.
2. “Package.swift” 파일 이용하여 Third Party 패키지 추가하기
- 현재 사용한 방식의 경우
tuist generate
시에 spm 의존성을 확인하고 추가한다. - 만약 기존에 tuist cache 가 없다면 spm 을 추가하는데 시간이 걸릴 수 있다.
- 그래서 “Package.swift” 를 이용하여
tuist install & tuist generate
을 통해 SPM 을 구성한다면 개발 속도에 큰 도움이 될 것 같다.
맺음말
- 다음 편에서는 위의 개선점을 개선하고 Tuist 를 이용하여 KMP shared module 을 추가하는 방법을 작성해보려고 한다.