ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Tuist
    Tuist 2022. 7. 11. 07:59

    안녕하세요. 그린입니다🟢

    이번 포스팅에서는 대망의 Tuist에 대해 알아보고 도입해보겠습니다🙌

     

    우선 다들 Tuist에 대해 들어보셨나요?

    현업에서 프로젝트를 하고 계시다면 아마 사용하던 안하던 한번쯤 들어봤을수 있습니다.

    Tuist가 왜 다들 그렇게 필요하다고하고 좋다고 하는지 이번 포스팅에서 확실히 정리해볼께요!

     

    Tuist란?

    Tuist는 한마디로 정리하자면 Xcode 프로젝트를 생성하고 유지관리를 해주는데 도움을 주는 커맨드 도구입니다.

    XcodeGen이라는 녀석과 양대산맥이죠.

    예전에 제 포스팅에서 SwiftGen과 R.Swift를 다룬적이 있는데요.

    여기서도 마찬가지로 적용되요.

    즉, XcodeGen은 yml 혹은 json 언어 파일로 만들어줘야하고 그와 반면 Tuist는 이러한 명령을 swift언어로 설정함으로 조금 더 친화적이죠.

     

    Tuist를 쓰면 뭐가 좋을까?

    Tuist를 정의했던걸 곰곰히 생각해보면 됩니다.

    Xcode 프로젝트를 생성하고 유지관리 해주는게 실제적으로 어떤 도움이 될까요?

    우선 가장 큰 도움은 협업 시 빛을 발할 수 있습니다.

    여러명의 인원이 한 프로젝트의 여러 부분을 도맡아 개발하다 보면 분명 컨플릭이 나는 상황이 많이 만들어집니다.

    그 중 대부분은 xcodeproj파일 혹은 xcworkspace파일에서 컨플릭이 나죠.

    즉 이런 부분들은 파일 경로가 서로 다르거나 충돌이 나거나 하든 등등의 예기치 못한 이슈를 만들어내요.

    그런데 Tuist를 도입한다면 사실상 xcodeproj, xcworkspace 파일을 먼저 만들어두고 사용하는것이 아닌 커맨드 명령어를 통해 생성해줍니다.

    즉 프로젝트 자체를 해당 명령을 통해 그때그때 생성하고 Tuist의 Project.swift 파일을 통해 의존성 및 타겟, 프로젝트 설정들을 관리해줄 수 있어요.

    그렇게된다면 깃헙에 프로젝트를 올릴때 더이상 xcodeproj, xcworkspace 파일들을 올리지 않아도 되죠.

    작업자는 본인들이 Tuist 커맨드를 통해 프로젝트 파일을 생성하여 작업하면 끝입니다.

    결국 이러한 프로젝트 파일을 올리지 않음으로 해당 부분에서의 컨플릭을 유발시키지 않습니다🙌

    또한 타겟으로 모듈 분리가 되어있다면 더욱 Project.swift Tuist 설정 파일을 통해 설정을 도와 프로젝트의 관리가 쉽습니다.

    즉 정리하자면 효율적인 프로젝트 관리를 해줄 수 있습니다👍

     

    그럼 본격적으로 Tuist를 도입해보시죠!

     

    Tuist 설치하기

    $ bash <(curl -Ls https://install.tuist.io)

    해당 스크립트를 통해 우선 Tuist 도구를 설치할 수 있습니다.

     

    Tuist 설정을 위한 Project.swift 파일 생성하기

    그 다음으로 Tuist 명령을 통해 프로젝트를 생성하고 관리하기 위해선 이 설정들이 담길 파일이 있어야겠죠?

    그걸 위해 우리는 Project.swift라는 파일을 만들어볼께요!

    우선 각자 프로젝트가 만들어졌다는 가정하에 해당 프로젝트 경로로 가겠습니다.

    그 다음 터미널 커맨드에서 아래 명령어를 입력해 파일을 생성하겠습니다.

    아래 명령어 뭐 둘다 무방합니다.

    touch Project.swift

     

    tuist init --platform ios

    그럼 위와 같이 Project.swift라는 파일이 자동으로 생성되는것을 볼 수 있습니다.

    이제 이 파일에 실제적으로 프로젝트를 생성하고 관리할 수 있는 설정들을 넣어볼꺼에요.

    아..! 참고로 기존 생성된 xcodeproj 혹은 xcworkspace 파일은 제거하셔도 됩니다.

    어차피 Tuist 커맨드로 생성해줄거에요.

     

    Project.swift 파일 설정

    이제 셋팅해보죠!

    tuist edit

    위 명령어를 입력해서 들어가거나 아니면 만들어진 Project.swift 파일을 직접 오픈해도 됩니다.

    그 다음 해당 파일이 열리면 우선 Swift 언어라고 했죠?

    그렇기에 전부 스위프트 언어로 작성해줄 수 있어요.

    제일 처음 Tuist project DSL에 액세스하기 위해서 아래와 같이 import 해주고 시작합니다.

    import ProjectDescription

    그 다음 프로젝트와 타겟을 만들어주는것이 가장 중요합니다.

    우선 프로젝트는 아래와 같이 기본 설정을 해줄 수 있습니다.

    let project = Project(
      name: "Green",
      organizationName: "<YOUR_ORG_NAME_HERE>",
      settings: nil,
      targets: []
    )

    프로젝트의 이름, 조직명, 설정 그리고 타겟을 넣어줍니다.

    이게 기본 설정이라고 보시면 됩니다.

    그 다음 타겟이에요.

    Target(
      name: "Green",
      platform: .iOS,
      product: .app,
      bundleId: "<YOUR_BUNDLE_ID_HERE>",
      infoPlist: "Green/Info.plist",
      sources: ["Green/Source/**"],
      resources: ["Green/Resources/**"],
      dependencies: [],
      settings: nil
    )

    타겟을 만드는 기본 설정이라고 보면 됩니다.

    물론 이 모든건 각자에 맞게 커스텀하게 설정해줘야합니다.

    가장 중요한건 프로젝트를 만들기 위한 타겟을 설정해주고(모듈별로 타겟이 분리되어 있다면 편리하겠죠?) 그 타겟들을 가지고 프로젝트를 생성하는 과정이라고 생각하시면 됩니다.

    그럼 이 모든걸 제 나름대로 커스텀하게 구성해본 집합체를 공유해보겠습니다.

    import ProjectDescription
    
    // MARK: - Script Dictionary
    let scripts: [String: ProjectDescription.TargetScript] = [
      "SwiftGen": .pre(
        script: """
          if [[ -f "${PODS_ROOT}/SwiftGen/bin/swiftgen" ]]; then
            "${PODS_ROOT}/SwiftGen/bin/swiftgen"
          else
            echo "warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it."
          fi
          """,
        name: "SwiftGen"
      ),
      "SwiftLint": .pre(
        script: """
          ${PODS_ROOT}/SwiftLint/swiftlint
          """,
        name: "SwiftLint"
      ),
    ]
    
    
    // MARK: - Extension Project Description Target Script
    extension ProjectDescription.TargetScript {
      static func greenScript(_ name: String) -> ProjectDescription.TargetScript {
        return scripts[name]!
      }
    }
    
    // MARK: - Component Module
    let assets = Target(
      name: "Assets",
      platform: .iOS,
      product: .framework,
      bundleId: "com.iOS.Green.Assets",
      deploymentTarget: .iOS(targetVersion: "15.0", devices: .iphone),
      sources: SourceFilesList(
        globs: [
          .glob(.relativeToRoot("Module/Component/Assets/Source/**/*.*"))
        ]
      ),
      resources: ResourceFileElements(
        resources: [
          .glob(pattern: .relativeToRoot("Module/Component/Assets/Resource/**/*.*"))
        ]
      ),
      scripts: [
        .greenScript("SwiftGen")
      ],
      settings: .settings(
        base: .init()
          .swiftVersion("5.0")
          .setDynamicLibraryInstallNameBase()
          .setSkipInstall(true),
        debug: .init()
          .setBuildActiveArchitectureOnly(true)
          .setSwiftActiveComplationConditions("DEBUG"),
        release: .init()
          .setBuildActiveArchitectureOnly(false)
          .setSwiftActiveComplationConditions("RELEASE"),
        defaultSettings: .none
      )
    )
    
    let common = makeTarget(
      name: "Common",
      type: .component
    )
    
    
    // MARK: - Feature Module
    
    
    
    // MARK: - Modules List
    let componentModules: [Target] = [
      assets,
      common,
    ]
    
    let featureModules: [Target] = [
    
    ]
    
    let modules: [Target] = componentModules + featureModules
    
    
    // MARK: - AppTarget
    let green = Target(
      name: "Green",
      platform: .iOS,
      product: .app,
      bundleId: "com.iOS.Green",
      deploymentTarget: .iOS(targetVersion: "15.0", devices: .iphone),
      infoPlist: .file(path: .relativeToRoot("Green/Info.plist")),
      sources: SourceFilesList(
        globs: [
          .glob(.relativeToRoot("Green/App/Source/**/*.*")),
        ]
      ),
      resources: ResourceFileElements(resources: [
        .glob(pattern: .relativeToRoot("Green/App/Resource/**/*.*")),
      ]),
      scripts: [
        .greenScript("SwiftLint"),
      ],
      dependencies: modules.map { .target(name: $0.name) },
      settings: .settings(
        base: .init()
          .setProductBundleIdentifier()
          .setAssetcatalogCompilerAppIconName(),
        configurations: [
          .release(
            name: .release,
            xcconfig: .relativeToRoot("xcconfigs/Green.release.xcconfig")
          ),
          .debug(
            name: .debug,
            xcconfig: .relativeToRoot("xcconfigs/Green.debug.xcconfig")
          ),
        ]
      )
    )
    
    let targets: [Target] = [village] + modules
    
    
    // MARK: - Project
    let proejct = Project(
      name: "Green",
      organizationName: "Green",
      options: .options(textSettings: .textSettings(usesTabs: false, indentWidth: 2, tabWidth: 2)),
      settings: .settings(
        base: .init()
          .swiftVersion("5.0")
          .setAlwaysSearchUserPath()
          .setStripDebugSymbolsDuringCopy("NO")
          .setExcludedArchitectures(),
        debug: .init()
          .setBuildActiveArchitectureOnly(true)
          .setSwiftActiveComplationConditions("DEBUG"),
        release: .init()
          .setBuildActiveArchitectureOnly(false)
          .setSwiftActiveComplationConditions("RELEASE"),
        defaultSettings: .none
      ),
      targets: targets,
      schemes: [
        Scheme(
          name: "Green",
          buildAction: .buildAction(
            targets: ["Village"]
          ),
          runAction: .runAction(
            configuration: .release,
            executable: "Green"
          ),
          archiveAction: .archiveAction(
            configuration: .release
          ),
          profileAction: .profileAction(
            configuration: .release
          ),
          analyzeAction: .analyzeAction(
            configuration: .release
          )
        ),
        Scheme(
          name: "Green-dev",
          buildAction: .buildAction(
            targets: ["Green"]
          ),
          runAction: .runAction(
            configuration: .debug,
            executable: "Green"
          ),
          archiveAction: .archiveAction(
            configuration: .debug
          ),
          profileAction: .profileAction(
            configuration: .debug
          ),
          analyzeAction: .analyzeAction(
            configuration: .debug
          )
        ),
      ],
      additionalFiles: [
        .glob(pattern: .relativeToRoot("xcconfigs/Green.shared.xcconfig")),
      ],
      resourceSynthesizers: [
      ]
    )
    
    
    // MARK: - Extension Setting Dictionary
    extension SettingsDictionary {
      func setProductBundleIdentifier(_ value: String = "com.iOS$(BUNDLE_ID_SUFFIX)") -> SettingsDictionary {
        merging(["PRODUCT_BUNDLE_IDENTIFIER": SettingValue(stringLiteral: value)])
      }
    
      func setAssetcatalogCompilerAppIconName(_ value: String = "AppIcon$(BUNDLE_ID_SUFFIX)") -> SettingsDictionary {
        merging(["ASSETCATALOG_COMPILER_APPICON_NAME": SettingValue(stringLiteral: value)])
      }
    
      func setBuildActiveArchitectureOnly(_ value: Bool) -> SettingsDictionary {
        merging(["ONLY_ACTIVE_ARCH": SettingValue(stringLiteral: value ? "YES" : "NO")])
      }
    
      func setExcludedArchitectures(sdk: String = "iphonesimulator*", _ value: String = "arm64") -> SettingsDictionary {
        merging(["EXCLUDED_ARCHS[sdk=\(sdk)]": SettingValue(stringLiteral: value)])
      }
    
      func setSwiftActiveComplationConditions(_ value: String) -> SettingsDictionary {
        merging(["SWIFT_ACTIVE_COMPILATION_CONDITIONS": SettingValue(stringLiteral: value)])
      }
    
      func setAlwaysSearchUserPath(_ value: String = "NO") -> SettingsDictionary {
        merging(["ALWAYS_SEARCH_USER_PATHS": SettingValue(stringLiteral: value)])
      }
    
      func setStripDebugSymbolsDuringCopy(_ value: String = "NO") -> SettingsDictionary {
        merging(["COPY_PHASE_STRIP": SettingValue(stringLiteral: value)])
      }
    
      func setDynamicLibraryInstallNameBase(_ value: String = "@rpath") -> SettingsDictionary {
        merging(["DYLIB_INSTALL_NAME_BASE": SettingValue(stringLiteral: value)])
      }
    
      func setSkipInstall(_ value: Bool = false) -> SettingsDictionary {
        merging(["SKIP_INSTALL": SettingValue(stringLiteral: value ? "YES" : "NO")])
      }
    }
    
    
    // MARK: - Make target for static library
    func makeTarget(
      name: String,
      type: ModuleType,
      dependencies: [ProjectDescription.TargetDependency] = []
    ) -> Target {
      return Target(
        name: name,
        platform: .iOS,
        product: .staticLibrary,
        bundleId: "com.iOS.Green.\(name)",
        deploymentTarget: .iOS(targetVersion: "15.0", devices: .iphone),
        sources: SourceFilesList(
          globs: [
            .glob(.relativeToRoot("Module/\(type.targetFolderName)/\(name)/**/*.swift"))
          ]
        ),
        dependencies: dependencies,
        settings: .settings(
          base: .init()
            .setSkipInstall(true),
          debug: .init(),
          release: .init(),
          defaultSettings: .none
        )
      )
    }
    
    
    // MARK: - Module Type
    enum ModuleType {
      case feature
      case component
      
      var targetFolderName: String {
        switch self {
        case .feature:
          return "Feature"
        case .component:
          return "Component"
        }
      }
    }

    어우 조금 많이? 설정이 많기는 하지만.....

    간략히 설명을 드려볼께요.

    우선 저는 모듈화를 프로젝트에서 먼저 진행하고 그 모듈 타겟들을 가지고 프로젝트를 만들어주었어요.

    즉 모듈은 크게 Feature와 Component 그룹에서 각각 가지고 있습니다.

    우선 각 모듈의 Build Phases에서 돌아줄 스크립트들을 제일 처음 정의해줬어요.

    그 다음 타겟을 만듭니다.

    기본적으로 프레임워크가 아니면 대부분의 모듈들은 라이브러리로 구성될거기에 makeTarget이라는 메서드를 통해 공통 설정들을 담아주게된 static 라이브러리로 만들어주게 됩니다.

    그런데 asset의 경우 에셋을 포함하기에 프레임워크임으로 별도로 만들어줍니다.

    source와 resource 파일을 잘 지정해줘야합니다.

    source는 말그대로 build phases시 사용할 소스 파일이라고 보면되요.

    resource 그를 통해 만들어진 산출물로 프로젝트에서 사용될거에요.

    그 후 해당 모듈 타겟들을 하나로 합치고 이를 통해 프로젝트를 만듭니다.

    저는 여기서 릴리즈, 디버그 스키마까지 만들었는데 이는 편하신 방향으로 자유입니다.

    그리고 또하나 SettingDictionary를 익스텐션해 안에 메서드를 통해 타겟이나 프로젝트 셋팅 시 필요한 설정들을 해줄 수 있도록 해줬어요.

    예를들어 번들ID라던지 그런 부분들을 처리해주죠.

    이렇게하면 완성입니다!

    다 되었으면 Control + C를 통해 파일을 나가줍니다.

    아..! 중요한건 이 스크립트는 순서가 중요해요.

    예를들어 즉 스크립트를 지정해준것이 타겟을 설정하는 부분보다 아래로 가게되면 먼저 실행되기에 오류를 발생시킵니다.

    즉 정상 작동을 하지 않아요..!

    그리고 또 제 코드에서는 없지만 의존하는 관계가 있다면 꼭 순서를 맞춰주셔야합니다🙌

     

    그럼 한번 Tuist 파일도 다 생성 및 설정했겠다..! 작동시켜보죠ㅎㅎ

     

    Tuist 명령어를 통해 프로젝트 생성

    아래 명령어를 통해 간단히 작동 시켜줍니다.

    tuist generate

    정상적이라면 위와 같이 어떤 프로젝트 파일이 생성되었고 시간은 총 얼마 걸렸으며 하는 오류 메시지가 없는 클린한 성공을 보여줍니다.

    그리고 생성된 프로젝트 파일을 가보면 아주 잘 만들어져있는걸 확인할 수 있어요.

     

    마무리

    생각보다 그렇게 다뤄보면 어렵진 않았어요.

    타겟들이 잘 모듈화만 되어있으면 그냥 숟가락 하나 얻는 느낌..?

    물론 진짜 이건 실제로 해봐야지 감이옵니다🥲

    추가로, 팟이나 스크립트가 초기에 빌드 시 실패하고 그 다음부터 빌드가 성공하는 현상이 있어요.

    왜냐하면 그건 팟이나 스크립트를 이후 체크하기에 그런거 같아요.

    그렇기에 make 스크립트 파일을 만들어 Tuist를 generate 시키면서 같이 이런걸 체크하고 실행할 수 있도록도 해보려고해요!

    이는 바로 다음 포스팅에서 이뤄지겠으며 Tuist와 더불어 더 좋은 사용이 될것 같아요🙌

     

    [참고자료]

    https://github.com/tuist/tuist

     

    GitHub - tuist/tuist: 🚀 Create, maintain, and interact with Xcode projects at scale

    🚀 Create, maintain, and interact with Xcode projects at scale - GitHub - tuist/tuist: 🚀 Create, maintain, and interact with Xcode projects at scale

    github.com

    https://www.raywenderlich.com/24508362-tuist-tutorial-for-xcode

     

    Tuist Tutorial for Xcode

    Learn how to use Tuist to create and manage complex Xcode projects and workspaces on-the-fly.

    www.raywenderlich.com

Designed by Tistory.