paint-brush
NativeBook: Unificando a experiência de desenvolvimento nativo para iOS com Storybook.jsby@psharanda
19,186
19,186

NativeBook: Unificando a experiência de desenvolvimento nativo para iOS com Storybook.js

Pavel Sharanda15m2023/12/19
Read on Terminal Reader

Storybook.js é uma ferramenta usada por desenvolvedores web para criar e testar componentes de UI isoladamente, fornecendo um playground para construir e exibir partes de UI separadamente. No entanto, para o desenvolvimento nativo do iOS, não há equivalente direto. O artigo apresenta o "NativeBook", um sistema proposto que combina um aplicativo de catálogo, visualizações SwidtUI, testes de instantâneos e geração de documentação HTML para criar uma experiência semelhante a Storybook.js para iOS. Embora ainda seja uma prova de conceito, NativeBook visa agilizar o processo de desenvolvimento e documentação de componentes de UI do iOS.
featured image - NativeBook: Unificando a experiência de desenvolvimento nativo para iOS com Storybook.js
Pavel Sharanda HackerNoon profile picture
0-item

Introdução

Implementar, manter, documentar e desenvolver um conjunto compartilhado de componentes de UI é um desafio em um aplicativo de grande escala. Os desenvolvedores da Web criaram uma solução poderosa para esse problema - Storybook.js ( https://storybook.js.org ). Mas e quanto ao desenvolvimento nativo do iOS ? Podemos conseguir uma experiência um tanto semelhante? (Alerta de spoiler: sim! )


Mas primeiro, vamos explorar o conceito original do Storybook.js.

Livro de histórias.js

Storybook.js é uma ferramenta de código aberto usada por desenvolvedores da web para criar e testar componentes de UI para aplicativos da web isoladamente. Pense nisso como um playground onde os desenvolvedores podem construir e exibir partes individuais de um site (como botões, formulários e barras de navegação) separadamente do resto do site.


Storybook.js serve como uma documentação viva de componentes e fornece recursos visuais para ver como os componentes se parecem e se comportam em diferentes estados e cenários junto com exemplos de código.


Exemplo de página Storybook.js para componente Button


A elegância do Storybook.js reside no fato de que os componentes da UI são usados no mesmo ambiente; seja em um site de produção ou em um Storybook, ainda os executamos em um navegador da web. Isso garante que os componentes pareçam consistentes em nossa documentação/playground e em produção, evitando que fiquem fora de sincronia.


As páginas da Web também são um excelente meio para documentação, proporcionando uma ótima experiência de leitura, fácil compartilhamento, tempos de carregamento instantâneos e disponibilidade universal em qualquer dispositivo e sistema operacional.


No entanto, para aplicativos iOS , a história é diferente.

Enquanto isso no iOS

No iOS, o cenário de ferramentas e métodos relacionados ao desenvolvimento de componentes de UI é bastante fragmentado. Vamos dar uma olhada neles da perspectiva do Storybook.js.

Construtor de interfaces

O Interface Builder fornece uma interface de arrastar e soltar, facilitando o design de UIs sem escrever código. Normalmente, ele é usado para definir componentes de UI existentes para telas inteiras, em vez de para o desenvolvimento de componentes de UI individuais. Ele não funciona bem para mostrar diferentes estados de componentes de UI e lidar com UIs complexas em geral, muitas vezes exigindo que muitos aspectos sejam implementados no código. No geral, é considerado um beco sem saída por muitos, incluindo a Apple.

Prévias do SwiftUI

SwiftUI Previews é uma ferramenta criada pela Apple para desenvolver e testar visualizações SwiftUI no Xcode.

As visualizações do SwiftUI também podem ser usadas para UIViews. Consulte https://sarunw.com/posts/xcode-previews-with-uiview

Prós:

  • Ótimo para desenvolvimento isolado
  • Suporte para recarga a quente
  • Use um simulador iOS real nos bastidores
  • Exibir componentes ativos ao lado de um snippet de código
  • Conveniente para testar o tipo dinâmico e o modo escuro

Contras:

  • Exija que o Xcode seja instalado com o ambiente de desenvolvimento configurado corretamente.
  • Os tempos de construção podem ser demorados
  • Trabalhe de forma não confiável e trave com frequência
  • Com muitos recursos, rodando lentamente mesmo em máquinas de última geração


Quando funciona, as visualizações do SwiftUI são provavelmente a experiência mais próxima do Storybook.js, exceto pelo aspecto de "documentação".

Geração de documentação baseada em HTML

A próxima coisa que temos é gerar documentação com base no código-fonte, comentários e anotações. Os exemplos mais notáveis são:

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

Jazzístico - https://github.com/realm/jazzy


Este tipo de ferramenta pode ser usado para criar referências de API de componentes na forma de uma página da web.


Grande parte da documentação do desenvolvedor da Apple foi criada usando DocC.

Teste de instantâneo

Os testes de instantâneo são uma ótima maneira de desenvolvimento isolado, testando vários estados visuais de um componente, garantindo que as coisas não mudem inesperadamente.


As duas bibliotecas mais populares que implementam testes de snapshot no iOS são:

Aplicativo de catálogo personalizado

Outra escolha óbvia para a experiência do Storybook.js seria construir um aplicativo de catálogo nativo personalizado.


Prós:

  • Usa o mesmo ambiente de produção: simulador ou dispositivo iOS
  • Exibe componentes reais na tela
  • Permite testes de acessibilidade, internacionalização e modo escuro.

Contras:

  • É um aplicativo; não é uma experiência instantânea e é um incômodo extra construí-lo e executá-lo.
  • A experiência de documentação é completamente inexistente.


Alguns exemplos:

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

https://github.com/hpennington/SwiftBook

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


Experiência de catálogo em https://github.com/eure/Storybook-ios


Uma ideia legal, embora extravagante, é incorporar um aplicativo de catálogo nativo ao site Storybook.js usando o serviço Appetize.io, que permite transmitir o conteúdo de um dispositivo iOS para uma página da web - https://medium.com/@vasikarla .raj/storybook-for-native-d772654c7133

Mais uma ideia

É fato que o desenvolvimento móvel nativo carece do ingrediente secreto necessário para uma experiência semelhante ao Storyboard.js: um ambiente único para executar documentação, playgrounds e produção, tudo dentro da plataforma mais acessível - um site.


Mas e se combinarmos um aplicativo de catálogo, testes de snapshot e geração de documentação baseada em HTML em um único sistema? Imagine escrever um trecho de código apenas uma vez e depois poder fazer o seguinte:

  • Exibir uma visualização produzida a partir de um snippet em um aplicativo de catálogo nativo
  • Incorporar uma visualização nas visualizações do SwiftUI
  • Execute um teste de snapshot para um snippet
  • Exiba este trecho de código e o instantâneo resultante em uma documentação gerada baseada em HTML.


Boas notícias! Eu construí uma prova de conceito para esse sistema:

https://github.com/psharanda/NativeBook


Vamos dar uma olhada e montar tudo!


NativeBook atualmente se concentra em componentes baseados em UIView. Os componentes SwiftUI também podem ser integrados de maneira semelhante, embora não sejam abordados neste artigo.

Implementação NativeBook

Histórias

A história é a base do nosso sistema. Essencialmente, é um trecho de código nomeado que mostra algum estado “interessante” de um componente de UI. Pode ser representado como uma estrutura simples:

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


O próximo objeto é ComponentStories , que contém uma lista de histórias de um componente:

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


Então, como vamos declarar um snippet? Para facilitar a análise e a conveniência, a função pode ter uma convenção de nomenclatura especial que começa com story_ . Em geral, cada trecho de código de uma história:

  • Cria uma visualização
  • Configura-o (que pode incluir a definição de restrições de largura/altura)
  • Retorna uma visualização


Aqui está um exemplo de trecho:

 @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 melhorar a experiência de escrita de histórias e reduzir a quantidade de clichês fazendo uso da natureza dinâmica do Objective-C. A ideia é ter uma classe base que seja capaz de recuperar todos os seletores de classe que começam com store_ e construir uma lista de histórias a partir deles.

 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 exemplo, uma classe que hospeda as histórias do UILabel pode ser estruturada da seguinte forma:

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

Aplicativo de catálogo

Um aplicativo de catálogo, em sua forma mais simples, normalmente consiste em duas telas.


Tela Principal: Esta tela exibe uma lista de componentes e suas histórias associadas.

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


Tela de detalhes: Nesta tela exibimos um componente no centro sem restringir sua largura ou altura, pois cabe ao snippet definir esses 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 o fluxo do catálogo pode ser inicializado da seguinte maneira:

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


Catálogo NativeBook


Prévias do SwiftUI

Podemos pegar a ideia do PreviewContainer e sua implementação em 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) } }


