大規模なアプリケーションでは、共有 UI コンポーネントのセットを実装、保守、文書化、および進化させることが課題です。 Web 開発者は、この問題に対する強力なソリューション Storybook.js ( https://storybook.js.org ) を作成しました。しかし、ネイティブiOS開発についてはどうでしょうか?ある程度似たような体験を実現できるだろうか? (ネタバレ注意:はい! )
まずは、元の Storybook.js の概念を見てみましょう。
Storybook.js は、Web 開発者が Web アプリケーションの UI コンポーネントを単独で作成およびテストするために使用するオープンソース ツールです。これは、開発者が Web サイトの個々の部分 (ボタン、フォーム、ナビゲーション バーなど) を Web サイトの残りの部分とは別に構築して紹介できる遊び場と考えてください。
Storybook.js はコンポーネントの生きたドキュメントとして機能し、さまざまな状態やシナリオでコンポーネントがどのように表示され、動作するかをコード サンプルとともに視覚的に確認できます。
Storybook.js の優れた点は、UI コンポーネントが同じ環境で使用されるという事実にあります。本番 Web サイトでも Storybook Web サイトでも、Web ブラウザーで実行しています。これにより、ドキュメント/プレイグラウンドと運用環境でコンポーネントの一貫性が確保され、コンポーネントが同期していないことが防止されます。
Web ページはドキュメントの優れた媒体でもあり、優れた読書体験、簡単な共有、瞬時の読み込み時間、およびあらゆるデバイスやオペレーティング システムでのユニバーサルな可用性を提供します。
ただし、 iOS アプリの場合は話が異なります。
iOS では、UI コンポーネント開発に関連するツールとメソッドの状況はかなり細分化されています。 Storybook.js の観点から見てみましょう。
Interface Builder はドラッグ アンド ドロップ インターフェイスを提供し、コードを記述せずに UI を簡単に設計できます。通常、個別の UI コンポーネントの開発ではなく、既存の UI コンポーネントを画面全体にレイアウトするために使用されます。一般に、さまざまな UI コンポーネントの状態を表示したり、複雑な UI を処理したりする場合にはうまく機能せず、多くの側面をコードに実装する必要があることがよくあります。全体として、Apple を含む多くの人がこれは行き止まりだと考えています。
SwiftUI Previews は、Xcode 内で SwiftUI ビューを開発およびテストするために Apple によって作成されたツールです。
SwiftUI プレビューは UIView にも使用できます。 https://sarunw.com/posts/xcode-previews-with-uiviewを参照してください。
長所:
短所:
SwiftUI プレビューが機能する場合、「ドキュメント」という点を除けば、SwiftUI プレビューはおそらく Storybook.js に最も近いエクスペリエンスになります。
次に行うのは、ソース コード、コメント、注釈に基づいてドキュメントを生成することです。最も注目すべき例は次のとおりです。
DocC - https://developer.apple.com/documentation/docc
ジャジー - https://github.com/realm/jazzy
このタイプのツールを使用すると、Web ページの形式でコンポーネント API 参照を作成できます。
スナップショット テストは、コンポーネントのさまざまな視覚的状態をテストして、状況が予期せず変化しないことを確認する、分離開発に最適な方法です。
iOS でスナップショット テストを実装する最も人気のある 2 つのライブラリは次のとおりです。
Storybook.js エクスペリエンスのためのもう 1 つの明らかな選択肢は、カスタムのネイティブ カタログ アプリを構築することです。
長所:
短所:
いくつかの例:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
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 回記述するだけで、次のことができるようになるところを想像してください。
朗報です!このようなシステムの概念実証を構築しました。
https://github.com/psharanda/NativeBook
見てまとめてみましょう!
NativeBook は現在、UIView ベースのコンポーネントに重点を置いています。 SwiftUI コンポーネントも同様の方法で統合できますが、この記事では取り上げません。
ストーリーは私たちのシステムの基礎です。基本的に、これは 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
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 では、 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()) } }
このタスクでは、別のスタック、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は、現在の状態では概念実証にすぎませんが、いくつかの追加と開発者のワークフローへの適切な統合により、多くの価値をもたらし始めることができます。
次のような機能強化も考えられます。