Different ways to manage feature toggles on iOS

We use different strategies based on a use case and depending on who should have control over the setting.
iphone

There are times during app development when you want to temporarily disable some functionality or enable it only under some conditions. For example, you work on a new feature that is not done yet, and it should not be accessible by everyone. Or you have a particular setting that enables extra functionality that is useful only during testing.


At Babbel, we have many different options that we enable only under some conditions. We use different strategies based on a use case and depending on who should have control over the setting. This blog post will cover how we implement these strategies for our iOS apps.

Using conditional compilation

Conditional compilation

In our project, we have a few of these conditions. The most widespread is DEBUG that we use in case we run our app in the Debug configuration. We use it to enable extra logging, and we switch to our staging environment.

#if DEBUG
    // do something only if the condition active
#else
    // otherwise do something else
#endif

The other place where we use this approach is to build different flavours of our app. At Babbel, we have an app for each language that we teach. The conditional compilation allows us to have different behaviour based on the app we are building. There are also possibilities to exclude or include files based on the configuration. If you want to dig deeper on this topic, I would recommend Dave DeLong’s blog series on conditional compilation.

Unfortunately, the conditional compilation makes testing slightly harder, because some code will not be present. You can work around this problem by creating a parameter on your class with a default value. The default value is determined based on the conditional compilation, but in your tests, you will be able to provide any value you want. Unfortunately, it is a bit more inconvenient than the following approaches. The most significant benefit with the conditional compilation is the ignored part of the code will not be part of the app. It reduces the size and hides things you don’t want others to discover.

Using arguments and environment variables in Xcode schemes

<img src="https://cms.babbel.news/wp-content/uploads/2020/12/Arguments-Environment-Vars-Xcode.png" alt="<!– wp:image –> <figure class="wp-block-image"><img src="https://bytes.babbel.com/images/Arguments-Environment-Vars-Xcode.png" alt="Arguments and environment variables in Xcode" title="Arguments and environment variables in Xcode"/></figure>

These have a huge benefit because if you apply them to a scheme that is not shared, you will not need to version them. Every developer can have different options. Another great thing about arguments is that they overwrite values in NSUserDefaults. Let’s say you have a flag, in your user defaults, that controls the visibility of a feature. Instead of modifying your code or looking for plist in your app sandbox you provide an argument -YourKeyInUserDefaults newValue, and your app will start with this value.

You can access these under ProcessInfo. There are two properties available processInfo.environment and processInfo.arguments.

These are also very helpful for UI tests because they are suppliable to XCUIApplication, so you can fine-tune your app during the tests.

Using Info.plist file

Every bundle (thus app) usually contains Info.plist that can also be a great place to store configuration variables. The benefit of this approach over preceding ones is the variables are persisted. At Babbel, we have a build phase with a script that reads environment variables of the operating system, and if the named variables are present, we write their values to the Info.plist file. This approach is helpful when using our CI because we can trigger a new build without changing anything in our project with our desired configuration. The benefit over conditional compilation is that we can quickly inspect the Info.plist file and see all provided options, and it is easier to manage than a single variable (SWIFT_ACTIVE_COMPILATION_CONDITIONS).

Here is an example of our script in the build phase. It is necessary to convert the file back to binary because PlistBuddy converts it to XML.

PLIST_FILE="$BUILT_PRODUCTS_DIR/$INFOPLIST_PATH"

add_entry() {
    if [ $3 ]; then
        /usr/libexec/PlistBuddy -c "Add :$1 $2 $3" "$PLIST_FILE"
    fi
} 

add_entry "YourDesiredKey" "bool" $YOUR_DESIRED_KEY

plutil -convert binary1 "$PLIST_FILE"

You can access these values easily through Bundle.main.infoDictionary.

Remote Config

Last but not least is Firebase Remote Config. The remote config is an excellent solution we use for our all product-related feature toggles. Thanks to that, we can change parameters on the fly, even for apps that are live in production, and customise them based on different conditions. For example, we can enable features only in specific regions, for particular versions or platforms. We can also smoothly run AB tests without much hassle. It is very convenient. The downside is that we fetch values on the app start, and if the app fails to do so, it will miss the desired behaviour. The integration is straightforward and very well-documented.

Combining the solutions

With so many options, it might be hard to decide which one to use when. The best option is to combine them into a single class that can be injected into your classes and enables easy testability. The benefit of using all three approaches is flexibility. A developer can change it easily in its scheme or trigger a new build on CI with the desired option. Or our product manager can switch it in the remote config. Here is an example to give you an idea.

class Config {
    private let processInfo: ProcessInfo
    private let bundle: Bundle
    private let remoteConfig: RemoteConfig

    init(processInfo: ProcessInfo = .processInfo, bundle: Bundle = .main, remoteConfig: RemoteConfig = .remoteConfig()) {
        self.processInfo = processInfo
        self.bundle = bundle
        self.remoteConfig = remoteConfig
    }

    var isMySettingEnabled: Bool {
        return
            processInfo.arguments.contains("-MySettingEnabled") ||
            bundle.infoDictionary?["MySettingEnabled"] as? Bool == true ||
            remoteConfig["MySettingEnabled"].boolValue
    }
}

What do you think? What is your preferred approach? Do you use any other? Let us know by leaving a comment below 👇!

Photo by Przemyslaw Marczynski on Unsplash

Want to join our Engineering team?
Apply today!
Share: