Bluetooth и SwiftUI. Разработка приложения для управления полосами RGB

В прошлом году купил письменный стол BEKANT.  Это выглядит минималистично и скучно, поэтому я хотел добавить немного освещения RGB. Я выбрал дешевую безымянную полоску RGB с контроллером, который также работает с ИК и Bluetooth. Продавец рекомендует использовать приложение HappyLighting . Он работает достаточно хорошо, поддерживает стили по умолчанию, такие как эффекты пульсации и стробоскопа, синхронизируется с музыкой и объемным звуком. Но интерфейс немного странный:

39beff39-a972-4dff-ba33-ab1d4cf66bdf.jpeg

Я разработчик iOS, изучающий SwiftUI и Combine, поэтому решил написать собственное приложение 🧑🏻‍💻. Это доказательство концепции, но в будущем я могу добавить больше функций, таких как приложение-компаньон для watchOS, поддержка Siri, виджеты и т. Д. В конце статьи вы можете найти ссылку на репозиторий Github с исходным кодом. Вот последняя демонстрация .

Bluetooth: Делегаты -> Объединить

С помощью CoreBluetooth фреймворка вы можете проверять состояние Bluetooth, сканировать периферийные устройства, обнаруживать необходимые службы и характеристики. По умолчанию он работает через шаблон делегата и ничего не знает о нем Combine. Если вы не знакомы с этим CoreBluetooth, я рекомендую проверить это руководство команды Рэя Венденлиха. Базовый алгоритм чтения и записи данных:

  1. Найдите устройство Bluetooth, также известное как периферийное устройство.
  2. Откройте для себя его услуги. Каждая услуга представляет собой набор данных, относящихся к периферийным функциям. Например, услуги по измерению пульса или молнии.
  3. Узнайте характеристики конкретных услуг. Каждая характеристика предоставляет информацию о периферийном состоянии. Также вы можете записывать данные для характеристик для обновления периферийных состояний.

Я написал простой 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 он показывает интерфейс по умолчанию без каких-либо настроек.

1cdde876-de9e-48d8-9b6d-7fce7ceeabd2.png

Есть два варианта привязки выделения – Color и CGColor. Я выбрал второй, потому что из него легко получить цветовые компоненты.

Заключение

Чем больше я использую SwiftUI, тем больше мне нравятся его концепции. В сочетании с Combine это делает логику приложения более выразительной. И я по-прежнему уверен, что разработка проектов для домашних животных – отличный способ совместить развлечения и обучение.

Финальный проект доступен на Github . Не стесняйтесь проверять это и делиться своими отзывами. Спасибо за прочтение!

Ответить