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, have already fixed on the board of special honor. And besides them there are many others that are not so common.
Among the strongmen, in my opinion, none is a universal pill for all cases:
- if you need to make a couple of small screens with a static data set, then introducing a full VIPER is quite expensive;
- if you do not like the reactive approach, then MVVM with a high degree of probability will pass by;
- If you encounter the Massive problem in a large project, then MVC is probably no longer suitable.
There is an option to use several architectures, because many allow to varying degrees to combine themselves with others, but this is also not very convenient for at least three reasons:
- as the module grows, it may be necessary to convert it to another architecture;
- when making changes to the module, you must first consider what architecture is used for it, and how exactly it is necessary to make changes there;
- the need to add a code adapter to share modules of different architectures, because from scratch, the code is unlikely to be native at the same time for both of them.
And over the past four years, having faced many projects (several projects from the banking sector, several heterogeneous custom ones, 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'm starting.
So far he has not failed me. At the same time, I do not think that I am a pioneer: for sure, many already use a similar approach. But since the projects that I personally encountered were rather difficult with architecture, I wanted to share my thoughts.
Briefly about SILVER
In my formation of this architecture variant, some key aspects were taken into account:
- it is necessary to equally apply it both for simple modules and for complex ones;
- one must be able to cover with tests broadly, if necessary;
- View may be partly active and be able to communicate with complex logic, but should not contain its implementation within itself;
- in order not to produce entities in Interactor for the sake of the fact of their existence, View, if necessary, can communicate directly with services - logic that is not tied to a specific module;
- on the life cycle of the iOS UI, the central link is the ViewController (View) , which should be used to simplify memory management.
Eventually:
- View allows itself to be a thin controller, communicating as needed with Interactor , Router and other services;
- dependencies are registered through ServiceLocator ;
- Communication with the module from the outside takes place via Router , but memory management is based on its View .
The main parts of the architecture:
- each module is at the top level Interactor , Router , View ;
- data for storage and processing is a separate common Entity layer ;
- Dependencies go through ServiceLocator .
I conditionally call it SILVER : the first letters.
SILVER by example
We will collect a small demonstration application that will maintain a list of countries and cities that we ourselves will remember, hoping for our own knowledge in geography.
First, let's see the public presentation of any module. In this phrase, the module represents a collective image that can be controlled and whose status can be displayed on the screen. So, in any module there are two public parts:
- Router , which allows you to manage the module and interact with other modules;
- ViewController , which allows you to display a visual representation of the module.
protocol IBaseRouter: class {
var viewController: UIViewController { get }
}
struct ModuleThis may ask why I repeated the ViewController into a separate property of the structure, if they are so connected.
The reason lies in the fact that to ensure the simplest memory management, the emphasis is shifted to the fact that the ViewController has strong connections with the rest of the module: when you return from the current screen back, the ViewController is removed from the UIKit hierarchy, and with it it conveniently dies and whole module.
For the same reason, from the parent module, communications with the child Router are made weak, if at all needed.
So, in order not to clog the memory, the ViewController for the first time is created 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 turn to its ViewController . However, to be able to gain control, you need to communicate with its Router .
If we 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 we get 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 module creation, and allows you to temporarily hold both strong links at once - to the Router and to the ViewController . As a result, while the structure is still alive in the local scope, the Router can be saved in a weak link, and the ViewController can be displayed on the screen where UIKit will keep a strong link to it.
func InputModuleAssembly(title: String, placeholder: String, doneButton: String) -> Module {
let router = InputRouter(title: title, placeholder: placeholder, doneButton: doneButton)
return Module(router: router, viewController: router.viewController)
} 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:
- accept the incoming parameters needed to configure the module (more often - through the constructor);
- accept the necessary callback, with the help of which the module can report that the user has performed some action;
- arrange receiving ViewController ;
- store Router child modules, if useful.
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 the module, the configuration method can contain all possible callbacks. This will allow you to remember to register their call in case of adding new callbacks during the development process.
// Так сложно забыть прописать дополнительный 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 very start of the application can be represented, which turns out 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 sake of clarity, it may be worth moving it to the RootInteractor ), and there are two main nuances associated with it:
- its creation occurs in the Root module ;
- the preparation of services for reuse takes place within itself.
As part of SILVER, it is assumed that the Root module is always there, since within its responsibility at least:
- switching root screens depending on the state of the application;
- register ServiceLocator .
struct ServiceLocator {
let geoStorage: IGeoStorageService
func prepareInjections() {
prepareInjection(geoStorage)
}
}
func inject() -> T! {
let key = String(describing: T.self)
return injections[key] as? T
}
fileprivate func prepareInjection(_ injection: T) {
let key = String(describing: T.self)
injections[key] = injection
} final class RootRouter: IRootRouter {
// ...
init(window: UIWindow) {
let serviceLocator = ServiceLocator(
geoStorage: GeoStorageService()
)
serviceLocator.prepareInjections()
}
// ...
}final class ListInteractor: IListInteractor {
// ...
private lazy var geoStorageService: IGeoStorageService = inject() // pretty easy!
// ...
}