|
| 1 | +--- |
| 2 | +title: "Android: Flexible configuration with Deferred Resources" |
| 3 | +excerpt: Deferred Resources provides flexibility beyond the standard resource-resolution approaches. |
| 4 | +tags: Mobile Android Open-source Deferred-Resources |
| 5 | +authors: |
| 6 | +- Drew Hamilton |
| 7 | +header: |
| 8 | + teaser: /assets/images/post/05_color_configuration.png |
| 9 | + teaser_alt: Deferred Resources |
| 10 | +category: Mobile |
| 11 | +--- |
| 12 | + |
| 13 | +Our feature libraries, which include UI, have a number of varying requirements regarding |
| 14 | +configuration by our customers. (My colleague Hari wrote about one such library's requirements |
| 15 | +[here]({% post_url 2020-08-14-android-configuration-driven-ui-from-epoxy-to-compose %}).) |
| 16 | +Resources (text, colors, images, etc.) are a common type of configuration, and can be defined in |
| 17 | +various ways—typically in code, resources, or theme attributes. We created |
| 18 | +[Deferred Resources](https://engineering.backbase.com/DeferredResources) to support such |
| 19 | +configurations, and with it our customers can consistently declare configuration properties from |
| 20 | +any of these common sources: |
| 21 | +```kotlin |
| 22 | +SomeConfiguration { |
| 23 | + textColor = DeferredColor.Constant(Color.WHITE) |
| 24 | + // or |
| 25 | + textColor = DeferredColor.Resource(R.color.splash_text) |
| 26 | + // or |
| 27 | + textColor = DeferredColor.Attribute(R.attr.colorOnSurface) |
| 28 | +} |
| 29 | + |
| 30 | +// Resolved to a @ColorInt in our UI: |
| 31 | +@ColorInt val textColor = configuration.textColor.resolve(context) |
| 32 | +``` |
| 33 | + |
| 34 | +This covers most of our customers' use cases. Deferred Resources has been working well for us for |
| 35 | +over a year now. |
| 36 | + |
| 37 | +But beyond these common use cases, Deferred Resources' design provides a lot of flexibility to |
| 38 | +provide resources in other ways. Each deferred resource type is an interface with one or more |
| 39 | +abstract functions to resolve the underlying resource. Thus, a user of the Deferred Resources |
| 40 | +library can define any resource-resolution implementation they'd like. Here are some examples of |
| 41 | +how we're taking advantage of this flexibility. |
| 42 | + |
| 43 | +## Color variants |
| 44 | + |
| 45 | +The Backbase design system has a concept of "color variants," where any theme color has lighter and |
| 46 | +darker alternates. These variants are defined by a computed overlay: a "lighter" variant is the |
| 47 | +base color with a 30% white overlay, while a "darker" variant is the base color with a 30% black |
| 48 | +overlay. |
| 49 | + |
| 50 | +```kotlin |
| 51 | +enum class ColorVariant( |
| 52 | + @ColorInt internal val overlay: Int |
| 53 | +) { |
| 54 | + LIGHTER(overlay = Color.WHITE.withAlpha(0x4D)), |
| 55 | + DARKER(overlay = Color.BLACK.withAlpha(0x4D)), |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +By shipping a `DeferredVariantColor` class with our design system, we can make it very easy for our |
| 60 | +feature libraries as well as for our customers to use the same variants: |
| 61 | + |
| 62 | +```kotlin |
| 63 | +/** |
| 64 | + * Convert a [DeferredColor] to a [variant] of the same color without resolving it |
| 65 | + * yet. |
| 66 | + */ |
| 67 | +public fun DeferredColor.variant(variant: ColorVariant): DeferredColor = |
| 68 | + DeferredVariantColor(this, variant) |
| 69 | + |
| 70 | +// See https://github.com/drewhamilton/Poko for more on the @Poko annotation |
| 71 | +@Poko internal class DeferredVariantColor( |
| 72 | + private val base: DeferredColor, |
| 73 | + private val variant: ColorVariant |
| 74 | +) : DeferredColor { |
| 75 | + |
| 76 | + /** |
| 77 | + * Using [context], resolve the base color with the variant applied. |
| 78 | + */ |
| 79 | + @ColorInt override fun resolve(context: Context): Int = |
| 80 | + ColorUtils.compositeColors(variant.foreground, base.resolve(context)) |
| 81 | + |
| 82 | + /** |
| 83 | + * Using [context], resolve the base color with the variant applied. |
| 84 | + * |
| 85 | + * This implementation does not support states other than the default state. |
| 86 | + */ |
| 87 | + override fun resolveToStateList(context: Context): ColorStateList = |
| 88 | + ColorStateList.valueOf(resolve(context)) |
| 89 | +} |
| 90 | +``` |
| 91 | + |
| 92 | +With this, anyone using our design system can convert any configured color to a variant of the same |
| 93 | +color, even if the base color comes from an outside source and its value has not been resolved yet. |
| 94 | + |
| 95 | +```kotlin |
| 96 | +SomeConfiguration { |
| 97 | + buttonColor = DeferredColor.Attribute(R.attr.colorPrimary) |
| 98 | + buttonRippleColor = buttonColor.variant(ColorVariant.DARKER) |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +## Supporting Lottie without depending on it |
| 103 | + |
| 104 | +Some of our customers want to use [Lottie](https://airbnb.design/lottie/) to provide fun |
| 105 | +micro-animations to our feature libraries' UI. Other customers don't want to use Lottie, or don't |
| 106 | +want these animations at all. A custom implementation of the `DeferredDrawable` interface lets us |
| 107 | +support Lottie animations indirectly, without actually coupling our libraries |
| 108 | +to it or forcing our customers to take it on as a dependency. |
| 109 | + |
| 110 | +As a standalone library, we ship a `DeferredLottieDrawable` class: |
| 111 | + |
| 112 | +```kotlin |
| 113 | +interface DeferredLottieDrawable : DeferredDrawable { |
| 114 | + |
| 115 | + override fun resolve(context: Context): LottieDrawable? |
| 116 | + |
| 117 | + class Resource( |
| 118 | + @RawRes private val rawRes: Int, |
| 119 | + private val transformations: LottieDrawable.(Context) -> Unit = {}, |
| 120 | + ) : DeferredLottieDrawable { |
| 121 | + override fun resolve(context: Context): LottieDrawable? { |
| 122 | + val compositionResult = |
| 123 | + LottieCompositionFactory.fromRawResSync(context, rawRes) |
| 124 | + when (val exception = compositionResult.exception) { |
| 125 | + null -> return compositionResult.value?.asDrawable()?.apply { |
| 126 | + transformations(context) |
| 127 | + } |
| 128 | + else -> throw exception |
| 129 | + } |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + // Other supported types are implemented too: Constant, Asset, and Stream |
| 134 | +} |
| 135 | + |
| 136 | +private fun LottieComposition.asDrawable() = LottieDrawable().apply { |
| 137 | + composition = this@asDrawable |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +Thanks to the base `Drawable` class and the `Animatable` interface, which are both part of the |
| 142 | +standard Android APIs, UI code that expects an animation can display this without knowing whether |
| 143 | +Lottie is involved: |
| 144 | + |
| 145 | +```kotlin |
| 146 | +val paymentSuccessIndication = |
| 147 | + configuration.paymentSuccessIndication.resolve(context) |
| 148 | +imageView.setImageDrawable(paymentSuccessIndication) |
| 149 | +if (paymentSuccessIndication is Animatable) { |
| 150 | + paymentSuccessIndication.start() |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +Our feature library consumers can provide a `DeferredLottieDrawable` if they are using Lottie, or |
| 155 | +any other `DeferredDrawable` if they don't use Lottie: |
| 156 | + |
| 157 | +```kotlin |
| 158 | +SomeConfiguration { |
| 159 | + paymentSuccessIndication = DeferredLottieDrawable.Raw( |
| 160 | + R.raw.payment_success_animation |
| 161 | + ) { |
| 162 | + repeatCount = LottieDrawable.INFINITE |
| 163 | + } |
| 164 | + // or |
| 165 | + paymentSuccessIndication = DeferredDrawable.Resource( |
| 166 | + R.drawable.payment_success_icon |
| 167 | + ) |
| 168 | + // or |
| 169 | + paymentSuccessIndication = SomeCustomDeferredAnimatedDrawable(customInputs) |
| 170 | +} |
| 171 | +``` |
| 172 | + |
| 173 | +## Remote configuration |
| 174 | + |
| 175 | +We're just starting to explore another possible resource-resolution approach with Deferred |
| 176 | +Resources: resolving values from a remote server. Imagine one of our libraries has a configuration |
| 177 | +to enable a new feature: |
| 178 | + |
| 179 | +```kotlin |
| 180 | +SomeConfiguration { |
| 181 | + coolNewFeatureEnabled = DeferredBoolean.Constant(false) |
| 182 | +} |
| 183 | +``` |
| 184 | + |
| 185 | +A factory that is hooked up to a feature flagging backend could determine in the background whether |
| 186 | +any remote configuration has changed, and surface that update when a custom `DeferredBoolean` |
| 187 | +implementation is resolved: |
| 188 | + |
| 189 | +```kotlin |
| 190 | +interface RemoteConfigApi { |
| 191 | + |
| 192 | + /** |
| 193 | + * Returns the boolean value defined by [key] according to this |
| 194 | + * [RemoteConfigApi]'s internal state. This may return a default value |
| 195 | + * if the remote API call has not completed. |
| 196 | + */ |
| 197 | + fun getBooleanValue(key: String): Boolean |
| 198 | +} |
| 199 | + |
| 200 | +class FeatureFlagFactory( |
| 201 | + private val remoteConfigApi: RemoteConfigApi, |
| 202 | +) { |
| 203 | + fun createDeferredFeatureFlag(featureName: String): DeferredBoolean = |
| 204 | + object : DeferredBoolean { |
| 205 | + override fun resolve(context: Context): Boolean = |
| 206 | + remoteConfigApi.getBooleanValue(featureName) |
| 207 | + } |
| 208 | +} |
| 209 | +``` |
| 210 | + |
| 211 | +The consuming app can fetch the remote values in the background when the app is launched and use |
| 212 | +this factory to create its deferred feature flag, and the feature will be enabled depending on |
| 213 | +whatever the configuration backend has returned: |
| 214 | + |
| 215 | +```kotlin |
| 216 | +val remoteConfigApi = MyRemoteConfigApi( |
| 217 | + url = "example.com", |
| 218 | + defaultValues = mapOf("coolNewFeature" to false), |
| 219 | +).also { |
| 220 | + coroutineScope.launch { it.fetchLatestValues() } |
| 221 | +} |
| 222 | +val featureFlagFactory = FeatureFlagFactory(remoteConfigApi) |
| 223 | + |
| 224 | +SomeConfiguration { |
| 225 | + coolNewFeatureEnabled = |
| 226 | + featureFlagFactory.createDeferredFeatureFlag("coolNewFeature") |
| 227 | +} |
| 228 | +``` |
| 229 | + |
| 230 | +We're still working on this API's design, but just like our Lottie support, we aim to ship remote |
| 231 | +configuration support for our customers that want it, while not forcing it on the customers who |
| 232 | +don't. |
| 233 | + |
| 234 | +--- |
| 235 | + |
| 236 | +All three of these utilizations of Deferred Resources have one thing in common: they decouple the |
| 237 | +specific feature in question from the consumption site—our feature libraries. With this |
| 238 | +abstraction, our feature libraries are almost limitlessly flexible while remaining uncoupled from |
| 239 | +any specialized resource-resolution approach. |
0 commit comments