Android 進階之程序通訊之 ContentProvider 內容提供者
ContentProvider 簡介
作為安卓 F4, ContentProvider
其實是比較低調的一個,日常開發中使用的頻率也沒那三位多。
它的誕生就是為了給不同應用提供內容訪問,自然在我們研究的“多程序通訊方式”之中。
ContentProvider
封裝了資料的跨程序傳輸,我們可以直接使用 getContentResolver()
拿到 ContentResolver
進行增刪改查即可。
ContentProvider
以一個或多個表(與在關係型資料庫中的表類似)的形式將資料呈現給外部應用。 行表示提供程式收集的某種資料型別的例項,行中的每個列表示為例項收集的每條資料。
實現一個 ContentProvider
時需要實現以下幾個方法:
onCreate() query() insert() update() delete() getType()
注意:1. onCreate()
預設執行在主執行緒,別做耗時操作, query()
也最好非同步執行 2. 上面的 4 個增刪改查操作都可能會被多個執行緒併發訪問,因此需要注意執行緒安全
ContentProvider 與 URI
ContentProvider
使用 URI 標識要操作的資料,這裡的內容 URI 主要包括兩部分:
- authority:整個提供程式的符號名稱
- path:指向表的名稱/路徑
內容 URI 統一的形式就是:
content://authority/path
例如:
content://user_dictionary/words
當你呼叫 ContentResolver
方法來訪問 ContentProvider
中的表時,需要傳遞要操作表的 URI。
在通過 ContentResolver
進行資料請求時(比如 contentResolver.insert(uri, contentValues);
), 系統會檢查指定 URI 的 authority 資訊,然後將請求傳遞給註冊監聽這個 authority 的 ContentProvider
。這個 ContentProvider
可以監聽 URI 想要操作的內容,Android 中為我們提供了 UriMatcher
來解析 URI。
許可權
由於內容提供者要被不同應用訪問,因此許可權必不可少。我們可以給內容提供者設定 “讀/寫”許可權。
設定自定義許可權分三步:
- 向系統宣告一個許可權
- 給要設定許可權的元件設定需要這個許可權
- 在想要使用上述元件的應用中註冊這個許可權
先定義許可權
<permission android:name="top.shixinzhang.permission.READ_CONTENT"//指定許可權的名稱 android:label="Permission for read content provider" android:protectionLevel="normal" />
其中 android:protectionLevel
可選的值主要如下:
-
normal
:低風險,任何應用都可以申請,在安裝應用時,不會直接提示給使用者 -
dangerous
:高風險,系統可能要求使用者輸入相關資訊才授予許可權,任何應用都可以申請,在安裝應用時,會直接提示給使用者 -
signature
:只有和定義了這個許可權的 apk 用相同的私鑰簽名的應用才可以申請該許可權 -
signatureOrSystem
:有兩種應用可以申請該許可權- 和定義了這個許可權的 apk 用相同的私鑰簽名的應用
- 在 /system/app 目錄下的應用
這裡我們設定的值為 normal
。
給 provider 中設定讀許可權
這裡設定的 readPermission
為上面宣告的值:
<provider android:name=".provider.IPCPersonProvider" android:authorities="net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider" android:exported="true" android:grantUriPermissions="true" android:process=":provider" android:readPermission="top.shixinzhang.permission.READ_CONTENT">
這個許可權無法在執行時請求,必須在清單檔案中使用 <uses-permission>
元素和內容提供者定義的準確許可權名稱指明你的許可權。
在應用中註冊這個許可權
<uses-permission android:name="top.shixinzhang.permission.READ_CONTENT"/>
在您的清單檔案中指定此元素後,您將有效地為應用“請求”此許可權。 使用者安裝您的應用時,會隱式授予允許此請求。
官方建議: 對於同一開發者提供的不同應用之間的 IPC 通訊,最好將 android:protectionLevel
屬性設定為 “signature” 保護級別。簽名許可權不需要使用者確認,因此,這種方式不僅能提升使用者體驗,而且在相關應用使用相同的金鑰進行簽名來訪問資料時,還能更好地控制對內容提供程式資料的訪問。
支援的資料型別
Android 本身包括的內容提供程式可管理音訊、視訊、影象和個人聯絡資訊等資料。
內容提供者可以提供多種不同的資料型別:
- int
- long
- double
- float
- BLOB:作為 64KB 位元組的陣列的二進位制大型物件
使用二進位制大型物件 (BLOB) 資料型別儲存大小或結構會發生變化的資料。 例如,您可以使用 BLOB 列來儲存協議緩衝區或 JSON 結構。 之前反編譯微信時,儲存朋友圈的資料就是 BLOB 型別。
ContentProvider
還會維護其定義的每個內容 URI 的 MIME 資料型別資訊。
你可以使用 MIME 型別資訊確定應用是否可以處理 ContentProvider
提供的資料,或根據 MIME 型別選擇處理型別。
在使用包含複雜資料結構或檔案的提供程式時,通常需要 MIME 型別。
ContentProvider 的使用
ContentProvider
的使用分為以下 4 步:
- 設計資料儲存
- 選擇檔案還是資料庫
- 如果您想提供 Bitmap 或其他龐大的檔案導向型資料,請將資料儲存在一個檔案中,然後間接提供這些資料,而不是直接將其儲存在表中
- 使用二進位制大型物件 (BLOB) 資料型別儲存大小或結構會發生變化的資料。 例如使用 BLOB 列來儲存 JSON
- 建立 ContentProvider 子類,實現關鍵方法
- ContentProvider 例項通過處理來自其他應用的請求來管理對結構化資料集的訪問
- 所有形式的訪問最終都會呼叫
ContentResolver
,後者接著呼叫ContentProvider
的具體方法來獲取訪問許可權 - 注意文章開頭提到的避免耗時操作和執行緒安全
- 儘管必須實現這些方法,它們的返回值並不重要,只要返回符合要求的資料型別即可,即使不執行任何其他操作
- 定義提供程式的授權字串(authority)、內容 URI 以及列名稱
- 對應前面設計的資料庫表名和欄位名
- 如果想讓內容提供者應用處理 Intent,則還要定義 Intent 操作、Extra 資料以及標誌
- 還要定義想要訪問該資料的應用必須具備的許可權
- 通過
ContentResolver
和 URI 進行增刪改查
下面以一個例子實驗一下。
設計資料儲存
這裡我們使用 SQLite 儲存資料,建立一個數據庫幫助類:
public class DbOpenHelper extends SQLiteOpenHelper { private final static String DB_NAME = "person_list.db"; public final static String TABLE_NAME = "person"; private final static int DB_VERSION = 1; private final String SQL_CREATE_TABLE = "create table if not exists " + TABLE_NAME + "(_id integer primary key, name TEXT, description TEXT)"; public DbOpenHelper(final Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(final SQLiteDatabase db) { db.execSQL(SQL_CREATE_TABLE); } @Override public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { } }
上面的程式碼建立了資料庫 person_list
和 person
表。
建立 ContentProvider 子類
public class IPCPersonProvider extends ContentProvider { private final String TAG = this.getClass().getSimpleName(); private static final UriMatcher mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); public static final String AUTHORITY = "net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider";//授權 public static final Uri PERSON_CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/person"); private SQLiteDatabase mDatabase; private Context mContext; private String mTable; private static final int TABLE_CODE_PERSON = 2; static { //關聯不同的 URI 和 code,便於後續 getType mUriMatcher.addURI(AUTHORITY, "person", TABLE_CODE_PERSON); } @Override public boolean onCreate() { initProvider(); return false; } /** * 初始化時清楚舊資料,插入一條資料 */ private void initProvider() { mTable = DbOpenHelper.TABLE_NAME; mContext = getContext(); mDatabase = new DbOpenHelper(mContext).getWritableDatabase(); new Thread(new Runnable() { @Override public void run() { mDatabase.execSQL("delete from " + mTable); mDatabase.execSQL("insert into " + mTable + " values(1,'shixinzhang','handsome boy')"); } }).start(); } @Nullable @Override public Cursor query(final Uri uri, final String[] projection, final String selection, final String[] selectionArgs, final String sortOrder) { String tableName = getTableName(uri); showLog(tableName + " 查詢資料" ); return mDatabase.query(tableName, projection, selection, selectionArgs, null, sortOrder, null); } @Nullable @Override public Uri insert(final Uri uri, final ContentValues values) { String tableName = getTableName(uri); showLog(tableName + " 插入資料"); mDatabase.insert(tableName, null, values); mContext.getContentResolver().notifyChange(uri, null); return null; } @Override public int delete(final Uri uri, final String selection, final String[] selectionArgs) { String tableName = getTableName(uri); showLog(tableName + " 刪除資料"); int deleteCount = mDatabase.delete(tableName, selection, selectionArgs); if (deleteCount > 0) { mContext.getContentResolver().notifyChange(uri, null); } return deleteCount; } @Override public int update(final Uri uri, final ContentValues values, final String selection, final String[] selectionArgs) { String tableName = getTableName(uri); showLog(tableName + " 更新資料"); int updateCount = mDatabase.update(tableName, values, selection, selectionArgs); if (updateCount > 0) { mContext.getContentResolver().notifyChange(uri, null); } return updateCount; } /** * CRUD 的引數是 Uri,根據 Uri 獲取對應的表名 * * @param uri * @return */ private String getTableName(final Uri uri) { String tableName = ""; int match = mUriMatcher.match(uri); switch (match){ case TABLE_CODE_PERSON: tableName = DbOpenHelper.TABLE_NAME; } showLog("UriMatcher " + uri.toString() + ", result: " + match); return tableName; } @Nullable @Override public String getType(final Uri uri) { return null; } private void showLog(final String s) { LogUtils.d(TAG, s + "***** @ " + Thread.currentThread().getName()); } }
定義 ContentProvider 的授權字串(authority)、內容 URI、許可權
①ContentProvider 可以關聯多個授權字串(authority),如上述程式碼所示,我們使用這個類的完整路徑名為一個authority:
public static final String AUTHORITY = "net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider";//授
②內容 URI 用於在 ContentProvider 中標識資料的 URI,可以使用 content:// + authority
作為 ContentProvider 的 URI,這裡就是:
content://net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider
如果該資料庫中有多個表,可以繼續增加 path:
content://net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider/table1 content://net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider/table2
這裡我們的 URI 為:
public static final Uri PERSON_CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/person");
在 ContentProvider 中可以通過 UriMatcher
來為不同的 URI 關聯不同的 code,便於後續根據 URI 找到對應的表。
③AndroidManifest 中宣告許可權
<uses-permission android:name="top.shixinzhang.permission.READ_CONTENT"/> <!--讀內容提供者的許可權--> <permission android:name="top.shixinzhang.permission.READ_CONTENT" android:label="Permission for read content provider" android:protectionLevel="normal" /> <provider android:name=".provider.IPCPersonProvider" android:authorities="net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider" android:exported="true" android:grantUriPermissions="true" android:process=":provider" android:readPermission="top.shixinzhang.permission.READ_CONTENT"></pre> 因為我們要測試跨程序通訊,因此這裡將 provider 宣告為另外一個程序 `android:process=":provider"`。 ### 通過 `ContentResolver` 和 URI 進行增刪改查 在 Activity 中呼叫 ContentResolver 進行增加和查詢操作: <pre class="prism-token tokenlanguage-javascript" style="box-sizing: border-box; list-style: inherit; margin: 24px 0px; font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; font-style: normal; font-variant: normal; font-weight: normal; font-stretch: normal; font-size: 14px; padding: 16px; overflow: auto; line-height: 1.45; background-color: rgb(247, 247, 247); border-radius: 3px; word-wrap: normal; text-align: left; white-space: pre; word-spacing: 0px; word-break: normal; tab-size: 2; hyphens: none; color: rgb(51, 51, 51); letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; -webkit-text-stroke-width: 0px;">private void getContentFromContentProvider() { Uri uri = IPCPersonProvider.PERSON_CONTENT_URI;//ContentProvider 中註冊的 URI ContentValues contentValues = new ContentValues(); contentValues.put("_id", id++); contentValues.put("name", "rourou" + DateUtils.getCurrentTime()); contentValues.put("description", "beautiful girl"); ContentResolver contentResolver = getContentResolver();//獲取內容處理器 contentResolver.insert(uri, contentValues);//插入一條資料 //再查詢一次 Cursor cursor = contentResolver.query(uri, new String[]{"name", "description"}, null, null, null, null); if (cursor == null) { return; } StringBuilder cursorResult = new StringBuilder("DB 查詢結果:"); while (cursor.moveToNext()) { String result = cursor.getString(0) + ", " + cursor.getString(1); LogUtils.d(TAG, "DB 查詢結果:" + result); cursorResult.append("\n").append(result); } mTvCpResult.setText(cursorResult.toString()); cursor.close(); } @OnClick(R.id.btn_add_person_to_db) public void addPersonToDB() { getContentFromContentProvider(); }
執行結果
呼叫 ContentProvider 的 Activity:

image
我們在另外一個程序的 provider 中打了些 Log,可以看到被呼叫了:

image
原始碼淺析
在上面列印 ContentProvider 增刪改查所線上程時,看到顯示的是 “Binder”,難不成也是使用 Binder 實現的麼,我們去看看原始碼。
先看 Activity 直接呼叫的 ContentResolver.insert()
方法:
public final @Nullable Uri insert(@RequiresPermission.Write @NonNull Uri url, @Nullable ContentValues values) { Preconditions.checkNotNull(url, "url"); IContentProvider provider = acquireProvider(url); if (provider == null) { throw new IllegalArgumentException("Unknown URL " + url); } try { long startTime = SystemClock.uptimeMillis(); Uri createdRow = provider.insert(mPackageName, url, values); long durationMillis = SystemClock.uptimeMillis() - startTime; maybeLogUpdateToEventLog(durationMillis, url, "insert", null /* where */); return createdRow; } catch (RemoteException e) {...} }
可以看到它呼叫了 IContentProvider.insert()
方法,直覺告訴我,這個類應該不簡單!
點開原始碼一看,果然!
/** * The ipc interface to talk to a content provider. * @hide */ public interface IContentProvider extends IInterface {...}
IContentProvider
也是個 IInterface
,跟我們前面看的 AIDL、Binder 一模一樣嘛!
在下水平時間有限,就不深入研究了,這裡借用 gityuan 的 理解ContentProvider原理 的一張圖大概瞭解一下:

注意事項
防止 SQL 注入
如果 ContentProvider
管理的資料位於 SQL 資料庫中,在儲存資料時,有可能會遇到惡意語句導致 SQL 注入。
這部分翻譯理解自官方文件,有不合適的地方求指出 0.0
比如 ContentProvider.query()
:
public Cursor query(final Uri uri, final String[] projection, final String selection, final String[] selectionArgs, final String sortOrder) { String tableName = getTableName(uri); return mDatabase.query(tableName, projection, selection, selectionArgs, null, sortOrder, null); }
這時如果輸入的 selection
為惡意 SQL,就可能被執行,造成意外的損失。
例如,傳入的 selection
為 name = nothing; DROP TABLE *;
,這會生成查詢子句 name = nothing; DROP TABLE *;
。
由於這個查詢子句被作為 SQL 語句處理,因此這可能會導致 ContentProvider
擦除資料庫中的所有表。
要避免此問題,可使用一個用於將 ? 作為可替換引數的查詢子句以及一個單獨的選擇引數陣列。
也就是將查詢的 “欄位名 = ?” 和具體值分別傳入到在上述程式碼的 selection
和 selectionArgs
。
這樣執行查詢操作時,使用者的輸入直接受查詢約束,而不會被作為 SQL 語句的一部分,因此無法注入惡意 SQL。
將 ? 用作可替換引數的條件語句和一個選擇引數陣列是指定查詢語句的首選方式,即使 ContentProvider
管理的資料型別不是 SQL 資料庫。
Cursor 搭配 ListView,使用 SimpleCursorAdapter 更配
ContentProvider.query()
會返回 Cursor
,如果要結合 ListView
展示,可以使用 SimpleCursorAdapter
// Cursor 中要獲取的資料列名稱 String[] mWordListColumns = { UserDictionary.Words.WORD, UserDictionary.Words.LOCALE }; // ListView 的 item 佈局中要展示上面兩個資料對於的 id int[] mWordListItems = { R.id.dictWord, R.id.locale}; mCursorAdapter = new SimpleCursorAdapter( getApplicationContext(),// The application's Context object R.layout.wordlistrow,// A layout in XML for one row in the ListView mCursor,// The result from the query mWordListColumns,// A string array of column names in the cursor mWordListItems,// An integer array of view IDs in the row layout 0);// Flags (usually none are needed) mWordList.setAdapter(mCursorAdapter);
注意:要通過 Cursor
顯示 ListView
,遊標必需包含名為 _ID 的列。
ContentProvider 的使用場景
只有在多個應用間分享資料時才需要使用 ContentProvider
,比如:
- 您想為其他應用提供複雜的資料或檔案
- 您想允許使用者將複雜的資料從您的應用複製到其他應用中
- 您想使用搜索框架提供自定義搜尋建議
否則直接使用應用內常用的資料儲存方式(sp, db, file)即可。
程式碼地址
喜歡的話請幫忙轉發一下能讓更多有需要的人看到吧,有些技術上的問題大家可以多探討一下。


以上Android資料以及更多Android相關資料及面試經驗可在QQ群裡獲取:936903570。有加群的朋友請記得備註上簡書,謝謝。