paint-brush
NativeBook: Vereinheitlichung der nativen iOS-Entwicklungserfahrung mit Storybook.jsby@psharanda
19,182
19,182

NativeBook: Vereinheitlichung der nativen iOS-Entwicklungserfahrung mit Storybook.js

Pavel Sharanda15m2023/12/19
Read on Terminal Reader

Storybook.js ist ein Tool, das von Webentwicklern zum isolierten Erstellen und Testen von UI-Komponenten verwendet wird und einen Spielplatz für die separate Erstellung und Präsentation von UI-Teilen bietet. Für die native iOS-Entwicklung gibt es jedoch kein direktes Äquivalent. Der Artikel stellt „NativeBook“ vor, ein vorgeschlagenes System, das eine Katalog-App, SwidtUI-Vorschauen, Snapshot-Tests und HTML-Dokumentationsgenerierung kombiniert, um ein Storybook.js-ähnliches Erlebnis für iOS zu schaffen. NativeBook ist zwar immer noch ein Proof of Concept, zielt aber darauf ab, den Entwicklungs- und Dokumentationsprozess für iOS-UI-Komponenten zu rationalisieren.
featured image - NativeBook: Vereinheitlichung der nativen iOS-Entwicklungserfahrung mit Storybook.js
Pavel Sharanda HackerNoon profile picture
0-item

Einführung

Die Implementierung, Pflege, Dokumentation und Weiterentwicklung eines gemeinsamen Satzes von UI-Komponenten ist in einer umfangreichen Anwendung eine Herausforderung. Webentwickler haben eine leistungsstarke Lösung für dieses Problem erstellt – Storybook.js ( https://storybook.js.org ). Aber wie sieht es mit der nativen iOS- Entwicklung aus? Können wir eine einigermaßen ähnliche Erfahrung machen? (Spoiler-Alarm: Ja! )


Aber lassen Sie uns zunächst das ursprüngliche Storybook.js-Konzept erkunden.

Storybook.js

Storybook.js ist ein Open-Source-Tool, das von Webentwicklern zum isolierten Erstellen und Testen von UI-Komponenten für Webanwendungen verwendet wird. Betrachten Sie es als einen Spielplatz, auf dem Entwickler einzelne Teile einer Website (wie Schaltflächen, Formulare und Navigationsleisten) getrennt vom Rest der Website erstellen und präsentieren können.


Storybook.js dient als lebendige Dokumentation von Komponenten und bietet neben Codebeispielen visuelle Darstellungen, um zu sehen, wie Komponenten in verschiedenen Zuständen und Szenarien aussehen und sich verhalten.


Beispiel einer Storybook.js-Seite für die Button-Komponente


Die Eleganz von Storybook.js liegt darin, dass UI-Komponenten in derselben Umgebung verwendet werden; Ob auf einer Produktionswebsite oder einer Storybook-Website, wir führen sie immer noch in einem Webbrowser aus. Dadurch wird sichergestellt, dass die Komponenten in unserer Dokumentation/Spielwiese und in der Produktion konsistent aussehen und verhindert wird, dass sie nicht synchron sind.


Webseiten sind auch ein hervorragendes Medium für die Dokumentation und bieten ein großartiges Leseerlebnis, einfaches Teilen, sofortige Ladezeiten und universelle Verfügbarkeit auf jedem Gerät und Betriebssystem.


Bei iOS-Apps sieht die Sache jedoch anders aus.

Mittlerweile auf iOS

Unter iOS ist die Landschaft der Tools und Methoden im Zusammenhang mit der Entwicklung von UI-Komponenten recht fragmentiert. Schauen wir sie uns aus der Perspektive von Storybook.j an.

Schnittstellen-Builder

Interface Builder bietet eine Drag-and-Drop-Oberfläche, die das Entwerfen von Benutzeroberflächen erleichtert, ohne Code schreiben zu müssen. Normalerweise wird es für das Layout vorhandener UI-Komponenten für ganze Bildschirme verwendet und nicht für die Entwicklung einzelner UI-Komponenten. Es funktioniert nicht gut bei der Darstellung verschiedener UI-Komponentenzustände und der Handhabung komplexer UIs im Allgemeinen, da oft viele Aspekte im Code implementiert werden müssen. Insgesamt wird es von vielen, auch von Apple, als Sackgasse angesehen.

SwiftUI-Vorschauen

SwiftUI Previews ist ein von Apple entwickeltes Tool zum Entwickeln und Testen von SwiftUI-Ansichten in Xcode.

SwiftUI Previews können auch für UIViews verwendet werden. Siehe https://sarunw.com/posts/xcode-previews-with-uiview

Vorteile:

  • Ideal für isolierte Entwicklung
  • Unterstützt Hot Reload
  • Verwenden Sie einen echten iOS-Simulator unter der Haube
  • Zeigen Sie Live-Komponenten neben einem Code-Snippet an
  • Praktisch zum Testen des dynamischen Typs und des Dunkelmodus

Nachteile:

  • Erfordert die Installation von Xcode mit ordnungsgemäß konfigurierter Entwicklungsumgebung.
  • Die Bauzeiten können lang sein
  • Arbeitet unzuverlässig und stürzt häufig ab
  • Ressourcenintensiv, läuft selbst auf High-End-Rechnern langsam


Wenn es funktioniert, sind SwiftUI-Vorschauen wahrscheinlich das Erlebnis, das Storybook.js am nächsten kommt, abgesehen vom Aspekt „Dokumentation“.

HTML-basierte Dokumentationserstellung

Als nächstes müssen wir eine Dokumentation basierend auf Quellcode, Kommentaren und Anmerkungen erstellen. Die bemerkenswertesten Beispiele sind:

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

Jazzig – https://github.com/realm/jazzy


Diese Art von Tooling kann zum Erstellen von Komponenten-API-Referenzen in Form einer Webseite verwendet werden.


Ein Großteil der Entwicklerdokumentation von Apple wurde mit DocC erstellt.

Snapshot-Tests

Snapshot-Tests sind eine großartige Möglichkeit für die isolierte Entwicklung, bei der verschiedene visuelle Zustände einer Komponente getestet werden, um sicherzustellen, dass sich die Dinge nicht unerwartet ändern.


Die beiden beliebtesten Bibliotheken, die Snapshot-Tests unter iOS implementieren, sind:

Benutzerdefinierte Katalog-App

Eine weitere offensichtliche Wahl für das Storybook.js-Erlebnis wäre die Erstellung einer benutzerdefinierten nativen Katalog-App.


Vorteile:

  • Verwendet dieselbe Umgebung wie die Produktion: iOS-Simulator oder -Gerät
  • Zeigt reale Komponenten auf dem Bildschirm an
  • Ermöglicht das Testen von Barrierefreiheit, Internationalisierung und Dunkelmodus.

Nachteile:

  • Es ist eine App; Es handelt sich nicht um ein sofortiges Erlebnis, und es ist ein zusätzlicher Aufwand, es zu erstellen und auszuführen.
  • Die Dokumentationserfahrung fehlt völlig.


Einige Beispiele:

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

https://github.com/hpennington/SwiftBook

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


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


Eine coole, wenn auch extravagante Idee besteht darin, mithilfe des Appetize.io-Dienstes eine native Katalog-App in die Storybook.js-Website einzubetten, die das Streamen der Inhalte eines iOS-Geräts auf eine Webseite ermöglicht – https://medium.com/@vasikarla .raj/storybook-for-native-d772654c7133

Noch eine Idee

Es ist eine Tatsache, dass der nativen mobilen Entwicklung die geheime Zutat fehlt, die für ein Storyboard.js-ähnliches Erlebnis erforderlich ist: eine einzige Umgebung zum Ausführen von Dokumentation, Spielplätzen und Produktion, alles auf der am besten zugänglichen Plattform – einer Website.


Was aber, wenn wir eine Katalog-App, Snapshot-Tests und HTML-basierte Dokumentationserstellung in einem einzigen System kombinieren? Stellen Sie sich vor, Sie schreiben nur einmal einen Codeausschnitt und können dann Folgendes tun:

  • Zeigen Sie eine aus einem Snippet erstellte Ansicht in einer nativen Katalog-App an
  • Betten Sie eine Ansicht in SwiftUI-Vorschauen ein
  • Führen Sie einen Snapshot-Test für ein Snippet durch
  • Zeigen Sie dieses Code-Snippet und den resultierenden Snapshot in einer generierten HTML-basierten Dokumentation an.


Gute Nachrichten! Ich habe einen Proof of Concept für ein solches System erstellt:

https://github.com/psharanda/NativeBook


Lasst uns einen Blick darauf werfen und es zusammenstellen!


NativeBook konzentriert sich derzeit auf UIView-basierte Komponenten. Auf ähnliche Weise können auch SwiftUI-Komponenten integriert werden, die in diesem Artikel jedoch nicht behandelt werden.

NativeBook-Implementierung

Geschichten

Die Geschichte ist ein Eckpfeiler unseres Systems. Im Wesentlichen handelt es sich um einen benannten Codeausschnitt, der einen „interessanten“ Zustand einer UI-Komponente darstellt. Es kann als einfache Struktur dargestellt werden:

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


Das nächste Objekt ist ComponentStories , das eine Liste von Storys für eine Komponente enthält:

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


Wie deklarieren wir also ein Snippet? Zur einfacheren Analyse und Bequemlichkeit kann die Funktion eine spezielle Namenskonvention haben, die mit story_ beginnt. Im Allgemeinen gilt für jedes Code-Snippet für eine Story Folgendes:

  • Erstellt eine Ansicht
  • Konfiguriert es (einschließlich der Festlegung von Breiten-/Höhenbeschränkungen).
  • Gibt eine Ansicht zurück


Hier ist ein Beispiel für einen Ausschnitt:

 @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 }


Wir können das Erlebnis beim Schreiben von Geschichten verbessern und die Menge an Boilerplate reduzieren, indem wir die dynamische Natur von Objective-C nutzen. Die Idee besteht darin, eine Basisklasse zu haben, die in der Lage ist, alle Klassenselektoren abzurufen, die mit store_ beginnen, und daraus eine Story-Liste zu erstellen.

 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 } } }() }


Beispielsweise kann eine Klasse, die die Storys für UILabel hostet, wie folgt strukturiert sein:

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

Katalog-App

Eine Katalog-App besteht in ihrer einfachsten Form normalerweise aus zwei Bildschirmen.


Hauptbildschirm: Dieser Bildschirm zeigt eine Liste der Komponenten und der zugehörigen Storys an.

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


Detailbildschirm: Auf diesem Bildschirm zeigen wir eine Komponente in der Mitte an, ohne ihre Breite oder Höhe einzuschränken, da es Sache des Snippets ist, diese Attribute zu definieren.

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


Der gesamte Katalogfluss kann auf folgende Weise initialisiert werden:

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


NativeBook-Katalog


SwiftUI-Vorschauen

Wir können die PreviewContainer-Idee und ihre Implementierung von https://sarunw.com/posts/xcode-previews-with-uiview/ übernehmen.

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


Und so einfach schreiben Sie unsere Vorschauen

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


SwiftUI-Vorschauen + NativeBook-Geschichten

Snapshot-Tests

In unserem Beispiel verwenden wir die iOSSnapshotTestCase- Bibliothek.


Mit iOS 17 wurden einige wertvolle Ergänzungen zur UIView API zum Überschreiben von Merkmalen durch UIView.traitOverrides eingeführt. Diese Eigenschaft ist äußerst nützlich für die Unterstützung von Snapshot-Tests zur Barrierefreiheit. Für unsere Snapshot-Tests werden wir dieselbe Ansicht unter verschiedenen Bedingungen testen, indem wir RTL, den Dunkelmodus und einige der dynamischen Typkategorien erzwingen.


Es ist wichtig zu beachten, dass wir zum Testen traitOverrides die Methode drawViewHierarchy(in:afterScreenUpdates:) verwenden und sicherstellen müssen, dass die von uns getesteten Ansichten dem Schlüssel UIWindow der App hinzugefügt werden.

 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() } }


Wenn diese Vorbereitungen getroffen sind, wird die Erstellung eines Testfalls tatsächlich ganz einfach:

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


Von NativeBookVerifyView erstellte Referenz-Snapshots

Dokumentationsgenerator

Für diese Aufgabe verwenden wir einen anderen Stack: Node.js-, TypeScript- und EJS-Vorlagen. Es ist viel einfacher, mit HTML-basierten Seiten zu arbeiten, indem man native Tools verwendet.


Als Erstes benötigen wir eine Art Konfigurationsdatei, in der wir unsere Codeschnipsel und Snapshot-Tests verknüpfen können. Für diesen Zweck eignet sich eine einfache JSON-Datei gut.

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


Nachdem wir eine einfache Node.js-App erstellt haben, definieren wir ein Modell.

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


Die interessante Herausforderung besteht darin, Codefragmente aus einer Swift-Datei zu extrahieren. Dies kann ganz einfach durch die Verwendung von Regex erreicht werden.

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


Die HTML-Generierung kann mit EJS erfolgen, einer leistungsstarken Template-Engine, die es uns ermöglicht, JavaScript innerhalb einer Vorlage zu verwenden:

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


Nun kombinieren wir alles in der Hauptfunktion:

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

Dokumentationsseite

Die Demo ist unter https://psharanda.github.io/NativeBook verfügbar


Um Dokumentation im NativeBook-Repo zu generieren, müssen Sie die folgenden Befehle ausführen:

 cd native_book_gen npm install npm run native-book-gen


Oder führen Sie einfach Folgendes aus:

 sh generate_native_book.sh


Die Dokumentation wird im Ordner docs angezeigt


Von NativeBook erstellte UIButton-Referenz


Wir können Schnappschüsse für verschiedene Barrierefreiheitsmodi, Dunkelmodus und RTL überprüfen!


Was kommt als nächstes?

Hoffentlich hat Ihnen die Reise gefallen. NativeBook ist in seinem aktuellen Zustand ein Proof of Concept, aber mit einigen Ergänzungen und der richtigen Integration in den Arbeitsablauf eines Entwicklers kann es einen großen Mehrwert bringen.


Wir können uns auch folgende Erweiterung vorstellen:

  • Unterstützung für Android hinzufügen: Wir können beide Plattformen unterstützen und verfügen über konsistente Kataloge, Snapshot-Tests und Dokumentation, mit denen Sie problemlos zwischen den Plattformen wechseln können
  • Einbetten von Figma-Designs in die Dokumentation
  • Zusammenführung mit Dokumentation, die von Tools wie DocC oder Jazzy generiert wurde, um zusätzlich zu Snapshots und Code-Snippets eine umfassende API-Referenz zu erhalten
  • Hinzufügen der richtigen Unterstützung für SwiftUI-Ansichten
  • Automatische Generierung des SwiftUI-Vorschaucodes