Approches architecturales dans les applications iOS

Aujourd'hui, nous allons parler des approches architecturales dans le développement iOS, de certaines des nuances et des développements dans la mise en œuvre de certaines choses. Je vais vous dire quelles approches nous suivons et approfondir un peu les détails.


Révélez immédiatement toutes les cartes. Nous utilisons MVVM-R (MVVM + Router).


En fait, il s'agit d'une MVVM standard, dans laquelle la navigation entre les écrans est placée dans une couche distincte - le routeur, et la logique de réception des données - dans les services. Ensuite, nous examinerons nos réalisations dans la mise en œuvre de chaque couche.


Pourquoi MVVM, pas VIPER ou MVC?


Contrairement à MVC, MVVM a une responsabilité assez divisée entre les couches. Il n'a pas autant de code «servant» que dans VIPER, bien que ViewModel pour les écrans soit également fermé par des protocoles. Cette architecture est quelque peu similaire à VIPER, seuls Presenter et Interactor sont combinés dans le ViewModel, et les connexions entre les couches sont simplifiées grâce à l'utilisation de la programmation réactive et des liants (nous utilisons ReactiveSwift).


Entité


Nous utilisons deux couches de modèles de données: la première est liée à une base de données (ci-après dénommée objets gérés ), la seconde est ce que l'on appelle les objets simples , qui n'ont rien à voir avec la base de données.


Chaque entité ordinaire implémente le protocole traduisible, qui peut être initialisé à partir d'un objet géré et à partir duquel un objet géré peut être créé. Nous utilisons Realm comme base de données, dans notre cas ManagedObject est RealmSwift.Object . Le mappage s'effectue via Codable : ils sont mappés en tant qu'objets simples et enregistrés en tant qu'objets gérés. Les autres services et ViewModel fonctionnent uniquement avec des objets simples.


 protocol Translatable { associatedtype ManagedObject: Object init(object: ManagedObject) func toManagedObject() -> ManagedObject } 

Pour enregistrer, récupérer et supprimer des objets de la base de données, une entité distincte est utilisée - Stockage. Le stockage étant fermé par le protocole, nous ne dépendons pas de l'implémentation d'une base de données spécifique et, si nécessaire, nous pouvons remplacer Realm par CoreData.


 protocol StorageProtocol { func cachedObjects<T: Translatable>() -> [T] func object<T: Translatable>(byPrimaryKey key: AnyHashable) -> T? func save<T: Translatable>(objects: [T]) throws func save<T: Translatable>(object: T) throws func delete<T: Translatable>(objects: [T]) throws func delete<T: Translatable>(object: T) throws func deleteAll<T: Translatable>(ofType type: T.Type) throws } 

Quels sont les avantages et les inconvénients de cette approche?


Chaque base de données a ses propres caractéristiques. Par exemple, un objet Realm déjà stocké dans la base de données ne peut être utilisé que dans le cadre du flux dans lequel il a été créé. C'est gênant.


En outre, un objet peut être supprimé de la base de données, alors qu'il se trouve dans la RAM, et y accéder plantera. Core Data a les mêmes fonctionnalités. Par conséquent, nous obtenons des objets de la base de données, les convertissons en objets simples, puis travaillons avec eux.


Avec cette approche, le code devient plus grand et doit être pris en charge. Quelles que soient les fonctionnalités de la base de données, nous perdons la possibilité d'utiliser des puces sympas. Dans le cas de CoreData, il s'agit d'un FetchedResultsController, où nous pouvons contrôler toutes les insertions, suppressions et modifications dans un tableau d'entités. À propos du même mécanisme dans Realm.


Composants principaux


Les composants principaux sont des entités qui exécutent l'une de leurs tâches. Par exemple, le mappage, l'interaction avec la base de données, l'envoi et le traitement des demandes réseau. Le stockage du paragraphe précédent n'est qu'un des composants principaux.


Protocoles


Nous utilisons activement des protocoles. Tous les composants principaux sont fermés par des protocoles, et il est possible de faire une simulation ou une implémentation de test pour des tests unitaires. Ainsi, nous obtenons une certaine flexibilité d'implémentation. Toutes les dépendances sont passées à init. Lors de l'initialisation de chaque objet, nous comprenons quel type de dépendances il y a, ce qu'il utilise à l'intérieur de lui-même.


Client HTTP


Une demande de réseau est décrite par le protocole NetworkRequestParams .


 protocol NetworkRequestParams { var path: String { get } var method: HTTPMethod { get } var parameters: Parameters { get } var encoding: ParameterEncoding { get } var headers: [String: String]? { get } var defaultHeaders: [String: String]? { get } } 

