📜 ⬆️ ⬇️

SILVER: how I design iOS apps

Another architecture?


In recent years, the topic of alternative architectures for creating applications for the iOS platform has noticeably gained momentum. Some strongmen, known as MVP, MVVM, VIPER, are already entrenched on the special honor board. And besides them there are many others, not so common.


Among the strong men, in my opinion, none is a universal pill for all cases:



There is an option to use several architectures, because many allow you to combine yourself with others to varying degrees, but this is also not very convenient for at least three reasons:



And so, having encountered a lot of projects over the past four years (several projects from the banking sector, several heterogeneous custom projects, as well as several of my own - both applications and games), I formed an architectural approach for myself, which I now try to use as much as possible in Any project that I start.


So far, he did not let me down. At the same time, I do not think that I am a pioneer: for sure, many already use a similar approach. But since in the projects that I personally encountered, architecture was rather difficult, I wanted to share my thoughts.


SILVER in brief


In my formation of this version of the architecture, several key aspects were taken into account:



Eventually:



The main parts of the architecture:



I conditionally call it SILVER : by the first letters.


SILVER by example


We will collect a small illustrative application that will keep a list of countries and cities, which we ourselves will recall, hoping for our own knowledge of geography.


First, let's take a look at the public presentation of any module. In this phrase, the module represents a collective image that can be controlled, and the state of which can be displayed on the screen. So, in any module there are two public parts:



protocol IBaseRouter: class { var viewController: UIViewController { get } } struct Module<RT> { let router: RT let viewController: UIViewController } 

Here a question may appear why I repeated the ViewController in a separate property of the structure, if they are so connected.


The reason lies in the fact that in order to ensure the most simple memory management, the emphasis is shifted to the fact that the ViewController has strong connections with the rest of the module: when you return back from the current screen, the ViewController is removed from the UIKit hierarchy, and with it comfortably dies whole module.


For the same reason, from the parent module, connections with child Routers are made weak, if they are needed at all.


So, in order not to clog the memory, the ViewController is created for the first time only at the moment when it is accessed. And so it turns out that in order for a viable module to appear, you need to refer to its ViewController . However, to be able to get control, you need to communicate with its Router .


If you get a Router from the module factory, then we will not have a strong link to the module, and it will be destroyed on the next line of code. And if you get a ViewController from the factory, then we will not have the ability to control and configure the module.


This problem is solved by the Module structure, which is filled at the time of the module creation, and allows you to temporarily hold both strong links at once - to Router and to ViewController . As a result, as long as the structure is alive in the local scope, Router can be saved to a weak link, and the ViewController display on the screen where UIKit will hold a strong link to it.


Module creation example
 func InputModuleAssembly(title: String, placeholder: String, doneButton: String) -> Module<IInputRouter> { let router = InputRouter(title: title, placeholder: placeholder, doneButton: doneButton) return Module<IInputRouter>(router: router, viewController: router.viewController) } 

Module usage example
 private func presentCountryInput() { let module = InputModuleAssembly(title: "Add city", placeholder: "Country", doneButton: "Next") self.countryInputRouter = module.router module.router.configure( doneHandler: { [unowned self] country in self.interactor.setCountry(country) self.presentNameInput() } ) internalViewController?.viewControllers = [module.viewController] } 

In general, Router is needed in order to:



Router Example
 protocol IInputRouter: IBaseRouter { func configure(doneHandler: @escaping (String) -> ()) } final class InputRouter: IInputRouter { private let title: String private let placeholder: String private let doneButton: String let interactor: IInputInteractor private weak var internalViewController: IInputViewController? init(title: String, placeholder: String, doneButton: String) { self.title = title self.placeholder = placeholder self.doneButton = doneButton interactor = InputInteractor() } var viewController: UIViewController { if let _ = internalViewController { return internalViewController as! UIViewController } else { let vc = InputViewController(title: title, placeholder: placeholder, doneButton: doneButton) vc.router = self vc.interactor = interactor internalViewController = vc interactor.view = vc return vc } } func configure(doneHandler: @escaping (String) -> ()) { internalViewController?.doneHandler = doneHandler } } 

In case several actions can be performed in a module, the configuration method can contain all possible callbacks. This will allow in case of adding new callbacks in the development process not to forget to register their call, too.


 //      callback, //     , //        . func configure(cancelHandler: @escaping () -> (), doneHandler: @escaping (String) -> ()) //       callback    , //      . func configure(cancelHandler: @escaping () -> ()) func configure(doneHandler: @escaping (String) -> ()) 

In exactly the same way, in the form of a stored module, the start of the application itself can be represented, which is obtained in this way quite concise:


 class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private weak var rootRouter: IRootRouter! func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { let window = UIWindow(frame: UIScreen.main.bounds) self.window = window let module = RootModuleAssembly(window: window) rootRouter = module.router window.rootViewController = module.viewController window.makeKeyAndVisible() return true } } 

Dependencies come from the ServiceLocator , which is configured in the RootRouter (although, for the purity of logic, it may be worthwhile to transfer it to the RootInteractor ), and there are two main nuances associated with it:



In the framework of SILVER, it is assumed that the Root module is always there, because as part of its responsibility at least:



Sample ServiceLocator
 struct ServiceLocator { let geoStorage: IGeoStorageService func prepareInjections() { prepareInjection(geoStorage) } } func inject<T>() -> T! { let key = String(describing: T.self) return injections[key] as? T } fileprivate func prepareInjection<T: Any>(_ injection: T) { let key = String(describing: T.self) injections[key] = injection } 

Example of creating a ServiceLocator
 final class RootRouter: IRootRouter { // ... init(window: UIWindow) { let serviceLocator = ServiceLocator( geoStorage: GeoStorageService() ) serviceLocator.prepareInjections() } // ... } 

ServiceLocator Example
 final class ListInteractor: IListInteractor { // ... private lazy var geoStorageService: IGeoStorageService = inject() // pretty easy! // ... } 

View the demo project on GitHub


')

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


All Articles