Skip to content
Ende Sun
Back to blog

Mobile development

Local persistence on mobile: Room, Content Providers and Core Data side by side

6 min read

Local persistence on mobile: Room, Content Providers and Core Data side by side

During my master's in mobile development I had to store data locally on both Android and iOS, often in the same week. Writing it down side by side helped me see that the two platforms solve the same problems (schema, threading, migrations, sharing data) with different tools, and that most bugs come from the same few places on both.

Pick the right level of storage first

Not everything needs a database. On Android, small key-value data such as settings or a "logged in" flag belongs in SharedPreferences or DataStore, and iOS has the same split with user preferences. A database earns its place when you have structured records that you need to query, relate and update.

SQLite or Room on Android

Android ships with SQLite, so you can use it directly without extra libraries. The cost shows up quickly:

  • A lot of boilerplate: ContentValues, cursors and manual mapping from rows to objects.
  • SQL is only checked at runtime. A typo such as SELETC compiles fine and crashes when a user reaches that screen.

Room is an abstraction layer on top of SQLite that fixes exactly those two problems: it validates queries at compile time, maps rows to objects for you and integrates with Flow and LiveData. For almost every local database on Android it is the sensible default.

Room needs three pieces: an entity (the table), a DAO (the queries) and the database class (the entry point).

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    @ColumnInfo(name = "text") val text: String
)

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY id DESC")
    fun observeAll(): Flow<List<Note>>

    @Insert
    suspend fun insert(note: Note): Long
}

The DAO is a contract: it says what you can do with the data, not how. It holds no business logic, only method definitions that Room translates into SQL.

One database instance, and never on the main thread

Two rules saved me from most problems.

First, open the database once. If several parts of the app create their own instance and two threads write at the same time through different connections, you get locking problems or worse. A singleton in the companion object solves it:

@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao

    companion object {
        @Volatile private var instance: AppDatabase? = null

        fun get(context: Context): AppDatabase =
            instance ?: synchronized(this) {
                instance ?: Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app.db"
                ).build().also { instance = it }
            }
    }
}

Second, never query on the main thread. Room blocks it by default, and the escape hatch allowMainThreadQueries() is tempting in a quick prototype. Don't use it: as soon as the table grows, the UI freezes while it reads and Android ends up showing an "Application Not Responding" dialog. With suspend DAO functions and coroutines the work moves off the UI thread with almost no extra code.

Sharing data between apps: Content Providers

A database is private to its app. When another app needs your data, Android's standard mechanism is a Content Provider: a component that sits between your storage and the outside world, exposes data through URIs and enforces permissions.

A content URI follows the pattern content://authority/path/id: the authority identifies the provider (usually your package name), the path identifies the table, and the optional id points to a single record.

The piece I would not skip is the Contract class: an object with nothing but constants (authority, base URI, table and column names, MIME types) shared by the provider and its clients, so both sides agree on the schema without copying strings around.

object NotesContract {
    const val AUTHORITY = "com.example.notes.provider"
    val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")

    object NoteEntry : BaseColumns {
        const val TABLE = "notes"
        const val COLUMN_TEXT = "text"
        val CONTENT_URI: Uri = BASE_URI.buildUpon().appendPath(TABLE).build()
    }
}

The provider extends ContentProvider and implements onCreate, getType, query, insert, update and delete, using a UriMatcher to tell a list URI from a single-item one. After an insert, call notifyChange so that observers refresh.

Clients go through ContentResolver:

contentResolver.query(
    NotesContract.NoteEntry.CONTENT_URI,
    arrayOf(BaseColumns._ID, NotesContract.NoteEntry.COLUMN_TEXT),
    null, null, null
)?.use { cursor ->
    while (cursor.moveToNext()) {
        val text = cursor.getString(1)
    }
}

Two details cost me time. Protect the provider with readPermission and writePermission in the manifest, and use grantUriPermissions when you only want to share a single URI temporarily. And since Android 11 the client app must declare the provider in a <queries> block; otherwise it simply does not find it, and there is no clear error telling you why.

Core Data on iOS

Core Data is not a database but an object graph and persistence framework, usually backed by SQLite (an in-memory store also exists for data that is cheap to recreate). Day to day you mostly touch three things: the data model (.xcdatamodeld), the NSPersistentContainer that builds the stack, and the NSManagedObjectContext, through which every read and write happens. Queries are NSFetchRequests filtered with NSPredicate; CONTAINS[cd] ignores case and diacritics, which is what you want behind a search box.

Concurrency

The same rule as on Android applies: heavy work does not belong on the main thread. In Core Data that means working with more than one context, each tied to its own queue:

container.performBackgroundTask { context in
    let request: NSFetchRequest<Note> = Note.fetchRequest()
    request.predicate = NSPredicate(format: "text CONTAINS[cd] %@", term)
    let notes = (try? context.fetch(request)) ?? []
    // expensive work here: export, upload...
}

Managed objects belong to their context. To use something fetched in the background on the main context, pass its objectID and load it there, or listen for NSManagedObjectContextDidSave and call mergeChanges(fromContextDidSave:) on the main context.

Migrations

Every schema change means a new model version. Lightweight migrations handle many changes automatically, but one trap is easy to miss: if you rename an attribute or an entity, Core Data treats it as deleting the old one and creating a new one, and the old values are gone. You have to tell it explicitly that it is a rename. For transformations it cannot infer, you need a heavy migration that describes how to map the old data to the new format.

Android has the same trap in a different place: in SQLiteOpenHelper.onUpgrade, a DROP TABLE to "start fresh" deletes every user's data on the next update.

Debugging what is actually stored

On Android, the Database Inspector in Android Studio shows the live tables and lets you run queries against the running app. On iOS, the SQLite file lives in the app sandbox under Library/Application Support and can be opened with sqlite3. Core Data prefixes tables with Z, and recent writes may still sit in the .wal file, which explains "missing" rows when you inspect the file directly.

What I take from it

Choose the storage level by need, keep a single entry point to the database, keep queries off the main thread, define a shared contract when data crosses app boundaries, and treat every schema change as a migration that can lose data. The APIs differ; the failure modes are the same.