Внедрение, поддержка, документирование и развитие общего набора компонентов пользовательского интерфейса является сложной задачей в крупномасштабном приложении. Веб-разработчики создали мощное решение этой проблемы — Storybook.js ( https://storybook.js.org ). А как насчет нативной разработки для iOS ? Можем ли мы добиться чего-то подобного? (Внимание, спойлер: да! )
Но сначала давайте рассмотрим исходную концепцию Storybook.js.
Storybook.js — это инструмент с открытым исходным кодом, используемый веб-разработчиками для изолированного создания и тестирования компонентов пользовательского интерфейса для веб-приложений. Думайте об этом как о игровой площадке, где разработчики могут создавать и демонстрировать отдельные части веб-сайта (например, кнопки, формы и панели навигации) отдельно от остальной части веб-сайта.
Storybook.js служит живой документацией компонентов и предоставляет визуальные эффекты, позволяющие увидеть, как компоненты выглядят и ведут себя в различных состояниях и сценариях, а также примеры кода.
Элегантность Storybook.js заключается в том, что компоненты пользовательского интерфейса используются в одной и той же среде; будь то рабочий веб-сайт или сайт Storybook, мы по-прежнему запускаем их в веб-браузере. Это гарантирует, что компоненты будут выглядеть согласованно в нашей документации/игровой площадке и в рабочей среде, предотвращая их рассинхронизацию.
Веб-страницы также являются отличным средством хранения документации, обеспечивая удобство чтения, удобство совместного использования, мгновенную загрузку и универсальную доступность на любом устройстве и в любой операционной системе.
Однако с приложениями для iOS ситуация другая.
В iOS набор инструментов и методов, связанных с разработкой компонентов пользовательского интерфейса, весьма фрагментирован. Давайте посмотрим на них с точки зрения Storybook.js.
Интерфейсный конструктор предоставляет интерфейс с возможностью перетаскивания, что позволяет легко разрабатывать пользовательские интерфейсы без написания кода. Обычно он используется для размещения существующих компонентов пользовательского интерфейса для целых экранов, а не для разработки отдельных компонентов пользовательского интерфейса. Он не очень хорошо подходит для демонстрации различных состояний компонентов пользовательского интерфейса и обработки сложных пользовательских интерфейсов в целом, часто требуя реализации многих аспектов в коде. В целом, многие, включая Apple, считают это тупиком.
SwiftUI Previews — это инструмент, созданный Apple для разработки и тестирования представлений SwiftUI в Xcode.
Предварительные просмотры SwiftUI также можно использовать для UIView. См. https://sarunw.com/posts/xcode-previews-with-uiview.
Плюсы:
Минусы:
Когда это работает, предварительный просмотр SwiftUI, вероятно, наиболее близок к Storybook.js, за исключением аспекта «документации».
Следующее, что нам предстоит сделать, — это генерация документации на основе исходного кода, комментариев и аннотаций. Наиболее яркими примерами являются:
DocC — https://developer.apple.com/documentation/docc.
Джаззи - https://github.com/realm/jazzy
Этот тип инструментов можно использовать для создания ссылок на API компонентов в форме веб-страницы.
Снимковые тесты — отличный способ изолированной разработки, тестирования различных визуальных состояний компонента, гарантирующих, что что-то не изменится неожиданно.
Две наиболее популярные библиотеки, реализующие тестирование снимков на iOS:
Еще одним очевидным выбором для Storybook.js является создание собственного собственного приложения-каталога.
Плюсы:
Минусы:
Некоторые примеры:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
https://github.com/eure/Storybook-ios
Одна классная, хотя и экстравагантная идея — встроить собственное приложение-каталог в веб-сайт Storybook.js с помощью сервиса Appetize.io, который позволяет транслировать содержимое устройства iOS на веб-страницу — https://medium.com/@vasikarla . .raj/storybook-for-native-d772654c7133
Это факт, что в собственной мобильной разработке отсутствует секретный ингредиент, необходимый для работы, подобной Storyboard.js: единая среда для запуска документации, игровых площадок и производства, и все это на самой доступной платформе — веб-сайте.
Но что, если мы объединим приложение-каталог, тесты снимков и генерацию документации на основе HTML в единую систему? Представьте себе, что вы написали фрагмент кода всего один раз, а затем можете сделать следующее:
Хорошие новости! Я построил доказательство концепции такой системы:
https://github.com/psharanda/NativeBook
Давайте посмотрим и соберем!
NativeBook в настоящее время фокусируется на компонентах на основе UIView. Компоненты SwiftUI также можно интегрировать аналогичным образом, хотя они не рассматриваются в этой статье.
История — краеугольный камень нашей системы. По сути, это именованный фрагмент кода, который демонстрирует «интересное» состояние компонента пользовательского интерфейса. Его можно представить в виде простой структуры:
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
Мы можем взять идею 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) } } }
В нашем примере мы будем использовать библиотеку 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()) } }
Для этой задачи мы будем использовать другой стек: шаблоны 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
Надеюсь, вам понравилось путешествие. NativeBook в своем нынешнем состоянии является подтверждением концепции, но с некоторыми дополнениями и правильной интеграцией в рабочий процесс разработчика он может начать приносить большую пользу.
Мы также можем представить себе следующее улучшение: