Revisiting SharedPreferences and Migrating to DataStore in Android
This article analyzes the performance pitfalls of Android's SharedPreferences, explains how its apply() method can still cause ANR, introduces Jetpack DataStore as a modern replacement, and provides detailed migration strategies including code examples, coroutine handling, and bytecode transformation techniques.
For Android developers, SharedPreferences is a long‑standing key‑value storage solution, but its asynchronous apply() API can still block the main thread and cause ANR when the write queue is not flushed during activity lifecycle events such as handleStopService() , handlePauseActivity() , and handleStopActivity() . The article shows the internal implementation of apply() and how the write operation is queued via QueuedWork .
public void apply() {
final long startTime = System.currentTimeMillis();
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {}
if (DEBUG && mcr.wasWritten) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration + " applied after " + (System.currentTimeMillis() - startTime) + " ms");
}
}
};
QueuedWork.addFinisher(awaitCommit);
// write to disk
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
notifyListeners(mcr);
}Google’s official documentation confirms that even with apply() , the write may be delayed, leading to UI thread blockage on older Android versions. To avoid these issues, the article recommends replacing SharedPreferences with Jetpack DataStore, which offers type‑safe, coroutine‑based, transactional storage.
DataStore provides two implementations: Preferences DataStore (key‑value) and Proto DataStore (protocol‑buffer based). The article focuses on Preferences DataStore and shows a simple Kotlin declaration:
val Context.dataStore: DataStore
by preferencesDataStore("filename")Reading and writing values requires a coroutine scope because DataStore operates asynchronously via Kotlin Flow:
// Reading
CoroutineScope(Dispatchers.Default).launch {
context.dataStore.data.collect { prefs ->
val value = prefs[booleanPreferencesKey(key)] ?: defValue
}
}
// Writing
CoroutineScope(Dispatchers.IO).launch {
context.dataStore.edit { settings ->
settings[booleanPreferencesKey(key)] = value
}
}The core of DataStore is the updateData suspend function, which creates a CompletableDeferred (a Deferred) to await the result, packages the update into a Message.Update , and sends it to a SimpleActor for sequential processing.
override suspend fun updateData(transform: suspend (t: T) -> T): T {
val ack = CompletableDeferred
()
val currentDownStreamFlowState = downstreamFlow.value
val updateMsg = Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)
actor.offer(updateMsg)
return ack.await()
}The actor runs on a coroutine scope, consumes messages from an unlimited Channel , and dispatches them to handleUpdate or handleRead . handleUpdate eventually calls transformAndWrite , which writes the new data to a temporary file and atomically renames it.
internal suspend fun writeData(newData: T) {
file.createParentDirectories()
val scratchFile = File(file.absolutePath + SCRATCH_SUFFIX)
FileOutputStream(scratchFile).use { stream ->
serializer.writeTo(newData, UncloseableOutputStream(stream))
stream.fd.sync()
}
if (!scratchFile.renameTo(file)) {
throw IOException("Unable to rename $scratchFile. This likely means multiple DataStore instances exist.")
}
}After understanding DataStore, the article outlines a migration path from SharedPreferences to DataStore. Existing SP data can be migrated automatically using SharedPreferencesMigration when creating the DataStore:
dataStore = context.createDataStore(
name = "preferenceName",
migrations = listOf(SharedPreferencesMigration(context, "sp_name"))
)For large codebases where manual replacement is impractical, the article proposes an ASM‑based bytecode transformation that replaces calls to getSharedPreferences with a custom wrapper DataPreference that forwards operations to DataStore. The transformation swaps the original INVOKEVIRTUAL instruction with an INVOKESTATIC call to an extension function getDataPreferences and inserts a CHECKCAST to keep the type compatible.
static void spToDataStore(MethodInsnNode node, ClassNode klass, MethodNode method) {
if (node.name.equals("getSharedPreferences") && node.desc.equals("(Ljava/lang/String;I)Landroid/content/SharedPreferences;")) {
MethodInsnNode hook = new MethodInsnNode(Opcodes.INVOKESTATIC,
"com/example/spider/StoreTestKt",
"getDataPreferences",
"(Landroid/content/Context;Ljava/lang/String;I)Landroid/content/SharedPreferences;",
false);
TypeInsnNode cast = new TypeInsnNode(Opcodes.CHECKCAST, "android/content/SharedPreferences");
InsnList list = new InsnList();
list.add(hook);
list.add(cast);
method.instructions.insertBefore(node, list);
method.instructions.remove(node);
}
}The article also acknowledges limitations: because DataStore writes are asynchronous, a subsequent immediate getBoolean after apply() may read stale data. The recommended pattern is to observe the Flow and react to updates rather than performing synchronous reads after writes.
In conclusion, the author suggests that DataStore’s performance is comparable to MMKV when used appropriately, and encourages developers to evaluate the best key‑value storage solution for their specific scenario.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.