Как стать автором
Обновить
101.76
KTS
Создаем цифровые продукты для бизнеса

Пишем типизированный DI-контейнер для iOS приложения. Часть 2. Жизненные циклы

Время на прочтение8 мин
Количество просмотров1.7K

Привет! На связи KTS и наш привлечённый эксперт по iOS-разработке Александр.

Забрав инициативу у коллеги, возвращаемся с новой статьей из серии, в которой делимся своим представлением о DI и пробуем решить основную проблему его библиотечных решений: нам нужно точно знать, что экран соберётся, зависимости подтянутся, а все ошибки мы отловим на этапе компиляции.

В первой статье мы рассказали о своём понимании Dependency Injection, какие бывают зависимости и откуда их получать. Разобрались в паттерне Dependency Container, написали собственную реализацию и поняли, какую проблему он решает.

В этой статье копнём глубже. Речь пойдёт о жизненных циклах зависимостей: разберёмся, какие они бывают, как ими управлять и какое преимущество это даёт.

Если вы готовы, погнали! 🚁

Содержание:

Зависимости

Чтобы быть на одной волне, договоримся о терминологии.

💡Жизненный цикл зависимости — это период существования экземпляра от создания до уничтожения.

Зависимости могут иметь принципиально разный жизненный цикл. Самый долгий имеют синглтоны — один экземпляр на всё время работы приложения. Также можно выделить stateless‑объекты, которые можно создавать каждый раз, когда они нужны, и уничтожать, когда они больше не требуются. Есть ещё один вариант: зависимость хранит в себе данные и живёт, пока эти данные кому‑то нужны.

Синглтон и stateless‑объекты с помощью контейнера мы создавали в прошлой статье. Помните вот этот код?

class AppContainer: ObservableObject {
    private let persistenceController = PersistenceController()
}

extension AppContainer: ListContainer {
    func makeItemService() -> ItemService {
        ItemServiceImpl(persistence: persistenceController)
    }
} 

Экземпляр ItemServiceImpl создаётся каждый раз, когда он нужен. А вот PersistenceController — один в рамках всего контейнера.

В этой статье сосредоточимся на промежуточном варианте, когда зависимость должна жить некое продолжительное время.

Проект

Чтобы проиллюстрировать потребность, посмотрим, во что превратился проект со времён прошлой статьи:

...

-- Container

  |-- AppContainer.swift

-- Persistence

  ...

  |-- NotificationsSettingsStore.swift

-- Views

  ...

  |-- Main

  |-- Settings

    |-- Settings

    |-- StartNotifications

    |-- StopNotifications

    |-- NotificationsFrequency 

В структуру добавились новые экраны: Main и Settings вместо List и Details, а также хранилище настроек уведомлений NotificationsSettingsStore.

Приведенная выжимка из структуры проекта — всё, что необходимо знать для погружения. Main — это экран с табами. В этом проекте 2 таба: первый секретный, и время для рассказа про него ещё не пришло. Второй — настройки, папка Settings. О нём подробнее.

Экран настроек

Чтобы видео ниже умещалось на экране, разверните его на весь экран или откройте на YouTube:

Приложение умеет отправлять локальные уведомления. В настройках можно задать:

  • время старта отправки;

  • время окончания отправки;

  • временной интервал, насколько часто отправлять.

Все 3 настройки, разумеется, можно разместить на одном экране, но ради примеров в этой статье допущена такая UX‑оплошность.

Пример ниже немного притянут, но он иллюстрирует частый паттерн дизайна приложений.
Представим, что пользователю надо заполнить некую анкету или ответить на несколько вопросов, чтобы приложение могло подобрать под него правильный контент. Например, заполнение анкеты для сайта знакомств или музыкальное приложение, которое подбирает музыку по вкусу. Чтобы сохранять ответы пользователя и не передавать их из одного экрана в другой до конца заполнения, мы будем записывать их в специальное хранилище. В нашем случае анкета — настройки уведомлений, поэтому назовём хранилище NotificationsSettingsStore.

NotificationsSettingsStore должен создаваться в момент открытия первого экрана из флоу настроек уведомлений, в данном случае — StartNotifications. По нажатию на кнопку Save на последнем экране настроек NotificationsFrequency NotificationsSettingsStore запишет данные (например, в UserDefaults), и экран закроется, а вместе с ним и все флоу.

Наша задача — сделать так, чтобы вместе с последним экраном уничтожился и NotificationsSettingsStore.

Мы можем сами следить, чтобы зависимость жила столько, сколько нужно. Можем хранить на неё сильную ссылку в контейнере, а при закрытии нужного экрана вызывать метод контейнера, который ее уничтожит. Но такой подход может со временем приводить к багам из-за неконсистентных состояний: один раз забудешь очистить часть графа зависимостей — и получишь утечку памяти или непредсказуемый сайд-эффект. Такой же подход работает для SwiftUI-приложений. Если во View зависимость описать с использованием @ObservedObject, то она будет жить, пока этот View находится в навигационном стеке приложения. Этим и воспользуемся. Сильные ссылки на NotificationsSettingsStore будут храниться во viewModels, а контейнер будет удерживать его лишь слабой ссылкой. В следующих статьях рассмотрим альтернативу — работу со скоупами внутри графа зависимостей.

Код

От описания перейдём к коду. Начнем с модели настроек уведомлений. Она Codable, чтобы её можно было легко сохранить в UserDefaults в виде Data и получить обратно.

 struct NotificationsSettingsState: Codable {
	let startDate: Date
	let stopDate: Date
	let frequency: Int

	init(
		startDate: Date = Date(),
		stopDate: Date = Date(),
		frequency: Int = 15
	) {
		self.startDate = startDate
		self.stopDate = stopDate
		self.frequency = frequency
	}
}

Store

Теперь ролевые протоколы для описания возможностей хранилища. Всё в рамках соблюдения принципа Interface Segregation: у хранилища разные клиенты, каждый из которых требует соблюдение только своей части контракта.

protocol StartNotificationsStore: AnyObject {
    var statePublisher: Published<NotificationsSettingsState>.Publisher { get }
	func setStartDate(_ date: Date)
}

protocol StopNotificationsStore: AnyObject {
	var statePublisher: Published<NotificationsSettingsState>.Publisher { get }
	func setStopDate(_ date: Date)
}

protocol NotificationsFrequencyStore: AnyObject {
	var statePublisher: Published<NotificationsSettingsState>.Publisher { get }
	func setFrequency(_ frequency: Int)
}

protocol NotificationStoreSaver: AnyObject {
  	func save()
}

Работать с этим хранилищем просто: можно либо подписаться на state, либо записать в него изменения, либо попросить сохранить их в UserDefaults. Так выглядит реализация:

class NotificationsSettingsStoreImpl {
	@Published private var state: NotificationsSettingsState

	init() {
		guard let settingsString = UserDefaults.standard.string(forKey: .notificationsSettingsKey),
			  let settingsData = settingsString.data(using: .utf8) else {
			state = .init()
			return
		}
		state = (try? JSONDecoder().decode(NotificationsSettingsState.self, from: settingsData)) ?? .init()
	}

	var statePublisher: Published<NotificationsSettingsState>.Publisher {
		$state
	}
}

extension NotificationsSettingsStoreImpl: StartNotificationsStore {
	func setStartDate(_ date: Date) {
		state = NotificationsSettingsState(startDate: date, stopDate: state.stopDate, frequency: state.frequency)
	}
}

extension NotificationsSettingsStoreImpl: StopNotificationsStore {
	func setStopDate(_ date: Date) {
		state = NotificationsSettingsState(startDate: state.startDate, stopDate: date, frequency: state.frequency)
	}
}

extension NotificationsSettingsStoreImpl: NotificationsFrequencyStore {
	func setFrequency(_ frequency: Int) {
		state = NotificationsSettingsState(startDate: state.startDate, stopDate: state.stopDate, frequency: frequency)
	}
}

extension NotificationsSettingsStoreImpl: NotificationStoreSaver {
    func save() {
        guard let settingsData = try? JSONEncoder().encode(state),
              let settingsString = String(data: settingsData, encoding: .utf8) else {
            return
        }
        UserDefaults.standard.set(settingsString, forKey: .notificationsSettingsKey)
    }
}

Все довольно просто. В конструкторе мы пробуем достать данные из UserDefaults или подставляем дефолтные значения. В методе save() пишем обратно в UserDefaults.

ViewModels

Код ViewModels экранов довольно типичный. Например, StartNotificationsViewModel:

class StartNotificationsViewModel: ObservableObject {
	private let router: StartNotificationsRouter
	private let store: StartNotificationsStore

	@Published var date: Date = Date()

	init(
        router: StartNotificationsRouter,
        store: StartNotificationsStore
    ) {
		self.router = router
		self.store = store
        
		_ = store.statePublisher.sink { [weak self] state in
			self?.date = state.startDate
		}
	}

	func stopNotificationsView() -> StopNotificationsView {
		store.setStartDate(date)
		return router.stopNotificationsView()
	}
}

Пара зависимостей: Store и Router. Подписка на изменение данных, сохранение данных перед переходом. Навигация через View и NavigationLink.

Если есть идеи, как сделать лучше в SwiftUI — welcome в комментарии 👇

Assembly

Самое главное в рамках статьи: как экземпляр хранилища попадает во viewModel? Код в assembly простой: мы запрашиваем её у контейнера.

let store = container.makeStartNotificationsStore()

В контейнере происходит вся магия. Здесь реализован простой способ — сохранение weak-ссылки и проверка, есть ли по ней экземпляр:

weak var notificationsSettingsStore: NotificationsSettingsStoreImpl?

private func makeNotificationsSettingsStore() -> NotificationsSettingsStoreImpl {
	guard let store = notificationsSettingsStore else {
		let store = NotificationsSettingsStoreImpl()
		notificationsSettingsStore = store
		return store
	}
	return store
}

func makeStartNotificationsStore() -> StartNotificationsStore {
	makeNotificationsSettingsStore()
}

func makeStopNotificationsStore() -> StopNotificationsStore {
	makeNotificationsSettingsStore()
}

func makeNotificationsFrequencyStore() -> NotificationsFrequencyStore {
	makeNotificationsSettingsStore()
}

Это сработает и решит наш вопрос. Теперь, пока в навигационном стеке будут экраны флоу настройки уведомлений, наш notificationsSettingsStore не будет уничтожен: на него будут сильные ссылки во вью моделях. И, что важно, экземпляр для всех экранов будет один и тот же.

Weak-контейнер

Что, если в контейнере появятся еще зависимости, на которые нужно будет держать слабые ссылки?

На такой случай сделаем универсальный механизм. Будем хранить ссылки в словаре и искать нужные по типу. Для этого напишем простенький контейнер со слабой ссылкой.

struct WeakContainer {
	weak var object: AnyObject?
}

А вот так будем хранить ссылки в контейнере:

var weakDependencies = [String: WeakContainer]()

Далее, чтобы удобно доставать нужные зависимости, а при их отсутствии создавать и записывать в этот словарь, напишем generic-реализацию:

func getWeak<T: AnyObject>(initialize: () -> T) -> T {
    let id = String(describing: T.self)
        
    if let dependency = weakDependencies[id]?.object as? T {
        return dependency
    }
        
    let object = initialize()
    weakDependencies[id] = .init(object: object)
        
    return object
 }

Получение зависимости внутри контейнера выглядит совсем тривиально:

func makeNotificationsSettingsStore() -> NotificationsSettingsStoreImpl {
    getWeak {
        NotificationsSettingsStoreImpl()
    }
}

Теперь, написав минимум кода, можно реализовывать в контейнере хранение зависимостей, которые должны жить дольше, чем время жизни одного экрана, но меньше, чем время жизни всего приложения.

Код проекта можно посмотреть тут, а пока давайте подводить итог.

Итоги

  • Мы обсудили жизненный цикл зависимостей и поняли, что он может быть контекстно-зависимым

  • Выяснили, что можно предоставить слежение за уничтожением зависимостей UI-фреймворку. Для этого подойдет как UIKit, так и SwiftUI

  • Реализовали возможность хранить в контейнере слабые ссылки на зависимости

В следующих статьях продолжим развивать контейнер. Посмотрим, чему его ещё нужно будет научить: ленивой инициализации, работе со скоупами, кодогенерации 🤞

Что ещё вы хотели бы узнать про DI и особенности реализации контейнеров под iOS? Пишите в комментариях!🤘📲


Другие наши статьи по iOS-разработке:

Теги:
Хабы:
Всего голосов 10: ↑10 и ↓0+10
Комментарии0

Публикации

Информация

Сайт
kts.tech
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия