paint-brush
NativeBook: unificando la experiencia de desarrollo nativo de iOS con Storybook.jspor@psharanda
19,795 lecturas
19,795 lecturas

NativeBook: unificando la experiencia de desarrollo nativo de iOS con Storybook.js

por Pavel Sharanda15m2023/12/19
Read on Terminal Reader

Demasiado Largo; Para Leer

Storybook.js es una herramienta utilizada por los desarrolladores web para crear y probar componentes de la interfaz de usuario de forma aislada, lo que proporciona un campo de juego para crear y mostrar partes de la interfaz de usuario por separado. Sin embargo, para el desarrollo nativo de iOS, no existe un equivalente directo. El artículo presenta "NativeBook", un sistema propuesto que combina una aplicación de catálogo, vistas previas de SwidtUI, pruebas de instantáneas y generación de documentación HTML para crear una experiencia similar a Storybook.js para iOS. Si bien sigue siendo una prueba de concepto, NativeBook tiene como objetivo agilizar el proceso de documentación y desarrollo de componentes de la interfaz de usuario de iOS.
featured image - NativeBook: unificando la experiencia de desarrollo nativo de iOS con Storybook.js
Pavel Sharanda HackerNoon profile picture
0-item

Introducción

Implementar, mantener, documentar y desarrollar un conjunto compartido de componentes de interfaz de usuario es un desafío en una aplicación a gran escala. Los desarrolladores web han creado una solución poderosa para este problema: Storybook.js ( https://storybook.js.org ). Pero ¿qué pasa con el desarrollo nativo de iOS ? ¿Podemos lograr una experiencia algo similar? (Alerta de spoiler: ¡Sí! )


Pero primero, exploremos el concepto original de Storybook.js.

Libro de cuentos.js

Storybook.js es una herramienta de código abierto utilizada por desarrolladores web para crear y probar componentes de interfaz de usuario para aplicaciones web de forma aislada. Piense en ello como un patio de juegos donde los desarrolladores pueden crear y mostrar partes individuales de un sitio web (como botones, formularios y barras de navegación) por separado del resto del sitio web.


Storybook.js sirve como documentación viva de los componentes y proporciona imágenes para ver cómo se ven y se comportan los componentes en diferentes estados y escenarios junto con ejemplos de código.


Ejemplo de página Storybook.js para el componente Botón


La elegancia de Storybook.js radica en el hecho de que los componentes de la interfaz de usuario se utilizan en el mismo entorno; ya sea en un sitio web de producción o en uno de Storybook, todavía los ejecutamos en un navegador web. Esto garantiza que los componentes se vean consistentes en nuestra documentación/área de juegos y en producción, evitando que no estén sincronizados.


Las páginas web también son un excelente medio para la documentación, ya que brindan una excelente experiencia de lectura, fácil intercambio, tiempos de carga instantáneos y disponibilidad universal en cualquier dispositivo y sistema operativo.


Sin embargo, para las aplicaciones de iOS , la historia es diferente.

Mientras tanto en iOS

En iOS, el panorama de herramientas y métodos relacionados con el desarrollo de componentes de UI está bastante fragmentado. Veámoslos desde la perspectiva de Storybook.js.

Generador de interfaces

Interface Builder proporciona una interfaz de arrastrar y soltar, lo que facilita el diseño de interfaces de usuario sin escribir código. Por lo general, se utiliza para diseñar componentes de UI existentes para pantallas completas en lugar de desarrollar componentes de UI individuales. No funciona bien para mostrar diferentes estados de los componentes de la interfaz de usuario ni para manejar interfaces de usuario complejas en general, ya que a menudo requiere que se implementen muchos aspectos en el código. En general, muchos, incluido Apple, lo consideran un callejón sin salida.

Vistas previas de SwiftUI

SwiftUI Previews es una herramienta creada por Apple para desarrollar y probar vistas de SwiftUI dentro de Xcode.

Las vistas previas de SwiftUI también se pueden utilizar para UIViews. Ver https://sarunw.com/posts/xcode-previews-with-uiview

Ventajas:

  • Ideal para desarrollo aislado
  • Soporte de recarga en caliente
  • Utilice un simulador de iOS real bajo el capó
  • Mostrar componentes en vivo junto a un fragmento de código
  • Conveniente para probar el tipo dinámico y el modo oscuro

Contras:

  • Requiere que Xcode se instale con el entorno de desarrollo configurado correctamente.
  • Los tiempos de construcción pueden ser largos
  • Trabaja de manera poco confiable y falla con bastante frecuencia
  • Con muchos recursos, funciona lentamente incluso en máquinas de alta gama.


Cuando funciona, las vistas previas de SwiftUI son probablemente la experiencia más cercana a Storybook.js, excepto por el aspecto de "documentación".

Generación de documentación basada en HTML

Lo siguiente que tenemos es generar documentación basada en código fuente, comentarios y anotaciones. Los ejemplos más notables son:

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

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


Este tipo de herramientas se puede utilizar para crear referencias de API de componentes en forma de página web.


Gran parte de la documentación para desarrolladores de Apple creada con DocC.

Prueba de instantáneas

Las pruebas instantáneas son una excelente manera de realizar desarrollo aislado, probar varios estados visuales de un componente y garantizar que las cosas no cambien inesperadamente.


Las dos bibliotecas más populares que implementan pruebas de instantáneas en iOS son:

Aplicación de catálogo personalizado

Otra opción obvia para la experiencia Storybook.js sería crear una aplicación de catálogo nativa personalizada.


Ventajas:

  • Utiliza el mismo entorno que producción: simulador o dispositivo iOS.
  • Muestra componentes reales en la pantalla.
  • Permite realizar pruebas de accesibilidad, internacionalización y modo oscuro.

Contras:

  • Es una aplicación; No es una experiencia instantánea y es una molestia adicional construirla y ejecutarla.
  • La experiencia en documentación falta por completo.


Algunos ejemplos:

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

https://github.com/hpennington/SwiftBook

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


Experiencia de catálogo de https://github.com/eure/Storybook-ios


Una idea interesante, aunque extravagante, es integrar una aplicación de catálogo nativa en el sitio web Storybook.js utilizando el servicio Appetize.io, que permite transmitir el contenido de un dispositivo iOS a una página web: https://medium.com/@vasikarla .raj/storybook-para-nativo-d772654c7133

una idea mas

Es un hecho que el desarrollo móvil nativo carece del ingrediente secreto necesario para una experiencia similar a Storyboard.js: un entorno único para ejecutar documentación, áreas de juego y producción, todo dentro de la plataforma más accesible: un sitio web.


Pero ¿qué pasa si combinamos una aplicación de catálogo, pruebas instantáneas y generación de documentación basada en HTML en un solo sistema? Imagínese escribir un fragmento de código solo una vez y luego poder hacer lo siguiente:

  • Mostrar una vista producida a partir de un fragmento en una aplicación de catálogo nativa
  • Incrustar una vista en las vistas previas de SwiftUI
  • Ejecute una prueba de instantánea para un fragmento
  • Muestre este fragmento de código y la instantánea resultante en una documentación generada basada en HTML.


¡Buenas noticias! He creado una prueba de concepto para dicho sistema:

https://github.com/psharanda/NativeBook


¡Echemos un vistazo y armémoslo!


NativeBook actualmente se centra en componentes basados en UIView. Los componentes de SwiftUI también se pueden integrar de manera similar, aunque no se tratan en este artículo.

Implementación de NativeBook

Cuentos

La historia es la piedra angular de nuestro sistema. Básicamente, es un fragmento de código con nombre que muestra algún estado "interesante" de un componente de la interfaz de usuario. Se puede representar como una estructura simple:

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


El siguiente objeto es ComponentStories , que contiene una lista de historias para un componente:

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


Entonces, ¿cómo vamos a declarar un fragmento? Para facilitar el análisis y facilitar el análisis, la función puede tener una convención de nomenclatura especial que comience con story_ . En general, cada fragmento de código de una historia:

  • Crea una vista
  • Lo configura (lo que puede incluir establecer restricciones de ancho/alto)
  • Devuelve una vista


A continuación se muestra un ejemplo de un fragmento:

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


Podemos mejorar la experiencia de escritura de historias y reducir la cantidad de texto repetitivo haciendo uso de la naturaleza dinámica de Objective-C. La idea es tener una clase base que sea capaz de recuperar todos los selectores de clases que comienzan con store_ y construir una lista de historias a partir de ellos.

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


Por ejemplo, una clase que aloja las historias de UILabel se puede estructurar de la siguiente manera:

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

Aplicación de catálogo

Una aplicación de catálogo, en su forma más simple, normalmente consta de dos pantallas.


Pantalla principal: esta pantalla muestra una lista de componentes y sus historias asociadas.

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


Pantalla de detalles: En esta pantalla mostramos un componente en el centro sin restringir su ancho o alto, ya que depende del fragmento definir estos atributos.

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


Todo el flujo del catálogo se puede inicializar de la siguiente manera:

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


Catálogo NativeBook


Vistas previas de SwiftUI

Podemos tomar la idea de PreviewContainer y su implementación de 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) } }