E escrever nossas prévias tão simples quanto isto

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


Pré-visualizações do SwiftUI + histórias do NativeBook

Testes instantâneos

Em nosso exemplo, usaremos a biblioteca iOSSnapshotTestCase .


Com o iOS 17, algumas adições valiosas à API UIView foram introduzidas para substituir características por meio de UIView.traitOverrides . Esta propriedade é extremamente útil para suporte de acessibilidade de teste de snapshot. Para nossos testes de instantâneo, testaremos a mesma visualização em várias condições, aplicando RTL, modo escuro e algumas categorias de tipo dinâmico.


É importante observar que, para testar traitOverrides , precisamos usar o método drawViewHierarchy(in:afterScreenUpdates:) e garantir que as visualizações que estamos testando sejam adicionadas à chave UIWindow do aplicativo.

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


Na verdade, com esses preparativos feitos, criar um caso de teste torna-se bastante simples:

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


Instantâneos de referência produzidos por NativeBookVerifyView

Gerador de documentação

Para esta tarefa, usaremos uma pilha diferente: modelos Node.js, TypeScript e EJS. É muito mais fácil trabalhar com páginas baseadas em HTML usando ferramentas nativas delas.


A primeira coisa que precisamos é de algum tipo de arquivo de configuração onde possamos vincular nossos trechos de código e testes de instantâneo. Um arquivo JSON simples funciona bem para essa finalidade.

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


