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 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.
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.
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.
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 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:
Nachteile:
Wenn es funktioniert, sind SwiftUI-Vorschauen wahrscheinlich das Erlebnis, das Storybook.js am nächsten kommt, abgesehen vom Aspekt „Dokumentation“.
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.
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:
Eine weitere offensichtliche Wahl für das Storybook.js-Erlebnis wäre die Erstellung einer benutzerdefinierten nativen Katalog-App.
Vorteile:
Nachteile:
Einige Beispiele:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
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
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:
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.
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:
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 { … } }
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
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) } } }
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()) } }
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); } })();
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
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: