Komponenten-UI-Architektur in iOS-Anwendungen



Hallo Habr!

Mein Name ist Valera und seit zwei Jahren entwickle ich eine iOS-Anwendung als Teil des Badoo-Teams. Eine unserer Prioritäten ist die einfache Pflege des Codes. Aufgrund der großen Anzahl neuer Funktionen, die wöchentlich in unsere Hände fallen, müssen wir zunächst über die Architektur der Anwendung nachdenken. Andernfalls ist es äußerst schwierig, dem Produkt eine neue Funktion hinzuzufügen, ohne vorhandene zu beschädigen. Dies gilt natürlich auch für die Implementierung der Benutzeroberfläche (UI), unabhängig davon, ob dies mit Code, Xcode (XIB) oder einem gemischten Ansatz erfolgt. In diesem Artikel werde ich einige UI-Implementierungstechniken beschreiben, mit denen wir die Entwicklung der Benutzeroberfläche vereinfachen und sie flexibel und bequem zum Testen machen können. Es gibt auch eine englische Version dieses Artikels.

Bevor Sie beginnen ...


Ich werde Implementierungstechniken für Benutzeroberflächen anhand einer in Swift geschriebenen Beispielanwendung betrachten. Die Anwendung zeigt auf Knopfdruck eine Liste von Freunden.

Es besteht aus drei Teilen:

  1. Komponenten sind benutzerdefinierte UI-Komponenten, dh Code, der sich nur auf die Benutzeroberfläche bezieht.
  2. Demoanwendung - Demoansichtsmodelle und andere Benutzeroberflächenentitäten, die nur Abhängigkeiten zur Benutzeroberfläche aufweisen.
  3. Die eigentliche Anwendung sind Ansichtsmodelle und andere Entitäten, die bestimmte Abhängigkeiten und Logik enthalten können.

Warum gibt es eine solche Trennung? Ich werde diese Frage unten beantworten, aber jetzt überprüfen Sie die Benutzeroberfläche unserer Anwendung:


Dies ist eine Popup-Ansicht mit Inhalten über einer anderen Vollbildansicht. Alles ist einfach.

Der vollständige Quellcode des Projekts ist auf GitHub verfügbar.

Bevor ich mich mit dem UI-Code befasse, möchte ich Ihnen die hier verwendete Hilfsklasse Observable vorstellen. Die Benutzeroberfläche sieht folgendermaßen aus:

var value: T func observe(_ closure: @escaping (_ old: T, _ new: T) -> Void) -> ObserverProtocol func observeNewAndCall(_ closure: @escaping (_ new: T) -> Void) -> ObserverProtocol 

Es benachrichtigt einfach alle zuvor signierten Beobachter über die Änderungen. Dies ist also eine Art Alternative zu KVO (Key-Value-Observing) oder, wenn Sie möchten, reaktiver Programmierung. Hier ist ein Anwendungsbeispiel:

 self.observers.append(self.viewModel.items.observe { [weak self] (_, newItems) in   self?.state = newItems.isEmpty ? .zeroCase(type: .empty) : .normal   self?.collectionView.reloadSections(IndexSet(integer: 0)) }) 

Der Controller abonniert Änderungen an der Eigenschaft self.viewModel.items Wenn die Änderung auftritt, führt der Handler die Geschäftslogik aus. Beispielsweise wird der Ansichtsstatus aktualisiert und die Sammlungsansicht mit neuen Elementen neu geladen.

Weitere Anwendungsbeispiele finden Sie weiter unten.

Methoden


In diesem Abschnitt werde ich über vier UI-Entwicklungstechniken sprechen, die in Badoo verwendet werden:

1. Implementierung der Benutzeroberfläche in Code.

2. Layoutanker verwenden.

3. Komponenten - teilen und erobern.

4. Trennung von Benutzeroberfläche und Logik.

# 1: Implementierung der Benutzeroberfläche in Code


