본문 바로가기

iOS

[Tuist] Tuist에 Contribute하기 - SwiftUI Font Template 지원

개발 중 느낀 SwiftUI Font의 필요성

최근 Tuist와 SwiftUI를 이용한 멀티플랫폼 프로젝트를 개발 중인데, tuist의 resource synthesizer가 제공하는 Font Template 내부에 SwiftUI의 Font가 존재하지 않아 편하게 사용할 수 있는 메서드가 존재하면 좋겠다고 생각했습니다.

 

Image 또는 Color의 경우는 아래와 같이 SwiftUI의 struct를 가져올 수 있는 메서드가 존재했기에 필요성이 더욱 크게 느껴졌습니다.

 

Tuist의 ResourceSynthesizer는 TuistGenerator Target에 존재하는 템플릿을 기반으로 아래와 같은 코드를 자동으로 생성해 줍니다

 

Fonts Template 파일을 살펴보니 swiftUIFont의 도입이 가능해 보였기에, tuist의 contribution guide를 읽기 시작했습니다.

Tuist contributor guide 읽기

tuist는 나름 문서화가 잘 되어 있는 오픈 소스라고 생각합니다. 여타 오픈소스와 마찬가지로 contributor guide를 제공합니다.

 

https://docs.tuist.io/contributors/get-started/

 

우선 tuist가 무엇인지부터 설명합니다. tuist에 관한 포스팅을 꽤 올렸지만 새삼스럽게 되새겨보면, tuist는 swift로 쓰인 command line interface이며, xcode의 복잡한 부분을 추상화하여 xcode projects를 구성할 수 있게 한다고 합니다. 이어서 CLI가 일반적인 iOS App과 다른 부분을 설명해 줍니다.

 

contributor guide를 읽기 전에 제가 기대했던 것은, tuist에 기능을 추가하고 테스트하는 방법을 찾을 수 있으리라는 점입니다. 앞서 언급했듯이 tuist는 swift로 만들어진 CLI이기 때문에, tuist 프로젝트를 build하면 마찬가지로 terminal 내부에서 사용할 수 있게 됩니다. 그 방법은 문서의 아랫 부분에 나와 있습니다.

 

 

방법은 일반적인 프로젝트의 관리 방식과 동일하나, 몇 가지의 run script를 실행해야 합니다.

우선 repo를 clone한 다음 nodejs의 버전과 ruby의 버전이 tuist 프로젝트에서 사용중인 버전과 동일한지 확인해야 합니다.

 

Tuist에서 Ruby Version을 관리해야 하는 이유

 

iOS 개발을 하며 Ruby를 간접적으로 접할 일이 많았는데, 이번 기회에 왜 ruby를 사용하는지에 대해 알아보았습니다.

 

1. DSL(Domain Specific Language)은 Ruby로 작성할 수 있다.

 

DSL이란 특정 분야의 문제를 해결하기 위해 작성된 언어입니다. 그리고 Ruby는 직관적인 문법과 동적 타이핑을 통해 DSL을 쉽게 작성할 수 있게 합니다.

 

2. Cocoapods는 Ruby로 작성된 DSL이다.

 

Cocoapods의 Podfile을 보면 Cocoapods만의 문법과 체계를 가지고 있습니다. 이것이 바로 DSL이며, cocoapods는 xcode porject의 의존성 관리를 위해 디자인된 DSL의 일종이라고 볼 수 있습니다.

 

3. tuist는 Cocoapods나 carthage가 담당하는 의존성 관리를 대신 담당해야 하는 동시에, 사용자에게 편리한 인터페이스를 제공해야 한다.

 

tuist는 프로젝트 관리 도구이기 때문에, 기존의 도구들이 수행하는 역할을 할 수 있어야 합니다. 호환성을 위해 Ruby를 택하는 것이 당연한 결과입니다.

 

Bundle Install (디버깅)과정

 

처음에 무턱대고 bundle install을 시도해보니 올바른 버전의 bundler가 존재하지 않아 오류가 발생했습니다.

 

