
Bonjour, Habr!
Je m'appelle Valera et depuis deux ans, je développe une application iOS au sein de l'équipe Badoo. L'une de nos priorités est la facilité de maintenance du code. En raison du grand nombre de nouvelles fonctionnalités qui tombent entre nos mains chaque semaine, nous devons d'abord penser à l'architecture de l'application, sinon il sera extrêmement difficile d'ajouter une nouvelle fonctionnalité au produit sans casser celles existantes. Évidemment, cela s'applique également à la mise en œuvre de l'interface utilisateur (UI), que cela soit fait en utilisant du code, Xcode (XIB) ou une approche mixte. Dans cet article, je décrirai certaines techniques de mise en œuvre de l'interface utilisateur qui nous permettent de simplifier le développement de l'interface utilisateur, la rendant flexible et pratique pour les tests. Il existe également une version
anglaise de cet article.
Avant de commencer ...
J'examinerai les techniques de mise en œuvre de l'interface utilisateur à l'aide d'un exemple d'application écrit en Swift. L'application au clic d'un bouton affiche une liste d'amis.
Il se compose de trois parties:
- Les composants sont des composants d'interface utilisateur personnalisés, c'est-à-dire du code lié uniquement à l'interface utilisateur.
- Application de démonstration - modèles de vue de démonstration et autres entités d'interface utilisateur qui n'ont que des dépendances d'interface utilisateur.
- L'application réelle est les modèles de vue et autres entités qui peuvent contenir des dépendances et une logique spécifiques.
Pourquoi existe-t-il une telle séparation? Je vais répondre à cette question ci-dessous, mais pour l'instant, consultez l'interface utilisateur de notre application:
Il s'agit d'une fenêtre contextuelle avec du contenu au-dessus d'une autre vue plein écran. Tout est simple.
Le code source complet du projet est disponible sur
GitHub .
Avant de plonger dans le code de l'interface utilisateur, je veux vous présenter la classe auxiliaire Observable utilisée ici. Son interface ressemble à ceci:
var value: T func observe(_ closure: @escaping (_ old: T, _ new: T) -> Void) -> ObserverProtocol func observeNewAndCall(_ closure: @escaping (_ new: T) -> Void) -> ObserverProtocol
Il informe simplement tous les observateurs précédemment signés des changements, c'est donc une sorte d'alternative à KVO (observation clé-valeur) ou, si vous le souhaitez, à la programmation réactive. Voici un exemple d'utilisation:
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)) })
Le contrôleur souscrit aux modifications apportées à la propriété
self.viewModel.items
et lorsque la modification se produit, le gestionnaire exécute la logique métier. Par exemple, il met à jour l'état de la vue et recharge la vue de la collection avec de nouveaux éléments.
Vous verrez plus d'exemples d'utilisation ci-dessous.
Méthodologies
Dans cette section, je vais parler de quatre techniques de développement d'interface utilisateur utilisées dans Badoo:
1. Implémentation de l'interface utilisateur en code.
2. Utilisation d'ancres de mise en page.
3. Composants - diviser pour mieux régner.
4. Séparation de l'interface utilisateur et de la logique.
# 1: Implémentation de l'interface utilisateur dans le code
Dans Badoo, la plupart des intérêts des utilisateurs sont implémentés dans le code. Pourquoi n'utilisons-nous pas les XIB ou les storyboards? Bonne question. La raison principale est la commodité de maintenir le code pour une équipe de taille moyenne, à savoir:
- les changements dans le code sont clairement visibles, ce qui signifie qu'il n'est pas nécessaire d'analyser le fichier de storyboard XML / XIB pour trouver les changements effectués par un collègue;
- les systèmes de contrôle de version (par exemple, Git) sont beaucoup plus faciles à travailler avec du code qu'avec des fichiers XLM "lourds", en particulier lors de conflits modérés; il est également pris en compte que le contenu des fichiers XIB / storyboard change à chaque fois qu'ils sont enregistrés, même si l'interface n'a pas changé (même si j'ai entendu dire que dans Xcode 9, ce problème a déjà été corrigé);
- il peut être difficile de modifier et de conserver certaines propriétés dans Interface Builder (IB), par exemple, les propriétés CALayer pendant le processus de relais des vues enfant (sous-vues de mise en page), ce qui peut conduire à plusieurs sources de vérité pour l'état de la vue;
- Interface Builder n'est pas l'outil le plus rapide, et il est parfois beaucoup plus rapide de travailler directement avec le code.
Jetez un œil au contrôleur suivant (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 }
Cet exemple montre que vous ne pouvez créer un contrôleur de vue qu'en fournissant un modèle de vue et une configuration de vue. Vous pouvez en savoir plus sur les modèles de présentation, c'est-à-dire le modèle de conception MVVM (Model-View-ViewModel)
ici . Étant donné que la configuration de la vue est une simple entité structurelle (entité struct) qui définit la disposition et le style de la vue, à savoir les retraits, les tailles, les couleurs, les polices, etc., je considère qu'il est approprié de fournir une configuration standard comme celle-ci:
extension FriendsListViewController.ViewConfig { static var defaultConfig: FriendsListViewController.ViewConfig { return FriendsListViewController.ViewConfig(backgroundColor: .white, cornerRadius: 16) } }
Toute l'initialisation de la vue se produit dans la méthode
setupContainerView
, qui n'est appelée qu'une seule fois à partir de viewDidLoad lorsque la vue est déjà créée et chargée, mais pas encore dessinée à l'écran, c'est-à-dire que tous les éléments nécessaires (sous-vues) sont simplement ajoutés à la hiérarchie de la vue, puis un balisage est appliqué (mise en page) et styles.
Voici à quoi ressemble maintenant le contrôleur de vue:
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 } }
Vous pouvez voir une
séparation claire
des responsabilités , et ce concept n'est pas beaucoup plus compliqué que d'appeler Segue sur un storyboard.
La création d'un contrôleur de vue est assez simple, étant donné que nous avons son modèle et que vous pouvez simplement utiliser la configuration de vue standard:
let friendsListViewController = FriendsListViewController( viewModel: infoViewModel, viewConfig: .defaultConfig)
# 2: Utilisation d'ancres de mise en page
Voici le code de mise en page:
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
Autrement dit, ce code place
infoView
dans la vue parent (superview), aux coordonnées (0, 0) par rapport à la taille d'origine de la superview.
Pourquoi utilisons-nous des ancres de mise en page? C'est simple et rapide. Bien sûr, vous pouvez définir UIView.frame manuellement et calculer toutes les positions et tailles à la volée, mais parfois cela peut s'avérer trop déroutant et / ou encombrant.
Vous pouvez également utiliser un format de texte pour le balisage, comme décrit
ici , mais cela conduit souvent à des erreurs, car vous devez suivre strictement le format, et Xcode ne vérifie pas le texte de description du balisage au stade de l'écriture / compilation du code, et vous ne pouvez pas utiliser le Guide de mise en page de la zone de sécurité:
NSLayoutConstraint.constraints( withVisualFormat: "V:|-(\(topSpace))-[headerView(headerHeight@200)]-[collectionView(collectionViewHeight@990)]|", options: [], metrics: metrics, views: views)
Il est assez facile de faire une erreur ou une faute de frappe dans la chaîne de texte qui définit le balisage, non?
# 3: Composants - Diviser pour mieux régner
Notre exemple d'interface utilisateur est divisé en composants, chacun remplissant une fonction spécifique, pas plus.
Par exemple:
FriendsListHeaderView
- Affiche des informations sur les amis et le bouton Fermer.FriendsListContentView
- affiche une liste d'amis avec des cellules cliquables, le contenu est chargé dynamiquement lorsqu'il atteint la fin de la liste.FriendsListView
- un conteneur pour les deux vues précédentes.
Comme mentionné précédemment, chez Badoo, nous aimons le
principe de la responsabilité exclusive lorsque chaque composant est responsable d'une fonction distincte. Cela aide non seulement dans le processus de correction de bogues (qui n'est peut-être pas la partie la plus intéressante du travail du développeur iOS), mais aussi pendant le développement de nouvelles fonctionnalités, car cette approche élargit considérablement les possibilités de réutilisation de code à l'avenir.
# 4: Séparation de l'interface utilisateur et de la logique
Et le dernier point, mais non moins important, est la séparation de l'interface utilisateur et de la logique. Une technique qui peut faire gagner du temps et des nerfs à votre équipe. Au sens littéral: un projet distinct pour l'interface utilisateur et un autre pour la logique métier.
Revenons à notre exemple. Comme vous vous en souvenez, l'essence de la présentation (présentateur) ressemble à ceci:
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) }
Il vous suffit de fournir des modèles de vue pour le titre et le contenu. Le reste est caché dans l'implémentation ci-dessus des composants de l'interface utilisateur.
Le protocole du modèle de vue d'en-tête ressemble à ceci:
protocol FriendsListHeaderViewModelProtocol { var friendsCountIcon: UIImage? { get } var closeButtonIcon: UIImage? { get } var friendsCount: Observable<String> { get } var onCloseAction: VoidBlock? { get set } }
Imaginez maintenant que vous ajoutez des tests visuels pour l'interface utilisateur - c'est aussi simple que de passer des modèles de stub pour les composants de l'interface utilisateur.
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) } }
Ça a l'air simple, non? Nous voulons maintenant ajouter une logique métier aux composants de notre application, ce qui peut nécessiter des fournisseurs de données, des modèles de données, etc.:
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)" }) } }
Quoi de plus simple? Implémentez simplement le fournisseur de données - et c'est parti!
La mise en œuvre du modèle de contenu semble un peu plus compliquée, mais la séparation des responsabilités simplifie encore grandement la vie. Voici un exemple de la façon d'instancier et d'afficher une liste d'amis en cliquant sur un bouton:
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) }
Cette technique permet d'isoler l'interface utilisateur de la logique métier. De plus, cela vous permet de couvrir l'ensemble de l'interface utilisateur avec des tests visuels, en transmettant les données de test aux composants! Par conséquent, la séparation de l'interface utilisateur et de la logique métier associée est essentielle à la réussite du projet, qu'il s'agisse d'un démarrage ou d'un produit déjà fini.
Conclusion
Bien sûr, ce ne sont que quelques-unes des techniques utilisées dans Badoo, et elles ne sont pas une solution universelle pour tous les cas possibles. Par conséquent, utilisez-les après avoir évalué s'ils conviennent à vous et à vos projets.
Il existe d'autres méthodes, par exemple, des composants d'interface utilisateur configurables XIB utilisant Interface Builder (ils sont décrits dans
notre autre
article ), mais pour diverses raisons, ils ne sont pas utilisés dans Badoo. N'oubliez pas que chacun a sa propre opinion et vision de la situation dans son ensemble.Par conséquent, pour développer un projet réussi, vous devez parvenir à un consensus au sein de l'équipe et choisir l'approche la plus adaptée à la plupart des scénarios.
Que Swift soit avec vous!
Les sources