android 使用SAF框架操作外置sd卡

本文深入解析Android 4.4及以上的SAF框架,探讨如何使用SAF操作外置SD卡,涵盖基本操作如打开、创建文档及文档树,以及高级功能如文件操作、权限管理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

android 使用SAF框架操作外置sd卡

在 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进行。
在这里插入图片描述

DocumentProvider中的文档组织形式

在文档提供程序内,数据结构采用传统的文件层次结构,如下图所示:
在这里插入图片描述

  • 每个DocumentProvider都可能有1个或多个做为文档结构树的Root根目录,每个根目录都有唯一的COLUMN_ROOT_ID,并且指向该根目录下表示内容的文档。
  • 每个根目录下都有一个文档,该文档指向1到n个文档,而其中的每个文档又可以指向1到N个文档,从而形成树形的文档结构。
  • 每个Document都会有唯一的COLUMN_DOCUMENT_ID用以引用它们,文档id具有唯一性,并且一旦发放就不得更改,因为它们用于所有设备重启过程中的永久性 URI 授权。
  • 文档可以是可打开的文件(具有特定 MIME 类型)或包含附加文档的目录(具有 MIME_TYPE_DIR MIME 类型)。
  • 每个文档都可以具有不同的功能,如 COLUMN_FLAGS 所述。例如,FLAG_SUPPORTS_WRITE、FLAG_SUPPORTS_DELETE 和 FLAG_SUPPORTS_THUMBNAIL。多个目录中可以包含相同的 COLUMN_DOCUMENT_ID。

常用类介绍

我们先看一下我们在使用SAF框架时,较常用的类图。
在这里插入图片描述

  • 下面简单介绍一下各个类的作用:
  • DocumentsContacts:协议类,规范了客户端app和DocumentProvider之间的交互,其子类Root和Document就代表了我们之前介绍的文件结构中的根和文档。该类同时定义了文档的操作,例如删除,新建,重命名等。
  • DocumentFile : 辅助操作类,直接使用DocumentsContact类比较麻烦,也不符合大家的操作习惯。因此google推出了DocumentFile类来帮助大家进行文档操作,该类的api和File类较为接近。其三个子类,TreeDocumentFile代表了一个文档树,而SingleDocumentFile仅仅代表单个文档。RawDocumentFile比较特殊,它代表的是一个普通的文件,而非SAF框架的Document uri。
  • DocumentProvider : 文档提供者,它的各个子类真正提供了文档的内容,例如我们访问外置sd卡,就是其子类ExternalStorageProvider提供的内容。它是真正的数据处理者,我们通过DocumentsContacts发出的各个文件操作,都将由它来实际完成。
  • PickActivity,OpenExternalDirectoryActivity : DocumentUi提供的页面,可以显示文档树,以及文档操作授权页面。

三个Action完成的基本操作

在Intent类中定义了三个ACTION,来完成对应的三种文档的基本操作,我们来学习一下。

ACTION_OPEN_DOCUMENT

这个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_OPEN_DOCUMENT_TREE

上面我们说到了如何打开文档,这个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);
    }
  • 对于我们打开的文档树,系统会赋予我们对该文档树下所有文档的读写权限,因此我们可以自由的使用我们上面介绍的输入输出流或者文件的方式来进行读写,该授权会一直保留到用户重启设备。
  • 但是有时候,我们需要能够永久性的访问这些文件的权限,而不是重启就需要重新授权,因此我们使用了takePersistableUriPermission方法来保留系统对我们的uri的授权,即使设备重启也不影响。
  • 我们可能保存了应用最近访问的 URI,但它们可能不再有效 — 另一个应用可能已删除或修改了文档。 因此,应该调用 getContentResolver().takePersistableUriPermission() 以检查有无最新数据。
  • 拿到了根目录的uri,我们就可用使用DocumentFile辅助类来方便的进行创建,删除文件等操作了,我们之后介绍。

ACTION_CREATE_DOCUMENT

该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类介绍

DocumentFile是google为了方便大家使用SAF进行文件操作,而推出的帮助类。它的api和java的File类比较接近,更符合一般用户的习惯,且内部实质都是使用了DocumentsContact类的方法来对文件进行操作。也就是说,我们也可以完全不使用DocumentFile而是使用DocumentsContact来完成SAF框架提供的文件操作,DocumentFile提供了三个静态工厂方法来创建自身。

  • fromSingleUri,该方法需要传入一个SAF返回的指向单个文件的uri,我们的ACTION_OPEN_DOCUMENT,ACTION_CREATE_DOCUMENT返回的uri就是该类型,其对应的实现类为SingleDocumentFile,代表的是单个的文件。
  • fromTreeUri,该方法传入指向文件夹的uri,我们的ACTION_OPEN_TREE返回的就是该类型,其对应的实现类为TreeDocumentFile,代表的是一个文件夹。
  • fromFile,该方法传入普通的File类,是对file类的一个模拟,我们这里不讨论。

DocumentFile提供了一系列操作文件的便捷方法,主要部分下面表格

方法名称作用SingleDocumentFileTreeDocumentFile
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<StorageVolume> 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权限。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值