📜 ⬆️ ⬇️

Architectural approaches in iOS applications

Today we will talk about architectural approaches in iOS development, about some nuances and developments of the implementation of individual things. I'll tell you what approaches we stick to and a little bit more detail.


Immediately reveal all the cards. We use MVVM-R (MVVM + Router).


In fact, this is the usual MVVM, in which the navigation between the screens is placed in a separate layer - Router, and the logic of receiving data is in services. Next, we consider our achievements in the implementation of each layer.


Why MVVM, not VIPER or MVC?


Unlike MVC in MVVM, responsibility between the layers is quite divided. It does not have as many "serving" code as in VIPER, although the ViewModel for screens is also closed by protocols. This architecture is somewhat similar to VIPER, only Presenter and Interactor are combined into a ViewModel, and the connections between layers are simplified by the use of reactive programming and bindings (we use ReactiveSwift).


Entity


We use two layers of data models: the first is tied to the database (hereinafter, managed objects ), the second is the so-called plain objects , which have nothing to do with the database.


Each plain entity implements the Translatable protocol, which can be initialized from a managed object, and from which you can create a managed object. We use Realm as a database, in our case ManagedObject is RealmSwift.Object . Mapping occurs through Codable : mapped as plain-objects and saved as managed-objects. Further services and ViewModel work only with plain-objects.


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

To save, retrieve and delete objects from the database, a separate entity is used - Storage. Since Storage is closed by protocol, we are independent of the implementation of a specific database and, if necessary, we can replace Realm with 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 } 

What are the pros and cons of this approach?


Each database has its own characteristics. For example, a Realm object already saved to the database can only be used within the framework of the thread in which it was created. This is a nuisance.


Also, the object can be deleted from the database, while it lies in the RAM, and when it is accessed, it will crash. Core Data has the same features. Therefore, we retrieve objects from the database, convert them to plain objects and then work with them.


With this approach, the code gets bigger and needs to be maintained. Without dependence on database features, we lose the possibility of using steep chips. In the case of CoreData, this is a FetchedResultsController, where we can control all inserts, deletions, changes within an array of entities. About the same mechanism for Realm.


Core Components


Core components are entities that perform one of their tasks. For example, mapping, interaction with the database, sending and processing network requests. Storage from the previous item is just one of the core components.


Protocols


We actively use protocols. All core components are closed by protocols, and there is an opportunity to make a mock or test implementation for unit tests. Thus, we get a certain flexibility of implementation. All dependencies are passed to init. When initializing each object, we understand what dependencies are there, what it uses inside of itself.


HTTP Client


The network request is described by the NetworkRequestParams protocol.


 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 } } 

We use enum to describe network requests. It looks like this:


 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 } } } 

Each NetworkRouter implements the URLRequestConvertible protocol. We give it to the network client, which converts it to URLRequest and uses it for its intended purpose.


The network client looks like this:


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

Mapper


We use Codable for data mapping.


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

Push Notifications


Each push-notification has a type and each type has its own handler. The handler receives a dictionary with information from the notification. Handlers are held by the aggregating entity, it is she who will receive the push and route it to the required handler. This is a fairly scalable approach that is convenient to work with if you need to handle several types of push notifications in different ways.


Services


Roughly speaking, one service is responsible for one entity. Consider this on the example of a social network application. There is a server of the user who receives the user - himself, and gives the changed entities, if we edited it. There is a post service that receives a list of posts, a detailed post, a payment service, etc. etc.


All services contain core components. When we call a method on a service, it starts pulling various methods of core components and eventually gives the result out.


The service, as a rule, performs work for a specific screen, or rather, for the view model of the screen (more on this below). If, when leaving the screen, the service is not destroyed, but will continue to perform an already unnecessary network request and will slow down other requests. This can be controlled manually, but it will be more difficult to maintain such a system. However, this approach has a minus: if the result of the service is needed even after we left the screen, you will have to look for other solutions, perhaps making some services a singleton.


Services do not contain a state. Since services are not singletons, we may have several instances of the same service, in which the states may differ from each other. This may lead to incorrect behavior.


An example of the method of one of the services:


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

Viewmodel


We will divide the ViewModel into 2 types:



ViewModel for ViewController is responsible for the logic of the screen. As a rule, it is sending network requests, preparing data, responding to UI events.


ViewModel prepares all the data for the view that came from the service. If the list of entities has arrived, the ViewModel transforms it into a ViewModel list and binds it to the view. If there are states (there is a check mark / no check mark), this is also controlled and transmitted to the ViewModel.


Also ViewModel controls the navigation logic. There is a separate Router layer for navigation, but the ViewModel gives the commands.


Typical functions of the view-model: get the user, contact the user service, make the ViewModel from the obtained value. When everything loads, View takes the ViewModel and draws the view cell.


ViewModel for the screen is closed by the protocol for the same reasons as services. However, there is another interesting case: for example, a banking application, where every action (transfer of funds, opening an account, blocking an account) is confirmed by SMS. On the confirmation screen there is a code entry field and a “send again” button.


ViewModel is closed by this protocol:


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

In ViewController, it is stored in this form:


 var viewModel: CodeInputViewModelProtocol? 

Depending on what exactly we are trying to confirm by SMS, sending the code and re-sending SMS can be represented by completely different requests, and after confirmation, transitions to different screens are needed, etc. Since the ViewController doesn’t matter what kind of ViewModel actually has, we can have several ViewModel implementations for different cases, and the UI will be common.


ViewModel for View and cells, as a rule, is engaged in formatting data and processing user input. For example, the storage state is "selected / not selected."


 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) } } 


Transitions between screens are performed by the Router.


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

Each screen has its own router, which is inherited from the base. It has methods for navigating to specific screens.


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

As can be seen from the example above, the assembly of the “module” takes place in a router. This is formally contrary to the letter S of SOLID, but in practice it is rather convenient and does not cause problems.


There are cases when the same method is needed in different routers. In order not to write it several times, we create a protocol in which there will be general methods, and implement an extension to it. Now it is enough to sign the necessary router for this protocol, and it will have the necessary methods.


 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) } } 

View


View is traditionally responsible for displaying information for the user and processing user actions. In MVVM, we consider the ViewController to be a View. It is important that there is no complicated logic, which is the place in the ViewModel. In any case, even in MVC, you shouldn’t load ViewController too much, although it’s hard to do.


View commands a ViewModel. If ViewController is loaded, we give the ViewModel command: load data from the network or from the cache. View also accepts signals from ViewModel. If the ViewModel says that something has changed (for example, the same data was loaded), then View responds to it and redraws.


We do not use storyboards. Navigation is strongly tied to the ViewController, and it is hard to fit into the architecture. In storyboards, conflicts often arise, which is a separate “pleasure” to rule.


What to do next?


You can use code generation for models (Translatable), since all initialization from a database object to a plane object and vice versa is now written manually.


You can also use a more universal query scheme, since many methods of services look like this: go online, apply mapping, save to database. This, too, can be universalized, set a common skeleton.


We have examined the architectural approaches, but do not forget that a quality application is not only architecture, but also a smooth, responsive, user-friendly interface. Love your users and write quality applications.


')

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


All Articles