Reputation: 402
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)
}
}
Upvotes: 1
Views: 790
Reputation: 299595
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.
Upvotes: 4