launchdarkly / ios-client-sdk

LaunchDarkly Client-side SDK for iOS (Swift and Obj-C)
https://docs.launchdarkly.com/sdk/client-side/ios
Other
68 stars 84 forks source link

suggestion: Dynamic property wrapper for SwiftUI #281

Closed lukeredpath closed 1 year ago

lukeredpath commented 1 year ago

In our project we've have had some success integration the LaunchDarkly SDK into our app using a custom property wrapper in our SwiftUI views that makes the whole thing seamless. I can't share my exact implementation, and its quite specific to a custom wrapper around the SDK anyway, but I thought I would share the API and how it works as a suggestion for something that could be incorporated into the main SDK.

Describe the solution you'd like SwiftUI lets you create property wrappers that conform to DynamicProperty - these let you define special properties that can change over time and will trigger a SwiftUI view to be updated when they do. SwiftUI has many built-in examples of these, like @State and @Environment.

Using this, it's possible to cook up a similar property wrapper that represents LaunchDarkly flag values. In order for this to work, we need to be able to access the LaunchDarkly client from the property wrapper and a good way to do that is by using the SwiftUI environment:

private struct LaunchDarklyClientEnvironmentKey: EnvironmentKey {
    /// Returns the global client by default - users must ensure they call `.start()` in their app
    /// launch process before this environment value is used.
    static var defaultValue: LDClient? { LDClient.get() }
}

public extension EnvironmentValues {
    var launchDarklyClient: LDClient? {
        get { self[LaunchDarklyClientEnvironmentKey.self] }
        set { self[LaunchDarklyClientEnvironmentKey.self] = newValue }
    }
}

By itself this makes it possible to dependency-inject a specific client throughout your SwiftUI hierarchy - in most cases the default value will be fine but you can override it like any other SwiftUI environment.

With this in place, you can now implement the property wrapper. This wrapper will manage an internal observer with a published property that tracks the current LaunchDarkly value and whenever it changes, it will trigger a view update. We'll use the update() function to set up the initial flag value and observer.

@propertyWrapper
public struct FlagVariation: DynamicProperty {
    let key: String
    let defaultValue: LDValue

    @StateObject
    private var observer = Observer()

    @Environment(\.launchDarklyClient)
    var launchDarklyClient: LDClient?

    public var wrappedValue: LDValue { observer.currentValue }

    public init(_ key: String, defaultValue: LDValue = .null) {
        self.key = key
        self.defaultValue = defaultValue
    }

    public func update() {
        // If we've already set up the observer we don't need to do anything.
        guard !observer.isObserving else { return }

        // Check that we have a client.
        guard let client = launchDarklyClient else {
            print("Warning: LaunchDarkly client is not configured in the SwiftUI environment")
            return
        }

        // The first time update is called gives us an opportunity to initialize
        // the current flag value and set up an observer for the lifetime of this
        // property wrapper that keeps the state in sync. We need to dispatch this
        // onto the main queue to avoid updating any internal state as part of the
        // current view update.
        DispatchQueue.main.async {
            observer.synchronize(
                client: client,
                key: key,
                defaultValue: defaultValue
            )
        }
    }

    private class Observer: ObservableObject {
        @Published
        var currentValue: LDValue = .null

        private var observerOwner: LDObserverOwner?

        var isObserving: Bool {
            observerOwner != nil
        }

        func synchronize(client: LDClient, key: String, defaultValue: LDValue) {
            // Initialise the state with the current value.
            self.currentValue = client.jsonVariation(forKey: key, defaultValue: defaultValue)

            // Now keep track of whenever it changes.
            let owner = UUID() as LDObserverOwner
            client.observe(key: key, owner: owner) { [weak self] in
                self?.currentValue = $0
            }
            self.observerOwner = owner
        }
    }
}

Now you can use it in a SwiftUI view:

struct ExampleView: View {
    @FlagVariation("demo-bool-key") var demoVariation

    /// A convenience property to unwrap the bool value.
    var isDemoEnabled: Bool { 
        guard case let .bool(value) = demoVariation else { return false } 
        return value
    }

    var body: some View {
        Text(isDemoEnabled ? "Demo Enabled" : "Demo Disabled")
    }
}

When first run this will display the current value and will automatically update when the flag variation is changed on the backend.

It would be interesting to see if a better API could be come up with that allows for a generic variation value so you don't need to manually unpack the LDValue as I have above.

keelerm84 commented 1 year ago

@lukeredpath thank you for investing the time to write up this detailed suggestion. I wanted to acknowledge that I've seen this but that I likely won't have a chance for a thorough review until mid-next week. But I do have it on my radar and will respond as soon as I'm able. Thanks again!

lukeredpath commented 1 year ago

A correction to the implementation above, having discovered some bugs in my implementation. The DispatchQueue.main.async hop is necessary to prevent a state change within a view update cycle (the property wrapper's update() is called within a view update cycle, but before the enclosing view's body).

As it turns out, there's a better way around this when initialising the internal state from the LDClient - making the internal state property not a @Published attribute allows us to set it initially without triggering a view update notification (its not needed at this point as we're setting it just before the view's body is initially called anyway) but still allows us to explicitly notify that we're about to publish a new value by calling objectWillChange.send() directly whenever the client observer receives a new value. Therefore a better implementation would be:

@propertyWrapper
public struct FlagVariation: DynamicProperty {
    let key: String
    let defaultValue: LDValue

    @StateObject
    private var observer = Observer()

    @Environment(\.launchDarklyClient)
    var launchDarklyClient: LDClient?

    public var wrappedValue: LDValue { observer.currentValue }

    public init(_ key: String, defaultValue: LDValue = .null) {
        self.key = key
        self.defaultValue = defaultValue
    }

    public func update() {
        // If we've already set up the observer we don't need to do anything.
        guard !observer.isObserving else { return }

        // Check that we have a client.
        guard let client = launchDarklyClient else {
            print("Warning: LaunchDarkly client is not configured in the SwiftUI environment")
            return
        }

        // The first time update is called gives us an opportunity to initialize
        // the current flag value and set up an observer for the lifetime of this
        // property wrapper that keeps the state in sync.
        observer.synchronize(
            client: client,
            key: key,
            defaultValue: defaultValue
        )
    }

    private class Observer: ObservableObject {
        // We don't use @Published here because we don't want to publish the
        // initial value as that will always be set during the first view update cycle
        // for the enclosing view anyway.
        var currentValue: LDValue = .null

        private var observerOwner: LDObserverOwner?

        var isObserving: Bool {
            observerOwner != nil
        }

        func synchronize(client: LDClient, key: String, defaultValue: LDValue) {
            // Initialise the state with the current value.
            self.currentValue = client.jsonVariation(forKey: key, defaultValue: defaultValue)

            // Now keep track of whenever it changes.
            let owner = UUID() as LDObserverOwner
            client.observe(key: key, owner: owner) { [weak self] in
                self?.objectWillChange.send()
                self?.currentValue = $0
            }
            self.observerOwner = owner
        }
    }
}
keelerm84 commented 1 year ago

My apologies for delaying longer than I intended.

Reviewing this code, it seems like an awesome idea and one I think we should pursue supporting. There are other internal company priorities that I have to put ahead of this, but I have filed a ticket for this internally (sc-171785) so we can get it on the roadmap.

I am going to close this issue here for now. Once we have implemented support for this, someone from LD will comment on this issue.

Thank you again for the great idea and contribution!