paint-brush
NativeBook: Hợp nhất trải nghiệm phát triển iOS gốc với Storybook.jsby@psharanda
19,182
19,182

NativeBook: Hợp nhất trải nghiệm phát triển iOS gốc với Storybook.js

Pavel Sharanda15m2023/12/19
Read on Terminal Reader

Storybook.js là một công cụ được các nhà phát triển web sử dụng để tạo và thử nghiệm các thành phần giao diện người dùng một cách riêng biệt, cung cấp một sân chơi để xây dựng và trưng bày các bộ phận giao diện người dùng một cách riêng biệt. Tuy nhiên, để phát triển iOS gốc thì không có cách nào tương đương trực tiếp. Bài viết giới thiệu "NativeBook", một hệ thống được đề xuất kết hợp ứng dụng danh mục, bản xem trước SwidtUI, kiểm tra ảnh chụp nhanh và tạo tài liệu HTML để tạo trải nghiệm giống như Storybook.js cho iOS. Mặc dù vẫn là bằng chứng về khái niệm, NativeBook nhằm mục đích hợp lý hóa quy trình tài liệu và phát triển thành phần giao diện người dùng iOS.
featured image - NativeBook: Hợp nhất trải nghiệm phát triển iOS gốc với Storybook.js
Pavel Sharanda HackerNoon profile picture
0-item

Giới thiệu

