Удивительно, как практика, демонстрирующая хорошую производительность и удобство работы для одной платформы демонизируется в лагере приверженцев другой платформы. Эту участь в полной мере ощущает на себе паттерн Локатор сервисов, который весьма популярен в .Net, и имеет плохую репутацию в iOS.
В одних статьях к ServiceLocator относятся с пренебрежением, называя его «велосипедом». Другие доказывают что это антипаттерн. Есть те, кто пытаются сохранять нейтралитет описывая как положительные так и отрицательные стороны локатора. В немалой степени этим гонениям способствует сам Хабрахабр, где содержатся несколько подобных статей с перекрестными ссылками. Каждый новый разработчик, сталкивающийся с реализацией Локатора почти сразу же заражается пренебрежением — ну не могут же множество разработчиков критиковать одно и то же без объективных причин.
articles
- 2010: https://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/
- 2015 : https://habr.com/ru/company/rambler-co/blog/258325/
- 2015: https://habr.com/ru/post/270005/
- 2016: https://habr.com/ru/company/jugru/blog/300886/
- 2017: https://habr.com/ru/company/badoo/blog/344506/
- 2018: https://habr.com/ru/company/redmadrobot/blog/352088
Так ли это на самом деле? Выражаясь словами одного известного персонажа «Вам не нравятся котики? Да вы просто не имеете их готовить!». В самом деле, пробовали ли Вы есть несоленое мясо? А необработанное сырое? Не приходится прилагать усилий для того чтобы выяснить, что употреблять в пищу свинину — это плохо, особенно в пятницу вечером. Так и с паттерном Локатор сервисов — чем большая распространенность сырой информации — тем больше предубеждение против нее.
Один из величайших умов античности Тит Лукреций Кар был убежден, что солнце вращается вокруг земли несмотря на то, что в остальных частях его книги «О природе вещей», относящейся к I веку до н.э. было сделано множество точнейших научных предсказаний — от силы гравитации до ядерной физики. Не ставя под сомнение силу авторитетов, покажем, что некоторые предубеждения легко нивелировать, воспользовавшись рычагом нужной длины.
Для начала напомним, что такое локатор сервисов и для чего он может быть применен.
Сервис — это автономный объект, который инкапсулирует в себе бизнес логику и может иметь ссылки на другие объекты. По сути, экземпляр любого класса может выступать сервисом. Очень часто, вместо понятия сервиса используется понятие «менеджер». Однако, распространена ситуация, когда в качестве менеджера выступает статический класс, который манипулирует данными репозитория. Сущность же сервиса в том, что он является экземпляром класса, со всеми вытекающими последствиями. А значит, он не может существовать в вакууме — он должен иметь носителя, к которому прикреплен на время жизни приложения. Таким носителем выступает локатор сервисов.
Пользователь (приложение или разработчик) запрашивает у локатора сервисов сервис заданного типа, и получает готовый к использованию экземпляр. Очень похоже на абстрактную фабрику, не так ли? Разница в том, что каждый раз, запрашивая у локатора сервисов экземпляр заданного типа, Вы будете получать снова и снова тот же самый экземпляр, который сохраняет в себе уже использованные данные. Казалось бы, что сервис ведет себя как типичный синглтон, но это не так. Вы можете создать сколько угодно экземпляров сервиса и использовать их независимо по своему усмотрению. При этом, каждый из них будет инкапсулировать данные, которые Вы там разместите на протяжении всей своей жизни.
Для чего такое может быть нужно? Самый очевидный и горячо любимый пример — это профиль пользователя. Если Вы не используете какое-либо хранилище в виде UserSettings или CoreData, то на время жизни приложения Вам придется где-то удерживать ссылку на экземпляр класса UserProfile, для того, чтоб использовать его на различных экранах приложения. При этом, если такой экземпляр не является синглтоном, его придется перебрасывать от одной формы к другой. Трудность неминуемо возникнет когда в каком-то цикле разработки приложения Вам придется создать временного пользователя, или независимого другого пользователя. Сингтон тут же становится бутылочным горлышком. А независимые экземпляры начинают перегружать логику приложения, сложность которого экспоненциально возрастает, по мере добавления все новых и новых заинтересованных в экземпляре контроллеров.
Локатор сервисов элегантно решает эту проблему: если у Вас есть абстрактный класс UserProfile, и унаследованные от него конкретные классы DefaultUserPrifile и TemporatyUserProfile (с абсолютно пустой реализацией, т. е. фактически идентичные), то обращение к локатор сервису вернет Вам два идентичных независимых объекта.
Другая область применения локатора — передача экземпляров данных сквозь цепочку контроллеров: на первом контроллере Вы создаете (получаете) объект и модифицируете его, а на последнем — используете те данные, которые ввели на первом объекте. Если число контроллеров довольно велико и они расположены в стеке, то использовать для этих целей делегат — будет достаточно трудоемко. Аналогично, часто возникает необходимость в корне стека отобразить информацию, которая изменилась на его вершине сразу после сворачивания стека (мы же помним, что стек любит удалять все созданные в его скопе экземпляры). Однако, если на вершине стека Вы получаете сервис и модифицируете его, а после этого инициируете сворачивание стека, то когда корневой контроллер станет доступен пользователю, модифицированные данные сохранятся и будут доступны для отображения.
Вообще говоря, если Вы используете паттерн «Координатор », так как он описан в большинстве туториалов (например здесь, здесь, здесь, здесь или здесь), то Вам нужно или размещать его экземпляр класса в AppDelegate или передавать ссылку на координатор всем ВьюКонтроллерам, которые будут его использовать. Нужно? А почему, собственно?
Лично я, предпочитаю, чтоб AppDelegate сиял чистотой. А наследоваться от ICoordinatable и задавать поле coordinator — не только тратит время (которое, как мы знаем, эквивалентно деньгам), но еще и лишает возможности человеческого декларативного программирования посредством сторибордов. Нет, это не наш метод.
Создание координатора как сервиса изящно делает недостатки достоинствами:
- вам не нужно заботится о сохранении целостности координатора;
- координатор становится доступным по всему приложению, даже в тех контроллерах, которые не наследуются от ICoordinatable;
- вы инициируете координатор только тогда, когда он нужен.
- вы можете использовать коодинатор совместно со сторибордом в произвольном (удобном Вам порядке).
- использование координатора со сторибордом позволяет создать неочевидные, но действенные механизмы навигации.
Но координатор это половина паттерна «Навигатор» (механизм перемещения по приложению, посредством вычисляемого пути при помощи роутера). При его реализации сложность возрастает на порядок. Особенности работы навигатора совместно с локатором сервисов — это отдельная широкая тема. Вернемся к нашему локатору.
К объективным причинам, которые приводят в качестве аргументации, почему Локатор сервиса плох, относят сложность его сопровождения для конкретного сервиса и невозможность контролировать состояние памяти локатора.
Традиционным механизмом создания сервиса является вот такой код:
...
ServiceLocator.shared.addService(CurrentUserProvider() as CurrentUserProviding)
...
let userProvider: UserProviding? = ServiceLocator.shared.getService()
guard let provider = userProvider else { return }
self.user = provider.currentUser()
или вот такой:
if let_:ProfileService = ServiceLocator.service() {
ServiceLocator.addService(ProfileService())
}
let service:ProfileService = ServiceLocator.service()!
service.update(name: "MyName")
Неправда ли он ужасен? Вначале нужно зарегистрировать сервис, а потом его извлечь чтоб использовать. В первом случае, гарантируется чтоб он будет существовать тогда, когда он будет запрошен. Зато второй не создает сервис раньше чем он понадобится. Если дернуть сервис нужно во множестве мест, то, альтернатива выбора может свести с ума.
Но ведь это все легко превратить вот в такой код:
ProfileService.service.update(name: "MyName")
Здесь экземпляр сервиса гарантированно существует, потому что если он отсутствует — он создается сами сервисом. Ничего лишнего.
Вторая претензия к локатору объясняется видимо тем, что разработчики, которые делают кальку паттерна с С# забывают о работе сборщика мусора, и не удосуживаются предоставить возможность очистки локатора от ненужного экземпляра, хотя это совсем не сложно:
ProfileService.service.remove()
Если этого не сделать — то при обращении к ProfileService.service() мы получим экземпляр того сервиса, с которым мы уже работали ранее в произвольном месте приложения. А вот если сделать remove(), то при обращении к сервису — Вы получите чистый экземпляр. В некоторых случаях вместо remove() можно сделать clear() очистив предопределенную часть данных, и продолжив работать все с тем же экземпляром.
В тестовом приложении демонстрируется согласованная работа двух сервисов: профиля пользователя и координатора. Координатор — не является целью статьи, а лишь удобный пример.
На видео видно, что введенное в поле значение транслируется на каждый последующий экран приложения. А на заключительном экране происходит вызов координатора, с тем, чтоб запустить приложение с первого экрана. Заметьте, здесь не происходит традиционного сворачивания стека навигации — он выталкивается из памяти целиком, в место него стартует новый стек. Если закомментировать строку удаления сервиса профиля, то имя пользователя перенесется на первый экран, так как будто мы свернули стек навигации.
ProfileService.service.remove()
Все приложение состоит из двух вью-контроллеров, с минимумом подготовительных операций.
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()
}
}
В первом контроллере экземпляр профиля создается в метода viewDidLoad(), и информация об имени пользователя загружается в поле ввода. А после нажатия на кнопку SignIn, даные сервиса вновь обновляются. После чего происходит принудительный переход на первую страницу мастера.
Внутри мастера данные отображаются на экране. Но событие привязано к кнопке только на последнем экране сториборда.
Казалось бы, что тут может быть сложного? Но за последние 5 лет постоянно сталкиваюсь с разработчиками которые не понимают, как это все работает.
Разумеется, вся основная работа происходит в локаторе и самом сервисе.
Локатор:
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))"
}
}
Если Вы присмотритесь, то заметите, что потенциально можно одним действием очистить всю область данных локатора:
ServiceLocator.clear()
Сервис профиля не намного сложнее:
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 = ""
}
Его можно еще упростить, перенеся область данных внутрь самого сервиса. Но в таком виде он становится понятным зона ответственности модели данных и сервиса.
Не исключено, что для работы Ваших сервисов потребуется осуществлять некоторые подготовительные операции, как в случае с созданием сервиса координатора.
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)
}
}
Здесь видно, что в момент помещения сервиса в локатор, происходит создание стека навигации, и передача его экземпляру класса координатора.
Если Вы используете iOS 13 (видимо, и выше), то не забудьте модифицировать класс SceneDelegate. В нем нужно обеспечить выполнение вот этого кода:
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()
}
В начале извлекаем дефолтный стек навигации и ассоциируем его с главным окном приложения, а затем открываем стартовое окно приложения с контроллером StartViewController.
Исходный код тестового примера доступен на GitHub.