Building Plugable Features on Android
As Gradle projects and their teams grow, modularization becomes an important tool to help ensure developers can continue to be productive without stepping on each other’s toes. This can lead to some interesting architectural challenges, however, when creating features that require aggregating dependencies.
One example of a feature like this could be a developer settings screen, which allows configuration of different features within an app, and whose features might change between apps.
A developer settings screen can allow testers to alter the state of the app to test different features, like altering the A/B test bucket, or enabling features hidden behind feature flags. There is a great blog post series by Zarah Dominguez which goes into detail about some things you can test and why you might want to.
At first, features like the developer settings screen might seem like a candidate for the app module, because allowing customisation of features used in an app involves knowledge of which features are included in an app.
As the project and team grows, however, it can grow increasingly more difficult to manage a monolithic screen that displays settings for many features within an app. If more apps get added to a project, it can mean having to duplicate that configuration code across multiple apps.
If we have a way to aggregate features in an app, however, we can make a reusable developer settings feature which automatically includes all available features and we can put the code to customize a feature with the code that it customizes.
Implementing developer settings⌗
The basis of our developer settings screen uses a PreferenceFragmentCompat
from the androidx.preferences
library. This Fragment makes it easy to display preferences to the user, and automatically syncs preferences that are backed by SharedPreferences, while also allowing you to handle preference changes on your own.
class DeveloperSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val screen = preferenceManager.createPreferenceScreen(context)
// Create a "General" Section
val general = PreferenceCategory(context).apply {
title = "General"
}.also { screen.addPreference(it) }
// Add preferences to the General section
EditTextPreference(context).apply {
title = "Layout Endpoint"
key = "debug_layout_filename"
summaryProvider = Preference.SummaryProvider<EditTextPreference> {
val storedValue = prefs.getString(key, "")
if (storedValue.isNullOrBlank()) "(inherited)" else storedValue
}
}.also { general.addPreference(it) }
EditTextPreference(context).apply {
title = "Install Campaign"
key = "install_campaign"
summaryProvider = Preference.SummaryProvider<EditTextPreference> {
prefs.getString(key, "(none)")
}
}.also { general.addPreference(it) }
SwitchPreferenceCompat(context).apply {
title = "Enable Leak Detection"
key = "detect_leaks"
}.also { general.addPreference(it) }
// ...
preferenceScreen = screen
}
}
This approach can quickly become unweildy, as the number of features in an app grows. Also, if any of these features end up being used in other apps, then you need to reproduce the developer settings in those other apps. Lastly, this approach means that our configuration code doesn’t live with the features it is configuring, which can easily lead to errors in the future.
A better approach might be to externalize the creation of the debug configuration components, allowing features to provide their own debug configuration, and dynamically build the developer settings screen from the available features.
Externalizing developer settings⌗
In order to externalize developer settings, allowing them to be defined alongside the code they configure, I’ve created a PreferenceFactory
interface, which my features can provide an implementation of.
interface PreferenceFactory {
val title: String
fun addPreference(preferenceScreen: PreferenceScreen)
}
This simple interface allows for a title
so that I can consistently sort my preferences, and a function allowing the feature to add whatever Preferences
it wants to the PreferenceScreen
.
With that in place, I can update my DeveloperSettingsFragment
to simply aggregate the PreferenceFactory
implementations for features used in my app and dynamically build a screen from them.
class DeveloperSettingsFragment : PreferenceFragmentCompat() {
private val factories = setOf(
DebugBillingPreferenceFactory(),
DebugNetworkPreferenceFactory(),
DebugOnboardingPreferenceFactory(),
)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceScreen = preferenceManager.createPreferenceScreen(context).apply {
factories.sortedBy { it.title }.forEach { it.addPreference(this) }
}
}
}
This is definitely cleaner, and has helped make my code more modular and easy to maintain. It still requires that the DeveloperSettingsFragment
live in my app module, since it is dependent on the specific feature set of my app. This means that the DeveloperSettingsFragment
itself isn’t reusable, and introduces a class that needs to be updated when the feature set of the app changes.
These issues could be solved by dynamically collecting the set of PreferenceFactory
s available in an app, allowing for the DeveloperSettingsFragment
to be reusable, and reducing the coordination required throughout large teams. Fortunately, here are a couple of options in Android (and JVM) projects to do just that.
Dagger Multibindings⌗
If you already use Dagger in your Android project then aggregating all of the implementations of an interface available on the classpath becomes trivial. (Don’t use Dagger? No problem, skip ahead to the next section.)
Basic usage of Dagger allows you to insert dependencies into a graph which can be used to construct or inject consumers throughout your app. With Multibindings, Dagger allows you to provide dependencies just like you otherwise would, but into a collection along with other implementations of some shared supertype. Since Dagger generation happens at build time, this means that the resulting collection will contain whatever dependencies are avaialable on the classpath.
In our case, this can be used to inject all PreferenceFactory
implementations that are available on the classpath at build time into our DeveloperSettingsFragment
. Using something like Anvil from the folks at Block (or Hilt from Google) makes this even easier, since we don’t need to manually wire up any modules in our app modules.
// Using Anvil
@ContributesMultibinding(scope = AppScope::class)
class DebugBillingPreferenceFactory @Inject constructor(
...
) : PreferenceFactory {}
// Using Hilt
@Module
@InstallIn(ActivityComponent::class)
abstract class DebugBillingModule {
@Binds
@IntoSet
abstract fun DebugBillingPreferenceFactory.bind(): PreferenceFactory
}
class DebugBillingPreferenceFactory @Inject constructor(
...
) : PreferenceFactory {}
Either of these approaches means that simply adding the billing module to an application classpath will make this DebugBillingPreferenceFactory
available in a Set of PreferenceFactory
implementations. We can now update our DeveloperSettingsFragment
to inject that set, and we have dynamic developer settings available in any app that includes our billing module.
class DeveloperSettingsFragment : PreferenceFragmentCompat() {
@Inject lateinit var factories: Set<@JvmSuppressWildcards PreferenceFactory>
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceScreen = preferenceManager.createPreferenceScreen(context).apply {
factories.sortedBy { it.title }.forEach { it.addPreference(this) }
}
}
}
ServiceLoader⌗
If you don’t use Dagger in your project, you can still dynamically load implementations of an interface using Java’s ServiceLoader.
A Service
is some interface or abstract class, and a ServiceProvider
is an implementation of a Service
. ServiceLoader
provides a way to discover and load all ServiceProviders
available on the classpath.
In order to declare a service provider you simply need to add a file in the META-INF/services
directory of your module’s resources named with the fully qualified interface name, which contains the fully qualified names of the implementing classes, each on their own line.
# in billing/src/debug/resources/META-INF/services/com.ryanharter.debug.PreferenceFactory
com.ryanharter.app.billing.DebugBillingPreferenceFactory
The AutoService library provides an annotation processor that allows you to simply annotate your implementation with the class of the
Service
that it implements and have these service files generated for you. If you have a lot of services this can be helpful, but I find that avoiding a single line file isn’t usually worth the overhead.
When the library is built, the resulting AAR (or JAR) file will contain this file in it’s resources, making it available to consumers of the library.
Once these files are in place, we simply need to update our DeveloperSettingsFragment
to use a ServiceLoader
to find all PreferenceFactory
implementations that are available on the classpath.
class DeveloperSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceScreen = preferenceManager.createPreferenceScreen(context).apply {
val loader = ServiceLoader.load(PreferenceFactory::class.java)
loader.sortedBy { it.title }.forEach { it.addPreference(screen) }
}
}
}
Note that ServiceLoader
instantiates the ServiceProvider
s by calling it’s no-arg constructor. This means that you’re implementations need to have one, but can be mitigated by making the PreferenceFactory
an abstract class with a little more to it.
abstract class PreferenceFactory {
abstract val title: String
abstract fun addPreference(preferenceScreen: PreferenceScreen)
fun onCreate(context: Context) {
}
This simple change can allow your implementations to configure themselves after creation, much like Android’s own Activity
and Fragment
classes.
class DebugNetworkPreferenceFactory : PreferenceFactory() {
override lateinit var title: String
private lateinit var networkManager: MyNetworkManager
override fun onCreate(context: Context) {
title = context.getString(R.string.debug_settings_network_group_title)
networkManager = context.getSystemService(MyNetworkManager::class.java)
}
override fun addPreference(preferenceScreen: PreferenceScreen) {
...
}
}
With that in place, ServiceLoader
is able to construct the DebugNetworkPreferenceFactory
using it’s no-arg constructor, but the implementation can still configure itself before use, and all we have to do is call onCreate
on each implementation before use in our DeveloperSettingsFragment
.
class DeveloperSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceScreen = preferenceManager.createPreferenceScreen(context).apply {
val loader = ServiceLoader.load(PreferenceFactory::class.java)
loader.map { it.onCreate(requireContext()) }
.sortedBy { it.title }
.forEach { it.addPreference(screen) }
}
}
}
ServiceLoader
can be a great way to provide multiple implementations of an interface by simply adding a dependency to the classpath, without any opinions about the dependency injection framework you, or your users, choose to use.