Nous utilisons enum pour décrire les requêtes réseau. Cela ressemble à ceci:


 enum UserNetworkRouter: URLRequestConvertible { case info case update(userJson:[String : Any]) } extension UserNetworkRouter: NetworkRequestParams { var path: String { switch self { case .info: return "/users/profile" case .update: return "/users/update_profile" } } var method: HTTPMethod { switch self { case .info: return .get case .update: return .post } } var encoding: ParameterEncoding { switch self { case .info: return URLEncoding() case .update: return JSONEncoding() } } var parameters: Parameters { switch self { case .info: return [:] case .update(let userJson): return userJson } } } 

Chaque NetworkRouter implémente le protocole URLRequestConvertible . Nous le donnons au client du réseau, qui le convertit en URLRequest et l'utilise URLRequest à sa destination.


Le client réseau est le suivant:


 protocol HTTPClientProtocol { func load(request: NetworkRequestParams & URLRequestConvertible) -> SignalProducer<Data, Error> } 

Mappeur


Nous utilisons Codable pour la cartographie des données.


 protocol MapperProtocol { func map<MappingResult: Codable>(data: Data, dateDecodingStrategy: JSONDecoder.DateDecodingStrategy) -> SignalProducer<MappingResult, Error> } 

Notifications push


Chaque notification push a un type et chaque type a son propre gestionnaire. Le gestionnaire reçoit un dictionnaire contenant des informations de la notification. L'entité d'agrégation détient les gestionnaires; c'est elle qui recevra le push et le dirigera vers le gestionnaire souhaité. Il s'agit d'une approche assez évolutive qui est pratique à utiliser si vous devez gérer différemment plusieurs types de notifications push.


Les services


En gros, un service est responsable d'une entité. Considérez ceci avec un exemple d'application de réseau social. Il y a un serveur de l'utilisateur qui reçoit l'utilisateur - lui-même, et donne les entités modifiées si nous le modifions. Il existe un service postal qui reçoit une liste de messages, un message détaillé, un service de paiement, etc. etc.


Tous les services contiennent des composants de base. Lorsque nous appelons une méthode sur un service, il commence à extraire diverses méthodes des composants principaux et donne finalement le résultat.


Un service, en règle générale, fonctionne pour un écran spécifique, ou plutôt pour un modèle de vue d'écran (plus d'informations ci-dessous). Si, en quittant l'écran, le service n'est pas détruit, mais continue de répondre à une demande réseau déjà inutile et ralentira les autres demandes. Cela peut être contrôlé manuellement, mais il sera plus difficile de maintenir un tel système. Cependant, cette approche a un inconvénient: si le résultat du service est nécessaire même après la sortie de l'écran, vous devrez chercher d'autres solutions, peut-être rendre certains services singleton.


Les services sont apatrides. Étant donné que les services ne sont pas des monotones, nous pouvons avoir plusieurs instances du même service, dans lesquelles les états peuvent différer les uns des autres. Cela peut conduire à un comportement incorrect.


Un exemple de méthode d'un des services:


 func currentUser() -> SignalProducer<User, Error> { let request = UserNetworkRouter.info return httpClient.load(request: request) .flatMap(.latest, mapUser) .flatMap(.latest, save) } 

ViewModel


Nous diviserons ViewModel en 2 types:


  • ViewModel pour l'écran (ViewController)
  • ViewModel pour UIView (y compris les cellules de tableau ou UICollectionView)

ViewModel pour ViewController est responsable de la logique de l'écran. En règle générale, cela envoie des requêtes réseau, prépare des données, répond aux événements de l'interface utilisateur.


ViewModel prépare toutes les données pour la vue issue du service. Si une liste d'entités arrive, le ViewModel la transforme en une liste de ViewModel et les lie à la vue. S'il y a des états (il y a une coche / pas de coche), cela est également géré et transmis au ViewModel.


ViewModel contrôle également la logique de navigation. Il existe une couche de routeur distincte pour la navigation, mais le ViewModel donne les commandes.


Fonctions typiques du modèle de vue: obtenir l'utilisateur, contacter le service utilisateur, créer le ViewModel à partir de la valeur reçue. Lorsque tout se charge, View prend le ViewModel et dessine la cellule de vue.


ViewModel pour l'écran est fermé par le protocole pour les mêmes raisons que les services. Cependant, il existe un autre cas intéressant: par exemple, une application bancaire, où chaque action (transfert de fonds, ouverture d'un compte, blocage d'un compte) est confirmée par SMS. Sur l'écran de confirmation, il y a un champ de saisie de code et un bouton «envoyer à nouveau».


