Paylaşılan bir kullanıcı arayüzü bileşenleri kümesini uygulamak, sürdürmek, belgelemek ve geliştirmek, büyük ölçekli bir uygulamada zorlu bir iştir. Web geliştiricileri bu soruna güçlü bir çözüm yarattı: Storybook.js ( https://storybook.js.org ). Peki ya yerel iOS geliştirme? Biraz benzer bir deneyim elde edebilir miyiz? (Spoiler Uyarısı: Evet! )
Ama önce orijinal Storybook.js konseptini inceleyelim.
Storybook.js, web geliştiricileri tarafından web uygulamalarına yönelik kullanıcı arayüzü bileşenlerini ayrı ayrı oluşturmak ve test etmek için kullanılan açık kaynaklı bir araçtır. Bunu, geliştiricilerin bir web sitesinin ayrı bölümlerini (düğmeler, formlar ve gezinme çubukları gibi) web sitesinin geri kalanından ayrı olarak oluşturup sergileyebilecekleri bir oyun alanı olarak düşünün.
Storybook.js, bileşenlerin canlı bir dokümantasyonu olarak hizmet eder ve kod örneklerinin yanı sıra bileşenlerin farklı durumlarda ve senaryolarda nasıl göründüğünü ve davrandığını görmek için görseller sağlar.
Storybook.js'nin zarafeti, kullanıcı arayüzü bileşenlerinin aynı ortamda kullanılmasında yatmaktadır; İster üretim web sitesinde ister Hikaye Kitabı web sitesinde olsun, bunları hâlâ bir web tarayıcısında çalıştırıyoruz. Bu, bileşenlerin dokümantasyonumuzda/oyun alanımızda ve üretimde tutarlı görünmesini sağlayarak senkronize olmamalarını sağlar.
Web sayfaları aynı zamanda harika bir okuma deneyimi, kolay paylaşım, anında yükleme süreleri ve herhangi bir cihaz ve işletim sisteminde evrensel kullanılabilirlik sağlayan mükemmel bir belgeleme ortamıdır.
Ancak iOS uygulamaları için hikaye farklıdır.
iOS'ta kullanıcı arayüzü bileşeni geliştirmeyle ilgili araç ve yöntemlerin yapısı oldukça parçalıdır. Gelin bunlara Storybook.js perspektifinden bakalım.
Arayüz Oluşturucu, sürükle ve bırak arayüzü sağlayarak, kod yazmadan kullanıcı arayüzleri tasarlamayı kolaylaştırır. Genellikle, tek tek kullanıcı arayüzü bileşenlerinin geliştirilmesi yerine tüm ekranlar için mevcut kullanıcı arayüzü bileşenlerinin düzenlenmesi için kullanılır. Farklı kullanıcı arayüzü bileşen durumlarını göstermede ve genel olarak karmaşık kullanıcı arayüzlerini ele almada pek işe yaramaz, çoğu zaman kodda birçok özelliğin uygulanmasını gerektirir. Genel olarak, Apple da dahil olmak üzere birçok kişi tarafından çıkmaz sokak olarak görülüyor.
SwiftUI Önizlemeleri, Xcode'da SwiftUI görünümlerini geliştirmek ve test etmek için Apple tarafından oluşturulan bir araçtır.
SwiftUI Önizlemeleri, UIView'ler için de kullanılabilir. Bkz. https://sarunw.com/posts/xcode-previews-with-uiview
Artıları:
Eksileri:
Çalıştığı zaman, SwiftUI önizlemeleri muhtemelen "belgeleme" özelliği dışında Storybook.js'ye en yakın deneyimdir.
Sonraki şey kaynak koduna, yorumlara ve ek açıklamalara dayalı belgeler oluşturmaktır. En dikkate değer örnekler şunlardır:
DocC - https://developer.apple.com/documentation/docc
Jazzy - https://github.com/realm/jazzy
Bu tür araçlar, bir web sayfası biçiminde bileşen API referansları oluşturmak için kullanılabilir.
Anlık görüntü testleri, bir bileşenin çeşitli görsel durumlarını test ederek, olayların beklenmedik şekilde değişmemesini sağlayarak izole geliştirme için harika bir yoldur.
İOS'ta anlık görüntü testini uygulayan en popüler iki kitaplık şunlardır:
Storybook.js deneyimi için bir diğer belirgin seçenek, özel bir yerel katalog uygulaması oluşturmak olacaktır.
Artıları:
Eksileri:
Bazı örnekler:
https://github.com/aj-bartocci/Storybook-SwiftUI
https://github.com/hpennington/SwiftBook
https://github.com/eure/Storybook-ios
Abartılı da olsa harika bir fikir, Appetize.io hizmetini kullanarak Storybook.js web sitesine yerel bir katalog uygulaması yerleştirmektir; bu hizmet, bir iOS cihazının içeriğinin bir web sayfasına aktarılmasına olanak tanır - https://medium.com/@vasikarla .raj/storybook-for-native-d772654c7133
Yerel mobil geliştirmenin Storyboard.js benzeri bir deneyim için gereken gizli bileşenden yoksun olduğu bir gerçektir: belgeleri, oyun alanlarını ve prodüksiyonu en erişilebilir platform olan bir web sitesinde çalıştırmak için tek bir ortam.
Peki ya katalog uygulamasını, anlık görüntü testlerini ve HTML tabanlı belge oluşturmayı tek bir sistemde birleştirirsek? Yalnızca bir kez kod pasajı yazdığınızı ve ardından aşağıdakileri yapabildiğinizi hayal edin:
İyi haberler! Böyle bir sistem için bir kavram kanıtı oluşturdum:
https://github.com/psharanda/NativeBook
Hadi bir göz atalım ve bir araya getirelim!
NativeBook şu anda UIView tabanlı bileşenlere odaklanmaktadır. SwiftUI bileşenleri de bu makalede ele alınmasa da benzer şekilde entegre edilebilir.
Hikaye sistemimizin temel taşıdır. Temel olarak, bir kullanıcı arayüzü bileşeninin bazı 'ilginç' durumlarını sergileyen adlandırılmış bir kod pasajıdır. Basit bir yapı olarak temsil edilebilir:
struct Story { let name: String let makeView: () -> UIView? }
Sonraki nesne, bir bileşenin öykülerinin listesini içeren ComponentStories
nesnesidir:
protocol ComponentStories { var componentName: String { get } var stories: [Story] { get } }
Peki bir snippet'i nasıl ilan edeceğiz? Daha kolay ayrıştırma ve kolaylık sağlamak için işlevin story_
ile başlayan özel bir adlandırma kuralı olabilir. Genel olarak bir hikayeye ilişkin her kod pasajı:
İşte bir snippet örneği:
@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'nin dinamik doğasından yararlanarak hikaye yazma deneyimini geliştirebilir ve standartların miktarını azaltabiliriz. Buradaki fikir, store_
ile başlayan tüm sınıf seçicileri alabilen ve onlardan bir hikaye listesi oluşturabilen bir temel sınıfa sahip olmaktır.
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 } } }() }
Örneğin UILabel için hikayeleri barındıran bir sınıf şu şekilde yapılandırılabilir:
class UILabelStories: DynamicComponentStories { @objc static func story_BasicLabel() -> UIView { … } @objc static func story_FixedWidthLabel() -> UIView { … } }
Bir katalog uygulaması en basit haliyle genellikle iki ekrandan oluşur.
Ana Ekran: Bu ekranda bileşenlerin listesi ve bunlarla ilgili hikayeler görüntülenir.
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) } }
Ayrıntılar ekranı: Bu ekranda, bu nitelikleri tanımlamak snippet'e bağlı olduğundan, genişliğini veya yüksekliğini kısıtlamadan bir bileşeni merkezde görüntüleriz.
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), ]) } }
Katalog akışının tamamı aşağıdaki şekilde başlatılabilir:
let vc = StorybookViewController(componentsStories: [ UILabelStories(), UIButtonStories(), UITextFieldStories(), ]) let nc = UINavigationController(rootViewController: vc) window.rootViewController = nc
PreviewContainer fikrini ve uygulamasını https://sarunw.com/posts/xcode-previews-with-uiview/ adresinden alabiliriz.
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) } }
Ve önizlemelerimizi bu kadar basit bir şekilde yazın
struct UIButton_Previews: PreviewProvider { static var previews: some View { ForEach(UIButtonStories().stories, id: \.name) { story in PreviewContainer { story.makeView()! } .previewDisplayName(story.name) } } }
Örneğimizde iOSSnapshotTestCase kütüphanesini kullanacağız.
iOS 17 ile birlikte, UIView.traitOverrides aracılığıyla özelliklerin geçersiz kılınması için UIView
API'sine bazı değerli eklemeler yapıldı. Bu özellik, anlık görüntü testi erişilebilirlik desteği için son derece kullanışlıdır. Anlık görüntü testlerimiz için RTL, karanlık mod ve bazı dinamik tür kategorilerini zorlayarak aynı görünümü çeşitli koşullarda test edeceğiz.
traitOverrides
test etmek için, drawViewHierarchy(in:afterScreenUpdates:)
yöntemini kullanmamız ve test ettiğimiz görünümlerin uygulamanın UIWindow
anahtarına eklendiğinden emin olmamız gerektiğini unutmamak önemlidir.
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() } }
Aslında, bu hazırlıklar tamamlandığında bir test senaryosu oluşturmak oldukça basit hale gelir:
final class UIButtonTests: NativeBookSnapshotTestCase { func test() { // recordMode = true runTests(for: UIButtonStories()) } }
Bu görev için farklı bir yığın kullanacağız: Node.js, TypeScript ve EJS şablonları. Kendilerine özgü araçları kullanarak HTML tabanlı sayfalarla çalışmak çok daha kolaydır.
İhtiyacımız olan ilk şey, kod parçacıklarımızı ve anlık görüntü testlerimizi bağlayabileceğimiz bir tür yapılandırma dosyasıdır. Basit bir JSON dosyası bu amaç için iyi çalışır.
{ "components": [ { "name": "UILabel", "storiesFilePath": "NativeBook/StorySets/UILabelStories.swift", "snapshotsFolderPath": "Ref/ReferenceImages_64/NativeBookTests.UILabelTests" }, … ] }
Basit bir Node.js uygulaması oluşturduktan sonra bir model tanımlayalım.
interface Story { name: string; codeSnippet: string; } interface Component { name: string; stories: Story[]; snapshotsFolderPath: string; }
İlginç olan zorluk, Swift dosyasından kod parçacıklarının nasıl çıkarılacağıdır. Bu, regex kullanılarak oldukça basit bir şekilde başarılabilir.
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 oluşturma, bir şablon içinde JavaScript kullanmamıza olanak tanıyan güçlü bir şablon motoru olan EJS kullanılarak yapılabilir:
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, } ) ); }
Şimdi her şeyi ana fonksiyonda birleştiriyoruz:
(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); } })();
Demoya https://psharanda.github.io/NativeBook adresinden ulaşılabilir.
NativeBook deposunda belge oluşturmak için aşağıdaki komutları yapmanız gerekir:
cd native_book_gen npm install npm run native-book-gen
Veya basitçe çalıştırın:
sh generate_native_book.sh
Dokümantasyon docs
klasöründe görünecektir
Umarım yolculuktan keyif almışsınızdır. NativeBook şu anki haliyle bir kavram kanıtıdır, ancak bazı eklemeler ve geliştiricinin iş akışına uygun entegrasyonla çok fazla değer sağlamaya başlayabilir.
Ayrıca şu gelişmeyi de hayal edebiliriz: