Синглтон, локатор сервисов и тесты в iOS
Привет, Хабр! Я Богдан, работаю в мобильной команде Badoo iOS-разработчиком.
В этой статье мы рассмотрим использование паттернов «Синглтон» и «Локатор сервисов» (service locator) в iOS и обсудим, почему их часто называют антипаттернами. Я расскажу, как и где их стоит применять, сохраняя код пригодным для тестирования.
Синглтон
Синглтон — это класс, единовременно имеющий только один экземпляр.
Даже если вы только приступили к iOS-программированию, вы, скорее всего, уже сталкивались с такими синглтонами, как UIApplication.shared
и UIDevice.current
. Эти объекты представляют собой сущности, в реальном мире существующие в единственном экземпляре, так что вполне логично, что и в приложении их по одной штуке.
Cинглтон довольно просто реализуется в Swift:
class SomeManager {
static let shared = SomeManager()
private init() {
}
}
…
let manager = SomeManager.shared
manager.doWork()
Заметьте, что инициализатор — приватный, поэтому мы не можем распределять новый экземпляр класса напрямую, как SomeManager()
, и обязаны получать доступ через SomeManager.shared
.
let managerA = SomeManager.shared //
правильноlet managerB = SomeManager() //
неправильно, ошибка компиляции
В то же время UIKit не всегда последователен в отношении своих синглтонов, например, UIDevice()
создаёт для вас новый экземпляр класса, который содержит информацию о том же устройстве (довольно бессмысленно), в то время как UIApplication()
выбрасывает исключение в runtime во время исполнения.
Пример ленивой (отложенной) инициализации синглтона:
class SomeManager {
private static let _shared: SomeManager?
static var shared: SomeManager {
guard let instance = SomeManager._shared else {
SomeManager._shared = SomeManager()
return SomeManager._shared!
}
return instance
}
private init() {
}
}
Важно понимать, что ленивый запуск может повлиять на состояние вашего приложения. Например, если ваши синглтоны подписаны на уведомления, удостоверьтесь, что в коде нет подобных строк:
_ = SomeManager.shared // initialization of a lazy singleton to achieve a side-effect
Это означает, что вы полагаетесь на нюансы реализации. Вместо этого я рекомендую делать ваши синглтоны явно заданными и либо позволять им существовать всегда, либо привязывать к важным статусам приложения вроде сессии пользователя.
Как понять, что сущность должна быть синглтоном
В объектно-ориентированном программировании мы стараемся разделить реальный мир на классы и их объекты, так что, если объект в вашем домене существует в единственном числе, он должен быть синглтоном.
К примеру, если мы создаём автопилот для конкретного автомобиля, то эта машина является синглтоном, поскольку не может существовать более одного конкретного автомобиля. С другой стороны, если мы делаем приложение для фабрики автомобилей, тогда объект «Автомобиль» не может быть синглтоном, поскольку на фабрике множество автомобилей, и все из них релевантны нашему приложению.
В дополнение к этому стоит задать себе вопрос: «А есть ли такая ситуация, в которой приложение может существовать без этого объекта?»
Если ответ положительный, то даже с учётом того, что объект является синглтоном, его хранение с помощью статической переменной может быть очень плохой идеей. В примере с автопилотом это значило бы, что если информация о конкретном автомобиле исходит от сервера, то она будет недоступна при запуске приложения. Следовательно, этот конкретный автомобиль — пример синглтона, который динамически создаётся и уничтожается.
Другой пример — приложение, требующее сущность «Пользователь». Пускай даже приложение бесполезно до тех пор, пока вы в нём не залогинились, оно всё равно работает, даже если вы не ввели свои данные. Значит, пользователь является синглтоном с ограниченным временем жизни. Для получения более подробной информации почитайте эту статью.
Злоупотребление синглтонами
Синглтоны, как и обычные объекты, могут быть в различных состояниях. Но синглтоны — объекты глобальные. Это означает, что их состояние проецируется на все объекты в приложении, что позволяет произвольному объекту принимать решения, основанные на общем состоянии. Это делает приложение крайне сложным для понимания и отладки. Доступ к глобальному объекту из любого уровня приложения нарушает принцип минимальных привилегий и мешает нашим попыткам контролировать зависимости.
Считайте это расширение UIImageView
контрпримером:
extension UIImageView {
func downloadImage(from url: URL) {
NetworkManager.shared.downloadImage(from: url) { image in
self.image = image
}
}
}
Это очень удобный способ загрузки изображения, ноNetworkManager
является скрытой переменной, недоступной снаружи. В этом случаеNetworkManager
работает асинхронно в отдельном потоке выполнения, но у методаdownloadImage
нет замыкания завершения, из чего можно сделать неверный вывод, что метод синхронен. Так что пока вы не откроете реализацию, вы никак не поймёте, загрузилось изображение после вызова метода или нет.
imageView.downloadImage(from: url)
изображение уже установлено или нет?
print(String(describing: imageView.image)) //
Синглтоны и модульное тестирование
Если вы проведёте модульное тестирование приведённого выше расширения, то поймёте, что ваш код делает сетевой запрос и что вы никак не можете на это повлиять!
Первое, что приходит на ум, — ввести вспомогательные методы в NetworkManager
и вызвать их в setUp()/tearDown()
:
class NetworkManager {
…
func turnOnTestingMode()
func turnOffTestingMode()
var stubbedImage: UIImage!
}
Но это очень плохая идея, поскольку вам придётся писать production-код, пригодный лишь для поддержки тестов. Более того, вы можете случайно использовать эти методы и в самом production-коде.
Вместо этого можно следовать принципу l«тесты превосходят инкапсуляцию» и создать публичный сеттер для статической переменной, удерживающей синглтон. Лично я считаю это тоже плохой идеей, поскольку не воспринимаю среды, функционирующие только благодаря обещаниям программистов не делать ничего плохого.
Оптимальным решением, на мой взгляд, будет накрыть Network Service протоколом и внедрить его как явную зависимость.
protocol ImageDownloading {
func downloadImage(from url: URL, completion: (UIImage) -> Void)
}
extension NetworkManager: ImageDownloading {
}
extension UIImageView {
func downloadImage(from url: URL, imageDownloader: ImageDownloading) {
imageDownloader.downloadImage(from: url) { image in
self.image = image
}
}
}
Это позволит нам использовать поддельную реализацию (mock implementation) и проводить модульное тестирование. А ещё мы сможем использовать разные реализации и с лёгкостью переключаться между ними. Пошаговое руководство: medium.com/flawless-app-stories/the-complete-guide-to-network-unit-testing-in-swift-db8b3ee2c327
Сервис
Сервис — это автономный объект, ответственный за выполнение одной бизнес-активности, который может обладать другими сервисами в качестве зависимостей.
Также сервис — это отличный способ обеспечить независимость бизнес-логики от UI-элементов (экраны/ UIViewControllers).
Хорошим примером является UserService (или Repository), содержащий ссылку на текущего уникального пользователя (в конкретный период времени может существовать только один экземпляр) и одновременно на других пользователей системы. Сервис — прекрасный кандидат на роль источника истины для вашего приложения.
Сервисы — отличный способ отделить экраны друг от друга. Допустим, у вас есть сущность «Пользователь». Вы можете вручную передавать её как параметр на следующий экран, и, если на следующем экране пользователь меняется, вы получаете его в виде обратной связи:
В качестве альтернативы экраны могут изменять текущего пользователя в UserService и прослушивать изменения пользователя из сервиса:
Локатор сервисов
Локатор сервисов — это объект, удерживающий и обеспечивающий доступ к сервисам.
Его реализация может выглядеть так:
protocol ServiceLocating {
func getService() -> T?
}
final class ServiceLocator: ServiceLocating {
private lazy var services: Dictionary = [:]
private func typeName(some: Any) -> String {
return (some is Any.Type) ? "\(some)" : "\(some.dynamicType)"
}
func addService(service: T) {
let key = typeName(T)
services[key] = service
}
func getService() -> T? {
let key = typeName(T)
return services[key] as? T
}
public static let shared: ServiceLocator()
}
Это может показаться заманчивой заменой внедрения зависимости, поскольку вам не приходится явно передавать зависимость:
protocol CurrentUserProviding {
func currentUser() -> User
}
class CurrentUserProvider: CurrentUserProviding {
func currentUser() -> String {
...
}
}
Регистрируете сервис:
...
ServiceLocator.shared.addService(CurrentUserProvider() as CurrentUserProviding)
...
Получаете доступ к сервису через локатор служб:
override func viewDidLoad() {
…
let userProvider: UserProviding? = ServiceLocator.shared.getService()
guard let provider = userProvider else { assertionFailure; return }
self.user = provider.currentUser()
}
И вы всё ещё можете заменять предоставленные сервисы для тестирования:
override func setUp() {
super.setUp()
ServiceLocator.shared.addService(MockCurrentUserProvider() as CurrentUserProviding)
}
Но на деле, если применять локатор сервисов таким образом, это может принести вам больше бед, чем пользы. Проблема в том, что за пределами сервиса user вы не можете понять, какие сервисы применяются в данный момент времени, то есть зависимости неявны. А теперь представьте, что класс, который вы написали, является публичным компонентом фреймворка. Как пользователь фреймворка поймёт, что ему следует зарегистрировать сервис?
Злоупотребление локатором служб
Если у вас запущены тысячи тестов и внезапно они начинают сбоить, то можно не сразу понять, что у тестируемой системы есть сервис со скрытой зависимостью.
Более того, когда вы добавляете или удаляете из объекта зависимость от сервиса (или глубокие зависимости), в ваших тестах не появляется ошибка компиляции, из-за которой пришлось бы обновить тест. Ваш тест даже начать сбоить может не сразу, оставаясь какое-то время «зелёным», и это худший сценарий, так как в конечном счёте тесты начинают сбоить после некоторых «несвязанных» изменений в сервисе.
Запуск неудачных тестов по отдельности приведёт к разным результатам из-за плохой изоляции, вызванной общим локатором служб.
Локатор сервисов и модульное тестирование
Первой реакцией на описанный сценарий может быть отказ от использования локаторов служб, но на деле очень удобно удерживать ссылки в службах, не передавать их как транзитивные зависимости и избежать кучи параметров для фабрик. Вместо этого лучше запретим использование локатора сервисов в коде, который мы собираемся тестировать!
Я предлагаю использовать локатор сервисов на уровне фабрик тем же способом, каким вы вводили бы синглтон. Типичная фабрика экрана тогда будет выглядеть так:
final class EditProfileFactory {
class func createEditProfile() -> UIViewController {
let userProvider: UserProviding? = ServiceLocator.shared.getService()
let viewController = EditProfileViewController(userProvider: provider!)
}
}
В модульном тесте мы не станем использовать локатор служб. Вместо этого будем постоянно передавать наши mock-объекты:
...
EditProfileViewController(userProvider: MockCurrentUserProvider())
...
Есть ли способ всё улучшить?
Что, если мы решим не использовать статические переменные для синглтонов в нашем собственном коде? Это позволит сделать код надёжнее. И если мы запретим это выражение:
public static let shared: ServiceLocator()
то даже самый безграмотный начинающий разработчик не сможет воспользоваться нашим локатором сервисов напрямую и обойти наше формальное требование по введению его в качестве постоянной зависимости.
Следовательно, мы будем вынуждены хранить явные ссылки на локатор сервисов (например, в качестве свойства делегата приложения) и передавать всем фабрикам локатор сервисов как необходимую переменную.
Все фабрики и роутеры/ контроллеры потоков будут иметь хотя бы одну зависимость, если им понадобится какой-либо сервис:
final class EditProfileFactory {
class func createEditProfile(serviceLocator: ServiceLocating) -> UIViewController {
let userProvider: UserProviding? = serviceLocator.getService()
let viewController = EditProfileViewController(userProvider: provider!)
}
}
Таким образом мы получим код, пожалуй, менее удобный, но гораздо более безопасный. К примеру, он не даст нам обращаться к фабрике из слоя View, поскольку локатор сервисов попросту недоступен оттуда, и действие будет перенаправлено на роутер / контроллер потока.
Заключение
Мы разобрали проблемы, возникающие из-за применения паттернов «Синглтон» и «Локатор служб». Стало ясно, что основная часть проблем появляется из-за неявных зависимостей и доступу к глобальному состоянию. Внедрение явных зависимостей и уменьшение количества сущностей, которые имеют доступ к глобальному состоянию, улучшает надежность и тестируемость кода. Теперь самое время пересмотреть, правильно ли используются синглтоны и сервисы в вашем проекте!