paint-brush
NativeBook: Storybook.js を使用したネイティブ iOS 開発エクスペリエンスの統合@psharanda
19,668 測定値
19,668 測定値

NativeBook: Storybook.js を使用したネイティブ iOS 開発エクスペリエンスの統合

Pavel Sharanda15m2023/12/19
Read on Terminal Reader

長すぎる; 読むには

Storybook.js は、Web 開発者が UI コンポーネントを個別に作成およびテストするために使用するツールで、UI パーツを個別に構築および表示するための遊び場を提供します。ただし、ネイティブ iOS 開発の場合、直接同等のものはありません。この記事では、カタログ アプリ、SwidtUI プレビュー、スナップショット テスト、HTML ドキュメント生成を組み合わせて、iOS 向けに Storybook.js のようなエクスペリエンスを作成する提案されたシステムである「NativeBook」を紹介しています。 NativeBook はまだ概念実証ですが、iOS UI コンポーネントの開発とドキュメントのプロセスを合理化することを目的としています。
featured image - NativeBook: Storybook.js を使用したネイティブ iOS 開発エクスペリエンスの統合
Pavel Sharanda HackerNoon profile picture
0-item

導入

大規模なアプリケーションでは、共有 UI コンポーネントのセットを実装、保守、文書化、および進化させることが課題です。 Web 開発者は、この問題に対する強力なソリューション Storybook.js ( https://storybook.js.org ) を作成しました。しかし、ネイティブiOS開発についてはどうでしょうか?ある程度似たような体験を実現できるだろうか? (ネタバレ注意:はい!


まずは、元の Storybook.js の概念を見てみましょう。

Storybook.js

Storybook.js は、Web 開発者が Web アプリケーションの UI コンポーネントを単独で作成およびテストするために使用するオープンソース ツールです。これは、開発者が Web サイトの個々の部分 (ボタン、フォーム、ナビゲーション バーなど) を Web サイトの残りの部分とは別に構築して紹介できる遊び場と考えてください。


Storybook.js はコンポーネントの生きたドキュメントとして機能し、さまざまな状態やシナリオでコンポーネントがどのように表示され、動作するかをコード サンプルとともに視覚的に確認できます。


Button コンポーネントの Storybook.js ページの例


Storybook.js の優れた点は、UI コンポーネントが同じ環境で使用されるという事実にあります。本番 Web サイトでも Storybook Web サイトでも、Web ブラウザーで実行しています。これにより、ドキュメント/プレイグラウンドと運用環境でコンポーネントの一貫性が確保され、コンポーネントが同期していないことが防止されます。


Web ページはドキュメントの優れた媒体でもあり、優れた読書体験、簡単な共有、瞬時の読み込み時間、およびあらゆるデバイスやオペレーティング システムでのユニバーサルな可用性を提供します。


ただし、 iOS アプリの場合は話が異なります。

一方、iOSでは

iOS では、UI コンポーネント開発に関連するツールとメソッドの状況はかなり細分化されています。 Storybook.js の観点から見てみましょう。

インターフェースビルダー

Interface Builder はドラッグ アンド ドロップ インターフェイスを提供し、コードを記述せずに UI を簡単に設計できます。通常、個別の UI コンポーネントの開発ではなく、既存の UI コンポーネントを画面全体にレイアウトするために使用されます。一般に、さまざまな UI コンポーネントの状態を表示したり、複雑な UI を処理したりする場合にはうまく機能せず、多くの側面をコードに実装する必要があることがよくあります。全体として、Apple を含む多くの人がこれは行き止まりだと考えています。

SwiftUI プレビュー

SwiftUI Previews は、Xcode 内で SwiftUI ビューを開発およびテストするために Apple によって作成されたツールです。

SwiftUI プレビューは UIView にも使用できます。 https://sarunw.com/posts/xcode-previews-with-uiviewを参照してください。

長所:

  • 孤立した開発に最適
  • ホットリロードのサポート
  • 実際の iOS シミュレーターを内部で使用する
  • コード スニペットの隣にライブ コンポーネントを表示する
  • ダイナミックタイプやダークモードのテストに便利

短所:

  • 開発環境が適切に構成された状態で Xcode をインストールする必要があります。
  • ビルド時間が長くなる可能性がある
  • 動作の信頼性が低く、頻繁にクラッシュする
  • リソースを大量に消費し、ハイエンド マシンでも動作が遅い


SwiftUI プレビューが機能する場合、「ドキュメント」という点を除けば、SwiftUI プレビューはおそらく Storybook.js に最も近いエクスペリエンスになります。

HTMLベースのドキュメント生成

次に行うのは、ソース コード、コメント、注釈に基づいてドキュメントを生成することです。最も注目すべき例は次のとおりです。

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

ジャジー - https://github.com/realm/jazzy


このタイプのツールを使用すると、Web ページの形式でコンポーネント API 参照を作成できます。


Apple の開発者ドキュメントの多くは DocC を使用して構築されています。

スナップショットテスト

スナップショット テストは、コンポーネントのさまざまな視覚的状態をテストして、状況が予期せず変化しないことを確認する、分離開発に最適な方法です。


iOS でスナップショット テストを実装する最も人気のある 2 つのライブラリは次のとおりです。

カスタムカタログアプリ

Storybook.js エクスペリエンスのためのもう 1 つの明らかな選択肢は、カスタムのネイティブ カタログ アプリを構築することです。


長所:

  • 本番環境と同じ環境 (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 からのカタログ エクスペリエンス


贅沢ではありますが、クールなアイデアの 1 つは、Appetize.io サービスを使用してネイティブ カタログ アプリを Storybook.js Web サイトに埋め込むことです。これにより、iOS デバイスのコンテンツを Web ページにストリーミングできます - https://medium.com/@vasikarla .raj/storybook-for-native-d772654c7133

もう一つのアイデア

ネイティブ モバイル開発には、Storyboard.js のようなエクスペリエンスに必要な秘密の要素、つまり、最もアクセスしやすいプラットフォームである Web サイト内で、ドキュメント、プレイグラウンド、プロダクションを実行するための単一環境が欠けているのは事実です。


しかし、カタログ アプリ、スナップショット テスト、HTML ベースのドキュメント生成を 1 つのシステムに組み合わせたらどうなるでしょうか?コード スニペットを 1 回記述するだけで、次のことができるようになるところを想像してください。

  • ネイティブ カタログ アプリでスニペットから生成されたビューを表示する
  • SwiftUI プレビューにビューを埋め込む
  • スニペットのスナップショット テストを実行する
  • このコード スニペットとその結果のスナップショットを、生成された HTML ベースのドキュメントに表示します。


朗報です!このようなシステムの概念実証を構築しました。

https://github.com/psharanda/NativeBook


見てまとめてみましょう!


NativeBook は現在、UIView ベースのコンポーネントに重点を置いています。 SwiftUI コンポーネントも同様の方法で統合できますが、この記事では取り上げません。

NativeBookの実装

ストーリー

ストーリーは私たちのシステムの基礎です。基本的に、これは UI コンポーネントの「興味深い」状態を示す名前付きのコード スニペットです。これは単純な構造体として表すことができます。

 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 { … } }

カタログアプリ

カタログ アプリは、最も単純な形式では、通常 2 つの画面で構成されます。


メイン画面: この画面には、コンポーネントとそれに関連するストーリーのリストが表示されます。

 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-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 では、 UIView.traitOverridesを通じて特性をオーバーライドするために、 UIView API にいくつかの貴重な追加機能が導入されました。このプロパティは、アクセシビリティ サポートのスナップショット テストに非常に役立ちます。スナップショット テストでは、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 ベースのページのネイティブ ツールを使用して、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 の生成は、テンプレート内で JavaScript を使用できる強力なテンプレート エンジンである EJS を使用して実行できます。

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


次に、main 関数のすべてを組み合わせます。

 (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 によって作成された UIButton リファレンス


さまざまなアクセシビリティ モード、ダーク モード、RTL のスナップショットを確認できます。


次は何ですか?

旅を楽しんでいただければ幸いです。 NativeBookは、現在の状態では概念実証にすぎませんが、いくつかの追加と開発者のワークフローへの適切な統合により、多くの価値をもたらし始めることができます。


次のような機能強化も考えられます。

  • Android のサポートの追加: 両方のプラットフォームをサポートし、プラットフォーム間を簡単に切り替えることができる一貫したカタログ、スナップショット テスト、ドキュメントを提供します。
  • Figma デザインをドキュメントに埋め込む
  • DocC や Jazzy などのツールによって生成されたドキュメントと結合して、スナップショットやコード スニペットに加えて包括的な API リファレンスを取得します。
  • SwiftUI ビューの適切なサポートの追加
  • SwiftUI プレビュー コードの自動生成