简体   繁体   中英

How to define a generic property that conforms to a protocol

Sorry for the somewhat vague question title, but I'm not exactly sure what a more appropriate title would be.

Let me start by explaining my setup and what I'm trying to achieve.

I have defined an interface called DeviceInterface . I have two objects that conform to that interface: a mocked object called MockedDevice and the actual implementation object called DeviceImplementation .

The plan is to use MockedDevice for SwiftUI previews and for running the app in the simulator (where certain device actions/values are not available) and DeviceImplementation for on device execution.

The issues arises in DeviceApp where I instantiate the main app view with an object that conforms to DeviceInterface . I define a generic property of type DeviceInterface that I try to set based on whether the code is executing on the simulator or on the device.

When I try to pass that property to the main view of the app ( ContentView that initializes with a generic type that conform to the interface DeviceInterface ) I get the following error:

Value of protocol type 'DeviceInterface' cannot conform to 'DeviceInterface'; only struct/enum/class types can conform to protocols

Initializing the property directly as let device = DeviceImplementation(device: UIDevice.current) or let device = MockedDevice(device: UIDevice.current) (by omitting the type) and then passing this value works totally fine, so it seems that my problem is in the type definition of the property.

I know I could just rearrange the code a bit and instantiate ContentView inside the #if TARGET_IPHONE_SIMULATOR cases using the above working instantiation methods where I omit the type definition, but I want to understand what I'm doing wrong and how can I make the below code work.

See the following example for a demonstration of what I'm trying to achieve. Please keep in mind it's a quick and simple demonstration of the problem I'm tying to solve.

// MARK: - Interfaces

protocol DeviceInterface {
    var name: String { get }
}

protocol ObservableDevice: DeviceInterface, ObservableObject {}

// MARK: - Implementations

class MockedDevice: ObservableDevice {
    @Published
    var name: String  = ""
    
    init(name: String) {
        self.name = name
    }
}

class DeviceImplementation: ObservableDevice {
    @Published
    private(set) var name: String  = ""
    
    let device: UIDevice
    
    init(device: UIDevice) {
        self.device = device
        name = device.name
    }
}

// MARK: - App

@main
struct DeviceApp: App {
    var body: some Scene {
        WindowGroup {
            let device: ObservableDevice = {
                #if TARGET_IPHONE_SIMULATOR
                return MockedDevice(name: "Mocked device")
                #else
                return DeviceImplementation(device: UIDevice.current)
                #endif
            }()
            
            ContentView(device: device) // Generates error: Value of protocol type 'DeviceInterface' cannot conform to 'DeviceInterface'; only struct/enum/class types can conform to protocols
        }
    }
}

// MARK: - Main view

struct ContentView<T>: View where T: ObservableDevice {
    
    @ObservedObject
    private(set) var device: T
    
    var body: some View {
        Text("Hello World")
    }
    
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let device = MockedDevice(name: "Mocked device")
        ContentView(device: device)
    }
}

First, how I'd really do this: subclassing. You already have a class, and the "abstract" version is precisely your "mock" version. So I'd just keep going and make a subclass:

// "Abstract" version (just set the values and they never change)
class Device: ObservableObject {
    @Published
    fileprivate(set) var name: String

    @Published
    fileprivate(set) var batteryLevel: Float

    init(name: String = "", batteryLevel: Float = -1) {
        self.name = name
        self.batteryLevel = batteryLevel
    }
}

// Self-updating version based on UIKit. Similar version could be made for WatchKit
class UIKitDevice: Device {
    private var token: NSObjectProtocol?

    init(device: UIDevice) {
        super.init(name: device.name, batteryLevel: device.batteryLevel)

        device.isBatteryMonitoringEnabled = true
        token = NotificationCenter.default.addObserver(forName: UIDevice.batteryLevelDidChangeNotification, 
                                                       object: device,
                                                       queue: nil) { [weak self] _ in
            self?.batteryLevel = device.batteryLevel
        }
    }

    deinit {
        NotificationCenter.default.removeObserver(token!)
    }
}

Then the device definition is:

        let device: Device = {
            #if TARGET_IPHONE_SIMULATOR
            return Device(name: "Mocked device")
            #else
            return UIKitDevice(device: .current)
            #endif
        }()

Easy. I like it.


But I'm not a huge fan of subclassing in Swift. In this case it works fine, but in general inheritance is not a great thing IMO. So how would you do this with composition rather than inheritance?

First, abstract a thing that can update the battery information:

import Combine // For Cancellable

protocol BatteryUpdater {
    func addBatteryUpdater(update: @escaping (Float) -> Void) -> Cancellable
}

And accept a BatteryUpdater to Device (marking final just to prove I can; I don't advocate sprinkling final all over the place):

final class Device: ObservableObject {
    @Published
    private(set) var name: String

    @Published
    private(set) var batteryLevel: Float

    private var batteryObserver: Cancellable?

    init(name: String = "", batteryLevel: Float = -1, batteryUpdater: BatteryUpdater? = nil) {
        self.name = name
        self.batteryLevel = batteryLevel

        batteryObserver = batteryUpdater?.addBatteryUpdater(update: { [weak self] level in
            self?.batteryLevel = level
        })
    }

    deinit {
        batteryObserver?.cancel()
    }
}

So now Device just holds data normally, but it can ask for its battery level to be updated by something else. And it can cancel that request. I could also have used KeyPaths here, or other fancier Combine tools, but this shows the idea. Abstract away the thing that changes, which is "how does the battery value get changed." Don't abstract away the thing that doesn't change, which is "I have a battery level and notify observers when it changes."

With this in place, behold the power of retroactive conformance. UIDevice can be a BatteryUpdater:

extension UIDevice: BatteryUpdater {
    func addBatteryUpdater(update: @escaping (Float) -> Void) -> Cancellable {
        let nc = NotificationCenter.default
        let token = nc.addObserver(forName: UIDevice.batteryLevelDidChangeNotification,
                                   object: self, queue: nil) { _ in
            // This retains self as long as the observer exists. That's intentional
            update(self.batteryLevel)
        }

        return AnyCancellable {
            nc.removeObserver(token)
        }
    }
}

And a convenience initializer makes it easy to create from a UIDevice:

extension Device {
    convenience init(device: UIDevice) {
        self.init(name: device.name, batteryLevel: device.batteryLevel, batteryUpdater: device)
    }
}

And now creating the Device looks like this:

        let device: Device = {
            #if TARGET_IPHONE_SIMULATOR
            return Device(name: "Mocked device")
            #else
            return Device(device: .current)
            #endif
        }()

It's just always a Device. No mocks. No protocol existentials. No generics.


What if there are more things to update than just BatteryLevel? It could get annoying to keep passing more and more closures. So you could instead turn BatteryUpdater into a full DeviceUpdater by passing the whole Device:

protocol DeviceUpdater {
    func addUpdater(for device: Device) -> Cancellable
}

Device is basically the same, just adding proximityState to have something else to update:

final class Device: ObservableObject {
    @Published
    private(set) var name: String

    @Published
    fileprivate(set) var batteryLevel: Float

    @Published
    fileprivate(set) var proximityState: Bool

    private var updateObserver: Cancellable?

    init(name: String = "", batteryLevel: Float = -1, proximityState: Bool = false,
         updater: DeviceUpdater? = nil) {
        self.name = name
        self.batteryLevel = batteryLevel
        self.proximityState = proximityState

        updateObserver = updater?.addUpdater(for: self)
    }

    deinit {
        updateObserver?.cancel()
    }
}

And UIDevice conforms about the same way, just kind of "inside out" by directly updating device .

extension UIDevice: DeviceUpdater {
    func addUpdater(for device: Device) -> Cancellable {

        let nc = NotificationCenter.default
        let battery = nc.addObserver(forName: UIDevice.batteryLevelDidChangeNotification,
                                   object: self, queue: nil) { [weak device] _ in
            device?.batteryLevel = self.batteryLevel
        }

        let prox = nc.addObserver(forName: UIDevice.proximityStateDidChangeNotification,
                                   object: self, queue: nil) { [weak device] _ in
            device?.proximityState = self.proximityState
        }

        return AnyCancellable {
            nc.removeObserver(battery)
            nc.removeObserver(prox)
        }
    }
}

That does force the properties to be non-private. You could fix that by passing WritableKeyPaths, if you wanted. Lots of approaches can work. But all follow the pattern of abstracting the updating rather than mocking the final data storage.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM