在 Android 4.4中,Google 对 SD卡 的访问已经做了严格的限制,在 Android 5.0中,开发者可以使用 新API 要求用户对某个指定的文件夹进行访问授权,这个所谓的新api就是SAF框架。
Android 4.4(API 级别 19)引入了存储访问框架 (SAF)。SAF 让用户能够在其所有首选文档存储提供程序中方便地浏览并打开文档、图像以及其他文件。 用户可以通过易用的标准 UI,以统一方式在所有应用和提供程序中浏览文件和访问最近使用的文件。
SAF 包括以下内容:
文档提供程序 —ConentProvider的子类,允许存储服务显示其管理的文件。 文档提供程序作为 DocumentsProvider 类的子类实现。文档提供程序的架构基于传统文件层次结构,但其实际数据存储方式由您决定。Android 平台包括若干内置文档提供程序,操作sd卡对应的为ExternalStorageProvider。
客户端应用 — 就是我们平时的app,它调用 ACTION_OPEN_DOCUMENT ,ACTION_CREATE_DOCUMENT ,ACTION_OPEN_DOCUMENT_TREE这三种Intent的Action,来实现打开,创建文档,以及打开文档树。
选取器 — 一种系统 UI,我们称为DocumentUi,允许用户访问所有满足客户端应用搜索条件的文档提供程序内的文档。
三者之间的关系见下图,可以看到,我们的app应用和DocumentProvider之间并不产生直接的交互,而是通过DocumentUi进行。
在文档提供程序内,数据结构采用传统的文件层次结构,如下图所示:
在Intent类中定义了三个ACTION,来完成对应的三种文档的基本操作,我们来学习一下。
这个Action的作用是打开文档,用我们熟悉的文件文件夹概念来说,可以类比于打开文件,我们先看一下使用示例:
private void openDocument() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
//文档需要是可以打开的
intent.addCategory(Intent.CATEGORY_OPENABLE);
//指定文档的minitype为text类型
intent.setType("text/*");
//是否支持多选,默认不支持
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE,false);
startActivityForResult(intent, OPEN_DOCUMENT_CODE);
}
使用比较简单,我们直接构建了一个Intent请求,并调用startActivityForResult方法,请求打开DocumentUi界面,让它提供一个打开文档的视图给我们。我们可以通过设置EXTRA_ALLOW_MULTIPLE来支持多选,其他一些可以选择的EXTRA字段,可以在DocumentsContract类中自行查找。
调起的DocumentUi见截图,可以看到,我们可以在sd卡或者内部存储设备中,选择任意的minitype为text的文本文件,而其他类型的文件则置灰不可选。
那么我们如何处理DocumentUi的打开结果呢,直接在Activity的onActivityResult方法中接收结果即可。
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == Activity.RESULT_OK) {
switch (requestCode){
case OPEN_DOCUMENT_CODE:
//根据request_code处理打开文档的结果
handleOpenDocumentAction(data);
break;
}
}
}
private void handleOpenDocumentAction(Intent data){
if (data == null) {
return;
}
//获取文档指向的uri,注意这里是指单个文件。
Uri uri = data.getData();
//根据该Uri可以获取该Document的信息,其数据列的名称和解释可以在DocumentsContact类的内部类Document中找到
//我们在此查询的信息仅仅只是演示作用
Cursor cursor = getContentResolver().query(uri,null,
null,null,null,null);
StringBuilder sb = new StringBuilder(" open document Uri ");
sb.append(uri.toString());
if(cursor!=null && cursor.moveToFirst()){
String documentId = cursor.getString(cursor.getColumnIndex(
DocumentsContract.Document.COLUMN_DOCUMENT_ID));
String name = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
String size = null;
if (!cursor.isNull(sizeIndex)) {
// Technically the column stores an int, but cursor.getString()
// will do the conversion automatically.
size = cursor.getString(sizeIndex);
} else {
size = "Unknown";
}
sb.append(" name ").append(name).append(" size ").append(size);
}
//以下为直接从该uri中获取InputSteam,并读取出文本的内容的操作,这个是纯粹的java流操作,大家应该已经很熟悉了
//我就不多解释了。另外这里也可以直接使用OutputSteam,向文档中写入数据。
BufferedReader br = null;
try {
InputStream is = getContentResolver().openInputStream(uri);
br = new BufferedReader(new InputStreamReader(is));
String line;
sb.append("\r\n content : ");
while((line = br.readLine())!=null){
sb.append(line);
}
showToast(sb.toString());
} catch (IOException e) {
e.printStackTrace();
}finally {
closeSafe(br);
}
}
从上面的示例中可以看到,我们接收的信息是文档提供器返回给我们的一个Uri,我们可以通过查询该uri,来获取文档的信息,例如文档id,名称,大小,minitype等,具体可获取的信息间DocumentsContract.Document中定义的数据列。
此外,我们最关心的如何读写文档的问题呢?其实也很简单,直接从该uri中获取Input,outputSteam,使用java的io流就可以完成我们的文件读写操作了,这个大家应该很熟悉了,这里就不多时了。我们的示例代码就是读取文件的所有内容,并显示toast。
当然了,我们也可以先拿到代表文件的ParcelFileDescriptor 对象,在从它里面取出输入输出流。或者如果是图片文件,我们和可以直接调用BitmapFactory的decode方法进行解码。总之,我们可以将该uri当做普通的文件uri进行读写操作就对了。
ParcelFileDescriptor fd = getContentResolver().openFileDescriptor(uri,"w");
FileOutputStream fileOutputStream =
new FileOutputStream(fd.getFileDescriptor());
//该示例是假设打开的文件为图片类型,在我们这里是行不通的。
FileDescriptor fileDescriptor = fd.getFileDescriptor();
Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
上面我们说到了如何打开文档,这个ACTION可以让我们直接打开文档树。类比的话,上面的Action相当于打开一个文件,这个相当于打开一个文件夹,这样大家就知道它们的区别在哪里了。我们依旧看一下示例。
private void openTree(){
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, OPEN_TREE_CODE);
}
打开文档树的操作很简单,不详细说了,我们看一下打开的DocumentUi的界面,可以看到,我们可以选择外置sd卡或者内部存储设备或者其他DocumentProvider提供的数据源下的任意文件夹,注意,这里是无法选择文件的。
处理结果的代码如下:
private void handleTreeAction(Intent data){
Uri treeUri = data.getData();
//授予打开的文档树永久性的读写权限
final int takeFlags = intent.getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(uri, takeFlags);
//使用DocumentFile构建一个根文档,之后的操作可以在该文档上进行
mRoot = DocumentFile.fromTreeUri(this, treeUri);
//显示结果toast
showToast(" open tree uri "+treeUri);
}
该Action的作用是让我们可以创建一个新的文件,我们就来看一下示例代码,注释已经比较清楚了,我们就不过多解释了。
private void createDocument(){
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
//设置创建的文件是可打开的
intent.addCategory(Intent.CATEGORY_OPENABLE);
//设置创建的文件的minitype为文本类型
intent.setType("text/*");
//设置创建文件的名称,注意SAF中使用minitype而不是文件的后缀名来判断文件类型。
intent.putExtra(Intent.EXTRA_TITLE, "123.txt");
startActivityForResult(intent,CREATE_DOCUMENT_CODE);
}
之后我们看一下创建文档的DocumentUi界面,可以看到,不但可以选择任意文件夹,而且文件名也是可以由用户手动自由更改的。
最后我们来看下对对创建完文件的处理,可以看到,比较简单,就是我们之前提到的从返回的uri中获取输出流,并向文件中写入我们需要的数据。
private void handleCreateDocumentAction(Intent data){
if (data == null) {
return;
}
BufferedWriter bw = null;
try {
OutputStream os = getContentResolver().openOutputStream(uri);
bw = new BufferedWriter(new OutputStreamWriter(os));
bw.write(" i am a text ");
showToast(" create document succeed uri "+uri);
} catch (IOException e) {
e.printStackTrace();
}finally {
closeSafe(bw);
}
}
上面我们提到的三个Action中,ACTION_OPEN_DOCUMENT和ACTION_CREATE_DOCUMENT会返回给我们一个文件对应的uri,而ACTION_OPEN_TREE_CODE返回给我们的则是一个文件夹对应的uri,但是之前我们学习的只是使用输入输出流来读取或者写入文件的内容,如果我们需要对文件执行删除,重命名或者复制等操作要怎么办呢?
答案就是使用google给我们提供的DocumentsContact以及帮助类DocumentFile,我们可以利用它来方便快捷的完成上述那些文件操作。
DocumentFile是google为了方便大家使用SAF进行文件操作,而推出的帮助类。它的api和java的File类比较接近,更符合一般用户的习惯,且内部实质都是使用了DocumentsContact类的方法来对文件进行操作。也就是说,我们也可以完全不使用DocumentFile而是使用DocumentsContact来完成SAF框架提供的文件操作,DocumentFile提供了三个静态工厂方法来创建自身。
DocumentFile提供了一系列操作文件的便捷方法,主要部分下面表格
方法名称 | 作用 | SingleDocumentFile | TreeDocumentFile |
---|---|---|---|
isDocumentUri | 判断uri类型是否为Document | 有效 | 有效 |
createFile | 创建文件 | 无效 | 有效 |
createDirectory | 创建文件夹 | 无效 | 有效 |
isDocumentUri | 判断uri类型是否为Document | 有效 | 有效 |
isFile | 判断是否为文件 | 有效 | 有效 |
isDirectory | 判断是否为文件夹 | 有效 | 有效 |
canWrite | 判断是否可写 | 有效 | 有效 |
canRead | 判断是否可读 | 有效 | 有效 |
exists | 判断文档是否存在 | 有效 | 有效 |
listFiles | 列出该目录下所有文件 | 无效 | 有效 |
findFile | 找出该目录下指定名称文件 | 无效 | 有效 |
createFile | 创建文件 | 无效 | 有效 |
createDirectory | 创建文件夹 | 无效 | 有效 |
delete | 删除文档 | 有效 | 有效 |
renameTo | 重命名文档 | 无效 | 有效 |
1,根据ACTION_OPEN_TREE返回的文档树uri,创建一个代表它的DocumentFile
2,在该目录下,查找名为handleCreateDocument的子目录。
3,如果未找到,则使用DocumentFile的createDirectory方法创建该子目录。
4,在该目录下,使用createFile方法创建文件。注意,如果存在重名文件,则该方法会创建一个 原文件名(n)的文件。
private void handleCreateDocument(Intent data){
if(data==null){
return;
}
OutputStream os = null;
try {
String name = edtName.getText().toString().trim();
String text = edtText.getText().toString().trim();
Uri path = data.getData();
//根据SAF返回的文档树uri,创建根Document
DocumentFile root = DocumentFile.fromTreeUri(this,path);
//在根目录下,查找名为handleCreateDocument的子目录
DocumentFile dpath = root.findFile("handleCreateDocument");
//如果该子目录不存在,则创建
if(dpath==null) {
dpath = root.createDirectory("handleCreateDocument");
}
//在handleCreateDocument子目录下,创建一个text类型的Document文件
DocumentFile dfile = dpath.createFile("text/*",name);
//获取该Document的输入流,并写入数据
os = getContentResolver().openOutputStream(dfile.getUri());
os.write(text.getBytes());
showToast(" create document succeed "+dfile.getUri());
}catch (Exception e){
showToast(" create document fail "+e.toString());
}finally {
closeSafe(os);
}
}
删除文件夹的操作如下,注意该操作会删除文件夹下所有的文件和文件夹本身,而不像java的File类删除文件夹一样,需要用户手动遍历删除。当然它的内部实现其实也是利用java的File类,遍历文件夹删除,最后删除自身,原理都是一样的,只是写法不同。
private void handleDeletePath(Intent data){
if(data==null){
return;
}
Uri uri = data.getData();
DocumentFile root = DocumentFile.fromTreeUri(this,uri);
boolean res = root.delete();
String str = res?(" delete succeed "):(" delete fail ");
showToast(str);
}
删除文件的操作,则只要uri来源于单个文件,并使用DocumentFile.fromTreeUri构造DocumentFile类,其他完成一样即可,这里就不多说了。
该操作主要是使用了DocumentsContract类的rename方法来完成操作,因为DocumentFile类的delete方法不支持删除单个文件。需要注意的点如下:
1,这里的uri需要是SAF返回给我们的单个文件的uri
2,重命名的文件和原文件必须要在同一个文件夹下,重命名的文件名称指定路径是无效的。
private void handleRenameFile(Intent data){
if(data==null){
return;
}
Uri uri = data.getData();
try {
DocumentsContract.renameDocument(getContentResolver(),uri,"renamefile");
showToast(" rename file succeed ");
} catch (FileNotFoundException e) {
showToast(" can not rename file ");
}
}
重命名文件夹的操作,除了可以可以使用我们上面的重命名文件的DocumentsContract类外,还可以使用DocumentFile类的方法来完成。
private void handleRenamePath(Intent data){
if (data == null) {
return;
}
String strPath = edtName.getText().toString().trim();
Uri path = data.getData();
//创建一个代表路径的DocumentFile,注意使用fromTreeUri创建,该uri必须是代表路径的Document uri
DocumentFile dPath = DocumentFile.fromTreeUri(this,path);
boolean res = dPath.renameTo(strPath);
//根据bool结果,来显示重命名文件夹是否成功
String strRes = res?(" rename path succeed "):(" rename fail ");
showToast(strRes);
}
1,根据ACTION_OPEN_TREE返回的文档树uri,创建一个代表它的DocumentFile。
2,直接调用DocumentFile的listFiles方法,即可返回其包含的所有子文档,注意子文档即可以是文件,也可以是文件夹。
private void handleListAllFile(Intent data){
if(data==null){
return;
}
Uri uri = data.getData();
DocumentFile root = DocumentFile.fromTreeUri(this,uri);
DocumentFile[] files = root.listFiles();
StringBuilder sb = new StringBuilder(" list all files \r\n ");
if(files!=null){
for(DocumentFile file : files){
sb.append(file.getName()).append("\r\n");
}
}
showToast(sb.toString());
}
还有一些文件操作,例如移动复制等,由于篇幅有限,这里就不介绍了,大家需要使用的时候,只需要去DocumentFile和DocumentsContact类下去查找它们的api即可,SAF框架所支持的所有文件操作,均由DocumentsContact提供。
1,通过之前的分析,我们已经知道,通过ACTION_OPEN_DOCUMENT以及ACTION_CREATE_DOCUMENT拿到的单个文件是有读写权限的;而通过ACTION_OPEN_TREE拿到的整个文件夹也是有读写权限的。
2,现在假设我们有一个需求,要在外置sd卡/DCIM/Text目录下,创建一个1.txt的文件,并向其写入文本,那么我们应该怎么做呢?看了之前的创建文件夹和文件以及写入文件一章,你可能会觉得很简单。不就是先调用ACTION_OPEN_TREE打开sd卡根目录,等用户选择后,我们拿到它的Document uri,之后还不是就是套路了。
3,但是且慢,ACTION_OPEN_TREE打开的DocumentUi界面,用户是可以选择目录的,用户要是没有选择sd卡根目录而是其他目录,甚至选择了内部存储,那我们的文件写的位置就完全不确定了,这不符合需求啊,那怎么办呢?
4,办法当然也是有的,经过搜索源码,发现授权访问外置sd卡根目录的方法竟然不在SAF框架相关中,而是存在StorageManager相关中,可以说是很坑爹了。其实例代码如下:
private void sdcardAuth(){
//获取存储管理服务
StorageManager sm = (StorageManager) getSystemService(Context.STORAGE_SERVICE);
//获取存储器
List list = sm.getStorageVolumes();
for(StorageVolume sv : list){
//遍历所有存储器,当它是Removable(包含外置sd卡,usb等)且已经装载时
if(sv.isRemovable() && TextUtils.equals(sv.getState(),Environment.MEDIA_MOUNTED)) {
//调用StorageVolume的createAccessIntent方法
Intent i = sv.createAccessIntent(null);
startActivityForResult(i, SDCARD_AUTH_CODE);
return;
}
}
showToast(" can not find sdcard ");
}
其实现过程分为以下几步:
1,获取存储管理服务。
2,获取并遍历所有存储器。
3,找到Removable(外置sd卡,usb存储等)类型的已经装载好的存储器。
4,调用StorageVolume的createAccessIntent方法产生一个inent,之后请求DocumentUi对其进行授权,注意该方法的参数为空表示对整个目录进行授权。
我们来看一些授权界面,看起来和普通的权限弹框类似,我们选择确定后,权限就被授予了。
我们在来看一些后继的处理,可以看到,也是直接获取sd卡根目录的Uri,之后赋予它永久性的访问权限。然后我们就可以用之前介绍的文件操作来对它进行我们任意操作了,为了方便,我们获取可以把该uri保存下来。
private void handleSdCardAuth(Intent data){
if (data == null) {
return;
}
//这里获取外置sd卡根目录的Uri,我们可以将它保存下来,方便以后使用
Uri treeUri = data.getData();
//赋予它永久性的读写权限
final int takeFlags = intent.getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(uri, takeFlags);
showToast(" sdcard auth succeed,uri "+treeUri);
}
1,SAF框架不仅可以操作外置sd卡,也可以操作其他存储空间。
2,使用SAF框架操作时,不需要额外的权限,例如使用它操作external storage时,并不需要我们申请WRITE_EXTERNAL_STORAGE权限。