paint-brush
NativeBook: объединение опыта разработки для iOS с помощью Storybook.jsк@psharanda
19,668 чтения
19,668 чтения

NativeBook: объединение опыта разработки для iOS с помощью Storybook.js

к Pavel Sharanda15m2023/12/19
Read on Terminal Reader

Слишком долго; Читать

Storybook.js — это инструмент, используемый веб-разработчиками для изолированного создания и тестирования компонентов пользовательского интерфейса, предоставляющий площадку для создания и демонстрации отдельных частей пользовательского интерфейса. Однако для разработки под iOS прямого эквивалента не существует. В статье рассказывается о «NativeBook», предлагаемой системе, объединяющей приложение-каталог, предварительный просмотр SwidtUI, тесты снимков и генерацию HTML-документации для создания интерфейса, подобного Storybook.js, для iOS. Несмотря на то, что NativeBook по-прежнему является подтверждением концепции, он направлен на оптимизацию процесса разработки и документирования компонентов пользовательского интерфейса iOS.
featured image - NativeBook: объединение опыта разработки для iOS с помощью Storybook.js
Pavel Sharanda HackerNoon profile picture
0-item

Введение

Внедрение, поддержка, документирование и развитие общего набора компонентов пользовательского интерфейса является сложной задачей в крупномасштабном приложении. Веб-разработчики создали мощное решение этой проблемы — Storybook.js ( https://storybook.js.org ). А как насчет нативной разработки для iOS ? Можем ли мы добиться чего-то подобного? (Внимание, спойлер: да! )


Но сначала давайте рассмотрим исходную концепцию Storybook.js.

Storybook.js

Storybook.js — это инструмент с открытым исходным кодом, используемый веб-разработчиками для изолированного создания и тестирования компонентов пользовательского интерфейса для веб-приложений. Думайте об этом как о игровой площадке, где разработчики могут создавать и демонстрировать отдельные части веб-сайта (например, кнопки, формы и панели навигации) отдельно от остальной части веб-сайта.


Storybook.js служит живой документацией компонентов и предоставляет визуальные эффекты, позволяющие увидеть, как компоненты выглядят и ведут себя в различных состояниях и сценариях, а также примеры кода.


Пример страницы Storybook.js для компонента Button


Элегантность Storybook.js заключается в том, что компоненты пользовательского интерфейса используются в одной и той же среде; будь то рабочий веб-сайт или сайт Storybook, мы по-прежнему запускаем их в веб-браузере. Это гарантирует, что компоненты будут выглядеть согласованно в нашей документации/игровой площадке и в рабочей среде, предотвращая их рассинхронизацию.


Веб-страницы также являются отличным средством хранения документации, обеспечивая удобство чтения, удобство совместного использования, мгновенную загрузку и универсальную доступность на любом устройстве и в любой операционной системе.


Однако с приложениями для iOS ситуация другая.

Тем временем на iOS

В iOS набор инструментов и методов, связанных с разработкой компонентов пользовательского интерфейса, весьма фрагментирован. Давайте посмотрим на них с точки зрения Storybook.js.

Разработчик интерфейсов

Интерфейсный конструктор предоставляет интерфейс с возможностью перетаскивания, что позволяет легко разрабатывать пользовательские интерфейсы без написания кода. Обычно он используется для размещения существующих компонентов пользовательского интерфейса для целых экранов, а не для разработки отдельных компонентов пользовательского интерфейса. Он не очень хорошо подходит для демонстрации различных состояний компонентов пользовательского интерфейса и обработки сложных пользовательских интерфейсов в целом, часто требуя реализации многих аспектов в коде. В целом, многие, включая Apple, считают это тупиком.

Предварительный просмотр SwiftUI

SwiftUI Previews — это инструмент, созданный Apple для разработки и тестирования представлений SwiftUI в Xcode.

Предварительные просмотры SwiftUI также можно использовать для UIView. См. https://sarunw.com/posts/xcode-previews-with-uiview.

Плюсы:

  • Отлично подходит для изолированной разработки
  • Поддержка горячей перезагрузки
  • Используйте настоящий симулятор iOS под капотом
  • Отображение активных компонентов рядом с фрагментом кода
  • Удобно для тестирования динамического типа и темного режима.

Минусы:

  • Требовать, чтобы Xcode был установлен с правильно настроенной средой разработки.
  • Время сборки может быть длительным
  • Работают ненадежно и довольно часто выходят из строя
  • Ресурсоемкий, работает медленно даже на высокопроизводительных машинах.


Когда это работает, предварительный просмотр SwiftUI, вероятно, наиболее близок к Storybook.js, за исключением аспекта «документации».

Генерация документации на основе HTML

Следующее, что нам предстоит сделать, — это генерация документации на основе исходного кода, комментариев и аннотаций. Наиболее яркими примерами являются:

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

Джаззи - https://github.com/realm/jazzy


Этот тип инструментов можно использовать для создания ссылок на API компонентов в форме веб-страницы.


Большая часть документации для разработчиков Apple создана с использованием DocC.

Снимковое тестирование

Снимковые тесты — отличный способ изолированной разработки, тестирования различных визуальных состояний компонента, гарантирующих, что что-то не изменится неожиданно.


Две наиболее популярные библиотеки, реализующие тестирование снимков на iOS:

Приложение индивидуального каталога

Еще одним очевидным выбором для Storybook.js является создание собственного собственного приложения-каталога.


Плюсы:

  • Использует ту же среду, что и в рабочей среде: симулятор iOS или устройство.
  • Отображает реальные компоненты на экране
  • Позволяет тестировать доступность, интернационализацию и темный режим.

Минусы:

  • Это приложение; это не мгновенный опыт, и его создание и запуск сопряжены с дополнительными хлопотами.
  • Опыт документирования полностью отсутствует.


Некоторые примеры:

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

https://github.com/hpennington/SwiftBook

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


Опыт каталога с https://github.com/eure/Storybook-ios


Одна классная, хотя и экстравагантная идея — встроить собственное приложение-каталог в веб-сайт Storybook.js с помощью сервиса Appetize.io, который позволяет транслировать содержимое устройства iOS на веб-страницу — https://medium.com/@vasikarla . .raj/storybook-for-native-d772654c7133

Еще одна идея

Это факт, что в собственной мобильной разработке отсутствует секретный ингредиент, необходимый для работы, подобной Storyboard.js: единая среда для запуска документации, игровых площадок и производства, и все это на самой доступной платформе — веб-сайте.


Но что, если мы объединим приложение-каталог, тесты снимков и генерацию документации на основе HTML в единую систему? Представьте себе, что вы написали фрагмент кода всего один раз, а затем можете сделать следующее:

  • Отображение представления, созданного на основе фрагмента, в собственном приложении-каталоге.
  • Встраивание представления в предварительный просмотр SwiftUI
  • Запустите проверку снимка фрагмента
  • Отобразите этот фрагмент кода и полученный снимок в созданной документации на основе HTML.


Хорошие новости! Я построил доказательство концепции такой системы:

https://github.com/psharanda/NativeBook


Давайте посмотрим и соберем!


NativeBook в настоящее время фокусируется на компонентах на основе UIView. Компоненты SwiftUI также можно интегрировать аналогичным образом, хотя они не рассматриваются в этой статье.

Реализация NativeBook

Истории

История — краеугольный камень нашей системы. По сути, это именованный фрагмент кода, который демонстрирует «интересное» состояние компонента пользовательского интерфейса. Его можно представить в виде простой структуры:

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


Следующий объект — ComponentStories , который содержит список историй для компонента:

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


Итак, как мы собираемся объявить фрагмент? Для упрощения анализа и удобства функция может иметь специальное соглашение об именах, которое начинается с story_ . В общем, каждый фрагмент кода для истории:

  • Создает представление
  • Настраивает его (что может включать в себя установку ограничений ширины/высоты)
  • Возвращает представление


Вот пример фрагмента:

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


Мы можем улучшить процесс написания историй и сократить количество шаблонов, используя динамическую природу Objective-C. Идея состоит в том, чтобы иметь базовый класс, способный извлекать все селекторы классов, начинающиеся с store_ , и создавать из них список историй.

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


Например, класс, в котором размещаются истории для UILabel, может быть структурирован следующим образом:

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

Приложение-каталог

Приложение-каталог в своей простейшей форме обычно состоит из двух экранов.


Главный экран: на этом экране отображается список компонентов и связанные с ними истории.

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


Экран сведений: на этом экране мы отображаем компонент в центре, не ограничивая его ширину или высоту, поскольку определение этих атрибутов зависит от фрагмента.

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


Весь поток каталога можно инициализировать следующим образом:

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


Каталог NativeBook


Предварительный просмотр SwiftUI

Мы можем взять идею PreviewContainer и ее реализацию с 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) } }


