paint-brush
NativeBook:使用 Storybook.js 统一原生 iOS 开发体验by@psharanda
19,186
19,186

NativeBook:使用 Storybook.js 统一原生 iOS 开发体验

Pavel Sharanda15m2023/12/19
Read on Terminal Reader

Storybook.js 是 Web 开发人员用来独立创建和测试 UI 组件的工具,为单独构建和展示 UI 部分提供了一个平台。但是,对于本机 iOS 开发,没有直接等效的方法。本文介绍了“NativeBook”,这是一个拟议的系统,结合了目录应用程序、SwidtUI 预览、快照测试和 HTML 文档生成,为 iOS 创建类似 Storybook.js 的体验。虽然仍是一个概念验证,NativeBook 旨在简化 iOS UI 组件的开发和文档流程。
featured image - NativeBook:使用 Storybook.js 统一原生 iOS 开发体验
Pavel Sharanda HackerNoon profile picture
0-item

介绍

实现、维护、记录和发展一组共享的 UI 组件是大型应用程序中的一项挑战。 Web 开发人员为这个问题创建了一个强大的解决方案 - Storybook.js ( https://storybook.js.org )。但是原生iOS开发又如何呢?我们能否获得类似的体验? (剧透警告:是的!


但首先,让我们探讨一下最初的 Storybook.js 概念。

Storybook.js

Storybook.js 是 Web 开发人员用来独立创建和测试 Web 应用程序的 UI 组件的开源工具。将其视为一个游乐场,开发人员可以在其中与网站的其余部分分开构建和展示网站的各个部分(如按钮、表单和导航栏)。


Storybook.js 充当组件的动态文档,并提供视觉效果以查看组件在不同状态和场景下的外观和行为以及代码示例。


Button 组件的 Storybook.js 页面示例


Storybook.js 的优雅之处在于 UI 组件在同一环境中使用;无论是在制作网站还是故事书网站上,我们仍然在网络浏览器中运行它们。这确保了组件在我们的文档/游乐场和生产中看起来一致,防止它们不同步。


网页也是一种极好的文档媒介,可提供出色的阅读体验、轻松共享、即时加载时间以及在任何设备和操作系统上的通用可用性。


然而,对于iOS 应用程序来说,情况就不同了。

同时在 iOS 上

在 iOS 上,与 UI 组件开发相关的工具和方法非常分散。让我们从 Storybook.js 的角度来看看它们。

界面生成器

Interface Builder 提供拖放界面,无需编写代码即可轻松设计 UI。通常,它用于为整个屏幕布局现有的 UI 组件,而不是用于开发单个 UI 组件。一般来说,它在展示不同的 UI 组件状态和处理复杂的 UI 方面效果不佳,通常需要在代码中实现许多方面。总体而言,包括苹果在内的许多人都认为这是一条死胡同。

SwiftUI 预览

SwiftUI Previews 是 Apple 创建的一个工具,用于在 Xcode 中开发和测试 SwiftUI 视图。

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/hpenington/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 API 引入了一些有价值的附加功能,用于通过UIView.traitOverrides覆盖特征。此属性对于快照测试可访问性支持非常有用。对于我们的快照测试,我们将通过强制 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 生成可以使用 EJS 来完成,EJS 是一个强大的模板引擎,允许我们在模板中使用 JavaScript:

 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, } ) ); }


现在,将主函数中的所有内容组合起来:

 (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 设计嵌入到文档中
  • 与 DocC 或 Jazzy 等工具生成的文档合并,除了快照和代码片段之外,还可以获得全面的 API 参考
  • 添加对 SwiftUI 视图的适当支持
  • 自动生成SwiftUI预览代码