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 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ã.
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.
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 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 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:
Nhược điểm:
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".
Đ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.
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à:
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:
Nhược điểm:
Vài ví dụ:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
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
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:
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.
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:
Đâ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, ở 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
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) } } }
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()) } }
Đố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); } })();
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
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: