实现、维护、记录和发展一组共享的 UI 组件是大型应用程序中的一项挑战。 Web 开发人员为这个问题创建了一个强大的解决方案 - Storybook.js ( https://storybook.js.org )。但是原生iOS开发又如何呢?我们能否获得类似的体验? (剧透警告:是的! )
但首先,让我们探讨一下最初的 Storybook.js 概念。
Storybook.js 是 Web 开发人员用来独立创建和测试 Web 应用程序的 UI 组件的开源工具。将其视为一个游乐场,开发人员可以在其中与网站的其余部分分开构建和展示网站的各个部分(如按钮、表单和导航栏)。
Storybook.js 充当组件的动态文档,并提供视觉效果以查看组件在不同状态和场景下的外观和行为以及代码示例。
Storybook.js 的优雅之处在于 UI 组件在同一环境中使用;无论是在制作网站还是故事书网站上,我们仍然在网络浏览器中运行它们。这确保了组件在我们的文档/游乐场和生产中看起来一致,防止它们不同步。
网页也是一种极好的文档媒介,可提供出色的阅读体验、轻松共享、即时加载时间以及在任何设备和操作系统上的通用可用性。
然而,对于iOS 应用程序来说,情况就不同了。
在 iOS 上,与 UI 组件开发相关的工具和方法非常分散。让我们从 Storybook.js 的角度来看看它们。
Interface Builder 提供拖放界面,无需编写代码即可轻松设计 UI。通常,它用于为整个屏幕布局现有的 UI 组件,而不是用于开发单个 UI 组件。一般来说,它在展示不同的 UI 组件状态和处理复杂的 UI 方面效果不佳,通常需要在代码中实现许多方面。总体而言,包括苹果在内的许多人都认为这是一条死胡同。
SwiftUI Previews 是 Apple 创建的一个工具,用于在 Xcode 中开发和测试 SwiftUI 视图。
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/hpenington/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
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()) } }
对于此任务,我们将使用不同的堆栈: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目前的状态是一个概念验证,但通过一些添加并正确集成到开发人员的工作流程中,它可以开始带来很多价值。
我们还可以想象以下增强: