Android 7.0 適配中 FileProvider 部分的總結

上周,寫了個小demo,正好同事使用的小米手機系統(tǒng)內核更新到7.0,遂拿來測試了一番。其中遇到的小問題,現(xiàn)在來跟大家分享一下。
友情提示:我在解決問題的過程中,看了很多博客,這其中內容詳實,排版簡潔的要推一下亦楓,他的主頁:http://yifeng.studio

問題點描述:

自動更新功能,下載更新的apk文件后,跳轉到系統(tǒng)的應用程序安裝界面進行安裝,這里傳入的Uri.fromFile(“apkFile”)的格式,默認得到的是 file:// 類型的 URI。

寫在前面的話

由于 Android 7.0 或更高版本的系統(tǒng)在國內手機市場上的占比不是很高,很多 Android 開發(fā)人員并沒有做 7.0 適配工作,同時測試人員也容易忽視這方面的兼容問題。這導致 7.0 及以上版本的手機用戶在使用到應用部分功能時可能出現(xiàn) App 崩潰閃退。其中,大部分原因都是由項目中使用到 file:// 類型的 URI 所引發(fā)的。

Android 7.0 權限變更

為了提高私有目錄的安全性,防止應用信息的泄漏,從 Android 7.0 開始,應用私有目錄的訪問權限被做限制。具體表現(xiàn)為,開發(fā)人員不能夠再簡單地通過 file:// URI 訪問其他應用的私有目錄文件或者讓其他應用訪問自己的私有目錄文件。
備注:如果你對應用私有目錄不太清楚的話,可以閱讀亦楓的這篇文章:了解 Android 應用的文件存儲目錄,掌握持久化數據的正確姿勢。
同時,也是從 7.0 開始,Android SDK 中的 StrictMode 策略禁止開發(fā)人員在應用外部公開 file:// URI。具體表現(xiàn)為,當我們在應用中使用包含 file:// URI 的 Intent 離開自己的應用時,程序會發(fā)生故障。
開發(fā)中,如果我們在使用 file:// URI 時忽視了這兩條規(guī)定,將導致用戶在 7.0 及更高版本系統(tǒng)的設備中使用到相關功能時,出現(xiàn) FileUriExposedException 異常,導致應用出現(xiàn)崩潰閃退問題。而這兩個過程的替代解決方案便是使用FileProvider。

FileProvider

作為四大組件之一的 ContentProvider,一直扮演著應用間共享資源的角色。這里我們要使用到的 FileProvider,就是 ContentProvider 的一個特殊子類,幫助我們將訪問受限的 file:// URI 轉化為可以授權共享的 content:// URI。

第一步,注冊一個 FileProvider

作為系統(tǒng)四大組件之一的 ContentProvider,其子類FileProvider,也同樣需要使用 元素在 Manifest 文件中添加注冊信息,并按照要求設置相關屬性值。

<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 值自定義的名稱(上面的yourname,你可以隨便寫,不過按江湖規(guī)矩是域名反轉)組成的 Uri 字符串(這樣寫是約定俗成的)。其他屬性值使用如上固定值即可。

第二步,添加共享目錄

在 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>中 name 直接寫的是 ".")
元素必須包含一到多個子元素。這些子元素用于指定共享文件的目錄路徑,必須是這些元素之一:

  • <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 屬性所指定的子目錄名稱取一個別名。后續(xù)生成 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>

<meta-data> 中android:resource填你上面在res/xml 目錄下新建的文件名

第三步,生成 Content URI

在 Android 7.0 出現(xiàn)之前,我們通常使用 Uri.fromFile() 方法生成一個 File URI。這里,我們需要使用 FileProvider 類提供的公有靜態(tài)方法 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

或者二者同時授權。這種形式的授權方式,權限有效期截止至發(fā)生設備重啟或者手動調用 revokeUriPermission() 方法撤銷授權時。

第二種方式,配合 Intent 使用。通過 setData() 方法向 intent 對象添加 Content URI。然后使用 setFlags() 或者 addFlags() 方法設置讀寫權限,可選常量值同上。這種形式的授權方式,權限有效期截止至其它應用所處的堆棧銷毀,并且一旦授權給某一個組件后,該應用的其它組件擁有相同的訪問權限。

第五步,提供 Content URI 給其它應用

擁有授予權限的 Content URI 后,便可以通過 startActivity() 或者 setResult() 方法啟動其他應用并傳遞授權過的 Content URI 數據。當然,也有其他方式提供服務。

如果你需要一次性傳遞多個 URI 對象,可以使用 intent 對象提供的 setClipData() 方法,并且 setFlags() 方法設置的權限適用于所有 Content URIs。

常見使用場景

前面介紹的內容都是理論部分,在 開發(fā)者官方 FileProvider 部分 都有所介紹。接下來我們看看,實際開發(fā)一款應用的過程中,會經常遇見哪些 FileProvider 的使用場景。
自動安裝文件(我此次遇到的問題就是自動更新時安裝文件在7.0上的問題)
版本更新完成時打開新版本 apk 文件實現(xiàn)自動安裝的功能,應該是最常見的使用場景,也是每個應用必備功能之一。常見操作為,通知欄顯示下載新版本完畢,用戶點擊或者監(jiān)聽下載過程自動打開新版本 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);

現(xiàn)在為了適配 7.0 及以上版本的系統(tǒng),必須使用 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);

如此這般,便完成了應用中調用系統(tǒng)功能打開 apk 文件的 7.0 適配工作。

調用系統(tǒng)拍照
調用系統(tǒng)拍照功能時也需要傳遞一個 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);

調用系統(tǒng)裁剪
調用系統(tǒng)裁剪的過程中涉及到兩個 Uri 對象:inputUri 和 outputUri,較為復雜一些。通常,調用系統(tǒng)裁剪的來源為調用系統(tǒng)拍照或選擇系統(tǒng)相冊。前者返回的是一個 File URI 對象,后者返回的是一個 Content URI 對象。作為裁剪源,我們要做的就是對其做進一步處理。但是不能像上面那樣使用 getUriForFile()
方法,這個并不難理解,因為如果是選擇系統(tǒng)相冊所得的圖片,本身也不一定屬于我們自己的應用。正確處理方式是這樣:

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 類型!這是很奇怪的一點,但是不得不這么做。事實上,使用這種方式調用系統(tǒng)裁剪功能本身就是有問題的!常見問題如:在部分機型上,調用系統(tǒng)裁剪并返回前一個頁面時,在 onActivityResult() 方法中得到的 resultCode 值不等于 RESULT_OK。Crop Intent 在官方文檔中本來就無跡可尋,本身就是一種不推薦的用法!取而代之的是,我們可以使用 GitHub 上的一些開源庫實現(xiàn)應用內的圖片裁剪功能,比如 uCropcropper 等。

歷史版本問題

說了這么多,還有一個大家比較關心的問題就是:哪些已經上線的舊版本應用沒有做 7.0 適配工作怎么辦?關于這個問題,Google 已經提前幫我們想好解決方案啦。
還記得 6.0 運行時權限問題嗎?如果你不想處理運行時權限事宜的話,只需要在 build.gradle 文件中將 targetSdkVersion 的值設為 23 以下即可。
同樣的,只要 targetSdkVersion 值小于 24,F(xiàn)ile URI 的使用依舊可以出現(xiàn)在 7.0 及以上版本的設備中。不過需要注意的是,如前面所述,調用系統(tǒng)裁剪功能比較特殊,可能會出現(xiàn)一些問題。
雖然 Google 在每次發(fā)布新版 Android 系統(tǒng)時,都提供這種設置 targetSdkVersion 的方式兼容舊版本,但只是一種臨時解決方案,并不推薦大家使用這種技巧繞開新版本的適配問題。要知道,新出現(xiàn)的 API 改變一定是在解決過去存在的系統(tǒng)問題,是一種進步的表現(xiàn)。遵循規(guī)范,是我們每個開發(fā)人員開發(fā)時都應銘記于心的格言。
補充:就在完成這篇文章之后的一個月,無意間發(fā)現(xiàn)博客大神「鴻洋」也針對 7.0 FileProvider 問題著有一篇。一番觀摩之后,發(fā)現(xiàn)該文章的細節(jié)分析更加到位,值得后續(xù)寫技術類博客時反思改進。博客地址:
Android 7.0 行為變更 通過FileProvider在應用間共享文件吧

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容