前言
存儲適配系列文章:
Android-存儲基礎
Android-10、11-存儲完全適配(上)
Android-10、11-存儲完全適配(下)
Android-FileProvider-輕松掌握
之前在分析Android 存儲相關知識點的時候,有同學提出希望也分析一下FileProvider,那時忙于總結線程并發知識點,并沒有立即著手分享。本次,將著重分析Android 應用之間如何使用第三方應用打開文件,如何分享文件給第三方應用。
通過本篇文章,你將了解到:
1、Android 應用間共享文件
2、FileProvider 應用與原理
3、FileProvider Uri構造與解析
1、Android 應用間共享文件
共享基礎
提到文件共享,首先想到就是在本地磁盤上存放一個文件,多個應用都可以訪問它,如下:
理想狀態下只要知道了文件的存放路徑,那么各個應用都可以讀寫它。
比如相冊里的圖片存放目錄:/sdcard/DCIM/、/sdcard/Pictures/ 。
再比如相冊里的視頻存放目錄:/sdcard/DCIM/、/sdcard/Movies/。
共享方式
一個常見的應用場景:
應用A里檢索到一個文件my.txt,它無法打開,于是想借助其它應用打開,這個時候它需要把待打開的文件路徑告訴其它應用。
假設應用B可以打開my.txt,那么應用A如何把路徑傳遞給應用B呢,這就涉及到了進程間通信。我們知道Android進程間通信主要手段是Binder,而四大組件的通信也是依靠Binder,因此我們應用間傳遞路徑可以依靠四大組件。
可以看出,Activity/Service/Broadcast 可以傳遞Intent,而ContentProvider傳遞Uri,實際上Intent 里攜帶了Uri變量,因此四大組件之間可以傳遞Uri,而路徑就可以存放在Uri里。
2、FileProvider 應用與原理
以使用其它應用打開文件為例,分別闡述Android 7.0 前后的不同點。
Android 7.0 之前使用
上面說到了傳遞路徑可以通過Uri,來看看如何使用:
private void openByOtherForN() {
Intent intent = new Intent();
//指定Action,使用其它應用打開
intent.setAction(Intent.ACTION_VIEW);
//通過路徑,構造Uri
Uri uri = Uri.fromFile(new File(external_filePath));
//設置Intent,附帶Uri
intent.setData(uri);
//跨進程傳遞Intent
startActivity(intent);
}
其中
- external_filePath="/storage/emulated/0/fish/myTxt.txt"
- 構造為uri 后uriString="file:///storage/emulated/0/fish/myTxt.txt"
可以看出,文件路徑前多了"file:///"字符串。
而接收方在收到Intent后,拿出Uri,通過:
filePath = uri.getEncodedPath() 拿到發送方發送的原始路徑后,即可讀寫文件。
然而此種構造Uri方式在Android7.0(含)之后被禁止了,若是使用則拋出異常:
可以看出,Uri.fromFile 構造方式的缺點:
1、發送方傳遞的文件路徑接收方完全知曉,一目了然,沒有安全保障。
2、發送方傳遞的文件路徑接收方可能沒有讀取權限,導致接收異常。
Android 7.0(含)之后的使用
先想想,若是我們自己操刀,如何規避以上兩個問題呢?
針對第一個問題:
可以將具體路徑替換為另一個字符串,類似以前密碼本的感覺,比如:
"/storage/emulated/0/fish/myTxt.txt" 替換為"myfile/Txt.txt",這樣接收方收到文件路徑完全不知道原始文件路徑是咋樣的。
不過這也引入了另一個額外的問題:接收方不知道真實路徑,如何讀取文件呢?
針對第二個問題
既然不確定接收方是否有打開文件權限,那么是否由發送方打開,然后將流傳遞給接收方就可以了呢?
Android 7.0(含)之后引入了FileProvider,可以解決上述兩個問題。
FileProvider 應用
先來看看如何使用FileProvider 來傳遞路徑。
細分為四個步驟:
一:定義FileProvider 子類
public class MyFileProvider extends FileProvider {
}
定義一個空的類,繼承自FileProvider,而FileProvider 繼承自ContentProvider。
注:FileProvider 需要引入AndroidX
二:AndroidManifest 里聲明FileProvider
既然是ContentProvider,那么需要像Activity一樣在AndroidManifest.xml里聲明:
<provider
android:authorities="com.fish.fileprovider"
android:name=".fileprovider.MyFileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_path">
</meta-data>
</provider>
字段解釋如下:
1、android:authorities 標識ContentProvider的唯一性,可以自己任意定義,最好是全局唯一的。
2、android:name 是指之前定義的FileProvider 子類。
3、android:exported="false" 限制其他應用獲取Provider。
4、android:grantUriPermissions="true" 授予其它應用訪問Uri權限。
5、meta-data 囊括了別名應用表。
5.1、android:name 這個值是固定的,表示要解析file_path。
5.2、android:resource 自己定義實現的映射表
三:路徑映射表
可以看出,FileProvider需要讀取映射表。
在/res/ 下建立xml 文件夾,然后再創建對應的映射表(xml),最終路徑如下:/res/xml/file_path.xml。
內容如下:
<?xml version="1.0" encoding="utf-8"?>
<paths>
<root-path name="myroot" path="." />
<external-path name="external_file" path="fish" />
<external-files-path name="external_app_file" path="myfile" />
<external-cache-path name="external_app_cache" path="mycache/doc/" />
<files-path name="inner_app_file" path="." />
<cache-path name="inner_app_cache" path="." />
</paths>
字段解釋如下:
1、root-path 標簽表示要給根目錄下的子目錄取別名(包括內部存儲、自帶外部存儲、擴展外部存儲,統稱用"/"表示),path 屬性表示需要被更改的目錄名,其值為:".",表示不區分目錄,name 屬性表示將path 目錄更改后的別名。
2、假若有個文件路徑:/storage/emulated/0/fish/myTxt.txt,而我們只配置了root-path 標簽,那么最終該文件路徑被替換為:/myroot/storage/emulated/0/fish/myTxt.txt。
可以看出,因為path=".",因此任何目錄前都被追加了myroot。
剩下的external-path等標簽對應的目錄如下:
1、external-path--->Environment.getExternalStorageDirectory(),如/storage/emulated/0/fish
2、external-files-path--->ContextCompat.getExternalFilesDirs(context, null)。
3、external-cache-path--->ContextCompat.getExternalCacheDirs(context)。
4、files-path--->context.getFilesDir()。
5、cache-path--->context.getCacheDir()。
你可能已經發現了,這些標簽所代表的目錄有重疊的部分,在替換別名的時候如何選擇呢?答案是:選擇最長匹配的。
假設我們映射表里只定義了root-path與external-path,分別對應的目錄為:
root-path--->/
external-path--->/storage/emulated/0/
現在要傳遞的文件路徑為:/storage/emulated/0/fish/myTxt.txt。需要給這個文件所在目錄取別名,因此會遍歷映射表找到最長匹配該目錄的標簽,顯然external-path 所表示的/storage/emulated/0/ 與文件目錄最為匹配,因此最后文件路徑被替換為:/external_file/myTxt.txt
四:使用FileProvider 構造路徑
映射表建立好之后,接著就需要構造路徑。
private void openByOther() {
//取得文件擴展名
String extension = external_filePath.substring(external_filePath.lastIndexOf(".") + 1);
//通過擴展名找到mimeType
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
//構造Intent
Intent intent = new Intent();
//賦予讀寫權限
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
//表示用其它應用打開
intent.setAction(Intent.ACTION_VIEW);
File file = new File(external_filePath);
//第二個參數表示要用哪個ContentProvider,這個唯一值在AndroidManifest.xml里定義了
//若是沒有定義MyFileProvider,可直接使用FileProvider替代
Uri uri = MyFileProvider.getUriForFile(this, "com.fish.fileprovider", file);
//給Intent 賦值
intent.setDataAndType(uri, mimeType);
try {
//交由系統處理
startActivity(intent);
} catch (Exception e) {
//若是沒有其它應用能夠接收打開此種mimeType,則拋出異常
Toast.makeText(this, e.getLocalizedMessage(),Toast.LENGTH_SHORT).show();
}
}
/storage/emulated/0/fish/myTxt.txt 最終構造為:content://com.fish.fileprovider/external_file/myTxt.txt
對于私有目錄:/data/user/0/com.example.androiddemo/files/myTxt.txt 最終構造為:
content://com.fish.fileprovider/inner_app_file/myTxt.txt
可以看出添加了:
content 作為scheme;
com.fish.fileprovider 即為我們定義的 authorities,作為host;
如此構造后,第三方應用收到此Uri后,并不能從路徑看出我們傳遞的真實路徑,這就解決了第一個問題:
發送方傳遞的文件路徑接收方完全知曉,一目了然,沒有安全保障。
3、FileProvider Uri構造與解析
Uri 構造輸入流
發送方將Uri交給系統,系統找到有能力處理該Uri的應用。發送方A需要別的應用打開myTxt.txt 文件,假設應用B具有能夠打開文本文件的能力,并且也愿意接收別人傳遞過來的路徑,那么它需要在AndroidManifest里做如下聲明:
<activity android:name="com.fish.fileprovider.ReceiveActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW"></action>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:scheme="http"/>
<data android:mimeType="text/*"></data>
</intent-filter>
</activity>
android.intent.action.VIEW 表示接收別的應用打開文件的請求。
android:mimeType 表示其具有打開某種文件的能力,text/* 表示只接收文本類型的打開請求。
當聲明了上述內容后,該應用就會出現在系統的選擇彈框里,當用戶點擊彈框里的該應用時,ReceiveActivity 將會被調用。我們知道,傳遞過來的Uri被包裝在Intent里,因此ReceiveActivity 需要處理Intent。
private void handleIntent() {
Intent intent = getIntent();
if (intent != null) {
if (intent.getAction().equals(Intent.ACTION_VIEW)) {
//從Intent里獲取uri
uri = intent.getData();
String content = handleUri(uri);
if (!TextUtils.isEmpty(content)) {
tvContent.setText("打開文件內容:" + content);
}
}
}
}
private String handleUri(Uri uri) {
if (uri == null)
return null;
String scheme = uri.getScheme();
if (!TextUtils.isEmpty(scheme)) {
if (scheme.equals("content")) {
try {
//從uri構造流
InputStream inputStream = getContentResolver().openInputStream(uri);
try {
//有流之后即可讀取內容
byte[] content = new byte[inputStream.available()];
inputStream.read(content);
return new String(content);
} catch (IOException e) {
e.printStackTrace();
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}
}
}
return null;
}
從Intent里拿到Uri,再通過Uri構造輸入流,最終從輸入流里讀取文件內容。
至此,應用A通過FileProvider可將其能夠訪問的任意路徑的文件傳遞給應用B,應用B能夠讀取文件并展示。
看到這里,你可能已經發現了:還沒有解決第二個問題呢:發送方傳遞的文件路徑接收方可能沒有讀取權限,導致接收異常。
這就需要從getContentResolver().openInputStream(uri)說起:
#ContentResolver.java
public final @Nullable InputStream openInputStream(@NonNull Uri uri)
throws FileNotFoundException {
Preconditions.checkNotNull(uri, "uri");
String scheme = uri.getScheme();
if (SCHEME_ANDROID_RESOURCE.equals(scheme)) {
...
} else if (SCHEME_FILE.equals(scheme)) {
//file開頭
} else {
//content開頭 走這
AssetFileDescriptor fd = openAssetFileDescriptor(uri, "r", null);
try {
//從文件描述符獲取輸入流
return fd != null ? fd.createInputStream() : null;
} catch (IOException e) {
throw new FileNotFoundException("Unable to create stream");
}
}
}
public final @Nullable AssetFileDescriptor openAssetFileDescriptor(@NonNull Uri uri,
@NonNull String mode, @Nullable CancellationSignal cancellationSignal)
throws FileNotFoundException {
...
//根據scheme 區分不同的協議
String scheme = uri.getScheme();
if (SCHEME_ANDROID_RESOURCE.equals(scheme)) {
//資源文件
} else if (SCHEME_FILE.equals(scheme)) {
//file 開頭
} else {
//content 開頭
if ("r".equals(mode)) {
return openTypedAssetFileDescriptor(uri, "*/*", null, cancellationSignal);
} else {
...
}
}
}
public final @Nullable AssetFileDescriptor openTypedAssetFileDescriptor(@NonNull Uri uri,
@NonNull String mimeType, @Nullable Bundle opts,
@Nullable CancellationSignal cancellationSignal) throws FileNotFoundException {
...
//找到FileProvider IPC 調用
IContentProvider unstableProvider = acquireUnstableProvider(uri);
try {
try {
//IPC 調用,返回文件描述符
fd = unstableProvider.openTypedAssetFile(
mPackageName, uri, mimeType, opts, remoteCancellationSignal);
if (fd == null) {
// The provider will be released by the finally{} clause
return null;
}
} catch (DeadObjectException e) {
...
}
...
//構造AssetFileDescriptor
return new AssetFileDescriptor(pfd, fd.getStartOffset(),
fd.getDeclaredLength());
} catch (RemoteException e) {
...
}
}
以上是應用B的調用流程,最終拿到應用A的FileProvider,拿到FileProvider 后即可進行IPC調用。
應用B發起了IPC,來看看應用A如何響應這動作的:
#ContentProviderNative.java
//Binder調用此方法
public boolean onTransact(int code, Parcel data, Parcel reply, int flags)
throws RemoteException {
case OPEN_TYPED_ASSET_FILE_TRANSACTION:
{
...
fd = openTypedAssetFile(callingPkg, url, mimeType, opts, signal);
}
}
#ContentProvider.java
@Override
public AssetFileDescriptor openTypedAssetFile(String callingPkg, Uri uri, String mimeType,
Bundle opts, ICancellationSignal cancellationSignal) throws FileNotFoundException {
...
try {
return mInterface.openTypedAssetFile(
uri, mimeType, opts, CancellationSignal.fromTransport(cancellationSignal));
} catch (RemoteException e) {
...
} finally {
...
}
}
public @Nullable AssetFileDescriptor openAssetFile(@NonNull Uri uri, @NonNull String mode)
throws FileNotFoundException {
ParcelFileDescriptor fd = openFile(uri, mode);
return fd != null ? new AssetFileDescriptor(fd, 0, -1) : null;
}
可以看出,最后調用了openFile()方法,而FileProvider重寫了該方法:
#ParcelFileDescriptor.java
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
throws FileNotFoundException {
//解析uri,從里面拿出對應的路徑
final File file = mStrategy.getFileForUri(uri);
final int fileMode = modeToMode(mode);
//構造ParcelFileDescriptor
return ParcelFileDescriptor.open(file, fileMode);
}
ParcelFileDescriptor 持有FileDescriptor,可以跨進程傳輸。
重點是mStrategy.getFileForUri(uri),如何通過Uri找到path,代碼很簡單,就不貼了,僅用圖展示。
關于IPC與四大組件相關可移步以下文章:
Android 四大組件通信核心
Android IPC 之Binder基礎
Uri與Path互轉
Path 轉Uri
回到最初應用A如何將path構造為Uri:
應用A在啟動的時候,會掃描AndroidManifest.xml 里的FileProvider,并讀取映射表構造為一個Map:
這個Map的Key 為映射表里的別名,而Value對應需要替換的目錄。
還是以/storage/emulated/0/fish/myTxt.txt 為例:
當調用MyFileProvider.getUriForFile(xx)時,遍歷Map,找到最匹配條目,最匹配的即為external_file。因此會用external_file 代替/storage/emulated/0/fish/,最終形成的Uri為:content://com.fish.fileprovider/external_file/myTxt.txt
Uri 轉Path
構造了Uri傳遞給應用B,應用B又通過Uri構造輸入流,構造輸入流的過程由應用A完成,因此A需要將Uri轉為Path:
A先將Uri分離出external_file/myTxt.txt,然后通過external_file 從Map里找到對應Value 為:/storage/emulated/0/fish/,最后將myTxt.txt拼接,形成的路徑為:
/storage/emulated/0/fish/myTxt.txt
可以看出,Uri成功轉為了Path。
現在來梳理整個流程:
1、應用A使用FileProvider通過Map(映射表)將Path轉為Uri,通過IPC 傳遞給應用B。
2、應用B使用Uri通過IPC獲取應用A的FileProvider。
3、應用A使用FileProvider通過映射表將Uri轉為Path,并構造出文件描述符。
4、應用A將文件描述符返回給應用B,應用B就可以讀取應用A發送的文件了。
由以上可知,不管應用B是否有存儲權限,只要應用A有權限就行,因為對文件的訪問都是通過應用A完成的,這就回答了第二個問題:發送方傳遞的文件路徑接收方可能沒有讀取權限,導致接收異常。
以上以打開文件為例闡述了FileProvider的應用,實際上分享文件也是類似的過程。
當然,從上面可以看出FileProvider構造需要好幾個步驟,還需要區分不同Android版本的差異,因此將這幾個步驟抽象為一個簡單的庫,外部直接調用對應的方法即可。
引入庫步驟:
1、project build.gradle 里加入:
allprojects {
repositories {
...
//庫是發布在jitpack上,因此需要指定位置
maven { url 'https://jitpack.io' }
}
}
2、在module build.gradle 里加入:
dependencies {
...
//引入EasyStorage庫
implementation 'com.github.fishforest:EasyStorage:1.0.1'
}
3、使用方式:
EasyFileProvider.fillIntent(this, new File(filePath), intent, true);
如上一行代碼搞定。
效果如下:
本文基于Android 10.0
演示代碼與庫源碼 若是有幫助,給github 點個贊唄~