PlugIn
Tuist를 사용하면서 각각의 역할을 나눠 Plugin을 만들어서 사용하면 가독성을 높일 수 있습니다.
두가지 플러그인을 만들어서 사용했고, 각각에 대해서 설명드리겠습니다.
TemplatePlugin
많은 사람이 한 번에 작업을 하면 프로젝트나 타겟을 추가하면서 앱의 구조에 통일성을 유지하는 것이 어려워지는 경우가 많았습니다.
scaffold를 사용해서 프로젝트의 기본 요소 세팅과 폴더링을 손쉽게 할 수 있도록 하였습니다.
App Template
TemplatePlugin 내부에는 두가지 템플릿이 존재하는데요, 먼저 App이라는 템플릿에 대해서 알아보겠습니다.
왼쪽처럼 플러그인들만 추가한 상태에서 tuist scaffold App --name Chansoo 다음과 같은 명령어를 사용하게 되면 오른쪽과 같이 App이라는 폴더 내부에 앱 최상단을 담당하는 프로젝트에 필요한 기본 요소들이 생기게 됩니다. 또한 Tuist 폴더 내부의 Dependency와 Project+Templates 파일 또한 만들어주게 됩니다.
그러면 어떻게 App이라는 템플릿을 구성했는지 보겠습니다.
let AppName: Template.Attribute = .required("name")
let App = Template(
description: "This Template is for making App files",
attributes: [
AppName,
],
items: [
.file(
path: "App/Project.swift",
templatePath: "AppProject.stencil"
),
.file(
path: "App/Sources/AppDelegate.swift",
templatePath: "AppDelegate.stencil"
),
.file(
path: "App/Sources/SceneDelegate.swift",
templatePath: "SceneDelegate.stencil"
),
.file(
path: "App/Resources/LaunchScreen.storyboard",
templatePath: "LaunchScreen.stencil"
),
.file(
path: "Tuist/ProjectDescriptionHelpers/Project+Templates.swift",
templatePath: "Project+Templates.stencil"
),
.file(
path: "Tuist/Dependencies.swift",
templatePath: "Dependencies.stencil"
),
.file(
path: "App/Sources/AppComponent.swift",
templatePath: "AppComponent.stencil"
),
]
)
App이라는 폴더 내부에 App.swift가 존재하고, 해당 파일에는 명령어에 대한 정보를 기입해줍니다.
먼저 코드를 생성한 author의 정보를 입력해주기 위해서 name이라는 필수적인 attribute를 추가했습니다.
만약 tuist scaffold App --name Chansoo 에서 —name은 required로 설정되어 있기 때문에 반드시 입력해야 명령어를 실행할 수 있습니다.
그리고 items에 추가할 파일들의 경로와 사용할 템플릿을 넣어줍니다.
템플릿들은 stencil 파일로 만들어서 attribute로 받은 값들을 필요한 곳에 넣어서 생성해주도록 했습니다.
최상단 모듈의 project.swift를 만들어주고, AppDelegate, SceneDelegate, LaunchScreen, AppComponent를 생성해줍니다.
또한 Dependency와 ProjectDescriptionHelpers 내부 파일을 만들어주어서 하나의 명령어로 초기 설정에 필요한 파일들을 추가할 수 있도록 하였습니다.
Utility Template
모듈을 여러개로 나누면서 각 타겟에 대해서 의존성을 추가할 때 경로를 하나하나 설정하면서 많은 휴먼에러를 접했습니다. 이를 방지하기 위해서 모듈에 대한 TargetDependency를 만들어서 편하게 사용할 수 있도록 하였습니다.
// MARK: Project
extension TargetDependency {
public struct Feature {
public struct Finance {
public struct Data {}
public struct Domain {}
public struct UserInterface {}
}
public struct Profile {
public struct Data {}
public struct Domain {}
public struct UserInterface {}
}
public struct Transport {
public struct Data {}
public struct Domain {}
public struct UserInterface {}
}
public struct Home {
public struct Data {}
public struct Domain {}
public struct UserInterface {}
}
public struct BaseDependency {}
}
public struct Core {
}
public struct CSNetwork {}
public struct ResourceKit {}
public struct ThirdParty {}
}
먼저 아래의 코드처럼 각 모듈이 속한 집단을 nested struct로 만들어서 구조를 보기 쉽도록 하였습니다.
// MARK: - Features/Finance
public extension TargetDependency.Feature.Finance {
static let folderName = "Finance"
static func project(name: String, isInterface: Bool) -> TargetDependency {
let postfix: String = isInterface ? "" : "Impl"
return .project(target: "\\(folderName)\\(name)\\(postfix)",
path: .relativeToRoot("Features/\\(folderName)/\\(folderName)\\(name)"))
}
}
public extension TargetDependency.Feature.Finance.UserInterface {
static let Interface = TargetDependency.Feature.Finance.project(name: "UserInterface", isInterface: true)
static let Implement = TargetDependency.Feature.Finance.project(name: "UserInterface", isInterface: false)
}
public extension TargetDependency.Feature.Finance.Domain {
static let Interface = TargetDependency.Feature.Finance.project(name: "Domain", isInterface: true)
static let Implement = TargetDependency.Feature.Finance.project(name: "Domain", isInterface: false)
}
public extension TargetDependency.Feature.Finance.Data {
static let Interface = TargetDependency.Feature.Finance.project(name: "Data", isInterface: true)
static let Implement = TargetDependency.Feature.Finance.project(name: "Data", isInterface: false)
}
그리고 위와 같이 각 프로젝트에 속하는 타겟들의 경로를 잡아주는 함수를 만들고, 각각의 nested struct를 extension으로 뽑아서 static 상수로 만들어주었습니다.
let project = Project.invertedDualTargetProjectWithDemoApp(
name: "FinanceUserInterface",
platform: .iOS,
iOSTargetVersion: "15.0.0",
interfaceDependencies: [
.Feature.Finance.Domain.Interface,
.Core.RIBsUtil,
.ThirdParty.RIBs,
],
implementDependencies: [
.Feature.Finance.Domain.Interface,
.Feature.Finance.Data.Interface,
.Core.RIBsUtil,
.Core.SuperUI,
.Core.DefaultsStore,
.ThirdParty.RIBs,
.ThirdParty.RxSwift,
.ThirdParty.RxRelay,
]
)
이를 통해 프로젝트를 생성할 때 각각의 TargetDependency를 String으로 잡아주는 것이 아닌 위에서 선언한 상수들로 사용이 가능하도록 할 수 있었습니다.
manifest 파일 구성하기
project.swift 파일을 구현하면서 중복되는 것들이 많은데 각각을 만들어주는 것이 비효율적이었습니다.
그래서 ProjectDescriptionHelpers 내부의 파일에 많이 사용되는 프로젝트 구조를 함수화하여 사용했습니다.
/// 현재 경로 내부의 Implement, Interface, DemoApp 세개의 디렉토리에 각각 Target을 가지는 Project를 만듭니다.
/// interface와 implement에 필요한 dependency를 각각 주입해줍니다.
/// implement는 자동으로 interface에 대한 종속성을 가지고 있습니다.
///
public static func invertedDualTargetProjectWithDemoApp(
name: String,
platform: Platform,
iOSTargetVersion: String,
interfaceDependencies: [TargetDependency] = [],
implementDependencies: [TargetDependency] = [],
// demoApp: Bool = false,
infoPlist: InfoPlist = .default
) -> Project {
let interfaceTarget = makeInterfaceDynamicFrameworkTarget(
name: name,
platform: platform,
iOSTargetVersion: iOSTargetVersion,
dependencies: interfaceDependencies
)
let implementTarget = makeImplementStaticLibraryTarget(
name: name,
platform: platform,
iOSTargetVersion: iOSTargetVersion,
dependencies: implementDependencies + [.target(name: name)]
)
let demoApp = Target(
name: "\\(name)DemoApp",
platform: .iOS,
product: .app,
bundleId: "com.chansoo.\\(name)Demoapp",
deploymentTarget: .iOS(
targetVersion: iOSTargetVersion,
devices: [.iphone]
),
infoPlist: InfoPlist.extendingDefault(
with:
[
"CFBundleDevelopmentRegion": "ko_KR",
"CFBundleShortVersionString": "1.0",
"CFBundleVersion": "1",
"UILaunchStoryboardName": "LaunchScreen"
]
),
sources: ["./DemoApp/Sources/**"],
resources: ["./DemoApp/Resources/**"],
dependencies: implementDependencies + [.target(name: name)]
)
return Project(name: name,
organizationName: organizationName,
targets: [interfaceTarget, implementTarget, demoApp])
}
// 사용부분
let project = Project.invertedDualTargetProjectWithDemoApp(
name: "FinanceUserInterface",
platform: .iOS,
iOSTargetVersion: "15.0.0",
interfaceDependencies: [
.Feature.Finance.Domain.Interface,
.Core.RIBsUtil,
.ThirdParty.RIBs,
],
implementDependencies: [
.Feature.Finance.Domain.Interface,
.Feature.Finance.Data.Interface,
.Core.RIBsUtil,
.Core.SuperUI,
.Core.DefaultsStore,
.ThirdParty.RIBs,
.ThirdParty.RxSwift,
.ThirdParty.RxRelay,
]
)
먼저 invertedDualTargetProjectWithDemoApp 이라는 함수입니다. 해당 함수는 UserInterface 프로젝트를 만들어줄 때 많이 사용되는 함수입니다. interface, implement, 그리고 DemoApp으로 3개의 타겟을 가지는 프로젝트를 만들어줍니다.
DemoApp을 통해서 현재 구현하고 있는 UserInterface에서 필요한 부분만 따로 실행시켜볼 수 있는 장점이 있었습니다.
위 코드 블럭의 사용부분처럼 사용이 가능하도록 하였습니다.
/// 현재 경로 내부의 Implement, Interface, test 세개의 디렉토리에 각각 Target을 가지는 Project를 만듭니다.
/// interface와 implement에 필요한 dependency를 각각 주입해줍니다.
/// implement는 자동으로 interface에 대한 종속성을 가지고 있습니다.
public static func invertedDualTargetProject(
name: String,
platform: Platform,
iOSTargetVersion: String,
interfaceDependencies: [TargetDependency] = [],
implementDependencies: [TargetDependency] = [],
demoApp: Bool = false,
infoPlist: InfoPlist = .default
) -> Project {
let interfaceTarget = makeInterfaceDynamicFrameworkTarget(
name: name,
platform: platform,
iOSTargetVersion: iOSTargetVersion,
dependencies: interfaceDependencies
)
let implementTarget = makeImplementStaticLibraryTarget(
name: name,
platform: platform,
iOSTargetVersion: iOSTargetVersion,
dependencies: implementDependencies + [.target(name: name)]
)
let testTarget = Target(
name: "\\(name)Tests",
platform: platform,
product: .unitTests,
bundleId: "team.io.\\(name)Tests",
deploymentTarget: .iOS(
targetVersion: iOSTargetVersion,
devices: [.iphone]
),
infoPlist: .default,
sources: ["./Tests/**"],
dependencies: [
.target(name: name),
.target(name: name + "Impl"),
]
)
return Project(name: name,
organizationName: organizationName,
targets: [interfaceTarget, implementTarget, testTarget])
}
// 사용부분
let project = Project.invertedDualTargetProject(
name: "FinanceData",
platform: .iOS,
iOSTargetVersion: "15.0.0",
interfaceDependencies: [
.CSNetwork.Interface,
.ThirdParty.RxSwift,
.ThirdParty.RxRelay,
.Feature.Finance.Domain.Interface,
],
implementDependencies: [
.CSNetwork.Interface,
.ThirdParty.RxSwift,
.ThirdParty.RxRelay,
.Feature.Finance.Domain.Interface,
]
)
두번째로 invertedDualTargetProject 함수입니다.
위 함수는 UserInterface 레이어를 제외한 나머지(Data Layer, Domain Layer 등)의 project.swift를 구현할 때 많이 사용됩니다.
interface, implement, test 총 세개의 target을 가지는 프로젝트 생성해줍니다.
코드블럭의 사용부분처럼 사용할 수 있도록 하였습니다.
코드 구조 설명
Domain Layer
FinanceDomain 프로젝트 내부의 파일 구조입니다.
Interface와 Implement, Tests 총 3개의 타겟이 폴더로 분리되어 있는 것을 볼 수 있습니다.
Interface는 다른 모듈에서 사용할 때 필요한 Entity와 UseCase(프로토콜)로 되어 있고, Implement는 Interface의 UseCase들의 구현부들이 위치하고 있습니다.
Data Layer
FinanceData 프로젝트 내부의 구조입니다.
Domain과 동일하게 Interface, Implement, Tests 총 3개의 타겟이 폴더로 분리되어 있습니다.
Interface에는 다른 모듈이 사용할 때 필요한 Response, Request (DTO라고 보시면 될 것 같습니다), Repository로 구성됩니다.
Implement는 Interface의 Repository 프로토콜을 채택하여 구현한 객체들이 있습니다.
UserInterface(Presentation)
마지막으로 UserInterface입니다.
Interface, Implement, DemoApp으로 구성되어 있습니다.
//
// AddPaymentMethodUserInterface.swift
//
//
// Created by 김찬수 on 2023/03/21.
//
import Foundation
import RIBs
import RIBsUtil
import FinanceDomain
public protocol AddPaymentMethodBuildable: Buildable {
func build(withListener listener: AddPaymentMethodListener, closeButtonType: DismissButtonType) -> ViewableRouting
}
public protocol AddPaymentMethodListener: AnyObject {
func addPaymentMethodDidTapClose()
func addPaymentMethodDidAddCard(paymentMethod: PaymentMethod)
}
Interface는 다른 모듈에서 사용할 때 필요한 아이들로 되어 있습니다. 저는 RIBs를 사용했기 때문에 Buildable과 Listener 를 Interface에 두고, 나머지는 Implement에 두었습니다. ( 이유는 이론편에서,..)
나머지 구현의 경우는 일반적으로 RIBs를 사용하는 것과 동일하게 진행했습니다.
ResourceKit
ResourceKit에 Asset과 폰트들을 추가하고 나서 Tuist가 만들어주는 Static 상수들을 사용해서 다른 모듈에서 편하게 Asset을 사용할 수 있습니다.
github action
Tuist는 test라는 명령어를 통해서 manifest에서 설정한 unit test들을 모아서 테스트할 수 있는 test라는 명령어를 제공합니다.
해당 명령어를 사용해서 편하게 간단한 ci/cd를 구축할 수 있습니다.
name: ModulariedSuperApp
on:
pull_request:
branches: [ main ]
jobs:
build:
name: test action
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Install Tuist
run: curl -Ls <https://install.tuist.io> | bash
- name: Fetch Swift Packages
run: tuist fetch
- name: Generate Project
run: tuist generate
- name: Build and Test
#run: xcodebuild test -workspace ModularizedSuperApp.xcworkspace -scheme ModularizedSuperApp-Workspace -destination 'platform=iOS Simulator,name=iPhone 13 Pro,OS=latest' build test
run: tuist test ModularizedSuperApp-Workspace
순서는 아래와 같습니다.
- tuist를 github action 인스턴스에 설치
- tuist fetch
- tuist generate
- tuist test 스킴이름
위와 같이 간단하게 main 브랜치에 pr을 날릴 때 테스트를 진행하는 action을 만들 수 있습니다.
tuist test와 xcodebuild test 중 원하시는 것으로 사용하시면 되겠습니다.
마치며
모듈화를 해보면서 초기 세팅에서는 시간이 걸릴 수 있지만 기반이 생긴 이후에는 각 모듈별로 나눠서 작업을 할 수 있는 점이 큰 장점으로 다가왔습니다.
빌드를 Target별로 분리해서 진행하고 Testable하게 모듈을 분리할 수 있는 것도 장점이었습니다.
긴 글 읽어주셔서 감사합니다.
자세한 코드가 궁금하신 분은 아래의 링크에서 확인해주세요.
https://github.com/chansooo/ModularizedSuperApp
GitHub - chansooo/ModularizedSuperApp
Contribute to chansooo/ModularizedSuperApp development by creating an account on GitHub.
github.com
'🍎 iOS > ♻️ 모듈화' 카테고리의 다른 글
Modular Architecture 이론편 (0) | 2023.04.23 |
---|---|
Tuist 도입기 (0) | 2022.11.19 |