/Library/Ruby/Site/2.6.0/rubygems.rb:263:in `find_spec_for_exe': can't find gem bundler (>= 0.a) with executable bundle (Gem::GemNotFoundException)
	from /Library/Ruby/Site/2.6.0/rubygems.rb:282:in `activate_bin_path'
	from /usr/local/bin/bundle:23:in `<main>'

 

그리고 bundler를 update하려고 보니 sudo 키워드를 붙였음에도 사용 중인 ruby version에 대한 권한이 없다는 오류가 나왔습니다.

 

글을 쓰는 시점에서야 깨달은 사실이지만 전부 ruby version이 낮아서 생긴 문제였습니다. 어찌저찌 rvm을 통해 여러 버전의 ruby를 사용하는 방법을 사용하게 되었습니다. tuist의 요구 버전은 3.0.3 이었는데, 제가 사용 중인 ruby의 version은 처참하게도 2.6.0이었습니다.

 

rvm install 3.0.3
rvm use 3.0.3

 

이후 bundler를 설치하고 bundle install을 하니 원하는 패키지들이 잘 다운로드 되나 싶었지만, 중간에 brew에서도 오류가 생겨서 brew를 최신화하는 과정도 거쳤습니다.

 

brew update
brew upgrade
brew tap --repair

 

이후 bundle install을 하니 완벽하게 다운로드가 되었습니다.

 

Tuist 프로젝트 환경 설정 및 구성

 

다시 Tuist로 돌아와서, ./fourier generate tuist를 입력하면 tuist 프로젝트에 필요한 환경을 자동으로 구성하고, 의존성을 추가해줍니다. 최종적으로 tuist.xcworksapce 파일이 생성됩니다. tuist repo를 살펴보면 일반적인 tuist project 구성에 필요한 것과 동일한 project 파일이 존재합니다. tuist project 또한 tuist cli를 통해 관리되고 있습니니다.

 

tuist로 관리되는 tuist 프로젝트

여기까지 tuist에 contribute하기 위한 환경 설정이 끝났습니다. 이제 기능을 구현하고, PR을 작성하는 일이 남았습니다.

SwiftUI Font 기능 구현하기

Fonts Template에 swiftUI Font를 추가로 구현하기 위해서는, 기존에 존재하는 template의 위치를 찾아야 합니다.

resource synthesizer를 통해 자동 생성될 템플릿은 아래 사진과 같이 String으로 관리됩니다. 

 

extension SynthesizedResourceInterfaceTemplates {
    static let fontsTemplate = """
    // swiftlint:disable all
    // swift-format-ignore-file
    // swiftformat:disable all
    // Generated using tuist — https://github.com/tuist/tuist

    {% if families %}
    {% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %}
    {% set fontType %}{{param.name}}FontConvertible{% endset %}
    #if os(macOS)
      import AppKit.NSFont
    #elseif os(iOS) || os(tvOS) || os(watchOS)
      import UIKit.UIFont
    #endif
    #if canImport(SwiftUI)
      import SwiftUI
    #endif

    ...
    
 }

 

코드를 살짝 훑어보면 lint 주석, 템플릿 엔진 문법, swift 문법이 함께 보입니다. 단순히 swift UI Font코드를 추가하는 일에는 다른 문법들을 알 필요가 없다고 판단하여 넘어갔습니다.

 

위의 코드를 template 엔진을 통해 자동생성하면 아래와 같은 코드로 실제 프로젝트에 추가됩니다.

 

// MARK: - Implementation Details

public struct DesignSystemIosFontConvertible {
  public let name: String
  public let family: String
  public let path: String

  #if os(OSX)
  public typealias Font = NSFont
  #elseif os(iOS) || os(tvOS) || os(watchOS)
  public typealias Font = UIFont
  #endif

  public func font(size: CGFloat) -> Font {
    guard let font = Font(font: self, size: size) else {
      fatalError("Unable to initialize font '\(name)' (\(family))")
    }
    return font
  }

  public func register() {
    // swiftlint:disable:next conditional_returns_on_newline
    guard let url = url else { return }
    CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
  }

  fileprivate var url: URL? {
    // swiftlint:disable:next implicit_return
    return Bundle.module.url(forResource: path, withExtension: nil)
  }
}

public extension DesignSystemIosFontConvertible.Font {
  convenience init?(font: DesignSystemIosFontConvertible, size: CGFloat) {
    #if os(iOS) || os(tvOS) || os(watchOS)
    if !UIFont.fontNames(forFamilyName: font.family).contains(font.name) {
      font.register()
    }
    #elseif os(OSX)
    if let url = font.url, CTFontManagerGetScopeForURL(url as CFURL) == .none {
      font.register()
    }
    #endif

    self.init(name: font.name, size: size)
  }
}
// swiftlint:enable all
// swiftformat:enable all

 

최신 버전이 아니라 repo에 존재하는 코드와는 차이가 있지만, 전반적인 맥락을 보는 데에는 문제가 없습니다. 각 platform에 따른 Font의 Type을 Typealias를 통해 관리하고 있습니다. 프로젝트에서 실질적으로 사용하게 되는 font(size: CGFloat) -> Font 메서드는 내부에서 convenience init을 통해 Font가 시스템에 등록되지 않은 경우 등록해주고 있습니다.

 

해당 코드에서 SwiftUIFont의 Getter를 추가하기 위해서, main이 되는 로직을 건드리지 않고 재사용 하고 싶었습니다. 따라서 아래와 같이 디자인했습니다.

 

  #if canImport(SwiftUI)
  @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *)
  {{accessModifier}} func swiftUIFont(size: CGFloat) -> SwiftUI.Font {
    guard let font = Font(font: self, size: size) else {
      fatalError("Unable to initialize font '\\(name)' (\\(family))")
    }
    #if os(macOS)
    return SwiftUI.Font.custom(font.fontName, size: font.size)
    #elseif os(iOS) || os(tvOS) || os(watchOS)
    return SwiftUI.Font(font)
    #endif
  }
  #endif

 

1. canImport : SwiftUI를 import 할 수 있는 경우에만 컴파일하도록 합니다.

 

2. 내부에 사용된 Font.custom() 및 Font() 메서드의 사용 가능한 최소 버전을 명시합니다.

 

3. 기존에 존재하던 font(size:)와 같은 방식으로 Font를 생성하고 register하도록 합니다.

 

4. Platform에 따라 다른 방식으로 SwiftUI Font를 생성하도록 합니다. macOS의 경우 NSFont를 SwiftUI.Font로 직접 생성할 수 있는 방법이 없었기에, custom 메서드를 사용했습니다.

Local에서 Tuist 테스트해보기

이제 기능을 구현했으니, 실제 프로젝트에서 잘 적용되는지 확인해야 합니다. 변경된 Tuist를 어떻게 적용해 볼 수 있을까요? 힌트는 Tuist가 Swift로 작성되었으며 Xcode Project로 관리되는 CLI라는 점입니다. Project이기에 macOS 프로그램으로 실행할 수 있으며, Xcode에서 관련 기능들을 제공합니다. Contributor Guide에서는 하위 모듈은 ProjectDescription과 ProjectAutomation을 빌드한 다음에, tuist 스킴을 이용하여 tuist 명령어를 실행할 수 있다고 합니다.

 

1. scheme에서 원하는 argument 생성 후 체크하기

 

2. 테스트할 Tuist Project가 존재하는 경로 지정하기

 

가장 아래에 보이는 경로는 제가 테스트하고 싶은 Tuist Project의 위치입니다. 이와 같이 지정하고 Scheme을 Run하면, Xcode의 Console에서 명령이 실행되는 것을 확인할 수 있습니다.

 

 

3. 실제 Target에서 테스트하기. MacOS 및 iOS Target에서 테스트해 보았습니다.

 

 

Lint Fix 및 Squash, PR

 

위와 같이 기능을 구현한 다음 커밋을 진행하고 PR을 올렸습니다. 여기서 maintainer가 approve를 하면 CI가 돌아가는데, tuist의 경우 40가지 이상의 test가 존재했습니다. 결과를 보니 lint test에서 실패가 발생했습니다. 여러 번의 코드 수정 과정에서 lint 오류가 생긴 것이 원인이었습니다.

 

 

./fourier lint tuist --fix를 실행하니 1.5초 안에 틀린 부분을 잡아줍니다. tuist와 같은 오픈소스는 보통 하나의 변경사항에 하나의 commit으로 관리하기 때문에 squash 이후 force push하여 PR의 commit을 교체했습니다. 이후 모든 테스트를 통과하니 몇 시간 내에 PR이 Merge 되었습니다.

오류 수정하기

Merge 이후 기능을 사용하려고 살펴보니, macOS에서 font의 size 프로퍼티를 잘못 사용하여 오류가 나고 있었습니다. 미리 확인을 했었던 부분인데, 중간에 수정 과정에서 실수를 했습니다. 실수를 빠르게 복구해야 할 책임이 있다고 생각했기에, 빠르게 size -> pointSize로 수정하고 다시 PR을 올렸습니다.

 

 

그런데 이번에는 acceptance test - precompiled에서 에러가 나고 있습니다. acceptance test는 tuist에서 제공하는 fixtures에 대해 직접 generate, build, archive의 작업을 수행하는 테스트로, 가장 오랜 시간이 걸리지만 coverage가 큰 테스트입니다. 아래 코드에서 볼 수 있듯이 Ruby 언어로 작성된 BDD 프레임워크인 cucumber를 통해 acceptance test가 수행됩니다.

 

Feature: A set of tests that run with pre-compiled binaries that are only compatible with a specific version of Swift

  Scenario: The project is an iOS application with frameworks and tests (ios_app_with_static_frameworks)
    Given that tuist is available
    And I have a working directory
    Then I copy the fixture ios_app_with_static_frameworks into the working directory
    Then tuist generates the project
    Then I should be able to build for iOS the scheme App
    Then I should be able to test for iOS the scheme App
    Then I should be able to build for iOS the scheme A
    Then I should be able to build for iOS the scheme B
    
  Scenario: The project is an iOS application with frameworks and tests (ios_app_with_static_libraries)
    Given that tuist is available
    And I have a working directory
    Then I copy the fixture ios_app_with_static_libraries into the working directory
    Then tuist generates the project
    Then I should be able to build for iOS the scheme App
    Then I should be able to test for iOS the scheme App
    Then I should be able to build for iOS the scheme A
    Then I should be able to build for iOS the scheme B

 

본론으로 돌아와서, precompiled test는 말 그대로 precompiled된 타겟에 대해 수행하는 test이며, 제 경우에는 xcframework를 다루는 부분에서 아카이빙 오류가 났습니다. 해당 fixture를 살펴 보니 fontTemplate을 사용하는 부분이 없었고, 논리적으로 acceptance에 영향을 줄 부분은 없었으니 CI의 오류라고 생각했습니다.

 

근거를 확실하게 마련하기 위해 local에서 실패하는 test를 running하려 했습니다. local에서 해당 테스트를 실행할 방법을 찾기 위해 문서와 코드베이스를 뒤졌고, Ruby 언어에 대해서도 조금 공부한 끝에, 아래 커맨드를 입력하면 된다는 것을 알게 되었습니다.

 

./fourier test tuist acceptance projects/tuist/features/precompiled.feature

 

로컬에서 돌렸더니 결과는 성공이었습니다. 따라서 해당 테스트 결과를 캡쳐해서 PR Comment에 올렸고, 메인테이너의 승인을 기다리는 상황입니다.


이번이 두 번째 오픈 소스 Contribution입니다. 지난 번에는 단순히 오픈 소스 contribute라는 과정 자체를 경험해 보고 싶었기에 typo를 개선했지만, 이번에는 순수하게 기능의 '필요성'을 느끼고 진행했기에 더욱 값지게 느껴집니다. tuist는 평소에 개발 과정에서 모듈화 및 project 관리를 위해 애용하는 도구입니다. contribute 과정을 통해 tuist에 대한 이해가 한층 깊어졌고, Ruby라는 언어에 대해 관심을 가지는 계기가 되었네요!