Service Locator - Dispelling Myths


It is amazing how a practice demonstrating good performance and usability for one platform is demonized in a camp of adherents of another platform. This fate is fully felt by the pattern of Service Locator, which is very popular in .Net and has a bad reputation in iOS.

In some articles, ServiceLocator is neglected, calling it a "bicycle." Others argue that it is antipattern. There are those who are trying to maintain neutrality by describing both the positive and negative sides of the locator. To a large extent, Habrahabr himself contributes to these persecutions, which contains several similar articles with cross-references. Each new developer who encounters the implementation of Latitude is almost immediately infected with neglect - well, many developers cannot criticize the same thing without objective reasons.


Is it really? In the words of one famous character, “Don't you like cats? Yes, you just don’t have them! ” In fact, have you tried to eat unsalted meat? And raw raw? You don’t have to make an effort to figure out what to eat pork is bad, especially on Friday night. So with the Service Locator pattern - the greater the prevalence of raw information - the greater the prejudice against it.

One of the greatest minds of antiquity Titus Lucretius Car was convinced that the sun revolves around the earth despite the fact that in the remaining parts of his book "On the nature of things", relating to the 1st century BC. many accurate scientific predictions have been made - from the force of gravity to nuclear physics. Without questioning the power of authority, we show that some prejudices are easily leveled by using a lever of the right length.

First, recall what a service locator is and what it can be used for.

A service is an autonomous object that encapsulates business logic within itself and can have links to other objects. In fact, an instance of any class can act as a service. Very often, instead of the concept of service, the concept of a manager is used. However, a common situation is when a static class acts as a manager, which manipulates the repository data. The essence of the service is that it is an instance of the class, with all the ensuing consequences. This means that it cannot exist in a vacuum - it must have a carrier to which it is attached for the lifetime of the application. Such a carrier is a service locator.

The user (application or developer) requests a service of the specified type from the service locator, and receives an instance ready for use. Very similar to an abstract factory, right? The difference is that each time you request an instance of the specified type from the service locator, you will receive the same instance again and again, which stores the data that has already been used. It would seem that the service behaves like a typical singleton, but this is not so. You can create as many instances of the service as you like and use them independently at your discretion. At the same time, each of them will encapsulate the data that you post there throughout your life.


Why might this be necessary? The most obvious and beloved example is the user profile. If you do not use any storage in the form of UserSettings or CoreData, then for the lifetime of the application you will have to hold the link to the instance of the UserProfile class somewhere in order to use it on various screens of the application. Moreover, if such an instance is not a singleton, it will have to be transferred from one form to another. The difficulty will inevitably arise when in some development cycle of the application you have to create a temporary user, or an independent other user. Sington immediately becomes a bottleneck. And independent instances begin to overload the application logic, the complexity of which increases exponentially as more and more controllers interested in the instance are added.


The service locator elegantly solves this problem: if you have an abstract UserProfile class, and the specific classes DefaultUserPrifile and TemporatyUserProfile inherited from it (with an absolutely empty implementation, that is, virtually identical), then accessing the service locator will return two identical independent objects to you.

Another application of the locator is the transfer of data instances through a chain of controllers: on the first controller, you create (receive) an object and modify it, and on the last - use the data that you entered on the first object. If the number of controllers is quite large and they are located on the stack, then using a delegate for these purposes will be quite difficult. Similarly, often there is a need in the root of the stack to display information that changed on its top immediately after minimizing the stack (we remember that the stack likes to delete all instances created in its copy). However, if at the top of the stack you get a service and modify it, and after that initiate folding of the stack, then when the root controller becomes available to the user, the modified data will be saved and will be available for display.

Generally speaking, if you use the "Coordinator" pattern, as it is described in most tutorials (for example, here , here , here , here or here ), you need to either place its instance of the class in AppDelegate or pass a link to the coordinator to all ViewControllers that will use it. Necessary? And why, actually?


