NativeBook: набор инструментов для создания компонентов пользовательского интерфейса iOS, вдохновленный Storybook.js
20 декабря 2023 г.Введение
Внедрение, поддержка, документирование и развитие общего набора компонентов пользовательского интерфейса — сложная задача в крупномасштабном приложении. Веб-разработчики создали мощное решение этой проблемы — Storybook.js (https://storybook.js.org). А как насчет разработки для iOS? Можем ли мы добиться чего-то подобного? (Внимание, спойлер: Да!)
Но сначала давайте рассмотрим исходную концепцию Storybook.js.
Storybook.js
Storybook.js — это инструмент с открытым исходным кодом, используемый веб-разработчиками для изолированного создания и тестирования компонентов пользовательского интерфейса веб-приложений. Думайте об этом как о игровой площадке, где разработчики могут создавать и демонстрировать отдельные части веб-сайта (например, кнопки, формы и панели навигации) отдельно от остальной части веб-сайта.
Storybook.js служит живой документацией компонентов и предоставляет визуальные эффекты, позволяющие увидеть, как компоненты выглядят и ведут себя в различных состояниях и сценариях, а также примеры кода.
Элегантность Storybook.js заключается в том, что компоненты пользовательского интерфейса используются в одной и той же среде; будь то рабочий веб-сайт или веб-сайт Storybook, мы по-прежнему запускаем их в веб-браузере. Это гарантирует, что компоненты будут выглядеть согласованно в нашей документации/на игровой площадке и в рабочей среде, предотвращая их рассинхронизацию.
Веб-страницы также являются отличным носителем документации: удобство чтения, удобство обмена, мгновенная загрузка и универсальная доступность на любом устройстве и в любой операционной системе.
Однако для приложений iOS история это другое.
Тем временем на iOS
В iOS набор инструментов и методов, связанных с разработкой компонентов пользовательского интерфейса, весьма фрагментирован. Давайте посмотрим на них с точки зрения Storybook.js.
Конструктор интерфейсов
Interface Builder предоставляет интерфейс с возможностью перетаскивания, что позволяет легко разрабатывать пользовательские интерфейсы без написания кода. Обычно он используется для размещения существующих компонентов пользовательского интерфейса для целых экранов, а не для разработки отдельных компонентов пользовательского интерфейса. Он не очень хорошо подходит для демонстрации различных состояний компонентов пользовательского интерфейса и обработки сложных пользовательских интерфейсов в целом, часто требуя реализации многих аспектов в коде. В целом многие, включая 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 компонентов в форме веб-страницы.
Тестирование моментальных снимков
Снимочные тесты — отличный способ изолированной разработки, тестирования различных визуальных состояний компонента и обеспечения отсутствия неожиданных изменений.
Две наиболее популярные библиотеки, реализующие тестирование снимков на iOS:
- iOSSnapshotTestCase — https://github.com/uber/ios-snapshot-test-case
- SnapshotTesting – https://github.com/pointfreeco/swift-snapshot-testing
Приложение пользовательского каталога
Другим очевидным выбором для Storybook.js является создание собственного собственного приложения-каталога.
Плюсы:
- Использует ту же среду, что и рабочая: симулятор iOS или устройство.
- Отображает реальные компоненты на экране.
- Включает тестирование доступности, интернационализации и темного режима.
Минусы:
- Это приложение; это не мгновенный опыт, и его создание и запуск требуют дополнительных хлопот.
- Опыт документирования полностью отсутствует.
Несколько примеров:
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 в единую систему? Представьте себе, что вы написали фрагмент кода всего один раз, а затем можете сделать следующее:
- Отображение представления, созданного на основе фрагмента, в собственном приложении-каталоге.
- Встроить представление в предварительный просмотр 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
Предварительный просмотр SwiftUI
Мы можем взять идею PreviewContainer и ее реализацию из https://sarunw.com/posts/xcode- превью-с-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 =
/funcs+story_(?<name>[A-Za-z0-9_]+)()s*->s*UIViews*{(?<codeSnippet>[sS]*?)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 в своем текущем состоянии является доказательством концепции, но с некоторыми дополнениями и правильной интеграцией в рабочий процесс разработчика он может начать работать. принося большую пользу.
Мы также можем представить себе следующие улучшения:
- Добавление поддержки Android: мы можем поддерживать обе платформы и иметь согласованные каталоги, тестовые снимки и документацию, позволяющую легко переключаться между платформами.
- Встраивание проектов Figma в документацию
- Объединение с документацией, созданной такими инструментами, как DocC или Jazzy, для получения подробного справочника по API в дополнение к снимкам и фрагментам кода.
- Добавление надлежащей поддержки представлений SwiftUI
Оригинал