課程 3: Content Providers 簡介

HsuJin發表於2018-03-24

這節課是 Android 開發(入門)課程 的第四部分《資料與資料庫》的第三節課,導師依然是 Jessica Lin 和 Katherine Kuan。這節課拋棄上節課直接在 Activity 中運算元據庫的做法 ,引入 Content Providers 作為資料庫和 Activity (UI) 之間的抽象層,使 Pets App 更符合 Android 框架的設計規範,保持以一致的方式管理對結構化資料集的訪問 (Keep a consistent way to manage access to a structured set of data)。

關鍵詞:Content Providers、Content Resolver、Content URI & URI Matcher、Content Authority、Data Validation、MIME Type

Content Providers 的優勢

Content Providers 主要為應用帶來三個方面的好處:

UI 把 Content Providers 看作一個暗箱操作

首先,Content Providers 在 Activity (UI) 與資料庫之間新增一個抽象層,將資料庫內部抽象化,隱藏資料儲存的詳情;也就是說,對於 UI 而言,UI 程式碼直接與 Content Providers 互動,不關心資料是儲存在資料庫中,還是文字檔案中。這樣一來,Content Providers 即使改變資料的儲存方式,UI 程式碼也可以保持不變。另外,Content Providers 通常也承擔著資料驗證 (Data Validation) 的重要角色,確保輸入資料庫的資料是有效的。

Cursor Loader 需要藉助 Content Providers 實現非同步查詢

其次,Content Providers 能夠與其它框架類配合使用,例如 Cursor Loader 藉助 Content Providers 實現資料變化時自動更新,使列表內容保持最新狀態,這是下節課的主要內容;還有,桌面小部件 (App Widgets) 以及實現資料上傳雲端和提供搜尋建議的 Sync Adapters 也需要利用 Content Providers。上面這些例子也證明了 Android 框架傾向於應用通過 Content Providers 來規範對結構化資料集的訪問。

通過 Content Providers 向獲得訪問許可權的應用分享資料

最後,在使用 Content Providers 之前,應用的資料是封閉的 (siloed),通過 Content Providers 可以將應用資料分享給獲得訪問許可權的應用。這是一種安全的管理應用資料訪問的方式,其它應用根據應用規定的 Contract(主要是 Content URI 與 CRUD 操作對應的方法)與 Content Providers 互動獲取資料;不過其實應用訪問自身的資料的流程也類似,這就帶出了 Content Providers 工作流的話題。

Content Providers 的工作流

應用通過 Content Providers 訪問自身資料

首先,在 Activity (UI) 中呼叫 Content Resolver 的 CRUD 操作對應的方法,並傳遞一個 URI。然後 ContentResolver 物件能夠把 Content Providers 作為客戶端進行互動,也就是說 Content Resolver 能夠根據 URI 選擇哪一個 Content Providers 客戶端傳遞 CRUD 操作指令及其資料,最終由 Content Providers 實現資料訪問。因此,UI 實際上是直接與 Content Resolver 互動的,選中的 Content Providers 完成資料訪問後返回資料給 Content Resolver 最終再傳遞給 UI。例如在 Pets App 中,CatalogActivity 通過呼叫 ContentResolver 的 query method 獲取一個 Cursor 物件。

In CatalogActivity.java

Cursor cursor = getContentResolver().query(
        PetEntry.CONTENT_URI,   // The content URI of the words table
        projection,             // The columns to return for each row
        null,                   // Selection criteria
        null,                   // Selection criteria
        null);                  // The sort order for the returned rows
複製程式碼

Tips:
Content Resolver 與 Content Provider 互動的一個重要特性是,UI 呼叫 ContentResolver 物件的 CRUD 操作對應的方法時,如 query method,Content Resolver 會呼叫選中的 Content Providers 中相同名稱的方法 (identically-named methods),即相同的 query method,以使 Content Providers 完成資料訪問。因此,在實現 Content Providers 這個抽象類的時候,必須 override 四個 CRUD 操作對應的方法,名稱分別為 queryupdateinsertdelete

Content URI

在 Content Providers 的整個端到端的工作流中,一個關鍵的引數是 UI 發出的 URI (Uniform Resource Identifier),指統一資源識別符號,之前在《課程 4: 偏好》中提到。對於 Content Providers 而言,URI 指定了需要進行 CRUD 操作的資料,它可以是一行、多行、整個資料庫,或者一個文字檔案、圖片檔案等媒體檔案。因此,在這裡 URI 被稱為 Content URI,格式如下:

<scheme>://<content authority>/<type of data>/<id>
複製程式碼
  1. Scheme: 固定為 content:// 表示該 URI 為 Content URI。

  2. Content Authority: 內容主機名,它是 Content URI 最重要的部分,它指定了所需的 Content Providers 客戶端。Content Authority 是由 AndroidManifest 中 provider 的 android:authorities 屬性決定的,通常設定為應用獨一無二的包名,例如 Pets App 的 Content Authority 就設定為 com.example.android.pets。如果是應用訪問自身資料的 Content URI,那麼其 Content Authority 就要和 AndroidManifest 中的保持一致。

Note:
Android Developers 文件推薦 Content Authority 在包名之外新增 provider 的字樣,以表示與包名有所區別。例如包名為 com.example.<appname> 時,Content Authority 可以是 com.example.<appname>.provider,不過課程中沒有這麼做。

  1. Type of Data: 資料型別,它指定了需要進行操作的資料。常見的模式是,資料型別為表名,表示需要訪問該表格的資料;如果在此之後 Content URI 沒有 ID 字尾,那麼就表示訪問整個表格的資料,這通常在 Create/Insert 新資料或者 Delete 整個表格的應用場景使用。例如在 Pets App 中表示需要訪問整個 pets 表格的 Content URI 為:

     content://com.example.android.pets/pets
    複製程式碼

Note:
當所需的資料不是資料庫,而是檔案時,這個部分就是檔案的目錄路徑,它可能是多層結構。

  1. ID: 可選,指定表格中某一行資料進行操作,通常用於 Update 或 Delete 某一行資料的應用場景,例如在 Pets App 中表示需要訪問 pets 表格第 5 行的 Content URI 為:(假設表格行數從 1 開始)

     content://com.example.android.pets/pets/5
    複製程式碼

綜上所述,將 Content URI 寫進 Pets App,首先在 AndroidManifest 中設定 Content Provider 的內容主機名,其中第一行的屬性指定了 Content Provider 的名稱及其路徑,.data.PetProvider 表示 PetProvider 類放在 data 目錄下;第三行的屬性設定應用資料是否通過 Content Provider 對外開放,設為 false 表示應用不對外分享資料。

In AndroidManifest.xml

<provider
    android:name=".data.PetProvider"
    android:authorities="com.example.android.pets"
    android:exported="false" />
複製程式碼

然後在 PetContract.java 中設定 Content URI 的各個常量,其中使用了 Uri 類的 parse method 將字串轉換為一個 Uri 物件,以及 withAppendedPath method 來構建新的 Uri 物件。

In PetContract.java

public static final String CONTENT_AUTHORITY = "com.example.android.pets";
public static final Uri BASE_CONTENT_URI = Uri.parse("content://" + CONTENT_AUTHORITY);
public static final String PATH_PETS = "pets";

public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, PATH_PETS);
複製程式碼

URI Matcher

課程 3: Content Providers 簡介

設計好 UI 端的 Content URI 之後,Content Providers 將通過 URI Matcher 接收並解析 URI,以確定如何處理訪問資料的請求。例如在 Pets App 中,存在兩種型別的 Content URI,一種表示對整個 pets 表格進行操作,另一種表示對 pets 表格的某一行進行操作,URI Matcher 需要判斷 UI 發出的 Content URI 是哪一種型別,針對不同型別的 Content URI,Content Providers 需要進行不同的處理方法。

因此,URI Matcher 應該根據所有可能的 Content URI 型別構建出一個匹配模型。首先,為所有可能的 Content URI 定義唯一的程式碼,例如在 Pets App 中,為兩種型別的 Content URI 分別定義兩個程式碼。程式碼數值可任意指定,但是要保證每個程式碼的唯一性。

URI pattern Code Constant Name
content://com.example.android.pets/pets 100 PETS
content://com.example.android.pets./pets/# 101 PET_ID

其中,對於第二種型別的 Content URI,使用了通用匹配符 # 表示任意長度數字的字串;另一個常用的萬用字元是 * 表示任意長度的字串,例如 Contacts App 中的一個 Content URI 用於通過姓名查詢聯絡人,因此 URI 會以未知長度的字串結束。

content://com.android.contacts/lookup/*
複製程式碼

在為所有可能的 Content URI 定義唯一的程式碼之後,新建一個 UriMatcher 物件,並通過 addURI method 新增匹配規則,完整程式碼如下。

In PetProvider.java

private static final int PETS = 100;
private static final int PET_ID = 101;