Việc triển khai, duy trì, ghi lại và phát triển một bộ thành phần UI dùng chung là một thách thức trong một ứng dụng quy mô lớn. Các nhà phát triển web đã tạo ra một giải pháp mạnh mẽ cho vấn đề này - Storybook.js ( https://storybook.js.org ). Nhưng còn phát triển iOS gốc thì sao? Chúng ta có thể đạt được trải nghiệm tương tự không? (Cảnh báo spoiler: Có! )


Nhưng trước tiên, hãy khám phá khái niệm Storybook.js ban đầu.

Storybook.js

Storybook.js là một công cụ nguồn mở được các nhà phát triển web sử dụng để tạo và kiểm tra các thành phần giao diện người dùng cho các ứng dụng web một cách riêng biệt. Hãy coi nó như một sân chơi nơi các nhà phát triển có thể xây dựng và giới thiệu các phần riêng lẻ của trang web (như nút, biểu mẫu và thanh điều hướng) tách biệt với phần còn lại của trang web.


Storybook.js đóng vai trò là tài liệu sống động về các thành phần và cung cấp hình ảnh trực quan để xem các thành phần trông như thế nào và hoạt động như thế nào trong các trạng thái và kịch bản khác nhau cùng với các mẫu mã.


Ví dụ về trang Storybook.js cho thành phần Nút


Sự tinh tế của Storybook.js nằm ở chỗ các thành phần UI được sử dụng trong cùng một môi trường; dù trên trang web sản xuất hay sách Truyện, chúng tôi vẫn chạy chúng trên trình duyệt web. Điều này đảm bảo rằng các thành phần trông nhất quán trong tài liệu/sân chơi của chúng tôi và trong quá trình sản xuất, ngăn không cho chúng không đồng bộ.


Các trang web cũng là một phương tiện tuyệt vời để lưu trữ tài liệu, mang lại trải nghiệm đọc tuyệt vời, chia sẻ dễ dàng, thời gian tải tức thì và tính khả dụng phổ biến trên mọi thiết bị và hệ điều hành.


Tuy nhiên, đối với ứng dụng iOS thì câu chuyện lại khác.

Trong khi đó trên iOS

Trên iOS, bối cảnh về các công cụ và phương pháp liên quan đến phát triển thành phần giao diện người dùng khá rời rạc. Chúng ta hãy xem chúng từ góc độ Storybook.js.

Trình tạo giao diện

Trình tạo Giao diện cung cấp giao diện kéo và thả, giúp bạn dễ dàng thiết kế giao diện người dùng mà không cần viết mã. Thông thường, nó được sử dụng để bố trí các thành phần giao diện người dùng hiện có cho toàn bộ màn hình thay vì để phát triển các thành phần giao diện người dùng riêng lẻ. Nó không hoạt động tốt trong việc hiển thị các trạng thái thành phần giao diện người dùng khác nhau và xử lý các giao diện người dùng phức tạp nói chung, thường yêu cầu triển khai nhiều khía cạnh trong mã. Nhìn chung, nó được nhiều người coi là ngõ cụt, bao gồm cả Apple.

Xem trước SwiftUI

Xem trước SwiftUI là một công cụ do Apple tạo ra để phát triển và thử nghiệm các chế độ xem SwiftUI trong Xcode.

Bản xem trước SwiftUI cũng có thể được sử dụng cho UIView. Xem https://sarunw.com/posts/xcode-previews-with-uiview

Ưu điểm:

  • Tuyệt vời cho sự phát triển biệt lập
  • Hỗ trợ tải lại nóng
  • Sử dụng trình mô phỏng iOS thực tế dưới mui xe
  • Hiển thị các thành phần trực tiếp bên cạnh đoạn mã
  • Thuận tiện cho việc thử nghiệm Loại động và Chế độ tối

Nhược điểm:

  • Yêu cầu cài đặt Xcode với môi trường phát triển được cấu hình đúng cách.
  • Thời gian xây dựng có thể kéo dài
  • Làm việc không đáng tin cậy và gặp sự cố khá thường xuyên
  • Ngốn tài nguyên, chạy chậm kể cả trên máy cao cấp


Khi nó hoạt động, bản xem trước SwiftUI có lẽ là trải nghiệm gần gũi nhất với Storybook.js, ngoại trừ khía cạnh "tài liệu".

Tạo tài liệu dựa trên HTML

Điều tiếp theo chúng tôi có là tạo tài liệu dựa trên mã nguồn, nhận xét và chú thích. Các ví dụ đáng chú ý nhất là:

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

Jazzy - https://github.com/realm/jazzy


Loại công cụ này có thể được sử dụng để tạo các tham chiếu API thành phần dưới dạng trang web.


Phần lớn tài liệu dành cho nhà phát triển của Apple được xây dựng bằng DocC.

Kiểm tra ảnh chụp nhanh

Kiểm tra ảnh chụp nhanh là một cách tuyệt vời để phát triển riêng biệt, kiểm tra các trạng thái trực quan khác nhau của một thành phần, đảm bảo rằng mọi thứ không thay đổi ngoài dự kiến.


Hai thư viện phổ biến nhất triển khai thử nghiệm ảnh chụp nhanh trên iOS là:

Ứng dụng danh mục tùy chỉnh

Một lựa chọn rõ ràng khác cho trải nghiệm Storybook.js là xây dựng một ứng dụng danh mục gốc tùy chỉnh.


Ưu điểm:

  • Sử dụng môi trường giống như môi trường sản xuất: thiết bị hoặc trình mô phỏng iOS
  • Hiển thị linh kiện thật trên màn hình
  • Cho phép kiểm tra khả năng truy cập, quốc tế hóa và chế độ tối.

Nhược điểm:

  • Nó là một ứng dụng; nó không phải là một trải nghiệm tức thì và việc xây dựng và chạy nó sẽ thêm rắc rối.
  • Kinh nghiệm tài liệu là hoàn toàn thiếu.


Vài ví dụ:

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

https://github.com/hpennington/SwiftBook

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


Trải nghiệm danh mục từ https://github.com/eure/Storybook-ios


Một ý tưởng thú vị, mặc dù xa hoa, là nhúng ứng dụng danh mục gốc vào trang web Storybook.js bằng dịch vụ Appetize.io, cho phép truyền trực tuyến nội dung của thiết bị iOS tới một trang web - https://medium.com/@vasikarla .raj/storybook-for-native-d772654c7133

Một ý tưởng nữa

Thực tế là hoạt động phát triển di động gốc thiếu thành phần bí mật cần thiết cho trải nghiệm giống như Storyboard.js: một môi trường duy nhất để chạy tài liệu, sân chơi và sản xuất, tất cả đều nằm trong nền tảng dễ truy cập nhất - trang web.


Nhưng điều gì sẽ xảy ra nếu chúng ta kết hợp ứng dụng danh mục, kiểm tra ảnh chụp nhanh và tạo tài liệu dựa trên HTML vào một hệ thống duy nhất? Hãy tưởng tượng bạn viết một đoạn mã chỉ một lần và sau đó có thể thực hiện những việc sau:

  • Hiển thị chế độ xem được tạo từ một đoạn mã trong ứng dụng danh mục gốc
  • Nhúng chế độ xem vào bản xem trước SwiftUI
  • Chạy thử nghiệm ảnh chụp nhanh cho một đoạn mã
  • Hiển thị đoạn mã này và ảnh chụp nhanh kết quả trong tài liệu dựa trên HTML được tạo.


Tin tốt! Tôi đã xây dựng một bằng chứng về khái niệm cho một hệ thống như vậy:

https://github.com/psharanda/NativeBook


Hãy cùng xem và tổng hợp lại nhé!


NativeBook hiện tập trung vào các thành phần dựa trên UIView. Các thành phần SwiftUI cũng có thể được tích hợp theo cách tương tự, mặc dù chúng không được đề cập trong bài viết này.

Triển khai Sách gốc

Những câu chuyện

Câu chuyện là nền tảng của hệ thống của chúng tôi. Về cơ bản, nó là một đoạn mã được đặt tên thể hiện trạng thái 'thú vị' nào đó của thành phần giao diện người dùng. Nó có thể được biểu diễn dưới dạng một cấu trúc đơn giản:

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


Đối tượng tiếp theo là ComponentStories , chứa danh sách các câu chuyện cho một thành phần:

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


Vì vậy, chúng ta sẽ khai báo một đoạn như thế nào? Để phân tích cú pháp dễ dàng và thuận tiện hơn, hàm có thể có quy ước đặt tên đặc biệt bắt đầu bằng story_ . Nói chung, mỗi đoạn mã cho một câu chuyện:

  • Tạo chế độ xem
  • Định cấu hình nó (có thể bao gồm cài đặt các ràng buộc về chiều rộng/chiều cao)
  • Trả về một lượt xem


Đây là một ví dụ về một đoạn trích:

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


Chúng ta có thể cải thiện trải nghiệm viết truyện và giảm số lượng mẫu soạn sẵn bằng cách sử dụng tính chất động của Objective-C. Ý tưởng là có một lớp cơ sở có khả năng truy xuất tất cả các bộ chọn lớp bắt đầu bằng store_ và xây dựng danh sách câu chuyện từ chúng.

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


Ví dụ: một lớp lưu trữ các câu chuyện cho UILabel có thể được cấu trúc như sau:

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

Ứng dụng danh mục

Ứng dụng danh mục, ở dạng đơn giản nhất, thường bao gồm hai màn hình.


Màn hình chính: Màn hình này hiển thị danh sách các thành phần và các story liên quan của chúng.

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


Màn hình chi tiết: Trên màn hình này, chúng tôi hiển thị một thành phần ở giữa mà không hạn chế chiều rộng hoặc chiều cao của nó vì việc xác định các thuộc tính này tùy thuộc vào đoạn mã.

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


Toàn bộ luồng danh mục có thể được khởi tạo theo cách sau:

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


Danh mục sách bản địa


Xem trước SwiftUI

Chúng tôi có thể lấy ý tưởng PreviewContainer và cách triển khai nó từ 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) } }


Và viết bản xem trước của chúng tôi đơn giản như thế này

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


Bản xem trước SwiftUI + Câu chuyện về sách gốc

Kiểm tra ảnh chụp nhanh

Trong ví dụ của chúng tôi, chúng tôi sẽ sử dụng thư viện iOSSnapshotTestCase .


Với iOS 17, một số bổ sung có giá trị cho API UIView đã được giới thiệu để ghi đè các đặc điểm thông qua UIView.traitOverrides . Thuộc tính này cực kỳ hữu ích cho việc hỗ trợ khả năng truy cập thử nghiệm ảnh chụp nhanh. Đối với các thử nghiệm chụp nhanh, chúng tôi sẽ thử nghiệm cùng một chế độ xem trong nhiều điều kiện khác nhau bằng cách thực thi RTL, chế độ tối và một số danh mục loại động.


Điều quan trọng cần lưu ý là để kiểm tra traitOverrides , chúng ta cần sử dụng phương thức drawViewHierarchy(in:afterScreenUpdates:) và đảm bảo rằng các chế độ xem mà chúng ta đang kiểm tra được thêm vào khóa UIWindow của ứng dụng.

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


Thật vậy, với những sự chuẩn bị sẵn có này, việc tạo một trường hợp thử nghiệm trở nên khá đơn giản:

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


