Привет, Хабра! Сегодня я приглашаю вас окунуться в мир современных архитектур мобильных приложений и поговорить об одном из самых эффективных и необычных подходов к их построению. Статья ориентирована на разработчиков, уже знакомых с основами SwiftUI. Я сосредоточусь на более продвинутых аспектах, поэтому некоторые базовые моменты могут быть упомянуты без подробных объяснений.
Шина появилась в нашем проекте по причине того, что хотелось добавить очень небольшую логику без риска поломки уже существовавшей. Рассмотрим, что такое шина данных и как с ее помощью работать с legacy. Будет интересно!
Что такое шина данных?
Шина данных (Data Bus) — это коммуникационная система, которая обеспечивает обмен данными между различными компонентами в приложении. Она действует как центральный узел для потока данных, позволяя разъединять компоненты между собой. Это значит, что каждый компонент может передавать и получать данные через шину, не имея прямой информации о других компонентах, что позволяет сохранить независимость компонентов друг от друга.
Как работает шина данных?
Внимательно рассмотрим и разберем изображение ниже:
Шина данных функционирует по следующим основным принципам:
Централизованное управление событиями: шина данных управляет всеми событиями в системе, обеспечивая упорядоченность и контроль над потоком информации. Это делает систему более предсказуемой и управляемой.
Модель издатель-подписчик: в этой модели компоненты могут отправлять (публиковать) и получать (подписываться на) события. Издатели отправляют события в шину данных, а подписчики получают уведомления о новых событиях, на которые они подписаны. Это уменьшает прямую зависимость между компонентами.
Передача данных по ключу и значению: Каждый отправленный или полученный пакет данных сопровождается ключом, определяющим тип или категорию данных. Это позволяет подписчикам получать только те данные, которые им нужны, фильтруя их по ключам, что делает систему более организованной и эффективной.
Пример использования шины данных
Рассмотрим пример на языке Swift, где шина данных используется для управления событиями в приложении. Самых голодных разработчиков приглашаем к себе на GitHub.
ShopApp - это имитация приложения для покупки продуктов. Приложение написано на SwiftUI и в данном примере я подробно разобрал как можно применить шину данных.
Примечание: важно понимать для чего шина данных применяется в проекте, это будет описано подробно в разделе “Применение шины данных в реальном проекте”.
Демонстрацию можно глянуть тут. Ну а для тех, кому лень смотреть демонстрацию, на изображениях ниже представлен пример приложения с открытой вкладкой каталога товаров:
Пример приложения с открытой вкладкой корзины:
Теперь, когда пример был разобран, можно перейти к повествованию. Перейдем к рассмотрению кода.
Отправка значения по ключу
Этот код отправляет сигнал о запуске приложения через шину данных при появлении интерфейса. Давайте посмотрим на код и разберем его подробнее:
var body: some Scene {
WindowGroup {
TabView {
// Ваша основная логика здесь
}
.onAppear() {
Bus.send(K.didLaunch, true)
}
}
}
Внутри onAppear есть вызов метода Bus.send. Этот метод отправляет сообщение или сигнал в шину данных. В данном случае используется ключ K.didLaunch и значение true.
Описание параметров:
ключ K.didLaunch: этот ключ определяет тип события, которое отправляется в шину данных. В данном случае, он сигнализирует о том, что приложение было запущено.
значение true: значение, связанное с этим событием. В этом контексте оно указывает на успешное выполнение действия (например, запуск приложения).
При каждом появлении TabView на экране, шина данных получает сигнал о запуске приложения. Это позволяет другим компонентам системы, подписанным на ключ K.didLaunch, узнать о событии и выполнить соответствующие действия. Представленный подход централизует управление событиями в приложении, уменьшая зависимость между компонентами и упрощая управление потоком данных.
Получение значения по ключу
Здесь приложение получает сигнал о запуске через шину данных, используя ключ K.didLaunch. Давайте посмотрим на код разберем его подробнее:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: …
) -> Bool {
Bus.receiveAsync(&subscriptions, [K.didLaunch]) { (_, v: Bool) in
// Обработка события запуска
}
// Другая логика запуска приложения
}
Внутри метода application(_:didFinishLaunchingWithOptions:) используется вызов Bus.receiveAsync, который позволяет подписываться на события, происходящие в шине данных. Этот метод асинхронно получает данные и обрабатывает их в момент, когда они становятся доступны.
Описание:
Параметр &subscriptions представляет собой коллекцию подписок, которая хранит ссылки на активные подписки. Это важно для управления жизненным циклом подписок, чтобы они не продолжали существовать, когда больше не нужны.
Массив [K.didLaunch] содержит ключи событий, на которые приложение подписывается. В данном случае это ключ K.didLaunch, который сигнализирует о том, что приложение было запущено.
Замыкание (Closure) (_, v: Bool) in: выполняется, когда событие, соответствующее ключу K.didLaunch, происходит в шине данных. В данном случае, v — это значение типа Bool, которое указывает на успешность запуска приложения. Внутри замыкания вы можете определить логику, которая будет выполняться при получении сигнала о запуске приложения. Например, выполнить какие-либо действия, необходимые при старте.
Получение данных в UI
В этом примере шина данных используется для получения и отображения количества товаров в корзине в UI:
struct CartView: View {
@StateObject private var products = BusUI.Value(K.cartList, [String]())
var body: some View {
VStack {
// Логика отображения корзины
}
.badge(products.v.count) // Отображение количества товаров в корзине
}
}
Когда данные о количестве товаров в корзине меняются (например, пользователь добавляет или удаляет товар), шина данных обновляет значение, связанное с ключом K.cartList. Поскольку объект products подписан на этот ключ, он автоматически получает обновленные данные и обновляет свое состояние. SwiftUI, в свою очередь, обнаруживает изменение состояния и перерисовывает UI, отображая актуальное количество товаров на бейдже.
Этот код иллюстрирует, как можно интегрировать шину данных с UI на SwiftUI. Используя BusUI.Value и подписку на ключ K.cartList, вы можете легко отображать динамические данные в интерфейсе, обеспечивая обновление UI при каждом изменении данных в шине. Это упрощает управление состоянием и снижает связанность между компонентами, так как данные могут поступать из центральной шины, а не напрямую между компонентами.
Организация ключей
Важной частью нашей реализации архитектуры шины данных является стандартизация ключей, используемых для взаимодействия с этой шиной. В нашем проекте это достигается с помощью специального enum, который централизует управление ключами и обеспечивает упорядоченность доступа к данным.
Наглядно это можно увидеть в примере ниже. Для хранения ключей используется следующий enum:
enum K {
static let addToCart = "ShopApp.addToCart"
static let removeFromCart = "ShopApp.removeFromCart"
static let cartList = "ShopApp.cartList"
static let initialProducts = "ShopApp.initialProducts"
static let products = "ShopApp.products"
static let didLaunch = "ShopApp.didLaunch"
static let clearCart = "ShopApp.clearCart"
}
По своей сути enum K выступает в роли единого источника истины для всех ключей, используемых в шине данных. Это значит, что все компоненты приложения, взаимодействующие с шиной, обращаются к набору ключей реализуемых внутри своего модуля. Такая централизация не только упрощает управление, но и снижает вероятность ошибок, связанных с неправильным или несогласованным использованием ключей в разных частях проекта.
В контексте модульной архитектуры, где разные части приложения могут быть разработаны и поддерживаться отдельными командами, стандартизация ключей через enum становится особенно важной. Каждый модуль может независимо работать с шиной данных, не беспокоясь о том, какие ключи используют другие модули. Это позволяет легко интегрировать новые модули или изменять существующие, не нарушая работу всей системы.
Добавление новых ключей в enum K легко и безопасно, что позволяет проекту расти и адаптироваться к новым требованиям. При этом общая структура и стандарты остаются неизменными, что способствует поддерживаемости и масштабируемости системы в долгосрочной перспективе.
А теперь кратко пробежимся по плюсам и минусам шины данных.
Преимущества использования шины данных
Разделенные компоненты: компоненты приложения меньше зависят друг от друга, что упрощает их изменение и тестирование.
Легкое управление событиями: централизованное управление событиями упрощает добавление и удаление обработчиков.
Улучшенная читаемость кода: код становится более чистым и понятным.
Гибкость в обработке данных: легко добавлять новые события и обработчики без изменения существующего кода.
Снижение зависимости между компонентами: компоненты взаимодействуют через общий интерфейс, что позволяет легко заменять или обновлять отдельные компоненты.
Повышение масштабируемости и поддерживаемости: добавление новых функций или компонентов становится проще и менее рискованным.
Недостатки использования шины данных
Сложность отладки: отладка событий может быть сложной, поскольку события обрабатываются в различных частях приложения, и их последовательность может быть трудно отследить.
Усложнение архитектуры: добавление шины данных увеличивает общую сложность архитектуры приложения, требуя дополнительного планирования и управления.
Повышенная зависимость от шины: при неправильном проектировании компоненты могут стать слишком зависимыми от шины данных, что затрудняет их тестирование и повторное использование.
Неявные зависимости: поскольку взаимодействие между компонентами происходит через шину, зависимости становятся менее очевидными, что усложняет понимание и поддержку системы.
Применение шины данных в реальном проекте
Небольшая предыстория. Некоторое время назад наша команда iOS перешла на использование отдельных UIWindow для отдельных модулей с целью уменьшения влияния визуальной иерархии одних модулей на другие. Например, в рамках этого подхода у нас Alert можно показать откуда угодно, т.к. у него свой UIWindow.
Внимательно посмотрим на следующее изображение:
В чем заключалась проблема? В приложении есть два разных модуля - Авторизация и Вход по ссылке. Каждый модуль имеет свой UIWindow. При определённом сценарии выходило так, что Авторизация перекрывала Вход по ссылке, хотя должно было быть наоборот. Наиболее прямолинейным решением являлось увеличение знания модулей друг о друге. Но этого делать категорически не хотелось, чтобы случайно не поймать новые баги.
На этом месте самым быстрым и наименее рисковым решением представилось использование Шины таким образом, чтобы модули Авторизации и Входа по ссылке сообщали об отображении своих UIWindow, а третий модуль - Приоритет окон - исправлял видимость окон в зависимости от их приоритета. Этот подход сработал. С тех пор у нас в команде живёт Шина.
Data Bus vs Notification Center vs Delegates
Почему бы не использовать что-то другое? Например центр уведомлений или делегаты? Давайте внимательно рассмотрим, когда использовать тот или иной подход:
1. Data Bus:
Когда использовать: шина данных наиболее эффективна в крупных системах, где важна масштабируемость и гибкость. Она позволяет разделить компоненты приложения и снизить их зависимость друг от друга, предоставляя централизованное управление событиями. Это особенно полезно в системах с модульной архитектурой или при работе с legacy-кодом.
Преимущества: гибкость, централизованное управление событиями, снижение зависимости между компонентами, улучшение масштабируемости и поддерживаемости.
Недостатки (обсуждали ранее): более сложная отладка, усложнение архитектуры, повышенная зависимость от шины и неявные зависимости.
2. Notification Center:
Когда использовать: центр уведомлений подходит для простых проектов, где не требуется строгая типизация и компоненты не сильно зависят друг от друга. Это быстрый и удобный способ отправлять и получать уведомления без необходимости создания жестких связей между объектами.
Преимущества: легкость использования, отсутствие необходимости в строгой типизации, подходит для простых сценариев.
Недостатки: может привести к хаосу при масштабировании, отсутствие строгой типизации может привести к ошибкам, сложнее управлять зависимостями в больших проектах.
3. Delegates:
Когда использовать: делегаты лучше всего подходят для небольших систем или отдельных компонентов, где важна строгая типизация и прямая связь между объектами.
Преимущества: строгая типизация, прямая и предсказуемая связь между объектами, удобство в использовании для небольших компонентов.
Недостатки: повышает связность компонентов, что усложняет изменение и тестирование, не подходит для сложных и масштабируемых систем.
Заключение
Шина данных является мощным инструментом, особенно в контексте работоспособности нового и старого кода.
Как было видно на примерах, использование шины данных позволяет упорядочить и стандартизировать доступ к данным, улучшить поддерживаемость и масштабируемость системы, а также облегчить интеграцию новых модулей в проект. Важно понимать, что этот подход требует более сложной архитектуры и может увеличивать нагрузку на производительность, но в случае правильного применения он существенно повышает устойчивость системы к изменениям и её способность адаптироваться к новым требованиям.
В завершение, помните, что архитектурные решения должны соответствовать масштабам и требованиям проекта. Используйте шину данных там, где она принесет наибольшую пользу, и не забывайте о простоте и целесообразности в более простых сценариях.
WEStor
Когда придумал NSNotificationCenter и в целом антипаттерн в разработке. Ребята, не надо так, пожалуйста