Y escribe nuestras vistas previas tan simples como esta.

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


Vistas previas de SwiftUI + Historias de NativeBook

Pruebas instantáneas

En nuestro ejemplo, usaremos la biblioteca iOSSnapshotTestCase .


Con iOS 17, se introdujeron algunas adiciones valiosas a la API UIView para anular rasgos a través de UIView.traitOverrides . Esta propiedad es extremadamente útil para la compatibilidad con pruebas de accesibilidad de instantáneas. Para nuestras pruebas de instantáneas, probaremos la misma vista en diversas condiciones aplicando RTL, modo oscuro y algunas de las categorías de tipo dinámico.


Es importante tener en cuenta que para probar traitOverrides , debemos usar el método drawViewHierarchy(in:afterScreenUpdates:) y asegurarnos de que las vistas que estamos probando se agreguen a la UIWindow clave de la aplicación.

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


De hecho, con estos preparativos, crear un caso de prueba resulta bastante sencillo:

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


Instantáneas de referencia producidas por NativeBookVerifyView

Generador de documentación

Para esta tarea, utilizaremos una pila diferente: plantillas de Node.js, TypeScript y EJS. Es mucho más fácil trabajar con páginas basadas en HTML utilizando herramientas nativas para ellas.


Lo primero que necesitamos es algún tipo de archivo de configuración donde podamos vincular nuestros fragmentos de código y pruebas instantáneas. Un archivo JSON simple funciona bien para este propósito.

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


Después de crear una aplicación Node.js simple, definamos un modelo.

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


El desafío interesante es cómo extraer fragmentos de código de un archivo Swift. Esto se puede lograr de manera muy simple usando expresiones regulares.

 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 generación de HTML se puede realizar utilizando EJS, un potente motor de plantillas que nos permite utilizar JavaScript dentro de una plantilla:

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


Ahora, combinando todo en la función principal:

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

Sitio de documentación

La demostración está disponible en https://psharanda.github.io/NativeBook


Para generar documentación en el repositorio de NativeBook, debe ejecutar los siguientes comandos:

 cd native_book_gen npm install npm run native-book-gen


O simplemente ejecute:

 sh generate_native_book.sh


La documentación aparecerá en la carpeta docs .


Referencia de UIButton producida por NativeBook


¡Podemos verificar instantáneas para diferentes modos de accesibilidad, modo oscuro y RTL!


¿Que sigue?

Esperemos que hayas disfrutado el viaje. NativeBook , en su estado actual, es una prueba de concepto, pero con algunas adiciones y una integración adecuada en el flujo de trabajo de un desarrollador, puede comenzar a aportar mucho valor.


También podemos imaginar la siguiente mejora:

  • Agregar soporte para Android: podemos admitir ambas plataformas y tener catálogos consistentes, pruebas instantáneas y documentación donde puede alternar fácilmente entre plataformas.
  • Incrustar diseños de Figma en la documentación
  • Fusionarse con documentación generada por herramientas como DocC o Jazzy para obtener una referencia API completa además de instantáneas y fragmentos de código.
  • Agregar soporte adecuado para las vistas SwiftUI
  • Generación automática de código de vista previa de SwiftUI