Bluetooth и SwiftUI. Разработка приложения для управления полосами RGB
В прошлом году купил письменный стол BEKANT. Это выглядит минималистично и скучно, поэтому я хотел добавить немного освещения RGB. Я выбрал дешевую безымянную полоску RGB с контроллером, который также работает с ИК и Bluetooth. Продавец рекомендует использовать приложение HappyLighting . Он работает достаточно хорошо, поддерживает стили по умолчанию, такие как эффекты пульсации и стробоскопа, синхронизируется с музыкой и объемным звуком. Но интерфейс немного странный:
Я разработчик iOS, изучающий SwiftUI и Combine, поэтому решил написать собственное приложение 🧑🏻💻. Это доказательство концепции, но в будущем я могу добавить больше функций, таких как приложение-компаньон для watchOS, поддержка Siri, виджеты и т. Д. В конце статьи вы можете найти ссылку на репозиторий Github с исходным кодом. Вот последняя демонстрация .
Bluetooth: Делегаты -> Объединить
С помощью CoreBluetooth
фреймворка вы можете проверять состояние Bluetooth, сканировать периферийные устройства, обнаруживать необходимые службы и характеристики. По умолчанию он работает через шаблон делегата и ничего не знает о нем Combine
. Если вы не знакомы с этим CoreBluetooth
, я рекомендую проверить это руководство команды Рэя Венденлиха. Базовый алгоритм чтения и записи данных:
- Найдите устройство Bluetooth, также известное как периферийное устройство.
- Откройте для себя его услуги. Каждая услуга представляет собой набор данных, относящихся к периферийным функциям. Например, услуги по измерению пульса или молнии.
- Узнайте характеристики конкретных услуг. Каждая характеристика предоставляет информацию о периферийном состоянии. Также вы можете записывать данные для характеристик для обновления периферийных состояний.
Я написал простой BluetoothManager
объект , который работает с CBCentralManager
и CBPeripheral
и транслирует любые обновления. Я решил использовать для этого Combine. С помощью магии издателей я могу подписаться на необходимые обновления и более декларативно фильтровать / отображать их.
Вот пример работы с состояниями и периферией:
import Combine
import CoreBluetooth
final class BluetoothManager: NSObject {
private var centralManager: CBCentralManager!
var stateSubject: PassthroughSubject<CBManagerState, Never> = .init()
var peripheralSubject: PassthroughSubject<CBPeripheral, Never> = .init()
func start() {
centralManager = .init(delegate: self, queue: .main)
}
func connect(_ peripheral: CBPeripheral) {
centralManager.stopScan()
peripheral.delegate = self
centralManager.connect(peripheral)
}
}
extension BluetoothManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
stateSubject.send(central.state)
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
peripheralSubject.send(peripheral)
}
}
В представлениях приложения я подписываюсь на обязательные темы и показываю связанные состояния. Вот пример обнаружения сервисов:
manager.servicesSubject
.map { $0.filter { Constants.serviceUUIDs.contains($0.uuid) } }
.sink { [weak self] services in
services.forEach { service in
self?.peripheral.discoverCharacteristics(nil, for: service)
}
}
.store(in: &cancellables)
Чтобы работать с конкретными устройствами, вы должны знать идентификаторы сервисов и характеристики для чтения и записи данных. К сожалению, у моего контроллера нет документации о его протоколе, но я нашел отличное описание на Github . Автор реконструировал протокол и описал практически все форматы данных. Они выглядят странно и имеют множество магических констант, но кто их не использует в проектах 😅. Я добавил в приложение необходимые идентификаторы на основе документации, по которой они использовались для фильтрации:
enum Constants {
static let readServiceUUID: CBUUID = .init(string: "FFD0")
static let writeServiceUUID: CBUUID = .init(string: "FFD5")
static let serviceUUIDs: [CBUUID] = [readServiceUUID, writeServiceUUID]
static let readCharacteristicUUID: CBUUID = .init(string: "FFD4")
static let writeCharacteristicUUID: CBUUID = .init(string: "FFD9")
}
SwiftUI: состояния и просмотр моделей
Для каждого представления в приложении я добавил модель представления, чтобы разделить макет и бизнес-логику. Просматривайте ObservableObject
протоколы поддержки моделей , работайте с ними BluetoothManager
, управляйте тематическими подписками и @Published
просматривайте обновления. Обратной стороной такого подхода является неудобный переход BluetoothManager
к каждой модели. Изначально я хотел использовать @EnvironmentObject
и передать его всем дочерним представлениям, но он хорошо работает только View
сам по себе. Наконец, я просто лениво добавил его ко всем моделям представления:
import SwiftUI
import CoreBluetooth
import Combine
final class DevicesViewModel: ObservableObject {
@Published var state: CBManagerState = .unknown
@Published var peripherals: [CBPeripheral] = []
private lazy var manager: BluetoothManager = .shared
private lazy var cancellables: Set<AnyCancellable> = .init()
deinit {
cancellables.cancel()
}
func start() {
manager.stateSubject
.sink { [weak self] state in
self?.state = state
}
.store(in: &cancellables)
manager.peripheralSubject
.filter { [weak self] in self?.peripherals.contains($0) == false }
.sink { [weak self] in self?.peripherals.append($0) }
.store(in: &cancellables)
manager.start()
}
}
Представления создают свою собственную модель и обрабатывают ее с помощью @StateObject
:
struct DevicesView: View {
@StateObject private var viewModel: DevicesViewModel = .init()
}
Напишите мне если вы хорошо разбираетесь в теме внедрения зависимостей SwiftUI, я очень ценю отзывы об этой логике.
Отлаживая приложение, я обнаружил, что оно onAppear
дважды вызывается для некоторых представлений. Люди на форумах подтверждают это и заливают радары. В приложении я просто добавил глупую проверку с флагом состояния:
struct DeviceView: View {
@State private var didAppear = false
var body: some View {
content()
.onAppear {
guard didAppear == false else {
return
}
didAppear = true
viewModel.connect()
}
}
}
Не хотелось изобретать цветовой круг заново, и я использовать ColorPicker
для выбора цвета:
ColorPicker("Change stripe color",
selection: $viewModel.state.color,
supportsOpacity: false)
Я использовал его в проектах UIKit несколько раз, вы можете просто показать его при любом действии пользователя. Но в SwiftUI он показывает интерфейс по умолчанию без каких-либо настроек.
Есть два варианта привязки выделения – Color
и CGColor
. Я выбрал второй, потому что из него легко получить цветовые компоненты.
Заключение
Чем больше я использую SwiftUI, тем больше мне нравятся его концепции. В сочетании с Combine это делает логику приложения более выразительной. И я по-прежнему уверен, что разработка проектов для домашних животных – отличный способ совместить развлечения и обучение.
Финальный проект доступен на Github . Не стесняйтесь проверять это и делиться своими отзывами. Спасибо за прочтение!