paint-brush
NativeBook : unifier l'expérience de développement iOS natif avec Storybook.jsby@psharanda
19,186
19,186

NativeBook : unifier l'expérience de développement iOS natif avec Storybook.js

Pavel Sharanda15m2023/12/19
Read on Terminal Reader

Storybook.js est un outil utilisé par les développeurs Web pour créer et tester des composants d'interface utilisateur de manière isolée, fournissant un terrain de jeu pour créer et présenter séparément les composants d'interface utilisateur. Cependant, pour le développement iOS natif, il n’existe pas d’équivalent direct. L'article présente « NativeBook », un système proposé combinant une application de catalogue, des aperçus SwidtUI, des tests d'instantanés et la génération de documentation HTML pour créer une expérience de type Storybook.js pour iOS. Bien qu'il s'agisse encore d'une preuve de concept, NativeBook vise à rationaliser le processus de développement et de documentation des composants de l'interface utilisateur iOS.
featured image - NativeBook : unifier l'expérience de développement iOS natif avec Storybook.js
Pavel Sharanda HackerNoon profile picture
0-item

Introduction

La mise en œuvre, la maintenance, la documentation et l'évolution d'un ensemble partagé de composants d'interface utilisateur constituent un défi dans une application à grande échelle. Les développeurs Web ont créé une solution puissante à ce problème : Storybook.js ( https://storybook.js.org ). Mais qu’en est-il du développement natif iOS ? Pouvons-nous vivre une expérience quelque peu similaire ? (Alerte spoiler : oui ! )


Mais d’abord, explorons le concept original de Storybook.js.

Storybook.js

Storybook.js est un outil open source utilisé par les développeurs Web pour créer et tester des composants d'interface utilisateur pour les applications Web de manière isolée. Considérez-le comme un terrain de jeu où les développeurs peuvent créer et présenter des parties individuelles d'un site Web (comme des boutons, des formulaires et des barres de navigation) séparément du reste du site Web.


Storybook.js sert de documentation vivante des composants et fournit des visuels pour voir à quoi ressemblent et se comportent les composants dans différents états et scénarios, ainsi que des exemples de code.


Exemple de page Storybook.js pour le composant Button


L'élégance de Storybook.js réside dans le fait que les composants de l'interface utilisateur sont utilisés dans le même environnement ; que ce soit sur un site Web de production ou sur un site Storybook, nous les exécutons toujours dans un navigateur Web. Cela garantit que les composants semblent cohérents dans notre documentation/terrain de jeu et en production, les empêchant ainsi d'être désynchronisés.


Les pages Web constituent également un excellent support de documentation, offrant une excellente expérience de lecture, un partage facile, des temps de chargement instantanés et une disponibilité universelle sur n'importe quel appareil et système d'exploitation.


Cependant, pour les applications iOS , l'histoire est différente.

Pendant ce temps sur iOS

Sur iOS, le paysage des outils et méthodes liés au développement de composants d’interface utilisateur est assez fragmenté. Jetons-y un coup d'œil du point de vue de Storybook.js.

Constructeur d'interfaces

Interface Builder fournit une interface glisser-déposer, ce qui facilite la conception d'interfaces utilisateur sans écrire de code. Habituellement, il est utilisé pour disposer les composants d’interface utilisateur existants pour des écrans entiers plutôt que pour le développement de composants d’interface utilisateur individuels. Cela ne fonctionne pas bien pour présenter les différents états des composants de l'interface utilisateur et pour gérer les interfaces utilisateur complexes en général, nécessitant souvent l'implémentation de nombreux aspects dans le code. Dans l’ensemble, beaucoup le considèrent comme une impasse, y compris Apple.

Aperçus SwiftUI

SwiftUI Previews est un outil créé par Apple pour développer et tester des vues SwiftUI dans Xcode.

Les aperçus SwiftUI peuvent également être utilisés pour les UIViews. Voir https://sarunw.com/posts/xcode-previews-with-uiview

Avantages:

  • Idéal pour un développement isolé
  • Prise en charge du rechargement à chaud
  • Utilisez un véritable simulateur iOS sous le capot
  • Afficher les composants actifs à côté d'un extrait de code
  • Pratique pour tester le type dynamique et le mode sombre

Les inconvénients:

  • Exiger que Xcode soit installé avec l'environnement de développement correctement configuré.
  • Les temps de construction peuvent être longs
  • Fonctionne de manière peu fiable et plante assez souvent
  • Beaucoup de ressources, fonctionnant lentement même sur des machines haut de gamme


Quand cela fonctionne, les aperçus SwiftUI sont probablement l'expérience la plus proche de Storybook.js, à l'exception de l'aspect "documentation".

Génération de documentation basée sur HTML

La prochaine chose que nous avons est de générer une documentation basée sur le code source, les commentaires et les annotations. Les exemples les plus notables sont :

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

Jazzy - https://github.com/realm/jazzy


Ce type d'outils peut être utilisé pour créer des références API de composants sous la forme d'une page Web.


Une grande partie de la documentation des développeurs Apple est construite à l'aide de DocC.

Test d'instantané

Les tests instantanés sont un excellent moyen de développement isolé, testant divers états visuels d'un composant et garantissant que les choses ne changent pas de manière inattendue.


Les deux bibliothèques les plus populaires qui implémentent les tests d'instantanés sur iOS sont :

Application de catalogue personnalisé

Un autre choix évident pour l’expérience Storybook.js serait de créer une application de catalogue native personnalisée.


Avantages:

  • Utilise le même environnement que la production : simulateur ou appareil iOS
  • Affiche les composants réels à l'écran
  • Permet de tester l'accessibilité, l'internationalisation et le mode sombre.

Les inconvénients:

  • C'est une application ; ce n'est pas une expérience instantanée, et c'est un problème supplémentaire de la créer et de l'exécuter.
  • L’expérience en matière de documentation fait complètement défaut.


Quelques exemples:

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

https://github.com/hpennington/SwiftBook

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


Expérience de catalogue depuis https://github.com/eure/Storybook-ios


Une idée intéressante, quoique extravagante, est d'intégrer une application de catalogue native dans le site Web Storybook.js à l'aide du service Appetize.io, qui permet de diffuser le contenu d'un appareil iOS sur une page Web - https://medium.com/@vasikarla .raj/storybook-for-native-d772654c7133

Encore une idée

C'est un fait que le développement mobile natif ne dispose pas de l'ingrédient secret nécessaire à une expérience de type Storyboard.js : un environnement unique pour exécuter la documentation, les terrains de jeu et la production, le tout au sein de la plate-forme la plus accessible : un site Web.


Mais que se passerait-il si nous combinions une application de catalogue, des tests instantanés et une génération de documentation HTML en un seul système ? Imaginez écrire un extrait de code une seule fois, puis être capable d'effectuer les opérations suivantes :

  • Afficher une vue produite à partir d'un extrait dans une application de catalogue native
  • Intégrer une vue dans les aperçus SwiftUI
  • Exécuter un test d'instantané pour un extrait
  • Affichez cet extrait de code et l'instantané résultant dans une documentation HTML générée.


Bonnes nouvelles! J'ai construit une preuve de concept pour un tel système :

https://github.com/psharanda/NativeBook


Jetons un coup d'œil et assemblons-le !


NativeBook se concentre actuellement sur les composants basés sur UIView. Les composants SwiftUI peuvent également être intégrés de manière similaire, bien qu'ils ne soient pas abordés dans cet article.

Implémentation de NativeBook

Histoires

L'histoire est la pierre angulaire de notre système. Essentiellement, il s'agit d'un extrait de code nommé qui présente un état « intéressant » d'un composant de l'interface utilisateur. Il peut être représenté comme une simple structure :

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


L'objet suivant est ComponentStories , qui contient une liste d'histoires pour un composant :

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


Alors, comment allons-nous déclarer un extrait de code ? Pour une analyse plus facile et plus pratique, la fonction peut avoir une convention de dénomination spéciale qui commence par story_ . En général, chaque extrait de code d'une histoire :

  • Crée une vue
  • Le configure (ce qui peut inclure la définition de contraintes de largeur/hauteur)
  • Renvoie une vue


Voici un exemple d'extrait :

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


