因爲 Android 7.0 或更高版本的系統在國內手機市場上的佔比不是很高,不少 Android 開發人員並無作 7.0 適配工做,同時測試人員也容易忽視這方面的兼容問題。這致使 7.0 及以上版本的手機用戶在使用到應用部分功能時可能出現 App 崩潰閃退。其中,大部分緣由都是由項目中使用到 file:// 類型的 URI 所引起的。本文咱們便來一探究竟。php
爲了提升私有目錄的安全性,防止應用信息的泄漏,從 Android 7.0 開始,應用私有目錄的訪問權限被作限制。具體表現爲,開發人員不可以再簡單地經過 file:// URI 訪問其餘應用的私有目錄文件或者讓其餘應用訪問本身的私有目錄文件。html
備註:若是你對應用私有目錄不太清楚的話,能夠閱讀個人這篇文章:瞭解 Android 應用的文件存儲目錄,掌握持久化數據的正確姿式。java
同時,也是從 7.0 開始,Android SDK 中的 StrictMode 策略禁止開發人員在應用外部公開 file:// URI。具體表現爲,當咱們在應用中使用包含 file:// URI 的 Intent 離開本身的應用時,程序會發生故障。android
開發中,若是咱們在使用 file:// URI 時忽視了這兩條規定,將致使用戶在 7.0 及更高版本系統的設備中使用到相關功能時,出現 FileUriExposedException 異常,致使應用出現崩潰閃退問題。而這兩個過程的替代解決方案即是使用 FileProvider
。git
做爲四大組件之一的 ContentProvider
,一直扮演着應用間共享資源的角色。這裏咱們要使用到的 FileProvider
,就是 ContentProvider
的一個特殊子類,幫助咱們將訪問受限的 file:// URI 轉化爲能夠受權共享的 content:// URI。程序員
第一步,註冊一個 FileProvidergithub
做爲系統四大組件之一的 ContentProvider,其子類FileProvider,也一樣須要使用
<application>
...
<provider android:name="android.support.v4.content.FileProvider" android:authorities="${applicationId}.yourname" android:exported="false" android:grantUriPermissions="true">
...
</provider>
...
</application>複製代碼
其中,android:authorities
屬性值是一個由 build.gradle 文件中的 applicationId 值和自定義的名稱組成的 Uri 字符串(這樣寫是約定俗成的)。其餘屬性值使用如上固定值便可。微信
第二步,添加共享目錄app
在 res/xml 目錄下新建一個 xml 文件,用於存放應用須要共享的目錄文件。這個 xml 文件的內容相似這樣:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="my_images" path="images/"/>
...
</paths>複製代碼
<files-path>
:內部存儲空間應用私有目錄下的 files/ 目錄,等同於 Context.getFilesDir()
所獲取的目錄路徑;
<cache-path>
:內部存儲空間應用私有目錄下的 cache/ 目錄,等同於 Context.getCacheDir()
所獲取的目錄路徑;
<external-path>
:外部存儲空間根目錄,等同於 Environment.getExternalStorageDirectory()
所獲取的目錄路徑;
<external-files-path>
:外部存儲空間應用私有目錄下的 files/ 目錄,等同於 Context.getExternalFilesDir(null)
所獲取的目錄路徑;
<external-cache-path>
:外部存儲空間應用私有目錄下的 cache/ 目錄,等同於 Context.getExternalCacheDir();
能夠看出,這五種子元素基本涵蓋內外存儲空間全部目錄路徑,包含應用私有目錄。同時,每一個子元素都擁有 name 和 path 兩個屬性。
其中,path 屬性用於指定當前子元素所表明目錄下須要共享的子目錄名稱。注意:path 屬性值不能使用具體的獨立文件名,只能是目錄名。
而 name 屬性用於給 path 屬性所指定的子目錄名稱取一個別名。後續生成 content:// URI 時,會使用這個別名代替真實目錄名。這樣作的目的,很顯然是爲了提升安全性。
若是咱們須要分享的文件位於同級別目錄下不一樣的子目錄中,就須要添加多個子元素逐一指定要分享的文件目錄,或者共享他們通用的父目錄也行。
添加完共享目錄後,再在 <provider>
元素中使用 <meta-data>
元素將 res/xml 中的 path 文件與註冊的 FileProvider 連接起來:
<provider android:name="android.support.v4.content.FileProvider" android:authorities="${applicationId}.yourname" android:exported="false" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/yourfilename" />
</provider>複製代碼
第三步,生成 Content URI
在 Android 7.0 出現以前,咱們一般使用 Uri.fromFile()
方法生成一個 File URI。這裏,咱們須要使用 FileProvider
類提供的公有靜態方法 getUriForFile
生成 Content URI。好比:
Uri contentUri = FileProvider.getUriForFile(this,
BuildConfig.APPLICATION_ID + ".myprovider", myFile);複製代碼
須要傳遞三個參數。第二個參數即是 Manifest 文件中註冊 FileProvider 時設置的 authorities 屬性值,第三個參數爲要共享的文件,而且這個文件必定位於第二步咱們在 path 文件中添加的子目錄裏面。
舉個例子:
String filePath = Environment.getExternalStorageDirectory() + "/images/"+System.currentTimeMillis()+".jpg";
File outputFile = new File(filePath);
if (!outputFile.getParentFile().exists()) {
outputFile.getParentFile().mkdir();
}
Uri contentUri = FileProvider.getUriForFile(this,
BuildConfig.APPLICATION_ID + ".myprovider", outputFile);複製代碼
生成的 Content URI 是這樣的:
content://com.yifeng.samples.myprovider/my_images/1493715330339.jpg複製代碼
其中,構成 URI 的 host 部分爲 <provider>
元素的 authorities 屬性值(applicationId + customname),path 片斷 my_images 爲 res/xml 文件中指定的子目錄別名(真實目錄名爲:images)。
第四步,授予 Content URI 訪問權限
生成 Content URI 對象後,須要對其受權訪問權限。受權方式有兩種:
第一種方式,使用 Context 提供的 grantUriPermission(package, Uri, mode_flags)
方法向其餘應用受權訪問 URI 對象。三個參數分別表示受權訪問 URI 對象的其餘應用包名,受權訪問的 Uri 對象,和受權類型。其中,受權類型爲 Intent 類提供的讀寫類型常量:
FLAG_GRANT_READ_URI_PERMISSION
FLAG_GRANT_WRITE_URI_PERMISSION
或者兩者同時受權。這種形式的受權方式,權限有效期截止至發生設備重啓或者手動調用 revokeUriPermission()
方法撤銷受權時。
第二種方式,配合 Intent 使用。經過 setData()
方法向 intent 對象添加 Content URI。而後使用 setFlags()
或者 addFlags()
方法設置讀寫權限,可選常量值同上。這種形式的受權方式,權限有效期截止至其它應用所處的堆棧銷燬,而且一旦受權給某一個組件後,該應用的其它組件擁有相同的訪問權限。
第五步,提供 Content URI 給其它應用
擁有授予權限的 Content URI 後,即可以經過 startActivity()
或者 setResult()
方法啓動其餘應用並傳遞受權過的 Content URI 數據。固然,也有其餘方式提供服務。
若是你須要一次性傳遞多個 URI 對象,可使用 intent 對象提供的 setClipData()
方法,而且 setFlags()
方法設置的權限適用於全部 Content URIs。
前面介紹的內容都是理論部分,在 開發者官方 FileProvider 部分 都有所介紹。接下來咱們看看,實際開發一款應用的過程當中,會常常碰見哪些 FileProvider 的使用場景。
自動安裝文件
版本更新完成時打開新版本 apk 文件實現自動安裝的功能,應該是最多見的使用場景,也是每一個應用必備功能之一。常見操做爲,通知欄顯示下載新版本完畢,用戶點擊或者監聽下載過程自動打開新版本 apk 文件。適配 Android 7.0 版本以前,咱們代碼多是這樣:
File apkFile = new File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "app_sample.apk");
Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
installIntent.setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive");
startActivity(installIntent);複製代碼
如今爲了適配 7.0 及以上版本的系統,必須使用 Content URI 代替 File URI。
在 res/xml 目錄下新建一個 file_provider_paths.xml 文件(文件名自由定義),並添加子目錄路徑信息:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path name="my_download" path="Download"/>
</paths>複製代碼
而後在 Manifest 文件中註冊 FileProvider 對象,並連接上面的 path 路徑文件:
<provider android:name="android.support.v4.content.FileProvider" android:authorities="com.yifeng.samples.myprovider" android:exported="false" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_provider_paths"/>
</provider>複製代碼
修改 java 代碼,根據 File 對象生成 Content URI 對象,並受權訪問:
File apkFile = new File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "app_sample.apk");
Uri apkUri = FileProvider.getUriForFile(this,
BuildConfig.APPLICATION_ID+".myprovider", apkFile);
Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
installIntent.setDataAndType(apkUri, "application/vnd.android.package-archive");
startActivity(installIntent);複製代碼
如此這般,便完成了應用中調用系統功能打開 apk 文件的 7.0 適配工做。
調用系統拍照
調用系統拍照功能時也須要傳遞一個 Uri 對象,用於保存圖片至指定目錄,這裏也須要適配 7.0 版本。其餘步驟再也不贅述,核心 java 代碼以下(路徑不一樣,注意添加 res/xml 中的 path 文件子目錄):
String filePath = Environment.getExternalStorageDirectory() + "/images/"+System.currentTimeMillis()+".jpg";
File outputFile = new File(filePath);
if (!outputFile.getParentFile().exists()) {
outputFile.getParentFile().mkdir();
}
Uri contentUri = FileProvider.getUriForFile(this,
BuildConfig.APPLICATION_ID + ".myprovider", outputFile);
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, contentUri);
startActivityForResult(intent, REQUEST_TAKE_PICTURE);複製代碼
調用系統裁剪
調用系統裁剪的過程當中涉及到兩個 Uri 對象:inputUri 和 outputUri,較爲複雜一些。一般,調用系統裁剪的來源爲調用系統拍照或選擇系統相冊。前者返回的是一個 File URI 對象,後者返回的是一個 Content URI 對象。做爲裁剪源,咱們要作的就是對其作進一步處理。可是不能像上面那樣使用 getUriForFile()
方法,這個並不難理解,由於若是是選擇系統相冊所得的圖片,自己也不必定屬於咱們本身的應用。正確處理方式是這樣:
private Uri getImageContentUri(String path){
Cursor cursor = getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
new String[]{MediaStore.Images.Media._ID},
MediaStore.Images.Media.DATA + "=? ",
new String[]{path}, null);
if (cursor != null && cursor.moveToFirst()) {
int id = cursor.getInt(cursor.getColumnIndex(MediaStore.Images.Media._ID));
Uri baseUri = Uri.parse("content://media/external/images/media");
return Uri.withAppendedPath(baseUri, ""+id);
}else {
ContentValues contentValues = new ContentValues(1);
contentValues.put(MediaStore.Images.Media.DATA, path);
return getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues);
}
}複製代碼
拿到正確的 Content URI 後,做爲 inputUri,傳遞給 Intent 對象:
Intent intent = new Intent("com.android.camera.action.CROP");
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(inputUri, "image/*");
intent.putExtra("crop", "true");
intent.putExtra("aspectX", 1);
intent.putExtra("aspectY", 1);
intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(outputFile));
startActivityForResult(intent, REQUEST_PICK);複製代碼
注意:這裏的 outputUri 並無改變,仍然使用的是 Uri.fromFile()
方法獲取的 File URI 類型!這是很奇怪的一點,可是不得不這麼作。事實上,使用這種方式調用系統裁剪功能自己就是有問題的!常見問題如:在部分機型上,調用系統裁剪並返回前一個頁面時,在 onActivityResult() 方法中獲得的 resultCode 值不等於 RESULT_OK。Crop Intent 在官方文檔中原本就無跡可尋,自己就是一種不推薦的用法!取而代之的是,咱們可使用 GitHub 上的一些開源庫實現應用內的圖片裁剪功能,好比 uCrop、cropper 等。
說了這麼多,還有一個你們比較關心的問題就是:哪些已經上線的舊版本應用沒有作 7.0 適配工做怎麼辦?關於這個問題,Google 已經提早幫咱們想好解決方案啦。
還記得 6.0 運行時權限問題嗎?若是你不想處理運行時權限事宜的話,只須要在 build.gradle 文件中將 targetSdkVersion 的值設爲 23 如下便可。
一樣的,只要 targetSdkVersion 值小於 24,File URI 的使用依舊能夠出如今 7.0 及以上版本的設備中。不過須要注意的是,如前面所述,調用系統裁剪功能比較特殊,可能會出現一些問題。
雖然 Google 在每次發佈新版 Android 系統時,都提供這種設置 targetSdkVersion 的方式兼容舊版本,但只是一種臨時解決方案,並不推薦你們使用這種技巧繞開新版本的適配問題。要知道,新出現的 API 改變必定是在解決過去存在的系統問題,是一種進步的表現。遵循規範,是咱們每一個開發人員開發時都應銘記於心的格言。
關於我:亦楓,博客地址:yifeng.studio/,新浪微博:IT亦楓
微信掃描二維碼,歡迎關注個人我的公衆號:安卓筆記俠
不只分享個人原創技術文章,還有程序員的職場遐想
![]()