Android Content Provider 教學

Photo by Jeremy Hynes on Unsplash
Photo by Jeremy Hynes on Unsplash
Content Provider 是 Android 的四個 application components 中的其中一個。它可以幫助 app 管理儲存在自身或儲存在其他 apps 的資料,並且提供一個分享資料給其他 apps 的方式。

Content Provider 是 Android 的四個 application components 中的其中一個。它可以幫助 app 管理儲存在自身或儲存在其他 apps 的資料,並且提供一個分享資料給其他 apps 的方式。本文章將介紹如何存取一個 content provider,以及如何建立一個自定義的 content provider。

完整程式碼可以在 下載。

概覽

Content Provider 可以幫助 app 管理儲存在自身或儲存在其他 apps 的資料,並且提供一個分享資料給其他 apps 的方式。此外,它還提供一種機制可以定義資料的安全性。如下圖所示,app 透過 content provider 讓其他的 apps 可以安全地存取或修改它的資料。

Overview of Content Provider from Google Developers
Overview of Content Provider from Google Developers

當 App 要存取 ContentProvider 時,它必須要透過 ContentResolver 來存取 ContentProvider。可是當使用 ContentResolver 時,我們希望這個動作會以非同步的方式在背景中執行。所以,官網建議在 UI thread 中透過 CursorLoader 在背景中存取 ContentResolver,如下圖。

Interaction between ContentProvider, other classes and storages
Interaction between ContentProvider, other classes and storages from Google Developers

Content URI 和 Authority

Content URI 是一個可用來識別 provider 裡的資料的 URI,而 authority 是一個可用來識別一個 provider 的名稱。Content URI 由 authority 和 path 組成。Path 可以是資料的資料表名稱。

如果你的 app 有實作一個 content provider,當你安裝 app 到 Android 系統時,app 的 content provider 的 authority 會被註冊到 Android 系統。可以說 authority 是 app 的 content provider 的名稱。所以它在 Android 系統中必須是唯一的名稱。官網建議使用 app 的 package name 來設計 authority。

在本文章中,我們會實作一個 BooksProvider。它的 package name 是 com.waynestalk.booksprovider

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.waynestalk.booksprovider">
</manifest>

所以,BooksProvider 的 authority 會是 com.waynestalk.booksprovider.provider

實作 ContentProvider – BooksProvider

實作一個 ContentProvider 時,先要定義它的 authority 和 content URI。然後,再實作 onCreate()、getType()、query()、insert()、update()、和 delete()。

其中 onCreate() 只有在建立 provider 並初始化時,才會呼叫一次,所以 onCreate() 是 thread-safe。但是其他所有的 methods,都可以被多個 threads 同時呼叫。因此開發者在實作這些 methods 時,必須要考慮到 thread-safe 的問題。

Database

BooksProvider 管理一個資料表 books。這個資料表有三個欄位,分別為:

  • id:書的 ID,此為 primary key。
  • name:書的名稱。
  • authors:書的作者們,用逗號分隔。
package com.waynestalk.booksprovider.db

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "books")
data class BookEntity(
    @PrimaryKey val id: Long?,
    val name: String,
    val authors: String?,
)
class BooksProvider : ContentProvider() {
    companion object {
        private const val _ID = "id"
        private const val NAME = "name"
        private const val AUTHORS = "authors"
    }

    private lateinit var database: BookDatabase
}

Content URI

接下來,我們定義 authority 為 com.waynestalk.booksprovider.provider,並且定義 content URI 為 authority 接上資料表的名稱 books。所以,content URI 就會是 com.waynestalk.booksprovider.provider/books。所以,這個 content URI 可以識別在 BooksProvider 所管理的 books 資料表裡的資料。在這 content URI 後面再接上書的 id 的話,則這個新的 URI 就可以識別 books 資料表裡的某一筆資料。例如, com.waynestalk.booksprovider.provider/books/1 指的是,在 BooksProvider 所管理的 books 資料表裡 id 為 1 的資料。

UriMatcher 是可以對應 URI 到所設定的整數的工具。addURI() 加入對應的規則到 UriMatcher。addURI() 的第二個參數是 path 的 pattern。Pattern 有兩個 wildcard 字元:

  • *:對應任何的字串。
  • #:對應任何的數字字串。
class BooksProvider : ContentProvider() {
    companion object {
        private const val AUTHORITY = "com.waynestalk.booksprovider.provider"
        private val CONTENT_URI = Uri.parse("content://${AUTHORITY}/books")

        private const val FIND_ALL = 1
        private const val FIND_ONE = 2
    }

    private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI(AUTHORITY, "books", FIND_ALL)
        addURI(AUTHORITY, "books/#", FIND_ONE)
    }
』

onCreate()

每次 Android 系統建立 provider 時,都會先呼叫 onCreate()。而,當 provider 沒有在被使用時,Android 系統可能隨時會終止它。所以,Android 系統可能會建立和終止 provider 很多次。因此,我們在 onCreate() 裡面要盡量地減少初始化的時間。如果 onCreate() 花太多時間的話,則會減速 provider 的啟動時間。我們可以將一些初始化的工作延後到當它們真的需要時才執行。

class BooksProvider : ContentProvider() {
    override fun onCreate(): Boolean {
        database = BookDatabase.getInstance(context ?: return false)
        return true
    }
}

getType()

getType() 會回傳 URI 對應的 MIME type。如果 URI 是對應到多筆資料的話,type 的 prefix 會是 ContentResolver.CURSOR_DIR_BASE_TYPE/vnd.android.cursor.dir/)。如果 URI 是對應到單一筆資料的話,type 的 prefix 會是 ContentResolver.CURSOR_ITEM_BASE_TYPE/vnd.android.cursor.item/)。

class BooksProvider : ContentProvider() {
    companion object {
        private const val BOOKS_MIME_TYPE =
            "${ContentResolver.CURSOR_DIR_BASE_TYPE}/vnd.com.waynestalk.booksprovider.provider.books"
        private const val BOOK_MIME_TYPE =
            "${ContentResolver.CURSOR_ITEM_BASE_TYPE}/vnd.com.waynestalk.booksprovider.provider.books"
    }

    override fun getType(uri: Uri): String {
        return when (uriMatcher.match(uri)) {
            FIND_ALL -> BOOKS_MIME_TYPE
            FIND_ONE -> BOOK_MIME_TYPE
            else -> throw IllegalArgumentException("Unsupported URI: $uri")
        }
    }
}

query()

query() 處理查詢資料的請求。它的參數可以對應到 SQL SELECT 的語法,如下表。

query()SELECT
uriFROM table_name
projectionSELECT col, col, col, …
selectionWHERE col = ? AND …
selectionArgs取代 selection 中的 ?
sortOrderORDER BY col, col, …
query() compared to SELECT

首先,先用 uriMatcher 判斷 uri 是,查詢多筆資料或是單筆資料的請求。之後,再用其他的參數建立查詢資料庫的 SQL 字串。

class BooksProvider : ContentProvider() {
    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor {
        val query = when (uriMatcher.match(uri)) {
            FIND_ALL -> {
                val builder = SQLiteQueryBuilder()
                builder.tables = "books"
                val sql = builder.buildQuery(
                    projection,
                    selection,
                    null,
                    null,
                    if (sortOrder?.isNotEmpty() == true) sortOrder else "id ASC",
                    null
                )
                SimpleSQLiteQuery(sql, selectionArgs)
            }
            FIND_ONE -> {
                val builder = SQLiteQueryBuilder()
                builder.tables = "books"
                val sql = builder.buildQuery(
                    projection,
                    "id = ${uri.lastPathSegment}",
                    null,
                    null,
                    null,
                    null
                )
                SimpleSQLiteQuery(sql)
            }
            else -> {
                throw IllegalArgumentException("Unsupported URI: $uri")
            }
        }

        val cursor = database.dao().query(query)
        cursor.setNotificationUri(context?.contentResolver, uri)

        return cursor
    
}

insert()

insert() 處理新增資料的請求。

class BooksProvider : ContentProvider() {
    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        if (uriMatcher.match(uri) != FIND_ALL)
            throw IllegalArgumentException("Unsupported URI for insertion: $uri")
        if (values == null) return null

        val name =
            values.getAsString(NAME) ?: throw IllegalArgumentException("Value NAME is required")
        val authors = values.getAsString(AUTHORS)
        val id = database.dao().insert(BookEntity(null, name, authors))
        val entityUri = ContentUris.withAppendedId(CONTENT_URI, id)
        context?.contentResolver?.notifyChange(entityUri, null)
        return entityUri
    }
}

update()

update() 處理更新資料的請求。

class BooksProvider : ContentProvider() {
    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        if (values == null) return 0

        return when (uriMatcher.match(uri)) {
            FIND_ALL -> {
                val rows = database.openHelper.writableDatabase.update(
                    "books",
                    OnConflictStrategy.REPLACE,
                    values,
                    selection,
                    selectionArgs
                )
                context?.contentResolver?.notifyChange(uri, null)
                rows
            }
            FIND_ONE -> {
                val name = values.getAsString(NAME)
                val authors = values.getAsString(AUTHORS)
                val id = uri.lastPathSegment?.toLong() ?: return 0
                val rows = database.dao().update(BookEntity(id, name, authors))
                context?.contentResolver?.notifyChange(uri, null)
                rows
            }
            else -> throw IllegalArgumentException("Unsupported URI: $uri")
        }
    }
}