Nous pouvons améliorer l'expérience d'écriture d'histoires et réduire la quantité de passe-partout en utilisant la nature dynamique d'Objective-C. L'idée est d'avoir une classe de base capable de récupérer tous les sélecteurs de classe commençant par store_ et de construire une liste d'histoires à partir d'eux.

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


Par exemple, une classe qui héberge les histoires pour UILabel peut être structurée comme suit :

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

Application de catalogue

Une application de catalogue, dans sa forme la plus simple, se compose généralement de deux écrans.


Écran principal : cet écran affiche une liste de composants et leurs histoires associées.

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


Écran Détails : Sur cet écran, nous affichons un composant au centre sans restreindre sa largeur ou sa hauteur, car c'est au snippet de définir ces attributs.

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


L'ensemble du flux de catalogue peut être initialisé de la manière suivante :

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


Catalogue NativeBook


Aperçus SwiftUI

Nous pouvons reprendre l'idée PreviewContainer et sa mise en œuvre sur https://sarunw.com/posts/xcode-previews-with-uiview/

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


Et écrivez nos aperçus aussi simplement que ceci

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


Aperçus SwiftUI + histoires NativeBook

Tests instantanés

Dans notre exemple, nous utiliserons la bibliothèque iOSSnapshotTestCase .


Avec iOS 17, certains ajouts précieux à l'API UIView ont été introduits pour remplacer les traits via UIView.traitOverrides . Cette propriété est extrêmement utile pour la prise en charge de l’accessibilité des tests instantanés. Pour nos tests d'instantanés, nous allons tester la même vue dans diverses conditions en appliquant RTL, le mode sombre et certaines catégories de types dynamiques.


Il est important de noter que pour tester traitOverrides , nous devons utiliser la méthode drawViewHierarchy(in:afterScreenUpdates:) et nous assurer que les vues que nous testons sont ajoutées à la clé UIWindow de l'application.

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


En effet, une fois ces préparatifs en place, créer un scénario de test devient assez simple :

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


Instantanés de référence produits par NativeBookVerifyView

Générateur de documentation

Pour cette tâche, nous allons utiliser une pile différente : modèles Node.js, TypeScript et EJS. Il est beaucoup plus facile de travailler avec des pages HTML à l’aide d’outils qui leur sont natifs.


La première chose dont nous avons besoin est une sorte de fichier de configuration dans lequel nous pouvons lier nos extraits de code et nos tests d'instantanés. Un simple fichier JSON fonctionne bien à cet effet.

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


Après avoir créé une simple application Node.js, définissons un modèle.

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


Le défi intéressant est de savoir comment extraire des extraits de code d'un fichier Swift. Ceci peut être réalisé tout simplement en utilisant des regex.

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


La génération HTML peut être effectuée à l'aide d'EJS, un puissant moteur de modèles qui nous permet d'utiliser JavaScript dans un modèle :

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


Maintenant, en combinant tout dans la fonction principale :

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

Site de documentation

La démo est disponible sur https://psharanda.github.io/NativeBook


Afin de générer de la documentation dans le dépôt NativeBook, vous devez exécuter les commandes suivantes :

 cd native_book_gen npm install npm run native-book-gen


Ou exécutez simplement :

 sh generate_native_book.sh


La documentation apparaîtra dans le dossier docs


Référence UIButton produite par NativeBook


Nous pouvons vérifier les instantanés pour différents modes d'accessibilité, mode sombre et RTL !


Et après?

J'espère que vous avez apprécié le voyage. NativeBook , dans son état actuel, est une preuve de concept, mais avec quelques ajouts et une bonne intégration dans le flux de travail d'un développeur, il peut commencer à apporter beaucoup de valeur.


On peut également imaginer l'amélioration suivante :

  • Ajout de la prise en charge d'Android : nous pouvons prendre en charge les deux plates-formes et disposer de catalogues, de tests instantanés et d'une documentation cohérents dans lesquels vous pouvez facilement basculer entre les plates-formes.
  • Intégration des conceptions Figma dans la documentation
  • Fusion avec la documentation générée par des outils comme DocC ou Jazzy pour obtenir une référence API complète en plus des instantanés et des extraits de code
  • Ajout d'une prise en charge appropriée pour les vues SwiftUI
  • Génération automatique du code des aperçus SwiftUI