paint-brush
NativeBook: Storybook.js로 기본 iOS 개발 경험 통합~에 의해@psharanda
2,802 판독값
2,802 판독값

NativeBook: Storybook.js로 기본 iOS 개발 경험 통합

~에 의해 Pavel Sharanda15m2023/12/19
Read on Terminal Reader

너무 오래; 읽다

Storybook.js는 웹 개발자가 UI 구성 요소를 독립적으로 생성 및 테스트하는 데 사용하는 도구로, UI 부분을 별도로 구축하고 표시하기 위한 놀이터를 제공합니다. 그러나 기본 iOS 개발의 경우 직접적으로 동등한 것은 없습니다. 이 기사에서는 카탈로그 앱, SwidtUI 미리보기, 스냅샷 테스트 및 HTML 문서 생성을 결합하여 iOS용 Storybook.js와 유사한 경험을 생성하는 제안된 시스템인 "NativeBook"을 소개합니다. 여전히 개념 증명이기는 하지만 NativeBook은 iOS UI 구성 요소 개발 및 문서화 프로세스를 간소화하는 것을 목표로 합니다.
featured image - NativeBook: Storybook.js로 기본 iOS 개발 경험 통합
Pavel Sharanda HackerNoon profile picture
0-item

소개

UI 구성 요소의 공유 세트를 구현, 유지 관리, 문서화 및 발전시키는 것은 대규모 애플리케이션에서 어려운 일입니다. 웹 개발자들은 이 문제에 대한 강력한 솔루션인 Storybook.js( https://storybook.js.org )를 만들었습니다. 하지만 네이티브 iOS 개발은 어떻습니까? 우리도 비슷한 경험을 할 수 있을까요? (스포일러 경고: 예! )


하지만 먼저 원래 Storybook.js 개념을 살펴보겠습니다.

스토리북.js

Storybook.js는 웹 개발자가 웹 애플리케이션용 UI 구성 요소를 별도로 만들고 테스트하는 데 사용하는 오픈 소스 도구입니다. 개발자가 웹사이트의 나머지 부분과 별도로 웹사이트의 개별 부분(예: 버튼, 양식, 탐색 모음)을 구축하고 선보일 수 있는 놀이터라고 생각하세요.


Storybook.js는 구성 요소에 대한 살아있는 문서 역할을 하며 코드 샘플과 함께 구성 요소가 다양한 상태와 시나리오에서 어떻게 보이고 동작하는지 확인할 수 있는 시각적 자료를 제공합니다.


Button 구성 요소에 대한 Storybook.js 페이지의 예


Storybook.js의 장점은 UI 구성 요소가 동일한 환경에서 사용된다는 사실에 있습니다. 프로덕션 웹사이트에서든 스토리북 웹사이트에서든 우리는 여전히 웹 브라우저에서 이를 실행하고 있습니다. 이렇게 하면 구성 요소가 문서/플레이그라운드 및 프로덕션에서 일관되게 표시되어 동기화되지 않는 것을 방지할 수 있습니다.


웹 페이지는 훌륭한 읽기 경험, 쉬운 공유, 즉각적인 로딩 시간, 모든 장치 및 운영 체제에서의 보편적인 가용성을 제공하는 훌륭한 문서화 매체이기도 합니다.


그러나 iOS 앱 의 경우 이야기가 다릅니다.

그동안 iOS에서는

iOS에서 UI 구성요소 개발과 관련된 도구 및 방법의 환경은 상당히 단편화되어 있습니다. Storybook.js 관점에서 살펴보겠습니다.

인터페이스 빌더

Interface Builder는 드래그 앤 드롭 인터페이스를 제공하므로 코드를 작성하지 않고도 UI를 쉽게 디자인할 수 있습니다. 일반적으로 개별 UI 컴포넌트 개발보다는 전체 화면에 기존 UI 컴포넌트를 레이아웃하는 데 사용됩니다. 다양한 UI 구성 요소 상태를 표시하고 일반적으로 복잡한 UI를 처리하는 데는 제대로 작동하지 않으며 종종 코드에서 많은 측면을 구현해야 합니다. 전반적으로 Apple을 포함한 많은 사람들은 이를 막다른 골목으로 간주합니다.

SwiftUI 미리보기

SwiftUI Previews는 Xcode 내에서 SwiftUI 보기를 개발하고 테스트하기 위해 Apple에서 만든 도구입니다.

SwiftUI 미리보기는 UIView에도 사용할 수 있습니다. https://sarunw.com/posts/xcode-previews-with-uiview를 참조하세요.

장점:

  • 격리된 개발에 적합
  • 핫 리로드 지원
  • 실제 iOS 시뮬레이터를 내부적으로 사용해 보세요
  • 코드 조각 옆에 라이브 구성 요소 표시
  • Dynamic형, Dark모드 테스트에 편리함

단점:

  • 개발 환경이 올바르게 구성된 상태에서 Xcode를 설치해야 합니다.
  • 빌드 시간이 길어질 수 있음
  • 불안정하게 작동하고 자주 충돌함
  • 리소스가 많이 들고 고급 시스템에서도 느리게 실행됨


작동할 때 SwiftUI 미리보기는 "문서화" 측면을 제외하면 아마도 Storybook.js에 가장 가까운 경험일 것입니다.

HTML 기반 문서 생성

다음으로 할 일은 소스 코드, 주석, 주석을 기반으로 문서를 생성하는 것입니다. 가장 주목할만한 예는 다음과 같습니다.

DocC - https://developer.apple.com/documentation/docc

재즈 - https://github.com/realm/jazzy


이러한 유형의 도구는 웹 페이지 형식으로 구성 요소 API 참조를 만드는 데 사용할 수 있습니다.


Apple 개발자 문서의 대부분은 DocC를 사용하여 작성되었습니다.

스냅샷 테스트

스냅샷 테스트는 구성 요소의 다양한 시각적 상태를 테스트하여 상황이 예기치 않게 변경되지 않도록 보장하는 격리된 개발을 위한 좋은 방법입니다.


iOS에서 스냅샷 테스트를 구현하는 가장 인기 있는 두 가지 라이브러리는 다음과 같습니다.

맞춤형 카탈로그 앱

Storybook.js 경험을 위한 또 다른 확실한 선택은 맞춤형 기본 카탈로그 앱을 구축하는 것입니다.


장점:

  • 프로덕션과 동일한 환경 사용: iOS 시뮬레이터 또는 장치
  • 화면에 실제 구성요소를 표시합니다.
  • 접근성, 국제화 및 어두운 모드를 테스트할 수 있습니다.

단점:

  • 앱입니다. 이는 즉각적인 경험이 아니며 이를 구축하고 실행하는 것이 추가로 번거롭습니다.
  • 문서화 경험이 전혀 부족합니다.


몇 가지 예:

https://github.com/aj-bartocci/Storybook-SwiftUI

https://github.com/hpennington/SwiftBook

https://github.com/eure/Storybook-ios


https://github.com/eure/Storybook-ios의 카탈로그 경험


비록 사치스럽기는 하지만 멋진 아이디어 중 하나는 Appetize.io 서비스를 사용하여 Storybook.js 웹사이트에 기본 카탈로그 앱을 삽입하는 것입니다. 이를 통해 iOS 장치의 콘텐츠를 웹페이지( https://medium.com/@vasikarla )로 스트리밍할 수 있습니다. .raj/storybook-for-native-d772654c7133

또 하나의 아이디어

네이티브 모바일 개발에는 Storyboard.js와 같은 경험에 필요한 비밀 요소, 즉 가장 접근하기 쉬운 플랫폼인 웹 사이트 내에서 문서, 플레이그라운드 및 프로덕션을 모두 실행하기 위한 단일 환경이 부족하다는 것이 사실입니다.


하지만 카탈로그 앱, 스냅샷 테스트, HTML 기반 문서 생성을 단일 시스템으로 결합하면 어떻게 될까요? 코드 조각을 한 번만 작성한 후 다음을 수행할 수 있다고 상상해 보십시오.

  • 기본 카탈로그 앱의 코드 조각에서 생성된 보기 표시
  • SwiftUI 미리보기에 뷰 포함
  • 스니펫에 대한 스냅샷 테스트 실행
  • 생성된 HTML 기반 문서에 이 코드 조각과 결과 스냅샷을 표시합니다.


좋은 소식! 나는 그러한 시스템에 대한 개념 증명을 구축했습니다.

https://github.com/psharanda/NativeBook


한번 살펴보고 정리해보자!


NativeBook은 현재 UIView 기반 구성 요소에 중점을 두고 있습니다. SwiftUI 구성 요소도 비슷한 방식으로 통합할 수 있지만 이 문서에서는 다루지 않습니다.

NativeBook 구현

스토리

스토리는 우리 시스템의 초석입니다. 본질적으로 이는 UI 구성 요소의 '흥미로운' 상태를 보여주는 명명된 코드 조각입니다. 간단한 구조체로 표현될 수 있습니다:

 struct Story { let name: String let makeView: () -> UIView? }


다음 개체는 구성 요소에 대한 스토리 목록을 포함하는 ComponentStories 입니다.

 protocol ComponentStories { var componentName: String { get } var stories: [Story] { get } }


그렇다면 스니펫을 어떻게 선언할까요? 더 쉬운 구문 분석과 편의를 위해 함수는 story_ 로 시작하는 특별한 명명 규칙을 가질 수 있습니다. 일반적으로 스토리의 각 코드 조각은 다음과 같습니다.

  • 뷰를 생성합니다
  • 구성합니다(너비/높이 제한 설정이 포함될 수 있음).
  • 뷰를 반환합니다.


다음은 스니펫의 예입니다.

 @objc static func story_RoundedCornerButton() -> UIView { let button = UIButton() var config = UIButton.Configuration.filled() config.title = "Rounded" config.cornerStyle = .medium button.configuration = config return button }


Objective-C의 동적 특성을 활용하여 스토리 작성 경험을 향상시키고 상용구의 양을 줄일 수 있습니다. 아이디어는 store_ 로 시작하는 모든 클래스 선택자를 검색하고 그로부터 스토리 목록을 구성할 수 있는 기본 클래스를 갖는 것입니다.

 class DynamicComponentStories: NSObject, ComponentStories { var componentName: String { return NSStringFromClass(type(of: self)) .split(separator: ".").last! .replacingOccurrences(of: "Stories", with: "") } private(set) lazy var stories: [Story] = { var methodCount: UInt32 = 0 guard let methodList = class_copyMethodList(object_getClass(type(of: self)), &methodCount) else { return [] } var uniqueSelectors = Set<String>() var selectors = [Selector]() for i in 0 ..< Int(methodCount) { let selector = method_getName(methodList[i]) let name = NSStringFromSelector(selector) if name.starts(with: "story_") { if !uniqueSelectors.contains(name) { uniqueSelectors.insert(name) selectors.append(selector) } } } free(methodList) let cls = type(of: self) return selectors.map { selector in Story(name: NSStringFromSelector(selector).replacingOccurrences(of: "story_", with: "")) { cls.perform(selector).takeUnretainedValue() as? UIView } } }() }


예를 들어 UILabel의 스토리를 호스팅하는 클래스는 다음과 같이 구성될 수 있습니다.

 class UILabelStories: DynamicComponentStories { @objc static func story_BasicLabel() -> UIView { … } @objc static func story_FixedWidthLabel() -> UIView { … } }

카탈로그 앱

가장 간단한 형태의 카탈로그 앱은 일반적으로 두 개의 화면으로 구성됩니다.


메인 화면: 이 화면에는 구성 요소 목록과 관련 스토리가 표시됩니다.

 class StorybookViewController: UITableViewController { let componentsStories: [ComponentStories] init(componentsStories: [ComponentStories]) { self.componentsStories = componentsStories super.init(style: .plain) title = "UIKit: NativeBook" } override func numberOfSections(in _: UITableView) -> Int { return componentsStories.count } override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { return componentsStories[section].stories.count } override func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? { return componentsStories[section].componentName } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "CellId") ?? UITableViewCell() cell.textLabel?.text = componentsStories[indexPath.section].stories[indexPath.row].name return cell } override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { let storyViewController = StoryViewController(story: componentsStories[indexPath.section].stories[indexPath.row]) navigationController?.pushViewController(storyViewController, animated: true) } }


세부정보 화면: 이 화면에서는 너비나 높이를 제한하지 않고 중앙에 구성 요소를 표시합니다. 이러한 속성을 정의하는 것은 스니펫에 달려 있기 때문입니다.

 class StoryViewController: UIViewController { let story: Story init(story: Story) { self.story = story super.init(nibName: nil, bundle: nil) title = story.name } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .secondarySystemBackground guard let storyView = story.makeView() else { return } view.addSubview(storyView) storyView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ storyView.centerXAnchor.constraint(equalTo: view.centerXAnchor), storyView.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) } }


전체 카탈로그 흐름은 다음과 같은 방식으로 초기화될 수 있습니다.

 let vc = StorybookViewController(componentsStories: [ UILabelStories(), UIButtonStories(), UITextFieldStories(), ]) let nc = UINavigationController(rootViewController: vc) window.rootViewController = nc 


네이티브북 카탈로그


SwiftUI 미리보기

https://sarunw.com/posts/xcode-previews-with-uiview/ 에서 PreviewContainer 아이디어와 구현을 가져올 수 있습니다.

 struct PreviewContainer<T: UIView>: UIViewRepresentable { let view: T init(_ viewBuilder: @escaping () -> T) { view = viewBuilder() } func makeUIView(context: Context) -> T { return view } func updateUIView(_ view: T, context: Context) { view.setContentHuggingPriority(.defaultHigh, for: .horizontal) view.setContentHuggingPriority(.defaultHigh, for: .vertical) } }


