Привет! На связи 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 вам интересен и Android, эта информация для вас ????
20 марта мы стартуем обновлённый курс мобильной разработки на Android.
Занятия проводят наши сотрудники, разработавшие приложения для таких компаний, как ПИК и GeekBrains. Поэтому на курсах вы получите актуальные знания, которые требуются на сегодняшнем рынке мобильной разработки.
Лучше всего курс подойдёт тем, кто уже немного знаком с разработкой под Android. За 10 модулей вы получите знания, необходимые для работы в компании, создадите проект в портфолио и изучите best practices Android-разработки:
— создание Android-приложения с использованием современных подходов
— использование Jetpack и других популярных библиотек для разработки приложения
— работа с Compose и KMM
— многопоточность и асинхронность с применением Kotlin Coroutines и Flow— и многое другое
Узнать подробнее и записаться на курс можно здесь:
???? На странице курса — здесь вы можете посмотреть программу и почитать отзывы
???? Через бота — здесь вы можете получить ссылки на видео и статьи, полезные при обучении
Ждём вас на курсе и желаем успехов!
Старт — 20 марта.