private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);

static {
    sUriMatcher.addURI(PetContract.CONTENT_AUTHORITY, PetContract.PATH_PETS, PETS);
    sUriMatcher.addURI(PetContract.CONTENT_AUTHORITY, PetContract.PATH_PETS + "/#", PET_ID);
}
複製程式碼
  1. UriMatcher 物件的名稱以一個小寫的 s 開頭表示靜態欄位,這是由 Android 的 Java 程式碼規範 建議的。類似的欄位命名規範還有,非公開且非靜態的欄位名稱以 m 開頭;其他欄位以小寫字母開頭;公開靜態 final 欄位(常量)為全部大寫並用下劃線連線。
  2. 新建 UriMatcher 物件時,其建構函式需要傳入一個初始匹配程式碼,通常使用 UriMatcher 類自帶的 NO_MATCH 常量。
  3. 分別通過 addURI method 傳入每個型別的 Content URI 及其對應的唯一程式碼,新增匹配規則。
  4. addURI method 放入 static 程式碼塊中,確保當這個類內的任何方法被呼叫時,static 程式碼塊內的程式碼會首先執行。

綜上所述,URI Matcher 的作用是,根據 Content URI 型別程式碼匹配 UI 傳來的 Content URI,確保 Content Providers 僅處理正確的 Content URI 傳遞的資料訪問請求。

1. Query

例如在 Pets App 中,PetProvider 的 query method 通過 URI Matcher 的匹配結果對資料庫進行不同的操作。

In PetProvider.java

@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
    SQLiteDatabase database = mDbHelper.getReadableDatabase();

    Cursor cursor;

    int match = sUriMatcher.match(uri);
    switch (match) {
        case PETS:
            cursor = database.query(PetEntry.TABLE_NAME, projection, selection, selectionArgs, null, null, sortOrder);
            break;
        case PET_ID:
            selection = PetEntry._ID + "=?";
            selectionArgs = new String[] { String.valueOf(ContentUris.parseId(uri)) };

            cursor = database.query(PetEntry.TABLE_NAME, projection, selection, selectionArgs, null, null, sortOrder);
            break;
        default:
            throw new IllegalArgumentException("Cannot query unknown URI " + uri);
    }
    return cursor;
}
複製程式碼
  1. mDbHelper 物件在 onCreate method 中新建,並且定義為全域性變數,所以在這裡首先通過 mDbHelper 獲取一個 SQLiteDatabase 物件。
  2. 通過呼叫 UriMatcher 的 match method 對傳入的 Content URI 進行匹配,獲得匹配程式碼後通過 switch/case 語句對資料庫進行不同的操作。
    (1)對於查詢整個表格的 Content URI,直接將輸入引數,包括 selection、selectionArgs 等引數,傳入 SQLiteDatabase 物件的 query method,返回一個 Cursor 物件。
    (2)對於查詢表格中某一行的 Content URI,需要手動設定 selection 和 selectionArgs 引數,其中需要呼叫 ContentUrisparseId method 解析出 Content URI 的 ID,並呼叫 String.valueOf method 將 int 轉換為 String。最後同樣是將引數傳入 SQLiteDatabase 物件的 query method,返回一個 Cursor 物件。
    (3)當 Content URI 未匹配以上兩種型別的任何一種時,丟擲一個異常,告知開發者異常資訊。

2. Insert

類似地,在 Pets App 中,PetProvider 的 insert method 通過 URI Matcher 的匹配,僅對請求整個表格的 Content URI 有效。

In PetProvider.java

@Override
public Uri insert(Uri uri, ContentValues contentValues) {
    final int match = sUriMatcher.match(uri);
    switch (match) {
        case PETS:
            return insertPet(uri, contentValues);
        default:
            throw new IllegalArgumentException("Insertion is not supported for " + uri);
    }
}
複製程式碼

當傳入的 Content URI 匹配程式碼為 PETS 代表的 100 時,呼叫 insertPet method 對資料庫進行插入新行操作,輸入引數為 Content URI 和 ContentValues 物件。否則,丟擲一個異常。

In PetProvider.java