Personally, I prefer AppDelegate to shine clean. And to inherit from ICoordinatable and set the coordinator field - not only spends time (which, as we know, is equivalent to money), but also deprives the possibility of human declarative programming through storyboards. No, this is not our method.

Creating a coordinator as a service gracefully makes the disadvantages of the advantages:

  • You do not need to care about maintaining the integrity of the coordinator;
  • the coordinator becomes available throughout the application, even in those controllers that are not inherited from ICoordinatable;
  • you initiate a coordinator only when you need it.
  • You can use the coordinator together with the storyboard in any order (convenient for you).
  • Using a coordinator with a storyboard allows you to create non-obvious, but effective navigation mechanisms.

But the coordinator is half the “Navigator” pattern (the mechanism for moving around the application through a calculated path using a router). With its implementation, complexity increases by an order of magnitude. Features of the navigator in conjunction with the service locator - this is a separate wide topic. Let's get back to our locator.

The objective reasons that lead to the reason why the service Locator is bad include the complexity of its maintenance for a particular service and the inability to control the state of the locator's memory.

The traditional mechanism for creating a service is this code:

...
 ServiceLocator.shared.addService(CurrentUserProvider() as CurrentUserProviding)
...
let userProvider: UserProviding? =  ServiceLocator.shared.getService()
guard let provider =  userProvider else { return }
self.user = provider.currentUser()

or like this:

 if let_:ProfileService = ServiceLocator.service() {
            ServiceLocator.addService(ProfileService())
        }
 let service:ProfileService = ServiceLocator.service()!
  service.update(name: "MyName")

Is he terrible? First you need to register the service, and then extract it in order to use it. In the first case, it is guaranteed that it will exist when it is requested. But the second does not create the service before it is needed. If you need to pull the service in many places, then the alternative of choice can drive you crazy.

But it’s easy to turn all this into such code:

ProfileService.service.update(name: "MyName")

Here the service instance is guaranteed to exist, because if it is absent, it is created by the service itself. Nothing extra.

The second claim to the locator is apparently due to the fact that developers who make pattern tracing with C # forget about the work of the garbage collector, and do not bother to provide the ability to clean the locator from an unnecessary instance, although this is not at all difficult:

ProfileService.service.remove()

If you do not do this, then when you turn to ProfileService.service (), we will get an instance of the service with which we already worked previously in an arbitrary place in the application. But if you do remove (), then when accessing the service, you will get a clean copy. In some cases, instead of remove (), you can make clear () by clearing the predefined part of the data and continuing to work with the same instance.

The test application demonstrates the coordinated work of two services: user profile and coordinator. The coordinator is not the purpose of the article, but only a convenient example.


The video shows that the value entered in the field is transmitted to each subsequent screen of the application. And on the final screen, the coordinator is called in order to launch the application from the first screen. Note that the traditional folding of the navigation stack does not occur here - it is ejected entirely from the memory, a new stack starts in its place. If you comment out the line for deleting the profile service, the username will be transferred to the first screen, as if we had minimized the navigation stack.

ProfileService.service.remove()

The entire application consists of two view controllers, with a minimum of preparatory operations.


StartViewController:

import UIKit

class StartViewController: UIViewController {

    @IBOutlet private weak var nameField: UITextField!

    override func viewDidLoad() {
        super.viewDidLoad()
        self.nameField.text = ProfileService.service.info.name
    }

    @IBAction func startAction(_ sender: UIButton) {
        ProfileService.service.update(name: self.nameField.text ?? "")
        CoordinatorService.service.coordinator.startPageController()
    }

}

PageViewController:

import UIKit

class PageViewController: UIViewController {

    @IBOutlet private weak var nameLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()
        self.nameLabel.text = ProfileService.service.info.name
    }


    @IBAction func finishAction(_ sender: UIButton) {
        ProfileService.service.remove()
        CoordinatorService.service.coordinator.start()
    }
}