delete()

delete() 處理刪除資料的請求。

class BooksProvider : ContentProvider() {
    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        return when (uriMatcher.match(uri)) {
            FIND_ALL -> {
                val rows = database.openHelper.writableDatabase.delete(
                    "books",
                    selection,
                    selectionArgs
                )
                context?.contentResolver?.notifyChange(uri, null)
                rows
            }
            FIND_ONE -> {
                val rows = database.openHelper.writableDatabase.delete(
                    "books",
                    "id = ?",
                    arrayOf(uri.lastPathSegment)
                )
                context?.contentResolver?.notifyChange(uri, null)
                rows
            }
            else -> throw IllegalArgumentException("Unsupported URI: $uri")
        }
    }
}

AndroidManifest.xml

在 AndroidManifest.xml 中,首先要先設定 package 的名稱。然後,宣告兩個 <permission/>,一個是 read permission,另一個是 write permission。屬性 android:protectionLevel 設定為 normal 就可以。

宣告 <provider/>,其屬性設定如下:

  • android:authorities:Provider 的 authority。
  • exported:設定為 true。這樣其他的的 apps 才可以存取它。
  • android:grantUriPermissions:設定 temporary permission 為 true。
  • android:readPermission:設定 read permission,即是我們剛剛宣告在上面的 READ_BOOKS。
  • android:writePermission:設定 write permission,即是我們剛剛宣告在上面的 WRITE_BOOKS。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.waynestalk.booksprovider">

    <permission
        android:name="com.waynestalk.booksprovider.provider.READ_BOOKS"
        android:protectionLevel="normal" />
    <permission
        android:name="com.waynestalk.booksprovider.provider.WRITE_BOOKS"
        android:protectionLevel="normal" />

    <application>
        <provider
            android:name=".provider.BooksProvider"
            android:authorities="com.waynestalk.booksprovider.provider"
            android:enabled="true"
            android:exported="true"
            android:grantUriPermissions="true"
            android:label="BooksProvider"
           
android:readPermission="com.waynestalk.booksprovider.provider.READ_BOOKS"
            android:writePermission="com.waynestalk.booksprovider.provider.WRITE_BOOKS" />
    </application>

</manifest>

實作 Contract Class – BooksContract

Contract class 是一個簡單的 class。它只有定義 Content URI、欄位名稱、MIME types、和其他一些常數。我們可以提供 BooksContract 給其他的 apps 使用。

import android.net.Uri

object BooksContract {
    val CONTENT_URI: Uri = Uri.parse("content://com.waynestalk.booksprovider.provider/books")

    const val _ID = "id"
    const val NAME = "name"
    const val AUTHORS = "authors"
}

存取 BooksProvider

接下來,我們將介紹如何存取 BooksProvider。

Query

我們用 ContentResolver.query() 來查詢 provider 的資料。傳給 ContentResolver.query() 的參數,都會傳給 BooksProvider.query()。

class BookListViewModel : ViewModel() {
    val books: MutableLiveData<List<Book>> = MutableLiveData()

    fun loadBooks(contentResolver: ContentResolver, namePattern: String? = null) {
        viewModelScope.launch(Dispatchers.IO) {
            val cursor = contentResolver.query(
                BooksContract.CONTENT_URI,
                arrayOf(BooksContract._ID, BooksContract.NAME, BooksContract.AUTHORS),
                namePattern?.takeIf { it.isNotEmpty() }?.let { "${BooksContract.NAME} LIKE ?" },
                namePattern?.takeIf { it.isNotEmpty() }?.let { arrayOf("%$it%") },
                "${BooksContract._ID} ASC",
            ) ?: return@launch

            if (cursor.count == 0) {
                cursor.close()
                books.postValue(emptyList())
                return@launch
            }

            val idIndex = cursor.getColumnIndex(BooksContract._ID)
            val nameIndex = cursor.getColumnIndex(BooksContract.NAME)
            val authorsIndex = cursor.getColumnIndex(BooksContract.AUTHORS)

            val list = mutableListOf<Book>()
            while (cursor.moveToNext()) {
                val id = cursor.getLong(idIndex)
                val name = cursor.getString(nameIndex)
                val authors = cursor.getString(authorsIndex)
                list.add(Book(id, name, authors))
            }
            Log.d(TAG, "Loaded books: $list")

            cursor.close()
            books.postValue(list)
        }
    }
}

