Xnavi AI

Published on

|

10 min

Implementing Feature Flags in Kotlin: A/B Testing and Gradual Rollouts

Ayush Shrivastava
Ayush Shrivastava

In this blog, I share how feature flags revolutionized my approach to Android development, transforming the way I release and manage features. After a disastrous app update early in my career, I realized the need for a better way to control feature rollouts, which led me to adopt feature flags. Over the years, these tools have become indispensable, allowing me to control rollouts, conduct A/B testing, mitigate risks, and customize user experiences. I dive into the specifics of implementing feature flags in Kotlin, from local and remote configurations to strategies for gradual rollouts and managing them in large-scale applications. Through real-world examples and lessons learned, I emphasize the importance of ethical considerations, data-driven decisions, and maintaining a culture of experimentation. My goal is to provide a comprehensive guide that helps other developers leverage feature flags effectively, reducing risks and delivering better user experiences.
Cover Image for Implementing Feature Flags in Kotlin: A/B Testing and Gradual Rollouts

Introduction

Ten years ago, when I first started developing Android apps, releasing a new feature was always a nail-biting experience. I remember the night we pushed a major UI overhaul for our fitness tracking app, confident it would revolutionize user experience. Instead, we woke up to a storm of negative reviews and a concerning spike in uninstalls. That was the moment I realized we needed a better way to release features – enter feature flags.

Feature flags, also known as feature toggles or switches, are conditional statements in your code that determine whether a particular feature is enabled or disabled. They've become an indispensable tool in modern app development, offering a range of benefits that can significantly improve your development process and user experience.

Over the years, I've seen feature flags transform the way we develop and release Android apps. They've allowed us to:

  1. Control Rollouts: Gradually release new features to subsets of users, minimizing the impact of potential issues.

  2. Implement A/B Testing: Easily compare different versions of a feature to determine which performs better.

  3. Mitigate Risks: Quickly disable problematic features without rolling back an entire release.

  4. Customize Experiences: Tailor feature availability based on user segments or preferences.

In this guide, we'll dive deep into implementing feature flags in Kotlin for Android development. I'll share real-world examples, best practices, and lessons learned from a decade in the trenches of app development.

Designing a Feature Flag System in Kotlin

When I first attempted to implement feature flags, I made the mistake of scattering boolean checks throughout the codebase. It quickly became a maintenance nightmare. Learn from my error – a well-designed feature flag system should be:

  1. Flexible: Easy to add, modify, and remove flags as needed.

  2. Scalable: Capable of handling a large number of flags without becoming unwieldy.

  3. Performant: Minimal impact on app performance, especially for frequently accessed flags.

  4. Testable: Easy to manipulate flag states for testing different scenarios.

  5. Maintainable: Clear organization and documentation of flags to prevent technical debt.

Here's a basic structure for a feature flag system in Kotlin that I've refined over the years:

kotlin

interface FeatureFlagProvider {
    fun isEnabled(flag: FeatureFlag): Boolean
}

enum class FeatureFlag {
    NEW_UI,
    IMPROVED_ALGORITHM,
    BETA_FEATURE
}

class FeatureFlagManager(private val provider: FeatureFlagProvider) {
    fun isEnabled(flag: FeatureFlag): Boolean = provider.isEnabled(flag)
}

This structure allows for different implementations of the FeatureFlagProvider, which we'll explore in the following sections.

Implementing Local Feature Flags for Development and Testing

Local feature flags are invaluable during development and testing phases. I learned their importance the hard way when our team of 20 developers were constantly stepping on each other's toes while working on interrelated features.

Here's a simple local feature flag system that saved us countless hours of merge conflicts and integration headaches:

kotlin

class LocalFeatureFlagProvider : FeatureFlagProvider {
    private val enabledFlags = mutableSetOf<FeatureFlag>()

    fun enableFlag(flag: FeatureFlag) {
        enabledFlags.add(flag)
    }

    fun disableFlag(flag: FeatureFlag) {
        enabledFlags.remove(flag)
    }

    override fun isEnabled(flag: FeatureFlag): Boolean = flag in enabledFlags
}
// Usage
val localProvider = LocalFeatureFlagProvider()
val featureFlagManager = FeatureFlagManager(localProvider)
// Enable a flag
localProvider.enableFlag(FeatureFlag.NEW_UI)
// Check if a flag is enabled
if (featureFlagManager.isEnabled(FeatureFlag.NEW_UI)) {
    
// Show new UI
} else {
    
// Show old UI
}

Pro tip: We extended this system to load initial flag states from our build.gradle file, making it even more flexible for different development and testing scenarios:

groovy

buildTypes {
    debug {
        buildConfigField "boolean", "ENABLE_NEW_UI", "true"
        buildConfigField "boolean", "ENABLE_BETA_FEATURE", "true"
    }
    release {
        buildConfigField "boolean", "ENABLE_NEW_UI", "false"
        buildConfigField "boolean", "ENABLE_BETA_FEATURE", "false"
    }
}

Then, we initialized our local provider using these values:

kotlin

class BuildConfigFeatureFlagProvider : FeatureFlagProvider {
    override fun isEnabled(flag: FeatureFlag): Boolean = when (flag) {
        FeatureFlag.NEW_UI -> BuildConfig.ENABLE_NEW_UI
        FeatureFlag.BETA_FEATURE -> BuildConfig.ENABLE_BETA_FEATURE
        else -> false
    }
}

This approach allowed us to easily manage different feature flag states across our build variants, streamlining our development and testing process.

Integrating Remote Feature Flags

Remote feature flags are where the real magic happens. They allow you to control features in production without deploying new code. I'll never forget the time we avoided a potential disaster by remotely disabling a feature that was causing unexpected battery drain on certain devices.

We've used two main approaches over the years: Firebase Remote Config and a custom solution.

Firebase Remote Config:

Firebase Remote Config is a popular choice, and for good reason. Here's how we integrated it:

  1. Add Firebase to your project and set up Remote Config in the Firebase console.

  2. Create a Firebase Remote Config provider:

kotlin

class FirebaseFeatureFlagProvider(private val remoteConfig: FirebaseRemoteConfig) : FeatureFlagProvider {
    override fun isEnabled(flag: FeatureFlag): Boolean {
        return remoteConfig.getBoolean(flag.name)
    }

    suspend fun fetchAndActivate() {
        remoteConfig.fetchAndActivate().await()
    }
}

3. Use the provider:

kotlin

val remoteConfig = FirebaseRemoteConfig.getInstance()
val firebaseProvider = FirebaseFeatureFlagProvider(remoteConfig)
val featureFlagManager = FeatureFlagManager(firebaseProvider)

// Fetch latest config
lifecycleScope.launch {
    firebaseProvider.fetchAndActivate()
}

// Check if a flag is enabled
if (featureFlagManager.isEnabled(FeatureFlag.NEW_UI)) {
    
// Show new UI
} else {
    
// Show old UI
}

Custom Remote Config Solution:

As our app grew, we needed more control over our feature flags. We implemented a custom solution that integrated with our backend:

  1. We created a backend API endpoint that returns feature flag states.

  2. We implemented a custom provider:
    
    kotlin
    
    class CustomRemoteFeatureFlagProvider(private val api: ApiService) : FeatureFlagProvider {
        private var flagStates: Map<String, Boolean> = emptyMap()
    
        suspend fun fetchFlags() {
            flagStates = api.getFeatureFlags()
        }
    
        override fun isEnabled(flag: FeatureFlag): Boolean {
            return flagStates[flag.name] ?: false
        }
    }
  3. We used the custom provider:
    
    kotlin
    
    val apiService = ApiService.create()
    val customProvider = CustomRemoteFeatureFlagProvider(apiService)
    val featureFlagManager = FeatureFlagManager(customProvider)
    
    
    // Fetch latest flags
    
    lifecycleScope.launch {
        customProvider.fetchFlags()
    }
    
    
    // Check if a flag is enabled
    
    if (featureFlagManager.isEnabled(FeatureFlag.NEW_UI)) {
        
    // Show new UI
    
    } else {
        
    /
    / Show old UI }

This custom solution allowed us to tie our feature flags directly into our user management system, enabling sophisticated user segmentation and personalization.

A/B Testing Strategies Using Feature Flags

A/B testing has been a game-changer for our app development process. I remember when we were debating the placement of our "Subscribe" button. Instead of arguing, we used feature flags to test both positions.

Here's how we set up the A/B test:

  1. We defined our test variants:
    
    kotlin
    
    enum class SubscribeButtonPosition { TOP, BOTTOM }
    
    class AbTestProvider(private val remoteConfig: FirebaseRemoteConfig) {
        fun getSubscribeButtonPosition(): SubscribeButtonPosition {
            val positionName = remoteConfig.getString("subscribe_button_position")
            return SubscribeButtonPosition.valueOf(positionName)
        }
    }
  2. We implemented the test in our code:
    
    kotlin
    
    val abTestProvider = AbTestProvider(remoteConfig)
    
    when (abTestProvider.getSubscribeButtonPosition()) {
        SubscribeButtonPosition.TOP -> showSubscribeButtonAtTop()
        SubscribeButtonPosition.BOTTOM -> showSubscribeButtonAtBottom()
    }
  3. We analyzed results using Firebase Analytics, tracking click-through rates and subscription conversions for each variant.

The results were surprising – the bottom placement increased subscriptions by 25%! This data-driven approach has become a cornerstone of our development process.

Gradual Rollout Techniques and Monitoring

Gradual rollouts have saved us from potential disasters more times than I can count. One particular instance stands out: we were introducing a new algorithm for workout recommendations. Despite extensive testing, we were nervous about its performance in the wild.

We implemented a gradual rollout like this:

kotlin

class GradualRolloutProvider(private val remoteConfig: FirebaseRemoteConfig) : FeatureFlagProvider {
    override fun isEnabled(flag: FeatureFlag): Boolean {
        val rolloutPercentage = remoteConfig.getDouble("${flag.name}_rollout")
        val userPercentile = getUserPercentile() 
// Based on user ID
        return userPercentile < rolloutPercentage
    }
}

We started with a 1% rollout and closely monitored our analytics dashboard. We tracked key metrics like user engagement, workout completions, and app rating submissions. As we saw positive results, we gradually increased the rollout percentage.

This cautious approach paid off. We caught a minor issue affecting a small subset of users early in the rollout, quickly patched it, and continued the rollout without any major hiccups.

Managing Feature Flags in Large-Scale Applications

As our app grew from a small project to a product with millions of users, managing feature flags became increasingly complex. Here are some best practices we've adopted:

  1. Centralized Management: We built an internal tool for managing all our feature flags, including metadata like purpose, owner, and expiration date.

  2. Naming Convention: We adopted a strict naming convention: 
    <FEATURE>_<ACTION>_<VARIANT>.
    For example: WORKOUT_RECOMMEND_NEW_ALGO.
  3. Regular Cleanup: We set up quarterly reviews to remove unused flags. This prevented our codebase from becoming cluttered with obsolete toggles.

  4. Documentation: Each flag is thoroughly documented, including its purpose, expected lifetime, and potential impacts.

  5. Access Control: We implemented role-based access control for flag management to prevent accidental changes.

These practices have helped us maintain a clean, efficient feature flag system even as our app has grown to hundreds of thousands of lines of code.

Testing and QA Strategies for Feature-Flagged Code

Testing feature-flagged code thoroughly is crucial. We learned this lesson the hard way when an untested flag combination caused a critical feature to break for a subset of users.

Now, we use parameterized tests to cover all flag combinations:

kotlin

@ParameterizedTest
@EnumSource(FeatureFlag::class)
fun `feature flag states are correctly handled`(flag: FeatureFlag) {
    val mockProvider = MockFeatureFlagProvider()
    val manager = FeatureFlagManager(mockProvider)

    mockProvider.setEnabled(flag, true)
    assertTrue(manager.isEnabled(flag))

    mockProvider.setEnabled(flag, false)
    assertFalse(manager.isEnabled(flag))
}

We also implement UI tests for different flag states and use dependency injection to easily mock feature flag providers in tests.

Analytics and Data Collection for Feature Flag Decisions

Data-driven decision making has been key to our success. We integrate analytics deeply with our feature flag system:

  1. We track feature flag states alongside every user action.

  2. We measure key performance indicators (KPIs) for each feature variant.

  3. We use A/B testing frameworks to analyze results statistically.

This approach has led to some surprising insights. For instance, we once found that a "simplified" UI we were testing actually decreased user engagement. Without comprehensive analytics, we might have shipped a feature that looked good but performed poorly.

Ethical Considerations in A/B Testing and User Data Collection

As we've scaled our A/B testing efforts, we've had to grapple with ethical considerations. We follow these guidelines:

  1. Transparency

    1. Clearly inform users about the A/B testing and data collection practices in your privacy policy. 

    2. Use clear, simple language to explain what data will be collected and how it will be used. 

  2. User Consent

    1. Ensure that users provide informed consent before participating in A/B tests. 

    2. Provide users with the option to opt-out of A/B tests.

  3. Data Privacy: 

    1. Implement robust security measures to protect user data. 

    2. Anonymize data to maintain user privacy.

  4. Ethical Testing Practices:

    1. Avoid testing changes that could negatively impact user experience or security. 

    2. Ensure that A/B tests do not discriminate against any user group.

We once considered testing different pricing models but decided against it due to ethical concerns about treating users differently. It's crucial to balance business goals with user trust and fairness.

Developing a Sample Feature Flag Manager Library in Kotlin

Over the years, we've refined our feature flag implementation into a reusable library. Here's a simplified version:

kotlin

class FeatureFlagManager(private val provider: FeatureFlagProvider) {
    private val cache = ConcurrentHashMap<FeatureFlag, Boolean>()

    fun isEnabled(flag: FeatureFlag): Boolean {
        return cache.getOrPut(flag) { provider.isEnabled(flag) }
    }

    suspend fun refreshFlags() {
        cache.clear()
        if (provider is RemoteFeatureFlagProvider) {
            provider.fetchFlags()
        }
    }
}

interface FeatureFlagProvider {
    fun isEnabled(flag: FeatureFlag): Boolean
}

interface RemoteFeatureFlagProvider : FeatureFlagProvider {
    suspend fun fetchFlags()
}

enum class FeatureFlag {
    NEW_UI,
    IMPROVED_ALGORITHM,
    BETA_FEATURE
}

This library has been battle-tested across multiple projects and has saved us countless hours of redevelopment.

Decision Flow Chart for Implementing New Features with Feature Flags

Over time, we've developed a decision-making process for new features:

  1. Identify new feature

  2. Assess risk and complexity

  3. If high risk/complexity -> Use feature flag If low risk/complexity -> Implement directly

  4. Design flag implementation

  5. Implement feature behind flag

  6. Test all flag states

  7. Plan rollout strategy

  8. Deploy and monitor

  9. Analyze data and make decisions

  10. If successful -> Remove flag If unsuccessful -> Disable flag and revise feature

This process has helped us make more informed decisions about feature development and rollout strategies.

Conclusion

Looking back over the past decade, it's clear that feature flags have revolutionized our Android development process. They've allowed us to move faster, take calculated risks, and deliver better experiences to our users. From avoiding late-night emergencies to enabling data-driven decision making, feature flags have become an indispensable tool in our development toolkit.

Implementing a robust feature flag system in Kotlin has its challenges, but the benefits far outweigh the costs. Whether you're working on a small project or a large-scale application, feature flags can significantly improve your development process, reduce risks, and help you deliver features that truly resonate with your users.

Remember, the key to successful feature flag implementation is not just in the code, but in the processes and culture around it. Embrace experimentation, trust in data, and always keep your users' best interests at heart.

I'd love to hear about your experiences with feature flags in Kotlin Android development. Have you implemented a feature flag system in your projects? What challenges did you face, and how did you overcome them? Perhaps you have a cautionary tale or a success story to share? Let's continue this discussion in the comments below – your insights could be invaluable to our community of developers. Together, we can push the boundaries of what's possible in Android development!