UI 구성 요소의 공유 세트를 구현, 유지 관리, 문서화 및 발전시키는 것은 대규모 애플리케이션에서 어려운 일입니다. 웹 개발자들은 이 문제에 대한 강력한 솔루션인 Storybook.js( https://storybook.js.org )를 만들었습니다. 하지만 네이티브 iOS 개발은 어떻습니까? 우리도 비슷한 경험을 할 수 있을까요? (스포일러 경고: 예! )
하지만 먼저 원래 Storybook.js 개념을 살펴보겠습니다.
Storybook.js는 웹 개발자가 웹 애플리케이션용 UI 구성 요소를 별도로 만들고 테스트하는 데 사용하는 오픈 소스 도구입니다. 개발자가 웹사이트의 나머지 부분과 별도로 웹사이트의 개별 부분(예: 버튼, 양식, 탐색 모음)을 구축하고 선보일 수 있는 놀이터라고 생각하세요.
Storybook.js는 구성 요소에 대한 살아있는 문서 역할을 하며 코드 샘플과 함께 구성 요소가 다양한 상태와 시나리오에서 어떻게 보이고 동작하는지 확인할 수 있는 시각적 자료를 제공합니다.
Storybook.js의 장점은 UI 구성 요소가 동일한 환경에서 사용된다는 사실에 있습니다. 프로덕션 웹사이트에서든 스토리북 웹사이트에서든 우리는 여전히 웹 브라우저에서 이를 실행하고 있습니다. 이렇게 하면 구성 요소가 문서/플레이그라운드 및 프로덕션에서 일관되게 표시되어 동기화되지 않는 것을 방지할 수 있습니다.
웹 페이지는 훌륭한 읽기 경험, 쉬운 공유, 즉각적인 로딩 시간, 모든 장치 및 운영 체제에서의 보편적인 가용성을 제공하는 훌륭한 문서화 매체이기도 합니다.
그러나 iOS 앱 의 경우 이야기가 다릅니다.
iOS에서 UI 구성요소 개발과 관련된 도구 및 방법의 환경은 상당히 단편화되어 있습니다. Storybook.js 관점에서 살펴보겠습니다.
Interface Builder는 드래그 앤 드롭 인터페이스를 제공하므로 코드를 작성하지 않고도 UI를 쉽게 디자인할 수 있습니다. 일반적으로 개별 UI 컴포넌트 개발보다는 전체 화면에 기존 UI 컴포넌트를 레이아웃하는 데 사용됩니다. 다양한 UI 구성 요소 상태를 표시하고 일반적으로 복잡한 UI를 처리하는 데는 제대로 작동하지 않으며 종종 코드에서 많은 측면을 구현해야 합니다. 전반적으로 Apple을 포함한 많은 사람들은 이를 막다른 골목으로 간주합니다.
SwiftUI Previews는 Xcode 내에서 SwiftUI 보기를 개발하고 테스트하기 위해 Apple에서 만든 도구입니다.
SwiftUI 미리보기는 UIView에도 사용할 수 있습니다. https://sarunw.com/posts/xcode-previews-with-uiview를 참조하세요.
장점:
단점:
작동할 때 SwiftUI 미리보기는 "문서화" 측면을 제외하면 아마도 Storybook.js에 가장 가까운 경험일 것입니다.
다음으로 할 일은 소스 코드, 주석, 주석을 기반으로 문서를 생성하는 것입니다. 가장 주목할만한 예는 다음과 같습니다.
DocC - https://developer.apple.com/documentation/docc
재즈 - https://github.com/realm/jazzy
이러한 유형의 도구는 웹 페이지 형식으로 구성 요소 API 참조를 만드는 데 사용할 수 있습니다.
스냅샷 테스트는 구성 요소의 다양한 시각적 상태를 테스트하여 상황이 예기치 않게 변경되지 않도록 보장하는 격리된 개발을 위한 좋은 방법입니다.
iOS에서 스냅샷 테스트를 구현하는 가장 인기 있는 두 가지 라이브러리는 다음과 같습니다.
Storybook.js 경험을 위한 또 다른 확실한 선택은 맞춤형 기본 카탈로그 앱을 구축하는 것입니다.
장점:
단점:
몇 가지 예:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
https://github.com/eure/Storybook-ios
비록 사치스럽기는 하지만 멋진 아이디어 중 하나는 Appetize.io 서비스를 사용하여 Storybook.js 웹사이트에 기본 카탈로그 앱을 삽입하는 것입니다. 이를 통해 iOS 장치의 콘텐츠를 웹페이지( https://medium.com/@vasikarla )로 스트리밍할 수 있습니다. .raj/storybook-for-native-d772654c7133
네이티브 모바일 개발에는 Storyboard.js와 같은 경험에 필요한 비밀 요소, 즉 가장 접근하기 쉬운 플랫폼인 웹 사이트 내에서 문서, 플레이그라운드 및 프로덕션을 모두 실행하기 위한 단일 환경이 부족하다는 것이 사실입니다.
하지만 카탈로그 앱, 스냅샷 테스트, HTML 기반 문서 생성을 단일 시스템으로 결합하면 어떻게 될까요? 코드 조각을 한 번만 작성한 후 다음을 수행할 수 있다고 상상해 보십시오.
좋은 소식! 나는 그러한 시스템에 대한 개념 증명을 구축했습니다.
https://github.com/psharanda/NativeBook
한번 살펴보고 정리해보자!
NativeBook은 현재 UIView 기반 구성 요소에 중점을 두고 있습니다. SwiftUI 구성 요소도 비슷한 방식으로 통합할 수 있지만 이 문서에서는 다루지 않습니다.
스토리는 우리 시스템의 초석입니다. 본질적으로 이는 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
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) } } }
이 예에서는 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()) } }
이 작업에서는 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은 개념 증명이지만 몇 가지 추가 사항과 개발자의 작업 흐름에 대한 적절한 통합을 통해 많은 가치를 가져올 수 있습니다.
다음과 같은 개선 사항도 상상할 수 있습니다.