19.
RxPreferences
Written by Alex Sullivan
Every good Android developer is intimately familiar with SharedPreferences. You use it to store one-off values that you want to persist across the lifetime of the app.
Many developers will also be familiar with the tools you use to listen to changes in these preferences. The RxPreferences library provides a reactive wrapper around these preference notification listeners.
In this chapter, you’ll learn how the library works and how you can use it to effectively stream preference changes.
Getting started
In this chapter, you’re going to put the final touches on the HexColor app that you started in the Chapter 15, “Testing RxJava Code,” code and expanded upon in Chapter 17, “RxBindings.”
Open the starter project in Android Studio and run the app. You should see a familiar screen:
Try tapping out a hex color. You’ll see the screen change to that color, and a color name will appear in the top right below the actual hex value. If you tap on that color name, you should see a new pop-up appear at the bottom of the screen with it’s own edit text where you can enter a hex code.
At the bottom of that pop-up, there will be a small heart that should be the color of whatever hex code you input in the edit text at the top of the pop-up.
The goal for this last update to the HexColor app is to allow the user to tap that heart icon and have the main app’s background update to that new color.
Right now, the app is using a BottomSheetDialogFragment to show the bottom dialog and an Activity to show the main keyboard and color view.
As you already know, communicating between fragments and activities is a painful process. It usually means defining an interface for the Activity to implement and then using getActivity from the BottomSheetDialogFragment to hopefully communicate any changes back up to the Activity. However, you need to be careful to make sure that the Activity you get back from getActivity isn’t null, since that’s always a possibility!
If only there was a better way to communicate this information…
Using SharedPreferences
There is! The Android SDK provides SharedPreferences as a means to save small amounts of information that the user may be interested in across app restarts. The Android SDK also provides a way to observe preference changes for individual preference keys using the OnSharedPreferenceChangedListener interface, allowing you to build up an app that reacts to preference changes.
You can use SharedPreferences to have the bottom sheet dialog save whatever color the user “loved” by tapping the heart. Then the activity (or the view model for the activity) can subscribe to those changes and update the app accordingly.
Start off by updating the ColorBottomSheetViewModel class to accept a new SharedPreferences argument:
class ColorBottomSheetViewModel(
startingColor: String,
colorCoordinator: ColorCoordinator,
sharedPreferences: SharedPreferences
) : ViewModel()
Next, just like you did in the RxBindings chapter, you need to create a subject representing favorite clicks. Add the following val at the top of the class:
private val favoriteClicksObservable =
PublishSubject.create<Unit>()
And add a new onFavoriteClick method at the bottom of the class:
fun onFavoriteClick() = favoriteClicksObservable.onNext(Unit)
favoriteClicksObservable is of type PublishSubject<Unit>. Every emission from the subject will represent a new click by the user.
Now, update the code creating the ColorBottomSheetViewModel object in the ColorBottomSheet class. The code creating the view model exists in onViewCreated(), in an anonymous object extending the NewInstanceFactory class. Add SharedPreferences as the final parameter for the ColorBottomSheetViewModel constructor.
return ColorBottomSheetViewModel(colorString, ColorCoordinator(),
PreferenceManager
.getDefaultSharedPreferences(requireContext())) as T
Next up, use the RxBindings clicks extension method to listen for clicks on the favorite button. Add the following below the block creating the viewModel:
favorite.clicks().subscribe {
viewModel.onFavoriteClick()
}.addTo(disposables)
You’ve used the RxBindings clicks() to get an Observable<Unit> representing clicks to the favorite view. You’re then forwarding that click event to the view model.
Now that you’ve got a way to react to Favorite clicks and an instance of SharedPreferences, it’s time to update ColorBottomSheetViewModel to save the currently displayed color object whenever a user clicks the Favorite icon.
Add the following to the bottom of the init block in ColorBottomSheetViewModel:
favoriteClicksObservable
.subscribe {
sharedPreferences.edit()
.putString("favoriteColor", closestColorLiveData.value)
.apply()
}
.addTo(disposables)
You’re now using the latest value from the closestColorLiveData object and saving it every time the user clicks the Favorite button.
Now you need to listen for this preference update in the activity and react accordingly.
Listening for preference updates
Just like before, you’ll need to pass in an instance of SharedPreferences into the view model corresponding to the ColorActivity. Update the ColorViewModel class to accept an instance of SharedPreferences:
class ColorViewModel(
backgroundScheduler: Scheduler,
mainScheduler: Scheduler,
colorApi: ColorApi,
colorCoordinator: ColorCoordinator,
sharedPreferences: SharedPreferences
) : ViewModel()
Now, update the ColorActivity to supply the SharedPreferences object. In ColorActivity‘s onCreate()’, update the line creating the new ColorViewModel to pass in SharedPreferences as the last parameter.
return ColorViewModel(Schedulers.io(),
AndroidSchedulers.mainThread(),
ColorApi, ColorCoordinator(),
PreferenceManager
.getDefaultSharedPreferences(this@ColorActivity)) as T
Now that you have an instance of SharedPreferences in ColorViewModel, you can start using it. Add the following to the bottom of the init block in ColorViewModel:
sharedPreferences.registerOnSharedPreferenceChangeListener {
sharedPreferences, key ->
if (key == "favoriteColor") {
hexStringSubject.onNext(
sharedPreferences.getString(key, ""))
}
}
You’re registering a shared preference change listener and checking if the key that’s changed is the key you’re interested in. If it is, you’re forwarding the new color along to hexStringSubject. Recall that hexStringSubject drives the rest of the logic of the app, so pushing a new color string into hexStringSubject should update the color of the main activity view, the name of the color, and everything else you’ve come to expect from inputting a new color.
Build and run the app. Then, input a color and tap on the color name in the top-right. You should see the bottom sheet expand. Now, enter a new color in the edit text at the top of the bottom sheet and tap the Favorite icon.
Hmmmm. Nothing happened! The main color view behind the bottom sheet dialog didn’t change!
If you try to debug the app, you’ll see that the OnSharedPreferenceChangeListener you’re supplying to sharedPreferences is never being called. What gives?
To find the culprit, take a look at the registerOnSharedPreferenceChangeListener listeners documentation:
Registers a callback to be invoked when a change happens to a preference.
Caution: The preference manager does not currently store a strong reference to the listener. You must store a strong reference to the listener, or it will be susceptible to garbage collection. We recommend you keep a reference to the listener in the instance data of an object that will exist as long as you need the listener.
Under the hood, registerOnSharedPreferenceChangeListener uses a WeakHashMap to store its listeners. That means that if you don’t store a strong reference to the listener, the JVM will garbage collect the listener and you’ll lose out on any notifications you would otherwise receive.
This weak reference is a common point of pain for Android developers looking to use the OnSharedPreferenceChangeListener interface. Such is life when developing Android apps!
To create a strong reference to the preference change listener, you can add it as an instance variable on the ColorViewModel class:
private val listener =
SharedPreferences.OnSharedPreferenceChangeListener {
sharedPreferences, key ->
hexStringSubject.onNext(
sharedPreferences.getString(key, ""))
}
Now, update the line where you register the preference change listener to reference the listener instance variable:
sharedPreferences
.registerOnSharedPreferenceChangeListener(listener)
Run the app and enter a color. Click the color name and then enter a new color in the bottom sheet. Then, click the Favorite icon. You should now see the background of the main view change to be the new color. Fancy!
Using RxPreferences
Now that you’ve seen how to write reactive code using SharedPreferences on your own, it’s time to take a look at the RxPreferences library to see an easier and more efficient way to use SharedPreferences reactively.
First, include the dependency in the apps build.gradle file, then sync the gradle file:
implementation 'com.f2prateek.rx.preferences2:rx-preferences:2.0.0'
Open ColorBottomSheetViewModel and replace the shared preferences class parameter:
sharedPreferences: SharedPreferences
With the Rx version:
sharedPreferences: RxSharedPreferences
RxPreferences introduces a new version of SharedPreferences called RxSharedPreferences. You’ll use that class instead of SharedPreferences moving forward.
Now, replace the block of code at the bottom of the init block that subscribes to favoriteClicksObservable with the following:
val preference = sharedPreferences.getString("favoriteColor")
favoriteClicksObservable
.map { closestColorLiveData.value!! }
.subscribe(preference::set)
.addTo(disposables)
The RxPreferences library exposes getX() methods you’re accustomed to using with SharedPreferences. However, instead of returning a String or Int, or any of the other types, it returns a Preference<X>, where X is the String or Int or whatever else you can pull out of SharedPreferences. In this case, the preference object is of type Preference<String>. The Preference interface exposes several handy functions, one of which is setting a new value, which you’re using in the subscribe() block using a method reference.
This code replaces the traditional edit(), putString("myKey", "myString") and apply() methods on the sharedPreferences object that you’re used to seeing when setting a new shared preference; RxPreferences abstracts those away!
Now you need to update the ColorBottomSheet class to pass in an instance of RxSharedPreferences instead of SharedPreferences to the ColorBottomSheetViewModel. Replace the existing SharedPreferences class argument in the onViewCreated method with the following:
return ColorBottomSheetViewModel(colorString, ColorCoordinator(),
RxSharedPreferences.create(
PreferenceManager
.getDefaultSharedPreferences(requireContext()))) as T
The RxSharedPreferences class wraps an existing instance of SharedPreferences, so you can pass whatever shared preferences object you want.
Subscribing to preference changes
You’re properly saving the favoriteColor preference, so now it’s time to start observing it using the RxSharedPreferences library. Just like before, you’ll need to swap out the class arguments for ColorViewModel.
In the ColorViewModel class, replace the sharedPreferences class argument with the following:
private val sharedPreferences: RxSharedPreferences
You’re using val here to make sure you have a strong reference.
Now, update the ColorActivity to pass in an instance of RxSharedPreferences into the ColorViewModel in the onCreate method:
return ColorViewModel(Schedulers.io(), AndroidSchedulers.mainThread(),
ColorApi, ColorCoordinator(),
RxSharedPreferences.create(
PreferenceManager
.getDefaultSharedPreferences(this@ColorActivity))) as T
Back in ColorViewModel, delete the instance variable listener. You’ll use the RxPreferences Rx integration instead of listeners. Make sure to also delete the call to registerOnSharedPreferenceChangeListener at the bottom of the init block.
Add the following code at the bottom of the init block to replace the registerOnSHaredPreferenceChangeListener() call you deleted:
sharedPreferences.getString("favoriteColor")
.asObservable()
.filter { !it.isBlank() }
.subscribe { hexStringSubject.onNext(it) }
Just like before, you’re using getString() to get a Preference<String> from RxSharedPreferences. This time, you’re also using asObservable() to turn that preference into an Observable representing any changes to the shared preference.
Run the app and try going through the flow. Everything should work perfectly!
Note: Just like before, it’s important to be mindful of the
WeakHashMapthat the Android SDK uses under the hood to store preference change listeners, which RxSharedPreferences utilizes. You need to make sure that you keep a strong reference to theRxSharedPreferencesobject to avoid your listeners’ garbage being collected prematurely!
Try deleting the app and setting a breakpoint on the filter() line above. Run the app in debug mode, and you’ll notice that the breakpoint pauses even without setting a value on the Preference. Each Preference will emit a default value if no value has been set yet. A common source of errors when using the RxSharedPreferences library is assuming that no value will be emitted if you haven’t saved any shared preferences objects yet, so make sure to keep in mind that an initial default value will be emitted!
Dealing with old versions of RxJava
There’s one thing missing with the Rx chain that you just wrote:
sharedPreferences.getString("favoriteColor")
.asObservable()
.filter { !it.isBlank() }
.subscribe { hexStringSubject.onNext(it) }
You’re not disposing of the Disposable that subscribe() returns!
Normally you’d use the addTo() RxKotlin extension function to add the disposable to your CompositeDisposable. Go ahead and give that a shot. You should see an error that looks something like this:
Unresolved reference. None of the following candidates is applicable because of receiver type mismatch:
public fun Disposable.addTo(compositeDisposable: CompositeDisposable): Disposable defined in io.reactivex.rxjava3.kotlin
The problem is that the RxPreferences library is using RxJava2 under the hood, whereas you’re using the newer RxJava3 library. RxJava2 and 3 have different package structures - meaning you can’t pass an RxJava2 Observable into a method that expects an RxJava3 Observable and vice versa!
Luckily, the Rx authors provide a bridging library to help ease the transition that you can use to solve this particular roadblock.
Add the following dependency in the app/build.gradle file and then sync:
implementation "com.github.akarnokd:rxjava3-bridge:3.0.0"
The rxjava3-bridge library exposes several methods to transition between RxJava2 and RxJava3 types.
Open the X.kt file and add the following method at the bottom of the file:
fun <T> io.reactivex.Observable<T>.toV3Observable():
io.reactivex.rxjava3.core.Observable<T> {
return RxJavaBridge.toV3Observable(this)
}
You’re defining a new extension function on the RxJava2 version of Observable that converts it into an instance of the RxJava3 Observable using toV3Observable() from the bridging library.
Now head back to the ColorViewModel class and replace the shared preferences rx block you added earlier with the following:
sharedPreferences.getString("favoriteColor")
.asObservable()
.toV3Observable()
.filter { !it.isBlank() }
.subscribe { hexStringSubject.onNext(it) }
.addTo(disposables)
You’re now using toV3Observable(), which you defined earlier to convert the Observable returned by RxSharedPreferences to a RxJava3 Observable. Since it’s now the right type of Observable, you can use the addTo RxKotlin method as expected.
Saving custom objects
You’re now sending color data from the ColorBottomSheet to the ColorActivity seamlessly.
However, there’s a major inefficiency. Every time you click the Favorite icon in the bottom sheet, the ColorViewModel class executes another network call to fetch the color data for the color you just favorited. That means that the same network call is being made twice: Once in the ColorBottomSheetViewModel when the user inputs the color and the once again in the ColorViewModel. It’d be great if you could share the whole ColorResponse object that the API returns rather than just the hex value of the color.
And you can! RxPreferences provides an easy mechanism with which to save custom object types into shared preferences. In this next section, you’ll update the app to share the entire ColorResponse object returned by the API.
First, add a new file called ColorResponseConverter into the hexcolor package and add the following code, importing com.f2prateek.rx.preferences2.Preference when prompted:
class ColorResponseConverter:
Preference.Converter<ColorResponse> {
override fun deserialize(serialized: String): ColorResponse {
TODO()
}
override fun serialize(value: ColorResponse): String {
TODO()
}
}
ColorResponseConverter is going to implement the Preference.Converter interface exposed by RxSharedPreferences. Preference.Converter is a simple conversion interface to serialize an object into a String and deserialize an object from a String into an object type, in this case a ColorResponse.
Now, replace the code in serialize() with the following:
val gson = Gson()
return gson.toJson(value)
To serialize an object, you’ll use Gson to convert the object into a String.
Similarly, replace the contents of deserialize():
val gson = Gson()
return gson.fromJson(serialized, ColorResponse::class.java)
You’re again using Gson, this time to take a previously serialized object and convert it into an instance of ColorResponse.
Note: It’s trivial to make a generic abstract class that extends the
Preference.Converterinterface and usesGsonunder the hood to deserialize any custom object type, so you don’t need to write converters for each of your objects!
You’ll also need a default, blank instance of ColorResponse to return in case the app hasn’t yet saved any objects with that type — remember earlier when we discussed default values being returned? Custom objects are no exception!
Add the following as a top level value in the ColorResponseConverter.kt, outside of the class. You may need to import com.raywenderlich.android.hexcolor.networking.ColorName to make sure you’re using the ColorName class instead of the enum:
val defaultColorResponse = ColorResponse(ColorName("#", "#"))
defaultColorResponse is a “blank” instance of ColorResponse.
Open ColorBottomSheetViewModel. Since you’re now going to be saving the results of the last completed network call, you’ll need to hold onto that value. Add the following as an instance variable in the view model:
private var previouslyFetchedColor: ColorResponse =
defaultColorResponse
Now, delete the code declaring preference at the bottom of the init block and replace it with the following:
// 1
val preference = sharedPreferences.getObject(
"favoriteColor",
defaultColorResponse,
ColorResponseConverter()
)
favoriteClicksObservable
// 2
.map { previouslyFetchedColor }
// 3
.subscribe(preference::set)
.addTo(disposables)
Here’s a breakdown:
- You’ve switched from using
getString()to usinggetObject().getObject()takes two additional parameters: A default instance of whatever object you’ll be operating on, which in this case isColorResponse, and aPreference.Converterto convert to and from that object type. - You’re then using the
mapoperator to transform theUnitvalue emitted byfavoriteClicksObservableinto thepreviouslyFetchedColorobject you defined earlier. - Finally, you’re using
set()like before!
All that’s left to do in ColorBottomSheetViewModel is to actually save the last ColorResponse received from the server.
Update the colorObservable declaration towards the top of the init block such that it saves the last response from the server. Specifically, add the following line before the map call:
.doOnNext { previouslyFetchedColor = it }
Now that you’re saving the value, it’s time to respond to the new object type in the ColorViewModel
Observing a custom object
Open the ColorViewModel class. Delete the code observing the "favoriteColor" preference string and replace it with the following:
sharedPreferences.getObject(
"favoriteColor",
defaultColorResponse,
ColorResponseConverter()
).asObservable()
.toV3Observable()
.map { it.name }
.subscribe {
colorNameLiveData.postValue(it.value)
hexStringSubject.onNext(it.closest_named_hex)
}
.addTo(disposables)
Just like before, you’re using getObject() to get a Preference<ColorResponse>. And like the earlier iteration you’re using asObservable() to convert it into an Observable.
You’re then using map() to convert the ColorResponse into a ColorName, which holds all the meaningful data. Finally, you’re posting the color name to the colorNameLiveData and the hex string to the hexStringSubject.
However, you’ve now run into an issue. Whenever hexStringSubject receives a hex string of length 7, it runs the following Rx block:
hexStringSubject
.filter { it.length == 7 }
.observeOn(mainScheduler)
.subscribe {
colorNameDisposable?.dispose()
colorNameDisposable = colorApi.getClosestColor(it)
.subscribeOn(backgroundScheduler)
.subscribe { response ->
colorNameLiveData.postValue(response.name.value)
}
}
.addTo(disposables)
This means that the code will still run a network request whenever the preference is updated.
To fix this issue, first delete the code block referenced above. You’ll handle making the API request elsewhere.
Then, replace the existing subscribe() block in the Rx chain subscribing to the digitsStream with the following:
hexStringSubject.onNext(currentHexValue() + it)
if (currentHexValue().length == 7) {
colorNameDisposable?.dispose()
colorNameDisposable =
colorApi.getClosestColor(currentHexValue())
.subscribeOn(backgroundScheduler)
.subscribe { colorResponse ->
colorNameLiveData.postValue(colorResponse.name.value)
}
}
You’ve moved the logic which executes the network request to be triggered based off of digits being input rather than the hexStringSubject being updated. That frees the code that subscribes to hexStringSubject to be entirely cosmetic. Now, whenever you change a preference, all the normal UI updates will happen without the network request.
Run the app and make sure the above is the case. You should see the color name updated in the main color view after you choose a favorite color via the bottom sheet.
Key points
- You can use RxPreferences to create reactive streams out of individual preferences.
- RxPreferences provides type safe ways to access data stored in shared preferences.
- Make sure to keep a strong reference to the
RxSharedPreferencesclass to avoid listeners being garbage collected prematurely! - If you want to store and retrieve custom objects, use the
Converterinterface to convert between strings and your object type. - You can use the rxjava-bridge library to bridge between RxJava2 and RxJava3 types
Where to go from here?
Now that you know all about making SharedPreferences reactive, you can move even farther Rx-ifying your apps! Hopefully you’re starting to notice that for every core component needed to write an Android app, an existing Rx-ified library exists to keep your code base reactive.
In the upcoming chapters, you’ll learn about a few more libraries, including some that were written by the Android platform team!