In Badoo wird das meiste Benutzerinteresse in Code implementiert. Warum verwenden wir keine XIBs oder Storyboards? Faire Frage. Der Hauptgrund ist die Bequemlichkeit, Code für ein mittelgroßes Team zu verwalten, nämlich:

  • Änderungen im Code sind deutlich sichtbar, sodass die XML-Storyboard- / XIB-Datei nicht analysiert werden muss, um die von einem Kollegen vorgenommenen Änderungen zu finden.
  • Versionskontrollsysteme (zum Beispiel Git) sind viel einfacher mit Code zu arbeiten als mit "schweren" XLM-Dateien, insbesondere bei moderaten Konflikten. Es wird auch berücksichtigt, dass sich der Inhalt von XIB / Storyboard-Dateien bei jedem Speichern ändert, auch wenn sich die Benutzeroberfläche nicht geändert hat (obwohl ich gehört habe, dass dieses Problem in Xcode 9 bereits behoben wurde).
  • Es kann schwierig sein, einige Eigenschaften in Interface Builder (IB) zu ändern und beizubehalten, z. B. CALayer-Eigenschaften während des Relayout-Prozesses von untergeordneten Ansichten (Layout-Unteransichten), was zu mehreren Wahrheitsquellen für den Ansichtsstatus führen kann.
  • Interface Builder ist nicht das schnellste Tool und manchmal ist es viel schneller, direkt mit dem Code zu arbeiten.