Depois de criar um aplicativo Node.js simples, vamos definir um modelo.

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


O desafio interessante é como extrair trechos de código de um arquivo Swift. Isso pode ser conseguido simplesmente usando 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; }


A geração de HTML pode ser feita usando EJS, um poderoso mecanismo de template que nos permite usar JavaScript dentro de um template:

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


Agora, combinando tudo na função 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); } })();

Site de documentação

A demonstração está disponível em https://psharanda.github.io/NativeBook


Para gerar documentação no repositório NativeBook, você precisa executar os seguintes comandos:

 cd native_book_gen npm install npm run native-book-gen


Ou simplesmente execute:

 sh generate_native_book.sh


A documentação aparecerá na pasta docs


Referência UIButton produzida por NativeBook


Podemos verificar os instantâneos para diferentes modos de acessibilidade, modo escuro e RTL!


Qual é o próximo?

Esperamos que você tenha gostado da viagem. NativeBook , em seu estado atual, é uma prova de conceito, mas com algumas adições e integração adequada ao fluxo de trabalho do desenvolvedor, pode começar a agregar muito valor.


Também podemos imaginar o seguinte aprimoramento:

  • Adicionando suporte para Android: podemos oferecer suporte a ambas as plataformas e ter catálogos consistentes, testes instantâneos e documentação onde você pode alternar facilmente entre plataformas
  • Incorporando designs Figma na documentação
  • Mesclando com documentação gerada por ferramentas como DocC ou Jazzy para obter uma referência de API abrangente, além de instantâneos e trechos de código
  • Adicionando suporte adequado para visualizações SwiftUI
  • Geração automática de código de visualização do SwiftUI