How to prevent singleton base class getting re-initialised

TL;DR my singleton BLEManager managing Bluetooth communication keeps getting re-initialised (see console log). How should I prevent this?

Using Swift 5.9 for iOS in Xcode 15.1

My code finds multiple BT devices, and lists them for selection, also building an array of devices for reference.

Most code examples connect each device immediately. I am trying to connect later, when a specific device is selected and its View opens.

I pass the array index of the device to the individual Model to serve as a reference, hoping to pass that back to BLEManager to connect and do further communication.

After scanning has completed, the log message shows there is 1 device in it, so its not empty. As soon as I try and pass a reference back to BLEManager, the app crashes saying the array reference is out of bounds. The log shows that BLEManager is being re-initialised, presumably clearing and emptying the array.

How should I be declaring the relationship to achieve this?

Console log showing single device found:

ContentView init
  BLEManager init
didDiscover id: 39D43C90-F585-792A-5BD6-8749BA0B5385
In didDiscover devices count is 1
stopScanning
After stopScanning devices count is 1
                          <--  selection made here
DeviceModel init to device id: 0       
  BLEManager init
BLEManager connectToDevice id: 0
devices is empty
Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range
2023-12-28 11:45:55.149419+0000 BlueTest1[20773:1824795] Swift/ContiguousArrayBuffer.swift:600: Fatal error: Index out of range

BlueTest1App.swift

import SwiftUI

@main
struct BlueTest1App: App {
    var body: some Scene {
        WindowGroup {
            ContentView(bleManager: BLEManager())
        }
    }
}

ContentView.swift

import SwiftUI

struct TextLine: View {
    @State var dev: Device

    var body: some View {
        HStack {
            Text(dev.name).padding()
            Spacer()
            Text(String(dev.rssi)).padding()
        }
    }
}

struct PeripheralLineView: View {
    @State var devi: Device

    var body: some View {
        NavigationLink(destination: DeviceView(device: DeviceModel(listIndex: devi.id))) {
            TextLine(dev: devi)
        }
    }
}

struct ContentView: View {
    @StateObject var bleManager = BLEManager.shared

    init(bleManager: @autoclosure @escaping () -> BLEManager) {
        _bleManager = StateObject(wrappedValue: bleManager())
        print("ContentView init")
    }

    var body: some View {
        VStack (spacing: 10) {
            if !bleManager.isSwitchedOn {
                Text("Bluetooth is OFF").foregroundColor(.red)
                Text("Please enable").foregroundColor(.red)
            }
            else {
                HStack {
                    Spacer()
                    if !bleManager.isScanning {Button(action: self.bleManager.startScanning){ Text("Scan  ")
                        }
                    } else { Text("Scanning")
                    }
                }
                NavigationView {
                    List(bleManager.devices) { peripheral in
                        PeripheralLineView(devi: peripheral)
                    }.frame(height: 300)
                }
            }
        }
    }
}

//@available(iOS 15.0, *)
struct DeviceView: View {
    var device: DeviceModel
    var body: some View {
        ZStack {
            VStack {
                Text("Data Window")
                Text("Parameters")
            }
        }.onAppear(perform: {
            device.setUpModel()
        })
    }
}

BLEManager.swift

import Foundation
import CoreBluetooth

struct Device: Identifiable {
    let id: Int
    let name: String
    let rssi: Int
    let peri: CBPeripheral
}

class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    static let shared: BLEManager = {
        let instance = BLEManager()
        return instance
    }()

    var BleManager = BLEManager.self
    var centralBE: CBCentralManager!
    @Published var isSwitchedOn = false
    @Published var isScanning = false
    var devices = [Device]()
    var deviceIds = [UUID]()
    private var activePeripheral: CBPeripheral!

    override init() {
        super.init()
        print("  BLEManager init")
        centralBE = CBCentralManager(delegate: self, queue: nil)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if central.state == .poweredOn {
            isSwitchedOn = true
        }
        else { isSwitchedOn = false }
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
 
        if let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String {
 
            if !deviceIds.contains(peripheral.identifier) {
                print("didDiscover id: \(peripheral.identifier)")
                deviceIds.append(peripheral.identifier)
                let newPeripheral = Device(id: devices.count, name: name, rssi: RSSI.intValue, peri: peripheral)
                devices.append(newPeripheral)
                print("didDiscover devices count now \(devices.count)")
            }
        }
    }

    /// save as activePeripheral and connect
    func connectToDevice(to index: Int) {
        print("BLEManager connectToDevice id: \(index)")
        if devices.isEmpty {print ("devices is empty")}
        activePeripheral = devices[index].peri
        activePeripheral.delegate = self
        centralBE.connect(activePeripheral, options: nil)
    }

    func startScanning() {
        centralBE.scanForPeripherals(withServices: nil, options: nil)
        isScanning = true
        // Stop scan after 5.0 seconds
        let _: Timer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(stopScanning), userInfo: nil, repeats: false)
    }
    @objc func stopScanning() {   // need @objc for above Timer selector
        print("stopScanning")
        centralBE.stopScan()
        isScanning = false
        print("After stopScanning devices count is \(devices.count)")
    }

    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { }

    func disconnect(peripheral: Int) { }

    func discoverServices(peripheral: CBPeripheral) { }

    func discoverCharacteristics(peripheral: CBPeripheral) { }

    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { }

    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { }
}

DeviceModel.swift

import Foundation
import CoreBluetooth

class DeviceModel: BLEManager {
    var index: Int   //CBPeripheral position in found array

    init(listIndex: Int) {
        index = listIndex
        print("DeviceModel init to device id: \(index)")
   }

    func setUpModel() {
        connectToDevice(to: index)
    }

}

Accepted Reply

I can reproduce your problem. Thank you for posting all your code, which made this possible.

BLEManager is supposed to a singleton, right? So you want exactly one of these in your program. To ensure this, you should make the initializer private and access the BLEManager only through shared. But in the ContentView, you create a BLEManager without going through shared, so now you have two - one created in the App's body, and one created by your ContentView.

If I make the BLEManager private, the compiler complains about the initializer for DeviceModel - "'super.init' isn't called on all paths before returning from initializer". Which is fair enough, but I'm not allowed to call super.init() - I made it private to ensure the all accesses to the singleton BLEManager go through its shared accessor.

I'm puzzled as to why a DeviceModel is a BLEManager. Just the names tell me that they are different objects; a Device may need access to a BLEManager, but it shouldn't be a BLEManager. Also, you expect an array of DeviceModels - how does that work if they are all the same object (BLEManager.shared?). It can work if they all use the same object.

Replies

I can reproduce your problem. Thank you for posting all your code, which made this possible.

BLEManager is supposed to a singleton, right? So you want exactly one of these in your program. To ensure this, you should make the initializer private and access the BLEManager only through shared. But in the ContentView, you create a BLEManager without going through shared, so now you have two - one created in the App's body, and one created by your ContentView.

If I make the BLEManager private, the compiler complains about the initializer for DeviceModel - "'super.init' isn't called on all paths before returning from initializer". Which is fair enough, but I'm not allowed to call super.init() - I made it private to ensure the all accesses to the singleton BLEManager go through its shared accessor.

I'm puzzled as to why a DeviceModel is a BLEManager. Just the names tell me that they are different objects; a Device may need access to a BLEManager, but it shouldn't be a BLEManager. Also, you expect an array of DeviceModels - how does that work if they are all the same object (BLEManager.shared?). It can work if they all use the same object.

I got your code to work by:

changing DeviceModel so it doesn't inherit from anything

change setUpModel() to use the singleton

func setUpModel() {
        BLEManager.shared.connectToDevice(to: index)
    }

made BLEManager.init private

deleted the init from ContentView

in the App body, call ContentView() with no parameters

import SwiftUI

@main
struct BlueTest1App: App {
    @State private var manager = BLEManager()
    var body: some Scene {
        WindowGroup {
            ContentView(bleManager: manager)
        }
    }
}

OR

import SwiftUI

@main
struct BlueTest1App: App {
    @State private var manager = BLEManager()
    var body: some Scene {
        WindowGroup {
            ContentView()
               .envitonmentObject(manager)
        }
    }
}

struct ContentView: View {
    @EnvironmentObject private var bleManager: BLEManager
    ...
}

Thanks for working through this @ssmith_c. Your last paragraph in your first reply about 'is a' and 'need access to' is spot on. As you implicitly say, the answer to the question heading is that the singleton class (BLEManager) should never subsequently be instantiated with a new instance BLEManager(), but should always be referred to as BLEManager.shared. This means that it can never be subclassed, so the access within another class can be achieved by pointing to it with let bleManager = BLEManager.shared within my device model (In my full code there are lots of other places where I refer to it).