這節課是 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 主要為應用帶來三個方面的好處:
首先,Content Providers 在 Activity (UI) 與資料庫之間新增一個抽象層,將資料庫內部抽象化,隱藏資料儲存的詳情;也就是說,對於 UI 而言,UI 程式碼直接與 Content Providers 互動,不關心資料是儲存在資料庫中,還是文字檔案中。這樣一來,Content Providers 即使改變資料的儲存方式,UI 程式碼也可以保持不變。另外,Content Providers 通常也承擔著資料驗證 (Data Validation) 的重要角色,確保輸入資料庫的資料是有效的。
其次,Content Providers 能夠與其它框架類配合使用,例如 Cursor Loader 藉助 Content Providers 實現資料變化時自動更新,使列表內容保持最新狀態,這是下節課的主要內容;還有,桌面小部件 (App Widgets) 以及實現資料上傳雲端和提供搜尋建議的 Sync Adapters 也需要利用 Content Providers。上面這些例子也證明了 Android 框架傾向於應用通過 Content Providers 來規範對結構化資料集的訪問。
最後,在使用 Content Providers 之前,應用的資料是封閉的 (siloed),通過 Content Providers 可以將應用資料分享給獲得訪問許可權的應用。這是一種安全的管理應用資料訪問的方式,其它應用根據應用規定的 Contract(主要是 Content URI 與 CRUD 操作對應的方法)與 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 操作對應的方法,名稱分別為 query
、update
、insert
、delete
。
Content URI
在 Content Providers 的整個端到端的工作流中,一個關鍵的引數是 UI 發出的 URI (Uniform Resource Identifier),指統一資源識別符號,之前在《課程 4: 偏好》中提到。對於 Content Providers 而言,URI 指定了需要進行 CRUD 操作的資料,它可以是一行、多行、整個資料庫,或者一個文字檔案、圖片檔案等媒體檔案。因此,在這裡 URI 被稱為 Content URI,格式如下:
<scheme>://<content authority>/<type of data>/<id>
複製程式碼
-
Scheme: 固定為
content://
表示該 URI 為 Content URI。 -
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
,不過課程中沒有這麼做。
-
Type of Data: 資料型別,它指定了需要進行操作的資料。常見的模式是,資料型別為表名,表示需要訪問該表格的資料;如果在此之後 Content URI 沒有 ID 字尾,那麼就表示訪問整個表格的資料,這通常在 Create/Insert 新資料或者 Delete 整個表格的應用場景使用。例如在 Pets App 中表示需要訪問整個 pets 表格的 Content URI 為:
content://com.example.android.pets/pets 複製程式碼
Note:
當所需的資料不是資料庫,而是檔案時,這個部分就是檔案的目錄路徑,它可能是多層結構。
-
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
設計好 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);
}
複製程式碼
- UriMatcher 物件的名稱以一個小寫的 s 開頭表示靜態欄位,這是由 Android 的 Java 程式碼規範 建議的。類似的欄位命名規範還有,非公開且非靜態的欄位名稱以 m 開頭;其他欄位以小寫字母開頭;公開靜態 final 欄位(常量)為全部大寫並用下劃線連線。
- 新建 UriMatcher 物件時,其建構函式需要傳入一個初始匹配程式碼,通常使用 UriMatcher 類自帶的 NO_MATCH 常量。
- 分別通過
addURI
method 傳入每個型別的 Content URI 及其對應的唯一程式碼,新增匹配規則。 - 將
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;
}
複製程式碼
- mDbHelper 物件在
onCreate
method 中新建,並且定義為全域性變數,所以在這裡首先通過 mDbHelper 獲取一個 SQLiteDatabase 物件。 - 通過呼叫 UriMatcher 的
match
method 對傳入的 Content URI 進行匹配,獲得匹配程式碼後通過 switch/case 語句對資料庫進行不同的操作。
(1)對於查詢整個表格的 Content URI,直接將輸入引數,包括 selection、selectionArgs 等引數,傳入 SQLiteDatabase 物件的query
method,返回一個 Cursor 物件。
(2)對於查詢表格中某一行的 Content URI,需要手動設定 selection 和 selectionArgs 引數,其中需要呼叫 ContentUris 的parseId
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);
}
複製程式碼
- 由於這裡從 UI 引入了新資料,所以需要先進行資料驗證 (Data Validation)。具體的做法是,呼叫 ContentValues 物件各個對應的 getter method,獲取必要的鍵的值,對值進行驗證。如果值不可接受,就丟擲異常,告知開發者異常資訊。例如,通過
getAsString
獲取傳入的 ContentValues 物件鍵為COLUMN_PET_NAME
的值並存為字串,如果該字串為null
,那麼就丟擲一個異常。 - 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);
}
}
複製程式碼
- 對於查詢整個表格的 Content URI,直接將輸入引數,包括 selection、selectionArgs 引數,傳入輔助方法
updatePet
進行處理。 - 對於查詢表格中某一行的 Content URI,需要手動設定 selection 和 selectionArgs 引數,方法與上述
query
method 相同。最後同樣是將引數傳入輔助方法updatePet
進行處理。 - 當 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);
}
複製程式碼
- 與
insert
method 類似,這裡的重點也是在資料驗證上。但不同的是,更新資料時 ContentValues 物件中某些鍵/值對可能不存在,因此首先需要通過containKey
method 判斷,僅對存在的鍵/值對進行資料驗證。 - 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);
}
}
複製程式碼
- 對於查詢整個表格的 Content URI,直接將輸入引數,包括 selection、selectionArgs 引數,傳入 SQLiteDatabase 物件的
delete
method,返回值為受影響的行數。 - 對於查詢表格中某一行的 Content URI,需要手動設定 selection 和 selectionArgs 引數,方法與上述 query method 相同。最後同樣是將引數傳入 SQLiteDatabase 物件的
delete
method,返回值為受影響的行數。 - 當 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);
}
}
複製程式碼