以前項目的新特性適配工做都是同事在作,一直沒有怎麼太關注,不過相似這些適配的工做仍是有必要作一些記錄的。html
對於Android 7.0,提供了很是多的變化,詳細的能夠閱讀官方文檔Android 7.0 行爲變動,記得當時作了多窗口支持、FileProvider以及7.1的3D Touch的支持,不過和咱們開發者關聯最大的,或者說必需要適配的就是去除項目中傳遞file://
相似格式的uri了。java
在官方7.0的以上的系統中,嘗試傳遞
file://URI
可能會觸發FileUriExposedException
。android
因此本文主要描述如何適配該問題,沒什麼難度,僅作記錄。git
注:本文targetSdkVersion 25 ,compileSdkVersion 25github
你們應該對於手機拍照必定都不陌生,在但願獲得一張高清拍照圖的時候,咱們經過Intent會傳遞一個File的Uri給相機應用。app
大體代碼以下:框架
private static final int REQUEST_CODE_TAKE_PHOTO = 0x110; private String mCurrentPhotoPath; public void takePhotoNoCompress(View view) { Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (takePictureIntent.resolveActivity(getPackageManager()) != null) { String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA) .format(new Date()) + ".png"; File file = new File(Environment.getExternalStorageDirectory(), filename); mCurrentPhotoPath = file.getAbsolutePath(); takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(file)); startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO); } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_TAKE_PHOTO) { mIvPhoto.setImageBitmap(BitmapFactory.decodeFile(mCurrentPhotoPath)); } // else tip? }
貼個效果圖吧~ide
未處理6.0權限,有須要的自行處理下,nexus系列若是未處理,須要手動在設置頁開啓存儲權限。測試
此時若是咱們使用Android 7.0或者以上的原生系統,再次運行一下,你會發現應用直接中止運行,拋出了android.os.FileUriExposedException
:ui
Caused by: android.os.FileUriExposedException: file:///storage/emulated/0/20170601-030254.png exposed beyond app through ClipData.Item.getUri() at android.os.StrictMode.onFileUriExposed(StrictMode.java:1932) at android.net.Uri.checkFileUriExposed(Uri.java:2348)
因此若是你意識到本身寫的代碼,在7.0的原生系統的手機上直接就crash是否是很方~
緣由在官網已經給瞭解釋:
對於面向 Android 7.0 的應用,Android 框架執行的 StrictMode API 政策禁止在您的應用外部公開 file:// URI。若是一項包含文件 URI 的 intent 離開您的應用,則應用出現故障,並出現 FileUriExposedException 異常。
一樣的,官網也給出瞭解決方案:
要在應用間共享文件,您應發送一項 content:// URI,並授予 URI 臨時訪問權限。進行此受權的最簡單方式是使用 FileProvider 類。如需瞭解有關權限和共享文件的詳細信息,請參閱共享文件。
https://developer.android.com/about/versions/nougat/android-7.0-changes.html#accessibility
那麼下面就看看如何經過FileProvider解決此問題吧。
其實對於如何使用FileProvider,其實在FileProvider的API頁面也有詳細的步驟,有興趣的能夠看下。
https://developer.android.com/reference/android/support/v4/content/FileProvider.html
FileProvider其實是ContentProvider的一個子類,它的做用也比較明顯了,file:///Uri
不給用,那麼換個Uri爲content://
來替代。
下面咱們看下總體的實現步驟,並考慮爲何須要怎麼作?
<provider android:name="android.support.v4.content.FileProvider" android:authorities="com.zhy.android7.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>
爲何要聲明呢?由於FileProvider是ContentProvider子類哇~~
注意一點,他須要設置一個meta-data,裏面指向一個xml文件。
<?xml version="1.0" encoding="utf-8"?> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <root-path name="root" path="" /> <files-path name="files" path="" /> <cache-path name="cache" path="" /> <external-path name="external" path="" /> <external-files-path name="name" path="path" /> <external-cache-path name="name" path="path" /> </paths>
在paths節點內部支持如下幾個子節點,分別爲:
<root-path/>
表明設備的根目錄new File("/")
;<files-path/>
表明context.getFilesDir()
<cache-path/>
表明context.getCacheDir()
<external-path/>
表明Environment.getExternalStorageDirectory()
<external-files-path>
表明context.getExternalFilesDirs()
<external-cache-path>
表明getExternalCacheDirs()
每一個節點都支持兩個屬性:
path即爲表明目錄下的子目錄,好比:
<external-path name="external" path="pics" />
表明的目錄即爲:Environment.getExternalStorageDirectory()/pics
,其餘同理。
當這麼聲明之後,代碼可使用你所聲明的當前文件夾以及其子文件夾。
本例使用的是SDCard因此這麼寫便可:
<?xml version="1.0" encoding="utf-8"?> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <external-path name="external" path="" /> </paths>
爲了簡單,咱們直接使用SDCard根目錄,因此path裏面就不填寫子目錄了~
這裏你可能會有疑問,爲何要寫這麼個xml文件,有啥用呀?
剛纔咱們說了,如今要使用content://uri
替代file://uri
,那麼,content://
的uri如何定義呢?總不能使用文件路徑吧,那不是騙本身麼~
因此,須要一個虛擬的路徑對文件路徑進行映射,因此須要編寫個xml文件,經過path以及xml節點肯定可訪問的目錄,經過name屬性來映射真實的文件路徑。
好了,接下來就能夠經過FileProvider把咱們的file轉化爲content://uri
了~
public void takePhotoNoCompress(View view) { Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (takePictureIntent.resolveActivity(getPackageManager()) != null) { String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA) .format(new Date()) + ".png"; File file = new File(Environment.getExternalStorageDirectory(), filename); mCurrentPhotoPath = file.getAbsolutePath(); Uri fileUri = FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file); takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri); startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO); } }
核心代碼就這一行了~
FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file);
第二個參數就是咱們配置的authorities
,這個很正常了,總得映射到肯定的ContentProvider吧~因此須要這個參數。
而後再看一眼咱們生成的uri:
content://com.zhy.android7.fileprovider/external/20170601-041411.png
能夠看到格式爲:content://authorities/定義的name屬性/文件的相對路徑
,即name隱藏了可存儲的文件夾路徑。
如今拿7.0的原生手機運行就正常啦~
不過事情到此並無結束~~
打開一個4.4的模擬器,運行上述代碼,你會發現又Crash啦,拋出了:Permission Denial
~
Caused by: java.lang.SecurityException: Permission Denial: opening provider android.support.v4.content.FileProvider from ProcessRecord{52b029b8 1670:com.android.camera/u0a36} (pid=1670, uid=10036) that is not exported from uid 10052 at android.os.Parcel.readException(Parcel.java:1465) at android.os.Parcel.readException(Parcel.java:1419) at android.app.ActivityManagerProxy.getContentProvider(ActivityManagerNative.java:2848) at android.app.ActivityThread.acquireProvider(ActivityThread.java:4399)
由於低版本的系統,僅僅是把這個當成一個普通的Provider在使用,而咱們沒有受權,contentprovider的export設置的也是false;致使Permission Denial
。
那麼,咱們是否能夠將export設置爲true呢?
很遺憾是不能的。
在FileProvider的內部:
@Override public void attachInfo(Context context, ProviderInfo info) { super.attachInfo(context, info); // Sanity check our security if (info.exported) { throw new SecurityException("Provider must not be exported"); } if (!info.grantUriPermissions) { throw new SecurityException("Provider must grant uri permissions"); } mStrategy = getPathStrategy(context, info.authority); }
肯定了exported必須是false,grantUriPermissions必須是true ~~
因此惟一的辦法就是受權了~
context提供了兩個方法:
能夠看到grantUriPermission須要傳遞一個包名,就是你給哪一個應用受權,可是不少時候,好比分享,咱們並不知道最終用戶會選擇哪一個app,因此咱們能夠這樣:
List<ResolveInfo> resInfoList = context.getPackageManager() .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); for (ResolveInfo resolveInfo : resInfoList) { String packageName = resolveInfo.activityInfo.packageName; context.grantUriPermission(packageName, uri, flag); }
根據Intent查詢出的因此符合的應用,都給他們受權~~
恩,你能夠在不須要的時候經過revokeUriPermission移除權限~
那麼增長了受權後的代碼是這樣的:
public void takePhotoNoCompress(View view) { Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (takePictureIntent.resolveActivity(getPackageManager()) != null) { String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA) .format(new Date()) + ".png"; File file = new File(Environment.getExternalStorageDirectory(), filename); mCurrentPhotoPath = file.getAbsolutePath(); Uri fileUri = FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file); List<ResolveInfo> resInfoList = getPackageManager() .queryIntentActivities(takePictureIntent, PackageManager.MATCH_DEFAULT_ONLY); for (ResolveInfo resolveInfo : resInfoList) { String packageName = resolveInfo.activityInfo.packageName; grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); } takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri); startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO); } }
這樣就搞定了,不過仍是挺麻煩的,若是你僅僅是對舊系統作兼容,仍是建議作一下版本校驗便可,也就是說不要管什麼受權了,直接這樣獲取uri
Uri fileUri = null; if (Build.VERSION.SDK_INT >= 24) { fileUri = FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file); } else { fileUri = Uri.fromFile(file); }
這樣會比較方便~也避免致使一些問題。固然了,徹底使用uri也有一些好處,好比你可使用私有目錄去存儲拍攝的照片~
文章最後會給出快速適配的方案~~不須要這麼麻煩~
好像,還有什麼知識點沒有提到,再看一個例子吧~
正常咱們在編寫安裝apk的時候,是這樣的:
public void installApk(View view) { File file = new File(Environment.getExternalStorageDirectory(), "testandroid7-debug.apk"); Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive"); startActivity(intent); }
拿個7.0的原生手機跑一下,android.os.FileUriExposedException
又來了~~
android.os.FileUriExposedException: file:///storage/emulated/0/testandroid7-debug.apk exposed beyond app through Intent.getData()
好在有經驗了,簡單修改下uri的獲取方式。
if (Build.VERSION.SDK_INT >= 24) { fileUri = FileProvider.getUriForFile(this, "com.zhy.android7.fileprovider", file); } else { fileUri = Uri.fromFile(file); }
再跑一次,沒想到仍是拋出了異常(警告,沒有Crash):
java.lang.SecurityException: Permission Denial: opening provider android.support.v4.content.FileProvider from ProcessRecord{18570a 27107:com.google.android.packageinstaller/u0a26} (pid=27107, uid=10026) that is not exported from UID 10004
能夠看到是權限問題,對於權限咱們剛說了一種方式爲grantUriPermission
,這種方式固然是沒問題的啦~
加上後運行便可。
其實對於權限,還提供了一種方式,即:
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
咱們能夠在安裝包以前加上上述代碼,再次運行正常啦~
如今我有兩個很是疑惑的問題:
Permission Denial
的問題?恩,之因此不須要權限,主要是由於Intent的action爲ACTION_IMAGE_CAPTURE
,當咱們startActivity後,會展轉調用Instrumentation的execStartActivity
方法,在該方法內部,會調用intent.migrateExtraStreamToClipData();
方法。
該方法中包含:
if (MediaStore.ACTION_IMAGE_CAPTURE.equals(action) || MediaStore.ACTION_IMAGE_CAPTURE_SECURE.equals(action) || MediaStore.ACTION_VIDEO_CAPTURE.equals(action)) { final Uri output; try { output = getParcelableExtra(MediaStore.EXTRA_OUTPUT); } catch (ClassCastException e) { return false; } if (output != null) { setClipData(ClipData.newRawUri("", output)); addFlags(FLAG_GRANT_WRITE_URI_PERMISSION|FLAG_GRANT_READ_URI_PERMISSION); return true; } }
能夠看到將咱們的EXTRA_OUTPUT,轉爲了setClipData,並直接給咱們添加了WRITE和READ權限。
注:該部分邏輯應該是21以後添加的。
由於addFlags主要用於setData
,setDataAndType
以及setClipData
(注意:4.4時,並無將ACTION_IMAGE_CAPTURE
轉爲setClipData實現)這種方式。
因此addFlags方式對於ACTION_IMAGE_CAPTURE
在5.0如下是無效的,因此須要使用grantUriPermission
,若是是正常的經過setData分享的uri,使用addFlags是沒有問題的(能夠寫個簡單的例子測試下,兩個app交互,經過content://
)。
終於將知識點都涵蓋到了~
總結下,使用content://
替代file://
,主要須要FileProvider的支持,而由於FileProvider是ContentProvider的子類,因此須要在AndroidManifest.xml中註冊;而又由於須要對真實的filepath進行映射,因此須要編寫一個xml文檔,用於描述可以使用的文件夾目錄,以及經過name去映射該文件夾目錄。
對於權限,有兩種方式:
相比來講方式二較爲麻煩,由於須要指定目標應用包名,不少時候並不清楚,因此須要經過PackageManager進行查找到全部匹配的應用,所有進行受權。不過更爲穩妥~
方式一較爲簡單,對於intent.setData,setDataAndType正常使用便可,可是對於setClipData,因爲5.0先後Intent#migrateExtraStreamToClipData
,代碼發生變化,須要注意~
好了,看到如今是否是以爲適配7.0挺麻煩的,其實一點都不麻煩,下面給你們總結一種快速適配的方式。
建立一個library
的module,在其AndroidManifest.xml中完成FileProvider的註冊,代碼編寫爲:
<application> <provider android:name="android.support.v4.content.FileProvider" android:authorities="${applicationId}.android7.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider> </application>
注意一點,android:authorities
不要寫死,由於該library最終可能會讓多個項目引用,而android:authorities
是不能夠重複的,若是兩個app中定義了相同的,則後者沒法安裝到手機中(authority conflict)。
一樣的的編寫file_paths~
<?xml version="1.0" encoding="utf-8"?> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <root-path name="root" path="" /> <files-path name="files" path="" /> <cache-path name="cache" path="" /> <external-path name="external" path="" /> <external-files-path name="external_file_path" path="" /> <external-cache-path name="external_cache_path" path="" /> </paths>
最後再編寫一個輔助類,例如:
public class FileProvider7 { public static Uri getUriForFile(Context context, File file) { Uri fileUri = null; if (Build.VERSION.SDK_INT >= 24) { fileUri = getUriForFile24(context, file); } else { fileUri = Uri.fromFile(file); } return fileUri; } public static Uri getUriForFile24(Context context, File file) { Uri fileUri = android.support.v4.content.FileProvider.getUriForFile(context, context.getPackageName() + ".android7.fileprovider", file); return fileUri; } public static void setIntentDataAndType(Context context, Intent intent, String type, File file, boolean writeAble) { if (Build.VERSION.SDK_INT >= 24) { intent.setDataAndType(getUriForFile(context, file), type); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); if (writeAble) { intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); } } else { intent.setDataAndType(Uri.fromFile(file), type); } } }
能夠根據本身的需求添加方法。
好了,這樣咱們的一個小庫就寫好了~~
若是哪一個項目須要適配7.0,那麼只須要這樣引用這個庫,而後只須要改動一行代碼便可完成適配啦,例如:
拍照
public void takePhotoNoCompress(View view) { Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (takePictureIntent.resolveActivity(getPackageManager()) != null) { String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA) .format(new Date()) + ".png"; File file = new File(Environment.getExternalStorageDirectory(), filename); mCurrentPhotoPath = file.getAbsolutePath(); Uri fileUri = FileProvider7.getUriForFile(this, file); takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri); startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO); } }
只須要改動
Uri fileUri = FileProvider7.getUriForFile(this, file);
便可。
安裝apk
一樣的修改setDataAndType爲:
FileProvider7.setIntentDataAndType(this, intent, "application/vnd.android.package-archive", file, true);
便可。
ok,繁瑣的重複性操做終於簡化爲一行代碼啦~
源碼地址: