Override
Table of Contents
- Background
- Installation
- Usage
- Feature Management UI
- Test Support
- Running The Examples
- Contribute
- Maintainers
- License
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:
- Automatic Handling: Invoke the
presentRestartAlert(from:completion:)
method prior to dismissing theFeaturesViewController
, and only actually dismiss the view controller in the completion handler. - 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
- Adam Kaplan, Twitter: @adkap
- David Grandinetti, Twitter: @dbgrandi
License
This project is licensed under the terms of the MIT open source license. Please refer to LICENSE for the full terms.