미리보기를 이렇게 간단하게 작성하세요.

 struct UIButton_Previews: PreviewProvider { static var previews: some View { ForEach(UIButtonStories().stories, id: \.name) { story in PreviewContainer { story.makeView()! } .previewDisplayName(story.name) } } } 


SwiftUI 미리보기 + NativeBook 스토리

스냅샷 테스트

이 예에서는 iOSSnapshotTestCase 라이브러리를 사용합니다.


iOS 17에서는 UIView.traitOverrides를 통해 특성을 재정의하기 위해 UIView API에 몇 가지 유용한 추가 기능이 도입되었습니다. 이 속성은 스냅샷 테스트 접근성 지원에 매우 유용합니다. 스냅샷 테스트에서는 RTL, 다크 모드 및 일부 동적 유형 범주를 적용하여 다양한 조건에서 동일한 뷰를 테스트할 예정입니다.


traitOverrides 테스트하려면 drawViewHierarchy(in:afterScreenUpdates:) 메서드를 사용해야 하고 테스트 중인 뷰가 앱의 키 UIWindow 에 추가되었는지 확인해야 한다는 점을 기억하는 것이 중요합니다.

 class NativeBookSnapshotTestCase: FBSnapshotTestCase { override func setUp() { super.setUp() usesDrawViewHierarchyInRect = true } func runTests(for componentStories: ComponentStories) { for story in componentStories.stories { if let view = story.makeView() { NativeBookVerifyView(view, storyName: story.name) } } } private func NativeBookVerifyView(_ view: UIView, storyName: String) { let window = UIApplication.shared.connectedScenes .compactMap { ($0 as? UIWindowScene)?.keyWindow }.last! window.addSubview(view) view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ view.centerXAnchor.constraint(equalTo: window.centerXAnchor), view.centerYAnchor.constraint(equalTo: window.centerYAnchor), ]) // testing a view for different dynamic type modes let contentSizeCategories: [UIContentSizeCategory: String] = [ .extraSmall: "xs", .medium: "m", .large: "default", .extraLarge: "xl", .extraExtraExtraLarge: "xxxl", ] for (category, categoryName) in contentSizeCategories { view.traitOverrides.preferredContentSizeCategory = category window.layoutIfNeeded() FBSnapshotVerifyView(view, identifier: storyName + "__" + categoryName) } // testing dark mode support view.traitOverrides.preferredContentSizeCategory = .large view.traitOverrides.userInterfaceStyle = .dark window.layoutIfNeeded() FBSnapshotVerifyView(view, identifier: storyName + "__dark") // testing RTL support view.traitOverrides.layoutDirection = .rightToLeft view.semanticContentAttribute = .forceRightToLeft view.traitOverrides.userInterfaceStyle = .light window.layoutIfNeeded() FBSnapshotVerifyView(view, identifier: storyName + "__rtl") view.removeFromSuperview() } }


실제로 이러한 준비가 완료되면 테스트 사례를 만드는 것이 매우 간단해집니다.

 final class UIButtonTests: NativeBookSnapshotTestCase { func test() { // recordMode = true runTests(for: UIButtonStories()) } } 


NativeBookVerifyView에서 생성된 참조 스냅샷

문서 생성기

이 작업에서는 Node.js, TypeScript 및 EJS 템플릿과 같은 다른 스택을 사용할 것입니다. HTML 기반 페이지에 기본으로 제공되는 도구를 사용하여 작업하는 것이 훨씬 쉽습니다.


가장 먼저 필요한 것은 코드 조각과 스냅샷 테스트를 연결할 수 있는 일종의 구성 파일입니다. 간단한 JSON 파일은 이 목적에 적합합니다.

 { "components": [ { "name": "UILabel", "storiesFilePath": "NativeBook/StorySets/UILabelStories.swift", "snapshotsFolderPath": "Ref/ReferenceImages_64/NativeBookTests.UILabelTests" }, … ] }


간단한 Node.js 앱을 만든 후 모델을 정의해 보겠습니다.

 interface Story { name: string; codeSnippet: string; } interface Component { name: string; stories: Story[]; snapshotsFolderPath: string; }


흥미로운 과제는 Swift 파일에서 코드 조각을 추출하는 방법입니다. 이는 정규식을 사용하여 매우 간단하게 달성할 수 있습니다.

 function storiesFromFile(filePath: string): Story[] { const sourceCode = fs.readFileSync(repoRoot + filePath, "utf-8"); const codeSnippetRegex = /func\s+story_(?<name>[A-Za-z0-9_]+)\(\)\s*->\s*UIView\s*{(?<codeSnippet>[\s\S]*?)return/g; const result: Story[] = []; let match; while ((match = codeSnippetRegex.exec(sourceCode)) !== null && match.groups) { result.push({ name: match.groups["name"], codeSnippet: formatMultilineString(match.groups["codeSnippet"]), }); } return result; }


HTML 생성은 템플릿 내에서 JavaScript를 사용할 수 있게 해주는 강력한 템플릿 엔진인 EJS를 사용하여 수행할 수 있습니다.

 function renderIndex(components: Component[]) { fs.writeFileSync( siteDir + "/index.html", ejs.render( fs.readFileSync(__dirname + "/templates/index.html.ejs", "utf-8"), { components: components, } ) ); }


 function renderComponent(component: Component) { fs.writeFileSync( siteDir + `/${component.name}.html`, ejs.render( fs.readFileSync(__dirname + "/templates/component.html.ejs", "utf-8"), { component: component, } ) ); }


이제 main 함수의 모든 것을 결합합니다.

 (function () { // load config const nativeBookConfigContents = fs.readFileSync( repoRoot + "native_book_config.json", "utf-8" ); const config = JSON.parse(nativeBookConfigContents); // gather information for a component const components: Component[] = []; for (const componentJson of config["components"]) { components.push({ name: componentJson["name"], stories: storiesFromFile(componentJson["storiesFilePath"]), snapshotsFolderPath: componentJson["snapshotsFolderPath"], }); } // prepare site folder if (fs.existsSync(siteDir)) { fs.rmSync(siteDir, { recursive: true }); } fs.mkdirSync(siteDir, { recursive: true }); // render html site renderIndex(components); for (const component of components) { renderComponent(component); } })();

문서 사이트

데모는 https://psharanda.github.io/NativeBook 에서 볼 수 있습니다.


NativeBook 저장소에서 문서를 생성하려면 다음 명령을 수행해야 합니다.

 cd native_book_gen npm install npm run native-book-gen


아니면 간단히 다음을 실행하세요:

 sh generate_native_book.sh


문서는 docs 폴더에 나타납니다.


NativeBook에서 생성된 UIButton 참조


다양한 접근성 모드, 다크 모드, RTL에 대한 스냅샷을 확인할 수 있습니다!


무엇 향후 계획?

바라건대, 당신은 여행을 즐겼기를 바랍니다. 현재 상태의 NativeBook은 개념 증명이지만 몇 가지 추가 사항과 개발자의 작업 흐름에 대한 적절한 통합을 통해 많은 가치를 가져올 수 있습니다.


다음과 같은 개선 사항도 상상할 수 있습니다.

  • Android에 대한 지원 추가: 우리는 두 플랫폼을 모두 지원할 수 있으며 플랫폼 간에 쉽게 전환할 수 있는 일관된 카탈로그, 스냅샷 테스트 및 문서를 보유하고 있습니다.
  • Figma 디자인을 문서에 포함시키기
  • 스냅샷 및 코드 조각 외에도 포괄적인 API 참조를 얻기 위해 DocC 또는 Jazzy와 같은 도구로 생성된 문서와 병합
  • SwiftUI 보기에 대한 적절한 지원 추가
  • SwiftUI 미리보기 코드 자동 생성