Schauen Sie sich den folgenden Controller an (FriendsListViewController):

 final class FriendsListViewController: UIViewController { struct ViewConfig { let backgroundColor: UIColor let cornerRadius: CGFloat } private var infoView: FriendsListView! private let viewModel: FriendsListViewModelProtocol private let viewConfig: ViewConfig init(viewModel: FriendsListViewModelProtocol, viewConfig: ViewConfig) { self.viewModel = viewModel self.viewConfig = viewConfig super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() self.setupContainerView() } private func setupContainerView() { self.view.backgroundColor = self.viewConfig.backgroundColor let infoView = FriendsListView( frame: .zero, viewModel: self.viewModel, viewConfig: .defaultConfig) infoView.backgroundColor = self.viewConfig.backgroundColor self.view.addSubview(infoView) self.infoView = infoView infoView.translatesAutoresizingMaskIntoConstraints = false infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true } // …. } 

Dieses Beispiel zeigt, dass Sie einen Ansichtscontroller nur erstellen können, indem Sie ein Ansichtsmodell und eine Ansichtskonfiguration bereitstellen. Weitere Informationen zu Präsentationsmodellen, dh zum MVVM-Entwurfsmodell (Model-View-ViewModel), finden Sie hier . Da die Ansichtskonfiguration eine einfache strukturelle Entität (Strukturentität) ist, die das Layout und den Stil der Ansicht definiert, nämlich Einrückungen, Größen, Farben, Schriftarten usw., halte ich es für angemessen, eine Standardkonfiguration wie diese bereitzustellen:

 extension FriendsListViewController.ViewConfig {   static var defaultConfig: FriendsListViewController.ViewConfig {       return FriendsListViewController.ViewConfig(backgroundColor: .white,                                                   cornerRadius: 16)   } } 

Die gesamte Ansichtsinitialisierung erfolgt in der setupContainerView Methode, die nur einmal von viewDidLoad aufgerufen wird, wenn die Ansicht bereits erstellt und geladen, aber noch nicht auf dem Bildschirm gezeichnet wurde. Das heißt, alle erforderlichen Elemente (Unteransichten) werden einfach zur Ansichtshierarchie hinzugefügt, und dann wird das Markup angewendet (Layout) und Stile.

So sieht der View Controller jetzt aus:

 final class FriendsListPresenter: FriendsListPresenterProtocol {   // …   func presentFriendsList(from presentingViewController: UIViewController) {       let controller = Class.createFriendsListViewController( presentingViewController: presentingViewController,           headerViewModel: self.headerViewModel,           contentViewModel: self.contentViewModel)       controller.modalPresentationStyle = .overCurrentContext       controller.modalTransitionStyle = .crossDissolve       presentingViewController.present(controller, animated: true, completion: nil)   }   private class func createFriendsListViewController( presentingViewController: UIViewController, headerViewModel: FriendsListHeaderViewModelProtocol,       contentViewModel: FriendsListContentViewModelProtocol) -> FriendsListContainerViewController {      let dismissViewControllerBlock: VoidBlock = { [weak presentingViewController] in           presentingViewController?.dismiss(animated: true, completion: nil)       }       let infoViewModel = FriendsListViewModel( headerViewModel: headerViewModel,           contentViewModel: contentViewModel)       let containerViewModel = FriendsListContainerViewModel(onOutsideContentTapAction: dismissViewControllerBlock)       let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig)       let controller = FriendsListContainerViewController( contentViewController: friendsListViewController,           viewModel: containerViewModel,           viewConfig: .defaultConfig)       return controller   } } 

Sie sehen eine klare Trennung der Verantwortlichkeiten , und dieses Konzept ist nicht viel komplizierter als das Aufrufen von Segue auf einem Storyboard.

Das Erstellen eines Ansichtscontrollers ist recht einfach, da wir sein Modell haben und Sie einfach die Standardansichtskonfiguration verwenden können:

 let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig) 

# 2: Layoutanker verwenden


Hier ist der Layoutcode:

 self.view.addSubview(infoView) self.infoView = infoView infoView.translatesAutoresizingMaskIntoConstraints = false infoView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true infoView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true infoView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true infoView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true 

Einfach ausgedrückt, platziert dieser Code infoView in der übergeordneten Ansicht (Übersicht) an Koordinaten (0, 0) relativ zur ursprünglichen Größe der Übersicht.

Warum verwenden wir Layoutanker? Es ist schnell und einfach. Natürlich können Sie UIView.frame manuell einstellen und alle Positionen und Größen im laufenden Betrieb berechnen, aber manchmal kann es sich als zu verwirrender und / oder sperriger Code herausstellen.

Sie können auch ein Textformat für das Markup verwenden, wie hier beschrieben. Dies führt jedoch häufig zu Fehlern, da Sie das Format genau befolgen müssen und Xcode den Markup-Beschreibungstext beim Schreiben / Kompilieren des Codes nicht überprüft und Sie das Safe Area Layout Guide nicht verwenden können:

 NSLayoutConstraint.constraints( withVisualFormat: "V:|-(\(topSpace))-[headerView(headerHeight@200)]-[collectionView(collectionViewHeight@990)]|",   options: [],   metrics: metrics,   views: views) 

Es ist ziemlich einfach, einen Fehler oder Tippfehler in der Textzeichenfolge zu machen, die das Markup definiert, oder?

# 3: Komponenten - Teilen und Erobern


Unsere Beispielbenutzeroberfläche ist in Komponenten unterteilt, von denen jede eine bestimmte Funktion ausführt, nicht mehr.

Zum Beispiel:

  1. FriendsListHeaderView - Zeigt Informationen zu Freunden und die Schaltfläche Schließen an.
  2. FriendsListContentView - Zeigt eine Liste von Freunden mit anklickbaren Zellen an. Der Inhalt wird dynamisch geladen, wenn er das Ende der Liste erreicht.
  3. FriendsListView - ein Container für die beiden vorherigen Ansichten.

Wie bereits erwähnt, lieben wir bei Badoo das Prinzip der alleinigen Verantwortung, wenn jede Komponente für eine separate Funktion verantwortlich ist. Dies hilft nicht nur bei der Fehlerbehebung (was vielleicht nicht der interessanteste Teil der Arbeit des iOS-Entwicklers ist), sondern auch bei der Entwicklung neuer Funktionen, da dieser Ansatz die Möglichkeiten der zukünftigen Wiederverwendung von Code erheblich erweitert.

# 4: Trennung von Benutzeroberfläche und Logik


Und der letzte, aber nicht weniger wichtige Punkt ist die Trennung von Benutzeroberfläche und Logik. Eine Technik, die Ihrem Team Zeit und Nerven sparen kann. Im wörtlichen Sinne: ein separates Projekt für die Benutzeroberfläche und ein separates für die Geschäftslogik.

Kehren wir zu unserem Beispiel zurück. Wie Sie sich erinnern, sieht das Wesentliche der Präsentation (Moderator) folgendermaßen aus:

 func presentFriendsList(from presentingViewController: UIViewController) {   let controller = Class.createFriendsListViewController( presentingViewController: presentingViewController,       headerViewModel: self.headerViewModel,       contentViewModel: self.contentViewModel)   controller.modalPresentationStyle = .overCurrentContext   controller.modalTransitionStyle = .crossDissolve   presentingViewController.present(controller, animated: true, completion: nil) } 

Sie müssen nur Ansichtsmodelle für den Titel und den Inhalt bereitstellen. Der Rest ist in der obigen Implementierung der UI-Komponenten verborgen.

Das Modellprotokoll für die Header-Ansicht sieht folgendermaßen aus:

 protocol FriendsListHeaderViewModelProtocol {   var friendsCountIcon: UIImage? { get }   var closeButtonIcon: UIImage? { get }   var friendsCount: Observable<String> { get }   var onCloseAction: VoidBlock? { get set } } 

Stellen Sie sich nun vor, Sie fügen visuelle Tests für die Benutzeroberfläche hinzu - es ist so einfach wie das Übergeben von Stub-Modellen für die Benutzeroberflächenkomponenten.

 final class FriendsListHeaderDemoViewModel: FriendsListHeaderViewModelProtocol {   var friendsCountIcon: UIImage? = UIImage(named: "ic_friends_count")   var closeButtonIcon: UIImage? = UIImage(named: "ic_close_cross")   var friendsCount: Observable<String>   var onCloseAction: VoidBlock?   init() {       let friendsCountString = "\(Int.random(min: 1, max: 5000))"       self.friendsCount = Observable(friendsCountString)   } } 

Es sieht einfach aus, oder? Jetzt möchten wir den Komponenten unserer Anwendung Geschäftslogik hinzufügen, für die möglicherweise Datenanbieter, Datenmodelle usw.: Erforderlich sind.

 final class FriendsListHeaderViewModel: FriendsListHeaderViewModelProtocol {   let friendsCountIcon: UIImage?   let closeButtonIcon: UIImage?   let friendsCount: Observable<String> = Observable("0")   var onCloseAction: VoidBlock?   private let dataProvider: FriendsListDataProviderProtocol   private var observers: [ObserverProtocol] = []   init(dataProvider: FriendsListDataProviderProtocol,        friendsCountIcon: UIImage?,        closeButtonIcon: UIImage?) {       self.dataProvider = dataProvider       self.friendsCountIcon = friendsCountIcon       self.closeButtonIcon = closeButtonIcon       self.setupDataObservers()   }   private func setupDataObservers() {       self.observers.append(self.dataProvider.totalItemsCount.observeNewAndCall { [weak self] (newCount) in           self?.friendsCount.value = "\(newCount)"       })   } } 

Was könnte einfacher sein? Einfach den Datenprovider implementieren - und los geht's!

Die Implementierung des Inhaltsmodells sieht etwas komplizierter aus, aber die Aufgabentrennung vereinfacht das Leben immer noch erheblich. Hier ist ein Beispiel für das Instanziieren und Anzeigen einer Liste von Freunden auf Knopfdruck:

 private func presentRealFriendsList(sender: Any) {   let avatarPlaceholderImage = UIImage(named: "avatar-placeholder")   let itemFactory = FriendsListItemFactory(avatarPlaceholderImage: avatarPlaceholderImage)   let dataProvider = FriendsListDataProvider(itemFactory: itemFactory)   let viewModelFactory = FriendsListViewModelFactory(dataProvider: dataProvider)   var headerViewModel = viewModelFactory.makeHeaderViewModel()   headerViewModel.onCloseAction = { [weak self] in       self?.dismiss(animated: true, completion: nil)   }   let contentViewModel = viewModelFactory.makeContentViewModel()   let presenter = FriendsListPresenter( headerViewModel: headerViewModel,       contentViewModel: contentViewModel)   presenter.presentFriendsList(from: self) } 

Diese Technik hilft, die Benutzeroberfläche von der Geschäftslogik zu isolieren. Darüber hinaus können Sie so die gesamte Benutzeroberfläche mit visuellen Tests abdecken und Testdaten an die Komponenten übergeben! Daher ist die Trennung der Benutzeroberfläche und der zugehörigen Geschäftslogik entscheidend für den Erfolg des Projekts, unabhängig davon, ob es sich um ein Startup oder ein bereits fertiges Produkt handelt.

Fazit


Dies sind natürlich nur einige der in Badoo verwendeten Techniken und keine universelle Lösung für alle möglichen Fälle. Verwenden Sie sie daher, nachdem Sie bewertet haben, ob sie für Sie und Ihre Projekte geeignet sind.

Es gibt andere Methoden, zum Beispiel XIB-konfigurierbare UI-Komponenten mit Interface Builder (sie werden in unserem anderen Artikel beschrieben ), aber aus verschiedenen Gründen werden sie in Badoo nicht verwendet. Denken Sie daran, dass jeder seine eigene Meinung und Vision vom Gesamtbild hat. Um ein erfolgreiches Projekt zu entwickeln, sollten Sie sich im Team einig werden und den Ansatz wählen, der für die meisten Szenarien am besten geeignet ist.

Möge Swift mit dir sein!

Quellen

Source: https://habr.com/ru/post/de421559/


All Articles