YMOverride 2.5.0

YMOverride 2.5.0

Maintained by Adam Kaplan, David Grandinetti, David Grandinetti.



  • By
  • Adam Kaplan and David Grandinetti

Override

CI Status

Table of Contents

Background

Override is a super easy to use feature flag management system for iOS, tvOS, watchOS and macOS. Override helps minimize the boilerplate involved with adding and maintaining large sets of feature flags. It provides the ability to group features logically into nestable groups of functionality, and support for searching for specific flags. Typically app developers employ feature flags to manage access to feature which are still in development, experimental, or behind an A/B test. Having a streamlined feature flag management process helps promote innovation by removing roadblocks to new experiments.

Feature flags typically have 3 states: on, off, or defaulted. The default state of a feature may be a preset mode or defined by a remote configuration or A/B testing system. Override supports these use cases, and more!

Installation

CocoaPods

CocoaPods is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate Override into your Xcode project using CocoaPods, specify it in your Podfile:

pod 'YMOverride', '~> 2.2'

SwiftPM

Add .package(url: "https://github.com/yahoo/Override.git", from: "2.5.0") to your package.swift

Usage

To use Override, you need a subclass of FeatureRegistry. Override will examine your registry class for instance properties that are a kind of the AnyFeature.

Basic Features

Let's create a basic feature called "blueText" which turns all text blue when enabled. To do this, add a property called blueText to your feature registry:

import YMOverride

@objc class Features : FeatureRegistry {

    @objc let blueText = Feature()
}

There is a heck of a lot provided by this simple class, but we'll get into that later. Let's see how to use this feature to detect if the blueText feature is enabled. Here's some code you can imagine in your app:

class ViewController : UIViewController {

    let myFeatures = Features()

    let label = UILabel()

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.view.addSubview(label)
        label.frame = view.frame
        label.text = "Hello World!"

        // Update text color based on feature flag
        label.textColor = myFeatures.blueText.enabled ? .blue : .green
    }
}

Grouping Related Features

For systems with a large number of feature flags, Override provides a means for logical grouping throug the FeatureGroup class. No additional runtime functionality is provided by FeatureGroup; it is primarily a means for organizing feature flags. Grouping features is very easy:

import YMOverride

@objc class ListFeatures : FeatureGroup {
    @objc let infinteScroll = Feature()
}

@objc class Features : FeatureRegistry {

    @objc let list = ListFeatures()

    @objc let blueText = Feature()
}

Now, reference the infiniteScroll feature flags in your code as myFeatures.list.infiniteScroll.enabled. The FeatureGroup class supports arbitrary depth nesting, so you can build multiple layers of FeatureGroup within FeatureGroup.

Controlling Feature Flags

Continuing with our example, the next thing you probably want to know is how to control the .enabled property of your features. Well, you can't! Not directly, at least. This is because .enabled is boolean, meaning it has just two states (true and false). Feature flags, on the other hand, have four distinct states: On, Off, Overridden-On and Overridden-Off.

For the most part, your app code will only care about two the basic states on and off (which is why .enabled is boolean). It just makes your if-statements simpler. However when you manually enable or disable a feature flag, that needs to be tracked so that the original state can be restored later on. This will become very important later on when we discuss Dyanmic Features.

Lets see how to manually turn on blueText at runtime.

    @objc func toggleBlueTextButtonTapped() {
        // Turn blueText ON
        myFeatures.blueText.override = .enabled
    }

After the code above is executed, the feature blueText is enabled. That is to say, the text will be blue the next time out example viewWillAppear executes label.textColor = myFeatures.blueText ? .blue : .green. Cool!

Similarly to turn blueText OFF regardless of it's default state, we could change the value to .disabled like this

    @objc func toggleBlueTextButtonTapped() {
        // Turn blueText ON
        myFeatures.blueText.override = .disabled
    }

And to if we wanted to remove any customization and return the feature flag to it's default state:

    @objc func toggleBlueTextButtonTapped() {
        // Turn blueText OFF
        myFeatures.blueText.override = .featureDefault
    }

Default Feature State

Ok, so far we know how to test the value of a feature flag, and how to override a feature flag's state. The next bit we need to understand is how to define what the initial state of a feature flag should be. By default, feature flags are turned off, and you can turn them on when needed.

Let's create a feature that is normally off, and turned on only for debugging:

@objc class Features : FeatureRegistry {

    @objc let blueText = Feature()

    @obj let debugLogging = Feature(defaultState: false)
}

Features Requiring Restart

It would be fantastic if all of our feature flags took effect immediately. In reality, many features are so fundamental that they cannot be enabled or disabled without a restart of the app. While Override will never restart your app on your behalf, it does provide a method to model this requirement. Let's add a feature that requires a restart...

@objc class Features : FeatureRegistry {

    @objc let blueText = Feature()

    @obj let debugLogging = Feature(defaultState: false)

    @objc let useTabBarNav = Feature(requiresRestart: true) 
}

Now when you change useTabBarNav, you can check the boolean property useTabBarNav.requiresRestart to determine if a restart is need, and handle this appropriately in your app code.

[Advanced] Derived and Dynamic Features

Sometimes, there is a need for a feature which defaults to on or off depending on aspects of the runtime environment. Let's say you want to turn a feature on by default, but only on Tuesdays! We can accomplish this by using the DynamicFeature type.

@objc class Features : FeatureRegistry {

    @objc let blueText = Feature()

    @obj let debugLogging = Feature(defaultState: false)

    @objc let useTabBarNav = Feature(requiresRestart: true)

    @objc let tuesdayExperiment = DynamicFeature() { _ in
        let components = Calendar.current.dateComponents(Set([.weekday]), from: Date())
        return components.weekday == 3
    }
}

The block provided to DynamicFeature is evaluated every time the default state is needed, so the value can change if you'd like, or you can cache it for performance!

[Advanced] Remote Controlled Features

Up until now we have discussed local features which only exist in the context of a single app. Many apps use remotely controlled feature flag services and experimentation platforms. Luckily, Override can easily support this using DynamicFeatures.

As an exercise, let's see what it would look like to create an Override wrapper for Flurry's FConfig remote config functionality (Souce Code):

@objc class FConfigFeature : DynamicFeature {

    init(key: String? = nil, requiresRestart: Bool = false, configDefault: Bool = false) {

        // Delegate to FConfig, using the provided default parameter as FConfig default.
        super.init(key: key, requiresRestart: requiresRestart) { (feature: AnyFeature) -> Bool in
            return FConfig.sharedInstance.getBool(forKey: feature.key, withDefault: configDefault)
        }
    }
}

Now using FConfigFeature in our feature registry is just more of the same:

@objc class Features : FeatureRegistry {

    @objc let blueText = Feature()

    @obj let debugLogging = Feature(defaultState: false)

    @objc let useTabBarNav = Feature(requiresRestart: true) 

    @objc let tuesdayExperiment = DynamicFeature() { _ in
        let components = Calendar.current.dateComponents(Set([.weekday]), from: Date())
        return components.weekday == 3
    }

    // FConfig backed feature
    @objc let redButtons = FConfigFeature()
}

Feature Management UI

Override ships with a simple table view controller – FeaturesViewController – that provides a generic user interface for managing feature flags. The view controller shows a list of available features from a given FeatureRegistry. It also allows you to find features using text search. Each feature state is depicted visually, and swipe gestures are installed that allow for convienant feature control.

Feature state is conveyed visually:

  • Overridden enabled features are in green
  • Overridden disabled features are shown in red
  • Underlined features – which are either green or red – are overriding the defaults

Feature state is controlled by gesture:

  • Slide left to reveal the "on" and "off" force overrides
  • Slide right to restore the default state of the feature

Support "Restart Required" Features

Sometimes enabling or disabling a feature is unsafe or impractical to do after the app has finished loading. To support these cases, the FeaturesViewController is aware of the restartRequired parameter in all of its displayed features.

Prior to dismissing FeaturesViewController, it is the responsability of the calling app to handle any restart requirements. There are two ways to do this:

  1. Automatic Handling: Invoke the presentRestartAlert(from:completion:) method prior to dismissing the FeaturesViewController, and only actually dismiss the view controller in the completion handler.
  2. Manual Handling: Check the value of the featuresRequiringRestart property, and manually trigger a restart if the list is not-empty.

Test Support

Rich unit test support is an absolute must for any feature control system. Developers generally want to test features in both the on and off states. Override enables this with the FeatureTestSupport class.

Test Setup

Tests support is provided in a separate CocoaPod framework. To include Override test support, add the pod OverrideTestSupport to your test target as shown below:

OverridePodVersion = '1.0.0'

target 'MyApp' do
    pod 'Override', OverridePodVersion

    target 'MyAppTests' do
        pod 'OverrideTestSupport', OverridePodVersion
    end
end

Using Unit Test Helpers

Override simplifies the task of testing your feature matrix. Instead of mocking or manually overriding features, Override provides a utility which selectively enables or disables features for a specific test.

describe("sans serif font experiment") {
    it("respects the enabled flag") {
        withFeature(features.useSansSerifFont).enabled {
            // test or snapshot test for enabled state
        }
    }

    it("respects the enabled flag") {
        withFeature(features.useSansSerifFont).disabled {
            // test or snapshot test for disabled state
        }
    }
}

Additionally, Override makes it easy to enable or disable many features at once using withFeatures() like this:

it("works with all experiments disabled") {
    withFeatures([features.useSansSerifFont, features.betaOnboarding]).enabled {
        // test with all listed features enabled
    }
}

The testing support works with Objective-C as well, using a similar syntax as shown below:

// (Just adds @ on the array literal, and `^()` after .enabled)
it(@"works with all experiments disabled"), {
    withFeature(features.useSansSerifFont).enabled(^{
        // ...
    });
});

Running The Examples

To run the example projects, clone the repo, and run pod install from the Example-Swift of Example-ObjC directory first.

Contribute

Please refer to the contributing.md file for information about how to get involved. We welcome issues, questions, and pull requests. Pull Requests are welcome.

Maintainers

License

This project is licensed under the terms of the MIT open source license. Please refer to LICENSE for the full terms.