private Uri insertPet(Uri uri, ContentValues values) {
    String name = values.getAsString(PetEntry.COLUMN_PET_NAME);
    if (name == null) {
        throw new IllegalArgumentException("Pet requires a name");
    }

    Integer gender = values.getAsInteger(PetEntry.COLUMN_PET_GENDER);
    if (gender == null || !PetEntry.isValidGender(gender)) {
        throw new IllegalArgumentException("Pet requires valid gender");
    }

    Integer weight = values.getAsInteger(PetEntry.COLUMN_PET_WEIGHT);
    if (weight != null && weight < 0) {
        throw new IllegalArgumentException("Pet requires valid weight");
    }

    SQLiteDatabase database = mDbHelper.getWritableDatabase();

    long id = database.insert(PetEntry.TABLE_NAME, null, values);
    if (id == -1) {
        Log.e(LOG_TAG, "Failed to insert row for " + uri);
        return null;
    }

    return ContentUris.withAppendedId(uri, id);
}
複製程式碼
  1. 由於這裡從 UI 引入了新資料,所以需要先進行資料驗證 (Data Validation)。具體的做法是,呼叫 ContentValues 物件各個對應的 getter method,獲取必要的鍵的值,對值進行驗證。如果值不可接受,就丟擲異常,告知開發者異常資訊。例如,通過 getAsString 獲取傳入的 ContentValues 物件鍵為 COLUMN_PET_NAME 的值並存為字串,如果該字串為 null,那麼就丟擲一個異常。
  2. Content Providers 的 insert method 的返回值為帶新插入行 ID 的一個 Content Uri 物件。因此,這裡需要通過呼叫 ContentUris 的 withAppendedId method 構建一個帶 ID 字尾的 Uri 物件,其中傳入的 ID 引數是 SQLiteDatabase 的 insert method 的返回值。

3. Update

類似地,在 Pets App 中,PetProvider 的 update method 通過 URI Matcher 的匹配,需要對兩種 Content URI 都有效。

In PetProvider.java

@Override
public int update(Uri uri, ContentValues contentValues, String selection, String[] selectionArgs) {
    final int match = sUriMatcher.match(uri);
    switch (match) {
        case PETS:
            return updatePet(uri, contentValues, selection, selectionArgs);
        case PET_ID:
            selection = PetEntry._ID + "=?";
            selectionArgs = new String[] { String.valueOf(ContentUris.parseId(uri)) };
            return updatePet(uri, contentValues, selection, selectionArgs);
        default:
            throw new IllegalArgumentException("Update is not supported for " + uri);
    }
}
複製程式碼
  1. 對於查詢整個表格的 Content URI,直接將輸入引數,包括 selection、selectionArgs 引數,傳入輔助方法 updatePet 進行處理。
  2. 對於查詢表格中某一行的 Content URI,需要手動設定 selection 和 selectionArgs 引數,方法與上述 query method 相同。最後同樣是將引數傳入輔助方法 updatePet 進行處理。
  3. 當 Content URI 未匹配以上兩種型別的任何一種時,丟擲一個異常,告知開發者異常資訊。

In PetProvider.java

private int updatePet(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
    if (values.containsKey(PetEntry.COLUMN_PET_NAME)) {
        String name = values.getAsString(PetEntry.COLUMN_PET_NAME);
        if (name == null) {
            throw new IllegalArgumentException("Pet requires a name");
        }
    }

    if (values.containsKey(PetEntry.COLUMN_PET_GENDER)) {
        Integer gender = values.getAsInteger(PetEntry.COLUMN_PET_GENDER);
        if (gender == null || !PetEntry.isValidGender(gender)) {
            throw new IllegalArgumentException("Pet requires valid gender");
        }
    }

    if (values.containsKey(PetEntry.COLUMN_PET_WEIGHT)) {
        Integer weight = values.getAsInteger(PetEntry.COLUMN_PET_WEIGHT);
        if (weight != null && weight < 0) {
            throw new IllegalArgumentException("Pet requires valid weight");
        }
    }

    if (values.size() == 0) {
        return 0;
    }

    SQLiteDatabase database = mDbHelper.getWritableDatabase();

    return database.update(PetEntry.TABLE_NAME, values, selection, selectionArgs);
}
複製程式碼
  1. insert method 類似,這裡的重點也是在資料驗證上。但不同的是,更新資料時 ContentValues 物件中某些鍵/值對可能不存在,因此首先需要通過 containKey method 判斷,僅對存在的鍵/值對進行資料驗證。
  2. Content Providers 的 insert method 的返回值為更新行的數量值。因此,如果資料驗證全部通過,直接將 SQLiteDatabase 的 update method 的返回值輸出;如果 ContentValues 物件為空,返回值 0,表示沒有行被更新。

4. Delete

類似地,在 Pets App 中,PetProvider 的 delete method 通過 URI Matcher 的匹配,需要對兩種 Content URI 都有效。

@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
    SQLiteDatabase database = mDbHelper.getWritableDatabase();

    final int match = sUriMatcher.match(uri);
    switch (match) {
        case PETS:
            return database.delete(PetEntry.TABLE_NAME, selection, selectionArgs);
        case PET_ID:
            selection = PetEntry._ID + "=?";
            selectionArgs = new String[] { String.valueOf(ContentUris.parseId(uri)) };
            return database.delete(PetEntry.TABLE_NAME, selection, selectionArgs);
        default:
            throw new IllegalArgumentException("Deletion is not supported for " + uri);
    }
}
複製程式碼
  1. 對於查詢整個表格的 Content URI,直接將輸入引數,包括 selection、selectionArgs 引數,傳入 SQLiteDatabase 物件的 delete method,返回值為受影響的行數。
  2. 對於查詢表格中某一行的 Content URI,需要手動設定 selection 和 selectionArgs 引數,方法與上述 query method 相同。最後同樣是將引數傳入 SQLiteDatabase 物件的 delete method,返回值為受影響的行數。
  3. 當 Content URI 未匹配以上兩種型別的任何一種時,丟擲一個異常,告知開發者異常資訊。

MIME Type

MIME (Multipurpose Internet Mail Extensions) 型別,也稱為內容型別 (Content Type)、媒體型別 (Media Type)。它是網路傳輸內容的一種識別符號,由檔案格式及其內容決定。也就是說,一個 MIME 型別至少包括兩個部分:一個型別 (Type) 和一個子型別 (Subtype)。此外,它還可能包括一個或多個可選引數 (Optional Parameter)。例如,一個 HTML 檔案的網際網路媒體型別可能是

text/html; charset = UTF-8
複製程式碼

在這個例子中,檔案型別為 text,子型別為 html,而 charset 是一個可選引數,其值為 UTF-8

對於 Android 應用而言,MIME 型別遵循特定的格式 (Vendor tree),即以 "vnd.android.cursor…" 開頭,後接 Content Authority 以及資料路徑。其中,開頭的基礎型別 (Base Type) 根據目錄 (directory, abbr. dir) 與子項 (item) 分為兩種:

  • 目錄 MIME 基礎型別:vnd.android.cursor.dir
  • 子項 MIME 基礎型別:vnd.android.cursor.item

上面兩種基礎型別已經在 ContentResolver 類中分別定義為常量,可直接呼叫。因此在 Pets App 中,為資料庫 pets 自定義兩個 MIME 型別,程式碼如下:

In PetContract.java

public static final String CONTENT_LIST_TYPE =
                ContentResolver.CURSOR_DIR_BASE_TYPE + "/" + CONTENT_AUTHORITY + "/" + PATH_PETS;

public static final String CONTENT_ITEM_TYPE =
                ContentResolver.CURSOR_ITEM_BASE_TYPE + "/" + CONTENT_AUTHORITY + "/" + PATH_PETS;
複製程式碼

MIME 型別與檔案擴充名相對應,因此計算機系統通常通過擴充名來確定一個檔案的媒體型別並決定與其相關聯的軟體。在 Android 中,MIME 型別通常在傳送 Intent 請求時與 URI 配合,幫助系統確定裝置上最適合處理請求的應用元件。例如,一個能夠顯示圖片的 Activity 不一定能夠播放音訊,但是兩者的 URI 類似,通過 MIME 型別就可以明確其中一種。

針對 Content URI 的情況,系統就會檢查相應的 Content Providers,通過其 getType() method 獲取 MIME 型別。這是因為,所有 Content Providers 都會通過 MIME 型別來定義它所處理的資料型別,這是一種標準化的做法。這種做法既保證了程式碼之間的標準化互動,也使不同資料型別之間不易混淆。例如一個 RailwayProvider 可能處理 trains、stations、tickets 等不同型別的資料,而通過獨一無二的 MIME 型別能將他們明確分別。

在 Pets App 中,Content Providers 在 getType() method 返回對應的 MIME 型別。

In PetProvider.java

@Override
public String getType(Uri uri) {
    final int match = sUriMatcher.match(uri);
    switch (match) {
        case PETS:
            return PetEntry.CONTENT_LIST_TYPE;
        case PET_ID:
            return PetEntry.CONTENT_ITEM_TYPE;
        default:
            throw new IllegalStateException("Unknown URI " + uri + " with match " + match);
    }
}
複製程式碼

相關文章