Posted by: Rotem Matityahu
Intro
This is a story about my recent battle with persistence in Android combined with an awesome tool called code shrinker.
When dealing with object persistence in Android, one has several implementation options:
A simple file, a database, SharedPreferences, or with the new and shiny Jetpack DataStore.
If you choose to store objects in SharedPreferences, you’re probably going to use a JSON representation.
When Android’s code shrinker joins this party, things can get pretty messy.
In this article, I’m not going to cover the capabilities of the Android code shrinker (R8/Progaurd), as there are plenty of articles about it.
Instead, I will showcase a problem I faced, my thinking process, and how I managed to solve it.
Let’s start with an example, shall we?
I’m developing an app that has some user custom settings.
Since there is a single settings object, for simplicity, I decided to store the settings object as a JSON using SharedPreferences.
class Settings( | |
val volume: Int, | |
val userName: String, | |
val fontSize: Float, | |
val privacyEnabled: Boolean | |
) |
After the app was released, I realized that the font size should be taken from the system settings (because who among us wants to mess with the users’ font settings?). Naively enough, I opened Settings
 class, removed fontSize
 from it, and deployed the version to production.
A couple of days later, I noticed an issue in production — the Settings
object couldn’t be parsed.
What the heck?
I looked for the Settings
 key in the SharedPreferences file (in /data/data/<packageName>/shared_prefs/<fileName>
) and this is what I saw:
{ | |
"a":55, | |
"b":"rotem matityahu", | |
"c":30.0, | |
"d":true | |
} |
First I was relieved because there was some data in the file; I thought it was deleted somehow.
The shrinker obfuscates the members’ names to short and meaningless names, in the same order as the class declares them. This process happens in each build.
In the first app version, the obfuscated Settings
 structure looks like this:
{“a”: Int, “b”: String, “c”: Float, “d”: Boolean}
Now let’s take a look at 2 scenarios:
- In the updated version the first field was deleted: The shrinker will assign the fields in the same order they’re written in the object and theÂ
Settings
 structure will look like this:Â{“a”: String, “b”: Float, “c”: Boolean}
. - In the updated version the first and second fields were swapped:The shrinker will change the structure to be :Â
{“a”: String, “b”: Int, “c”: Float, “d”: Boolean}
.
In both scenarios, there’s a fields’ types mismatch between the first and the updated versions. Therefore, the persisted object in the first version’s scheme will be failed to be parsed in the updated version’s scheme.
In my case, it was the fontSize
 removal.
Got it! To fix the issue I just need to add fontSize
 again to the Settings
object (in the same order as it was before) and the parsing will succeed because all the original members exist in the Settings
 class.
But did that really solve my issue? Not so fast.
I added the missing member, so why is the Settings
 object still not being parsed?
Then it finally hit me — I didn’t use the member; even invoking the member’s getter would have been enough.
The code shrinker will discard members that aren’t used (and on the way almost made me go to a shrink). This is what it does! It is equivalent to removing the members by yourself.
Job Offers
Getting it right
As responsible developers, we always test our code during development, but we do it using a debug build (because who has time to wait for Proguard in every build?). Eventually, these types of issues fall between the cracks.
Ok, so how can we prevent and resolve persistent data migration issues in Android, similar to the one I’ve encountered?
Prevention
First and foremost, think carefully about which data persistence API is better for your use case when dealing with objects:
- Collections of objects: Prefer using a database. There’s a migration process to implement in order to add or remove members from DB objects.
- Single objects: Forget about SharedPreferences and go take a look at Proto DataStore. It offers compile-time safety and supports migration from version to version, using a predefined scheme.
If you still insist on using SharedPreferences and JSON, always provide a name to serialized members.
Depending on the JSON parsing library you’re using, there are several options. With Gson, for example, you can use @SerializeName and in Moshi you have theÂ
@Json
Â
annotation (most serialization libraries will have a similar feature).
This way, you are decoupling the members’ names and how they are being saved, which protects you from any future change in the stored object structure.
Take notice though, this solution might expose your business logic through the member names.
For a more generic solution, you can create a custom annotation
, annotate the persisted object and add a Progaurd rule:Â keepnames
.
Usage:Â -keepnames @com.your.annotation.package.PersistedObject public class * { *; }
By using this rule, the shrinker will keep the members’ names and won’t discard unused members. When storing an object this way, its members’ names will be saved as they are (without any obfuscation).
For that reason, beware of changing the members’ names after the object has been saved since the parse will fail.
Recovery
There’s actually just one solution, and you probably knew it was coming: Migration. Parse the Object in the old format, map it to the new format and save it again.
Jetpack DataStore offers a simple and straightforward migration API from SharedPreferences.
Digesting it
As developers, we’re not always aware of what is happening to our code after the shrinker does its thing; this can lead to bugs when we mix it with persistence.
Always keep in mind that your object is being shrunk and obfuscated by the shrinker.
I know it’s kind of obvious, but sometimes we forget it.
My 2 cents
Preferring SharedPreferences was wrong in the first place.
I strongly believe that Jetpack Proto DataStore is the best solution for this use case and the future of persisting single objects. Besides being a superior communication protocol, it defeats JSON yet in another battle — the battle of persistence (2–0).
Thank you for reading and I hope you enjoyed it.