Ảnh chụp nhanh tham chiếu được tạo bởi NativeBookVerifyView

Trình tạo tài liệu

Đối với nhiệm vụ này, chúng ta sẽ sử dụng một ngăn xếp khác: các mẫu Node.js, TypeScript và EJS. Làm việc với các trang dựa trên HTML sẽ dễ dàng hơn nhiều bằng cách sử dụng các công cụ có sẵn cho chúng.


Điều đầu tiên chúng ta cần là một số loại tệp cấu hình nơi chúng ta có thể liên kết các đoạn mã và kiểm tra ảnh chụp nhanh. Một tệp JSON đơn giản hoạt động tốt cho mục đích này.

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


Sau khi tạo một ứng dụng Node.js đơn giản, hãy xác định một mô hình.

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


Thử thách thú vị là làm thế nào để trích xuất các đoạn mã từ tệp Swift. Điều này có thể đạt được khá đơn giản bằng cách sử dụng biểu thức chính quy.

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


Việc tạo HTML có thể được thực hiện bằng EJS, một công cụ tạo mẫu mạnh mẽ cho phép chúng ta sử dụng JavaScript trong một mẫu:

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


Bây giờ, kết hợp mọi thứ trong chức năng chính:

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

Trang web tài liệu

Bản demo có sẵn tại https://psharanda.github.io/NativeBook


Để tạo tài liệu trong kho lưu trữ NativeBook, bạn cần thực hiện các lệnh sau:

 cd native_book_gen npm install npm run native-book-gen


Hoặc đơn giản là chạy:

 sh generate_native_book.sh


Tài liệu sẽ xuất hiện trong thư mục docs


Tham chiếu UIButton do NativeBook tạo ra


Chúng tôi có thể kiểm tra ảnh chụp nhanh để biết các chế độ trợ năng, chế độ tối và RTL khác nhau!


Cái gì tiếp theo?

Hy vọng rằng bạn thích cuộc hành trình. NativeBook , ở trạng thái hiện tại, là một bằng chứng về khái niệm, nhưng với một số bổ sung và tích hợp thích hợp vào quy trình làm việc của nhà phát triển, nó có thể bắt đầu mang lại nhiều giá trị.


Chúng ta cũng có thể tưởng tượng sự cải tiến sau:

  • Thêm hỗ trợ cho Android: Chúng tôi có thể hỗ trợ cả hai nền tảng và có danh mục, kiểm tra ảnh chụp nhanh và tài liệu nhất quán để bạn có thể dễ dàng chuyển đổi giữa các nền tảng
  • Nhúng thiết kế Figma vào tài liệu
  • Hợp nhất với tài liệu được tạo bởi các công cụ như DocC hoặc Jazzy để có được tài liệu tham khảo API toàn diện bên cạnh ảnh chụp nhanh và đoạn mã
  • Thêm hỗ trợ thích hợp cho chế độ xem SwiftUI
  • Tự động tạo mã xem trước SwiftUI