И напишите наши превью так же просто, как это

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


Превью SwiftUI + истории NativeBook

Снимковые тесты

В нашем примере мы будем использовать библиотеку iOSSnapshotTestCase .


В iOS 17 были введены некоторые ценные дополнения к API UIView для переопределения признаков через UIView.traitOverrides . Это свойство чрезвычайно полезно для поддержки доступности при тестировании моментальных снимков. Для наших тестов снимков мы собираемся протестировать одно и то же представление в различных условиях, применяя RTL, темный режим и некоторые категории динамического типа.


Важно отметить, что для тестирования traitOverrides нам нужно использовать метод drawViewHierarchy(in:afterScreenUpdates:) и убедиться, что тестируемые представления добавлены в ключ приложения UIWindow .

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


Действительно, при наличии этих приготовлений создание тестового примера становится довольно простым:

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


Справочные снимки, созданные NativeBookVerifyView

Генератор документации

Для этой задачи мы будем использовать другой стек: шаблоны Node.js, TypeScript и EJS. Гораздо проще работать со страницами на основе HTML, используя встроенные в них инструменты.


Первое, что нам нужно, это какой-то файл конфигурации, в котором мы можем связать наши фрагменты кода и тестовые снимки. Для этой цели хорошо подойдет простой файл JSON .

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


Создав простое приложение Node.js, давайте определим модель.

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


Интересная задача — как извлечь фрагменты кода из файла Swift. Этого можно добиться довольно просто, используя регулярное выражение.

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


Генерацию HTML можно выполнить с помощью EJS, мощного механизма шаблонов, который позволяет нам использовать JavaScript внутри шаблона:

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


Теперь объединим все в основной функции:

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

Сайт документации

Демо-версия доступна по адресу https://psharanda.github.io/NativeBook .


Чтобы создать документацию в репозитории NativeBook, вам необходимо выполнить следующие команды:

 cd native_book_gen npm install npm run native-book-gen


Или просто запустите:

 sh generate_native_book.sh


Документация появится в папке docs


Ссылка на UIButton, созданная NativeBook


Мы можем проверить снимки для разных режимов доступности, темного режима и RTL!


Что дальше?

Надеюсь, вам понравилось путешествие. NativeBook в своем нынешнем состоянии является подтверждением концепции, но с некоторыми дополнениями и правильной интеграцией в рабочий процесс разработчика он может начать приносить большую пользу.


Мы также можем представить себе следующее улучшение:

  • Добавление поддержки Android: мы можем поддерживать обе платформы и иметь согласованные каталоги, тесты моментальных снимков и документацию, в которой вы можете легко переключаться между платформами.
  • Встраивание проектов Figma в документацию
  • Объединение с документацией, созданной такими инструментами, как DocC или Jazzy, для получения подробного справочника по API в дополнение к снимкам и фрагментам кода.
  • Добавление правильной поддержки представлений SwiftUI.
  • Автоматическое создание кода предварительного просмотра SwiftUI