ViewModel est fermé par ce protocole:


 protocol CodeInputViewModelProtocol { ///    func send(code: String) -> SignalProducer<Void, Error> ///    func resendCode() -> SignalProducer<Void, Error> } 

Dans ViewController, il est stocké sous la forme suivante:


 var viewModel: CodeInputViewModelProtocol? 

Selon ce que nous essayons de confirmer par SMS, l'envoi d'un code et l'envoi de SMS peuvent être représentés par des demandes complètement différentes, et après confirmation, des transitions vers différents écrans, etc. sont nécessaires. Étant donné que le ViewController ne se soucie pas du type de temps dont dispose le ViewModel, nous pouvons avoir plusieurs implémentations de ViewModel pour différents cas, et l'interface utilisateur sera courante.


Le ViewModel pour View et les cellules traite généralement du formatage des données et du traitement des entrées utilisateur. Par exemple, stocker l'état sélectionné / non sélectionné.


 final class FeedCellViewModel { let url: URL? let title: String let subtitle: String init(feed: FeedItem) { url = URL(string: feed.imageUrl) title = feed.title subtitle = DateFormatter.feed.string(from feed.publishDate) } } 


Les transitions entre les écrans sont effectuées par le routeur.


 class BaseRouter { init(sourceViewController: UIViewController) { self.sourceViewController = sourceViewController } weak var sourceViewController: UIViewController? } 

Chaque écran possède son propre routeur, hérité de la base. Il a des méthodes de transition pour des écrans spécifiques.


 final class FeedRouter : BaseRouter { func showDetail(viewModel: FeedDetailViewModelProtocol) { let vc = FeedDetailViewController() vc.viewModel = viewModel sourceViewController?.navigationController?.pushViewController(vc, animated: true) } } 

Comme vous pouvez le voir dans l'exemple ci-dessus, l'assemblage du "module" se produit dans le routeur. Cela contredit formellement la lettre S de SOLID, mais en pratique, cela s'avère assez pratique et ne pose pas de problème.


Il y a des moments où la même méthode est nécessaire dans différents routeurs. Afin de ne pas l'écrire plusieurs fois, nous créons un protocole dans lequel il y aura des méthodes générales, et nous l'implémenterons. Il suffit maintenant de signer le routeur souhaité pour ce protocole, et il disposera des méthodes nécessaires.


 protocol FeedRouterProtocol { func showDetail(viewModel: FeedDetailViewModelProtocol) } extension FeedRouterProtocol where Self: BaseRouter { func showDetail(viewModel: FeedDetailViewModelProtocol) { let vc = FeedDetailViewController() vc.viewModel = viewModel sourceViewController?.navigationController?.pushViewController(vc, animated: true) } } 

Afficher


View est traditionnellement responsable de l'affichage des informations pour l'utilisateur et du traitement des actions de l'utilisateur. Dans MVVM, nous pensons que ViewController est une vue. Il est important qu'aucune logique complexe n'ait sa place dans le ViewModel. Dans tous les cas, même dans MVC, vous ne devez pas charger le ViewController lourdement, bien qu'il soit difficile de le faire.


View commande le ViewModel. Si le ViewController est chargé, nous donnons la commande ViewModel: charger les données du réseau ou du cache. View accepte également les signaux du ViewModel. Si le ViewModel indique que quelque chose a changé (par exemple, les données mêmes ont été chargées), alors View réagit et redessine.


Nous n'utilisons pas de storyboards. La navigation est fortement liée au ViewController et il est difficile de s'intégrer dans l'architecture. Les conflits surviennent souvent dans les story-boards, qui sont un «plaisir» distinct à éditer.


Que faire ensuite?


Vous pouvez utiliser la génération de code pour les modèles (traduisible), car toute l'initialisation de l'objet de base de données vers l'objet plan et vice versa est désormais enregistrée manuellement.


Vous pouvez également utiliser un schéma de requête plus universel, car de nombreuses méthodes de services ressemblent à ceci: aller sur le réseau, appliquer le mappage, enregistrer dans la base de données. Cela peut également être universalisé, pour définir un squelette commun.


Nous avons considéré des approches architecturales, mais n'oubliez pas qu'une application de haute qualité n'est pas seulement une architecture, mais aussi une interface fluide, réactive et pratique. Aimez vos utilisateurs et écrivez des applications de qualité.

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


All Articles