The Complete Guide to Android Data Storage
The Complete Guide to Android Data Storage
From a single key-value pair to a full relational database — every storage option Android offers, when to use each, and how to implement them correctly.
Table of Contents
- Choosing the Right Storage
- SharedPreferences — Simple Key-Value
- DataStore — The Modern Replacement
- Internal Storage — Private Files
- External Storage & Scoped Storage
- SQLite — Raw Database Access
- Room — The Recommended Database
- FileProvider — Sharing Files Safely
- Security & Encryption
- Best Practices Checklist
- Conclusion
1. Choosing the Right Storage
Android offers many storage options. Pick based on data complexity, persistence needs, and visibility:
| Storage | Best For | Async? | Structured? | Visibility |
|---|---|---|---|---|
SharedPreferences |
Simple settings (deprecated path) | No | No | App-private |
DataStore (Preferences) |
Simple settings (modern) | Yes (coroutines) | No | App-private |
DataStore (Proto) |
Typed structured settings | Yes | Yes (schema) | App-private |
| Internal storage | Private files (cache, sensitive) | No | No | App-private |
| External storage | Media, documents, shared files | No | No | Shared (scoped) |
| SQLite (raw) | Relational data (low-level) | No | Yes | App-private |
| Room | Relational data (recommended) | Yes | Yes | App-private |
EncryptedSharedPreferences |
Sensitive small data | No | No | App-private |
Golden rule: Use
DataStorefor key-value settings,Roomfor structured data, and scoped external storage for user-facing media/documents.
2. SharedPreferences — Simple Key-Value
SharedPreferences is the oldest and simplest API. It’s still everywhere, but Google now recommends DataStore for new code.
2.1 Write
val sharedPrefs = getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
sharedPrefs.edit().apply {
putString("username", "alice")
putInt("age", 28)
putBoolean("is_premium", true)
apply() // async — preferred over commit()
}.also { /* nothing */ }
apply()— saves asynchronously (background thread). Use this in 99% of cases.commit()— saves synchronously and returns aBoolean. Use only when you need immediate confirmation.
2.2 Read
val username = sharedPrefs.getString("username", "default_user")
val age = sharedPrefs.getInt("age", 0)
val isPremium = sharedPrefs.getBoolean("is_premium", false)
2.3 Listen for changes
sharedPrefs.registerOnSharedPreferenceChangeListener { prefs, key ->
when (key) {
"username" -> println("Username changed to ${prefs.getString(key, "")}")
}
}
Warning: The listener holds a strong reference. Unregister it in
onDestroy()to avoid memory leaks.
2.4 Why migrate away?
- No type safety — everything is read with explicit casts and defaults.
- No async API —
apply()hides disk I/O but can still block ongetString()if called before the write completes. - Not designed for large or structured data.
- Prone to
ClassCastExceptionif schema evolves.
3. DataStore — The Modern Replacement
DataStore is Jetpack’s async, type-safe replacement for SharedPreferences. There are two flavors:
- Preferences DataStore — key-value, no schema (direct replacement for
SharedPreferences). - Proto DataStore — typed objects backed by Protocol Buffers (schema-safe).
3.1 Preferences DataStore
Add dependency:
dependencies {
implementation("androidx.datastore:datastore-preferences:1.1.1")
}
Create the DataStore:
private val Context.dataStore by preferencesDataStore(name = "user_prefs")
Define keys:
object UserPrefs {
val USERNAME = stringPreferencesKey("username")
val AGE = intPreferencesKey("age")
val IS_PREMIUM = booleanPreferencesKey("is_premium")
}
Write (suspend):
suspend fun saveUser(username: String, age: Int, isPremium: Boolean) {
context.dataStore.edit { prefs ->
prefs[UserPrefs.USERNAME] = username
prefs[UserPrefs.AGE] = age
prefs[UserPrefs.IS_PREMIUM] = isPremium
}
}
Read (Flow):
val usernameFlow: Flow<String> = context.dataStore.data
.map { it[UserPrefs.USERNAME] ?: "default_user" }
// Collect in a coroutine
viewModelScope.launch {
usernameFlow.collect { username ->
// update UI
}
}
Read once (suspend):
suspend fun getUsername(): String {
return context.dataStore.data.first()[UserPrefs.USERNAME] ?: "default_user"
}
3.2 Proto DataStore
For structured settings (e.g., a UserSettings object with multiple fields), Proto DataStore gives you a typed schema. It requires a .proto file and generated classes — more setup, but fully type-safe and evolvable.
Recommendation: Start with Preferences DataStore. Move to Proto DataStore only if you have many related fields that belong together as an object.
4. Internal Storage — Private Files
Internal storage is app-private, always available, and automatically deleted when the user uninstalls your app. Perfect for sensitive data and temporary files.
4.1 Write a file
fun writeToFile(filename: String, content: String) {
context.openFileOutput(filename, Context.MODE_PRIVATE).use { output ->
output.write(content.toByteArray())
}
}
4.2 Read a file
fun readFromFile(filename: String): String {
return context.openFileInput(filename).bufferedReader().use { it.readText() }
}
4.3 Cache directory
For temporary files that the system may evict under low-storage pressure:
val cacheFile = File(context.cacheDir, "temp_image.jpg")
Tip: Call
context.cacheDir.deleteRecursively()on low-memory warnings (onTrimMemory) to free space proactively.
5. External Storage & Scoped Storage
Starting with Android 10 (API 29), Google introduced Scoped Storage. Apps can no longer freely read/write the entire shared filesystem. Instead, use dedicated APIs per media type.
5.1 Save a media image (MediaStore)
@RequiresApi(Build.VERSION_CODES.Q)
fun saveImage(bitmap: Bitmap, displayName: String): Uri {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/MyApp")
}
val uri = context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: throw IOException("Failed to create MediaStore entry")
context.contentResolver.openOutputStream(uri)?.use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
return uri
}
5.2 Pick a document (Storage Access Framework)
For user-chosen files (documents, images, any type), use the Storage Access Framework — no permissions needed:
val pickFile = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
uri?.let {
// Read the file via contentResolver.openInputStream(it)
}
}
// Launch with MIME types
pickFile.launch(arrayOf("image/*", "application/pdf"))
5.3 App-specific external storage
For files that are app-private but on shared media (not auto-deleted on uninstall, but cleared via “Clear data”):
// Persistent
val file = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "photo.jpg")
// Cache
val cacheFile = File(context.externalCacheDir, "temp.jpg")
No permission required for app-specific external directories.
5.4 Permissions summary
| Action | Permission needed |
|---|---|
App-specific dirs (getExternalFilesDir) |
None |
| Read shared media via MediaStore | None (API 29+) |
| Read all media (broad access) | READ_MEDIA_IMAGES / READ_MEDIA_VIDEO / READ_MEDIA_AUDIO (API 33+) |
| Modify media not created by your app | MediaStore.createWriteRequest() + user consent dialog |
| Legacy broad access (API < 29) | READ_EXTERNAL_STORAGE / WRITE_EXTERNAL_STORAGE |
6. SQLite — Raw Database Access
Android ships with SQLite. You can use it directly via SQLiteOpenHelper, but Room is strongly preferred. Here’s the raw approach for completeness.
6.1 Define the helper
class AppDatabase(context: Context) : SQLiteOpenHelper(context, "app.db", null, 1) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE,
created_at INTEGER
)
""".trimIndent())
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP TABLE IF EXISTS users")
onCreate(db)
}
}
6.2 Insert & query
val db = AppDatabase(context).writableDatabase
// Insert
val values = ContentValues().apply {
put("name", "Alice")
put("email", "alice@example.com")
put("created_at", System.currentTimeMillis())
}
db.insert("users", null, values)
// Query
val cursor = db.query("users", null, "name = ?", arrayOf("Alice"), null, null, null)
cursor.use {
while (it.moveToNext()) {
val id = it.getLong(it.getColumnIndexOrThrow("id"))
val name = it.getString(it.getColumnIndexOrThrow("name"))
}
}
Why avoid raw SQLite? No compile-time query validation, manual cursor management, boilerplate mapping, and easy SQL injection if you concatenate strings. Room solves all of this.
7. Room — The Recommended Database
Room is an abstraction layer over SQLite that provides compile-time query validation, automatic mapping, and coroutine/RxJava support.
7.1 Add dependencies
dependencies {
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
}
Use
ksp(Kotlin Symbol Processing) instead ofkaptfor faster builds.
7.2 Define the Entity
@Entity(tableName = "users")
data class User(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "email") val email: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)
7.3 Define the DAO
@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(user: User): Long
@Update
suspend fun update(user: User)
@Delete
suspend fun delete(user: User)
@Query("SELECT * FROM users WHERE id = :id")
suspend fun getById(id: Long): User?
@Query("SELECT * FROM users ORDER BY name ASC")
fun getAllFlow(): Flow<List<User>> // observable query
@Query("SELECT * FROM users WHERE name LIKE :query")
suspend fun search(query: String): List<User>
}
7.4 Define the Database
@Database(entities = [User::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app.db"
)
.fallbackToDestructiveMigration()
.build()
.also { INSTANCE = it }
}
}
}
}
7.5 Use it
class UserRepository(private val dao: UserDao) {
suspend fun createUser(name: String, email: String): Long {
return dao.insert(User(name = name, email = email))
}
fun observeAllUsers(): Flow<List<User>> = dao.getAllFlow()
}
7.6 Migrations
When you change the schema, increment the version and provide a migration:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE users ADD COLUMN avatar_url TEXT")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
Always set
exportSchema = trueand commit the generated schema JSON files. They enable automated migration testing.
8. FileProvider — Sharing Files Safely
To share a file (e.g., via an intent to another app), you must use a ContentUri via FileProvider. Sharing a raw file:// URI throws FileUriExposedException on API 24+.
8.1 Declare in AndroidManifest.xml
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
8.2 Define paths in res/xml/file_paths.xml
<paths>
<files-path name="my_files" path="." />
<cache-path name="my_cache" path="." />
<external-files-path name="my_external" path="." />
</paths>
8.3 Get the URI and share
fun shareImage(file: File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/jpeg"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share image"))
}
9. Security & Encryption
9.1 EncryptedSharedPreferences
For sensitive key-value data (tokens, small secrets), use the Jetpack Security library:
implementation("androidx.security:security-crypto:1.1.0-alpha06")
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secret_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
encryptedPrefs.edit().putString("auth_token", token).apply()
9.2 SQLCipher for encrypted databases
For encrypted Room databases, use the sqlcipher support library. It transparently encrypts the entire DB file with a passphrase.
9.3 Android Keystore
For cryptographic keys that must never leave the device, use the Android Keystore:
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
keyGenerator.init(
KeyGenParameterSpec.Builder(
"my_key_alias",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
val secretKey = keyGenerator.generateKey()
Keys in the Keystore are hardware-backed on devices with a Trusted Execution Environment (TEE).
10. Best Practices Checklist
- [ ] Use DataStore instead of
SharedPreferencesfor new code. - [ ] Use Room instead of raw
SQLiteOpenHelper. - [ ] Never store secrets in plaintext — use
EncryptedSharedPreferencesor the Keystore. - [ ] Scope file access — prefer MediaStore and SAF over broad
READ_EXTERNAL_STORAGE. - [ ] Use
apply()notcommit()forSharedPreferenceswrites (async). - [ ] Unregister listeners (
registerOnSharedPreferenceChangeListener) to avoid leaks. - [ ] Export Room schemas (
exportSchema = true) and write migration tests. - [ ] Use
@Volatile+ double-checked locking for singleton database instances. - [ ] Clean cache on
onTrimMemory()to be a good system citizen. - [ ] Use
FileProviderto share files — never rawfile://URIs. - [ ] Run DB operations off the main thread — Room enforces this by default.
- [ ] Use
Flowfor observable queries so your UI auto-updates on data changes.
11. Conclusion
Android storage has evolved significantly. Here’s the modern stack at a glance:
| Need | Use |
|---|---|
| Simple settings | Preferences DataStore |
| Typed settings | Proto DataStore |
| Structured data | Room |
| Private files | Internal storage (openFileOutput) |
| Media/documents | MediaStore + Storage Access Framework |
| Sensitive small data | EncryptedSharedPreferences |
| Cryptographic keys | Android Keystore |
| Sharing files | FileProvider |
The migration from legacy APIs (SharedPreferences, raw SQLite, broad storage permissions) to modern ones (DataStore, Room, scoped storage) is worth the effort. You get type safety, async APIs, better security defaults, and forward compatibility with future Android versions.
Start with DataStore and Room in your next project — your future self will thank you.
Written by [Your Name] · Last updated: 2026-08-22
- 点赞
- 收藏
- 关注作者
评论(0)