Insertion

我們用 ContentResolver.insert() 來向 provider 新增一筆資料。傳給 ContentResolver.insert() 的參數,都會傳給 BooksProvider.insert()。它會回傳新增的資料的 URI。

class AddBookViewModel : ViewModel() {
    val result: MutableLiveData<Result<Uri>> = MutableLiveData()

    private fun addBook(contentResolver: ContentResolver, name: String, authors: String?) {
        try {
            val values = ContentValues()
            values.put(BooksContract.NAME, name)
            authors?.let { values.put(BooksContract.AUTHORS, it) }

            val uri = contentResolver.insert(BooksContract.CONTENT_URI, values)
            if (uri == null) {
                result.postValue(Result.failure(Exception("Returned URI is null")))
            } else {
                result.postValue(Result.success(uri))
            }
        } catch (e: Exception) {
            result.postValue(Result.failure(e))
        }
    }
}

Update

我們用 ContentResolver.update() 來向 provider 更新一筆或多筆資料。傳給 ContentResolver.update() 的參數,都會傳給 BooksProvider.update()。它會回傳更新資料的筆數。

如果是更新多筆資料的話,就會用識別 books 資料表的 URI。若是更新單筆資料的話,就會用識別該筆資料的 URI。

class AddBookViewModel : ViewModel() {
    val result: MutableLiveData<Result<Uri>> = MutableLiveData()

    var editedBookId: Long? = null

    private fun editBook(contentResolver: ContentResolver, name: String, authors: String?) {
        try {
            val values = ContentValues()
            values.put(BooksContract.NAME, name)
            authors?.let { values.put(BooksContract.AUTHORS, it) }

            val uri = ContentUris.withAppendedId(BooksContract.CONTENT_URI, editedBookId!!)
            val rows = contentResolver.update(uri, values, null, null)
            if (rows > 0) {
                result.postValue(Result.success(uri))
            } else {
                result.postValue(Result.failure(Exception("Couldn't update the book")))
            }
        } catch (e: Exception) {
            result.postValue(Result.failure(e))
        }
    }
}

Deletion

我們用 ContentResolver.delete() 來向 provider 刪除一筆或多筆資料。傳給 ContentResolver.delete() 的參數,都會傳給 BooksProvider.delete()。它會回傳刪除資料的筆數。

如果是刪除多筆資料的話,就會用識別 books 資料表的 URI。若是刪除單筆資料的話,就會用識別該筆資料的 URI。

class BookListViewModel : ViewModel() {
    val result: MutableLiveData<Result<Uri>> = MutableLiveData()

    fun deleteBook(contentResolver: ContentResolver, book: Book) {
        viewModelScope.launch {
            try {
                val uri = ContentUris.withAppendedId(BooksContract.CONTENT_URI, book.id)
                val rows = contentResolver.delete(uri, null, null)
                if (rows > 0) {
                    result.postValue(Result.success(uri))
                } else {
                    result.postValue(Result.failure(Exception("Couldn't delete the book")))
                }
            } catch (e: Exception) {
                result.postValue(Result.failure(e))
            }
        }
    }
}

Permissions

如果是 BooksProvider 的 activity 或 fragment 存取 provider 的話,就不需要加上 <uses-permission/> 了。但是如果是其他 apps 存取 provider 的話,就要加上 <uses-permission/>。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="com.waynestalk.booksprovider.provider.READ_BOOKS" />
    <uses-permission android:name="com.waynestalk.booksprovider.provider.WRITE_BOOKS" />
</manifest>

還在記得在 Activity 中,請求 permissions。

ActivityCompat.requestPermissions(
    this,
    arrayOf(BooksContract.READ_PERMISSION, BooksContract.WRITE_PERMISSION),
    PERMISSION_REQUEST_CODE
)

結語

雖然本文章很長,但其實實作 content provider 並不困難。大部分的程式碼只是在將參數轉換成適當的 SQL 字串,然後對資料庫執行 SQL 字串。雖然我們可能很少有機會需要實作一個 content provider,但是系統提供了不少內建的 content provider。了解實作的細節,也有助於讓我們更了解如何存取 content provider。

參考

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Hans-Jurgen Mager on Unsplash
Read More

Kotlin Coroutine 教學

Kotlin 的 coroutine 是用來取代 thread。它不會阻塞 thread,而且還可以被取消。Coroutine core 會幫你管理 thread 的數量,讓你不需要自行管理,這也可以避免不小心建立過多的 thread。
Read More