In the first controller, a profile instance is created in the viewDidLoad () method, and the user name information is loaded into the input field. And after clicking on the SignIn button, the service data is updated again. After which there is a forced transition to the first page of the wizard.
Inside the wizard, data is displayed on the screen. But the event is tied to a button only on the last screen of the storyboard.

It would seem that it can be complicated? But over the past 5 years, I have constantly come across developers who do not understand how it all works.

Of course, all the main work takes place in the locator and the service itself.

Locator:

import Foundation

protocol IService {
    static var service: Self {get}
    
    func clear()
    func remove()
}
protocol IServiceLocator {
    func service<T>() -> T?
}

final class ServiceLocator: IServiceLocator {
    
    private static let instance = ServiceLocator()
    private lazy var services: [String: Any] = [:]
    
    // MARK: - Public methods
    class func service<T>() -> T? {
        return instance.service()
    }
    
    class func addService<T>(_ service: T) {
        return instance.addService(service)
    }
    
    class func clear() {
        instance.services.removeAll()
    }
    
    class func removeService<T>(_ service: T) {
        instance.removeService(service)
    }
    
    func service<T>() -> T? {
        let key = typeName(T.self)
        return services[key] as? T
    }
    
    // MARK: - Private methods
    private fun caddService<T>(_ service: T) {
        let key = typeName(T.self)
        services[key] = service
    }
    
    private func removeService<T>(_ service: T) {
        let key = typeName(T.self)
        services.removeValue(forKey: key)
    }
    
    private func typeName(_ some: Any) -> String {
        return (some isAny.Type) ? "\(some)" : "\(type(of: some))"
    }
}


If you take a closer look, you will notice that you can potentially clean up the entire locator data area with one action:

ServiceLocator.clear () A

service profile is not much more complicated:

import UIKit

final class ProfileService: IService {
    
    private (set) var info = ProfileInfo()
    
    class var service: ProfileService {
        if let service: ProfileService = ServiceLocator.service() {
            return service
        }
        
        let service = ProfileService()
        ServiceLocator.addService(service)
        return service
    }

    func clear() {
        self.info = ProfileInfo()
    }

    func remove() {
        ServiceLocator.removeService(self)
    }
    
    func update(name: String) {
        self.info.name = name
    }
}

struct ProfileInfo {
    varname = ""
}

It can be further simplified by moving the data area inside the service itself. But in this form, it becomes clear the area of ​​responsibility of the data model and service.

It is possible that for the work of your services you will need to carry out some preparatory operations, as is the case with the creation of a coordinator service.

import UIKit

final class CoordinatorService: IService {
    
    private (set)var coordinator: MainCoordinator!
    
    var navController: UINavigationController {
        return self.coordinator.navigationController
    }
    
    class var service: CoordinatorService {
        if let service: CoordinatorService = ServiceLocator.service() {
            return service
        }
        
        let service = CoordinatorService()
        service.load()
        ServiceLocator.addService(service)
        return service
    }

    func clear() {
    }

    func remove() {
        ServiceLocator.removeService(self)
    }
    
    // MARK - Private
    private func load() {
        let nc                    = UINavigationController()
        nc.navigationBar.isHidden = true
        self.coordinator          = MainCoordinator(navigationController:nc)
    }
}

Here you can see that at the moment the service is placed in the locator, the navigation stack is created and passed to the coordinator class instance.

If you are using iOS 13 (apparently above), then be sure to modify the SceneDelegate class. It is necessary to ensure the execution of this code:

 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let scene = (scene as? UIWindowScene) else { return }
        let window = UIWindow(windowScene: scene)
        window.rootViewController = CoordinatorService.service.navController
        self.window = window
        CoordinatorService.service.coordinator.start()
        window.makeKeyAndVisible()
    }

First, we extract the default navigation stack and associate it with the main application window, and then open the application start window with the StartViewController controller.

The source code for the test case is available on GitHub .

All Articles