SAF(Storage Access Framework)使用攻略

8,000 阅读4分钟

漫长的假期,在家整理了一下Android 10的适配内容。因为适配篇的篇幅问题,就将这一部本单独出来,也先放出来。

1.介绍

Android 4.4 就引入了存储访问框架 (SAF)。借助 SAF,用户可轻松在其所有首选文档存储提供程序中浏览并打开文档、图像及其他文件。用户可通过易用的标准界面,以统一方式在所有应用和提供程序中浏览文件,以及访问最近使用的文件。

SAF 提供的部分功能:

  • 让用户浏览所有文档提供程序的内容,而不仅仅是单个应用的内容。
  • 让您的应用获得对文档提供程序所拥有文档的长期、持续性访问权限。用户可通过此访问权限添加、编辑、保存和删除提供程序上的文件。
  • 支持多个用户帐户和临时根目录,如只有在插入驱动器后才会出现的 USB 存储提供程序。

虽说早在Android 4.4就已经引入了,但是我却从未使用过。。。然而在适配Android 10中它却是一个无法忽略的存在。因为Android 10的外部存储访问限制,我们无法像以前一样自由的操作文件。SAF就是应对这一限制的方法之一。

2.使用

选择文件

使用Intent.ACTION_OPEN_DOCUMENT可以调起文件选择页面,选择一个文件。我以选择图片文件为例:

    //通过系统的文件浏览器选择一个文件
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
    //筛选,只显示可以“打开”的结果,如文件(而不是联系人或时区列表)
    intent.addCategory(Intent.CATEGORY_OPENABLE);
    //过滤只显示图像类型文件
    intent.setType("image/*");
    startActivityForResult(intent, REQUEST_CODE_FOR_SINGLE_FILE);

文件选择页面如下(系统MIUI 11):

在这里插入图片描述

onActivityResult获取文件Uri,同时也可以通过ContentResolver查询文件信息:

private final String[] IMAGE_PROJECTION = {
            MediaStore.Images.Media.DISPLAY_NAME,
            MediaStore.Images.Media.SIZE,
            MediaStore.Images.Media._ID };

@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        Uri uri = null;
        if (resultData != null) {
            // 获取选择文件Uri
            uri = resultData.getData();
            // 获取图片信息
            Cursor cursor = this.getContentResolver()
                .query(uri, IMAGE_PROJECTION, null, null, null, null);

            if (cursor != null && cursor.moveToFirst()) {
                String displayName = cursor.getString(cursor.getColumnIndexOrThrow(IMAGE_PROJECTION[0]));
                String size = cursor.getString(cursor.getColumnIndexOrThrow(IMAGE_PROJECTION[1]));
                Log.i(TAG, "Uri: " + uri.toString());
                Log.i(TAG, "Name: " + displayName);
                Log.i(TAG, "Size: " + size);
            }
            cursor.close();
        }
    }
}

创建文件

这部分的用法我暂时也只在淘宝App -> 商品评论 -> 保存评论图片的地方看到过。有兴趣的可以去试试。

具体用法(我以创建txt文件为例):

    public void createFile() {
        Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        // 文件类型
        intent.setType("text/plain");
        // 文件名称
        intent.putExtra(Intent.EXTRA_TITLE, System.currentTimeMillis() + ".txt");
        startActivityForResult(intent, WRITE_REQUEST_CODE);
    }

交互页面如下:

在这里插入图片描述

读取文件

获得文件的 Uri 后,就可以对其执行任何操作。

  1. Bitmap
    private Bitmap getBitmapFromUri(Uri uri) throws IOException {
    	ParcelFileDescriptor parcelFileDescriptor =
            	getContentResolver().openFileDescriptor(uri, "r");
    	FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
    	Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
    	parcelFileDescriptor.close();
    	return image;
    }
  1. 获取 InputStream
    private String readTextFromUri(Uri uri) throws IOException {
    	StringBuilder stringBuilder = new StringBuilder();
    	try (InputStream inputStream = getContentResolver().openInputStream(uri);
            BufferedReader reader = new BufferedReader(
            new InputStreamReader(Objects.requireNonNull(inputStream)))) {
        	String line;
        	while ((line = reader.readLine()) != null) {
            	stringBuilder.append(line);
        	}
    	}
    	return stringBuilder.toString();
    }

修改文件

    private void alterDocument(Uri uri) {
        if (uri != null) {
            OutputStream outputStream = null;
            try {
                // 获取 OutputStream
                outputStream = getContentResolver().openOutputStream(uri);
                outputStream.write("Storage Access Framework Example".getBytes(StandardCharsets.UTF_8));
            } catch (IOException e) {
                Toast.makeText(this, "修改文件失败!", Toast.LENGTH_SHORT).show();
            } finally {
                if (outputStream != null) {
                    try {
                        outputStream.close();
                    } catch (IOException e) {
                        e.fillInStackTrace();
                    }
                }
            }
        } 
    }

    private void alterDocument(Uri uri) {
    	try {
        	ParcelFileDescriptor pfd = getContentResolver().
                openFileDescriptor(uri, "w");
        	FileOutputStream fileOutputStream =
                new FileOutputStream(pfd.getFileDescriptor());
        	fileOutputStream.write(("Storage Access Framework Example").getBytes());
        	fileOutputStream.close();
        	pfd.close();
    	} catch (FileNotFoundException e) {
        	e.printStackTrace();
    	} catch (IOException e) {
        	e.printStackTrace();
    	}
    }

删除文件

使用DocumentsContract.deleteDocument 方法进行删除。

    public void deleteFile(Uri uri) {
        if (uri != null) {
            try {
                DocumentsContract.deleteDocument(getContentResolver(), uri);
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }
        } 
    }

选择目录(Android 5.0以上支持)

使用Intent.ACTION_OPEN_DOCUMENT_TREE可以调起文件目录选择页面,选择一个目录,并将其子文件夹的读写权限授予APP。

    private void selectDir() {
        // 用户可以选择任意文件夹,将它及其子文件夹的读写权限授予APP。
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
        startActivityForResult(intent, REQUEST_CODE_FOR_DIR);
    }

交互页面如下:

在这里插入图片描述
onActivityResult获取目录的Uri,并创建DocumentFile来进行文件操作:

@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
    if (requestCode == REQUEST_CODE_FOR_DIR && resultCode == Activity.RESULT_OK) {
        Uri uriTree = null;
    	if (data != null) {
            uriTree = data.getData();
    	}
    	if (uriTree != null) {
            // 创建所选目录的DocumentFile,可以使用它进行文件操作
            DocumentFile root = DocumentFile.fromTreeUri(this, uriTree);
            // 比如使用它创建文件夹
            DocumentFile dir = root.createDirectory(”Test“);
   	}
    }
}

当然每次这样选择授权会很麻烦,所以我们也可以在首次授权时保存获取的目录权限:

	// 获取权限
    final int takeFlags = resultData.getFlags()
			& (Intent.FLAG_GRANT_READ_URI_PERMISSION
            | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    getContentResolver().takePersistableUriPermission(uri, takeFlags);
	// 保存获取的目录权限
    SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
    SharedPreferences.Editor editor = sp.edit();
    editor.putString("uriTree", uri.toString());
    editor.apply();

使用时从SharedPreferences获取uriTree,不存在或是无权限则重新授权:

    SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
    String uriTree = sp.getString("uriTree", "");
    if (TextUtils.isEmpty(uriTree)) {
    	// 重新授权
    } else {
    	try {
            Uri uri = Uri.parse(uriTree);
            final int takeFlags = getIntent().getFlags()
        	        & (Intent.FLAG_GRANT_READ_URI_PERMISSION
                	| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
            getContentResolver().takePersistableUriPermission(uri, takeFlags);
            DocumentFile root = DocumentFile.fromTreeUri(this, uri);
    	} catch (SecurityException e) {
            // 重新授权
    	}
    }

上面代码中使用到的takePersistableUriPermission方法是为了检查最新的数据。防止另一个应用可能删除或修改了文件导致Uri失效。

有了授权就有撤销授权,使用releasePersistableUriPermissionrevokeUriPermission方法就可以实现权限的撤销。

    public void releasePermission(View view) {
        SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
        String uriTree = sp.getString("uriTree", "");
        if (!TextUtils.isEmpty(uriTree)) {
            Uri uri = Uri.parse(uriTree);
                final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION
                		| Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
                
            getContentResolver().releasePersistableUriPermission(uri, takeFlags);
            // 或
            this.revokeUriPermission(uri, takeFlags);
            // 重启才会生效,所以可以清除uriTree
            SharedPreferences.Editor editor = sp.edit();
            editor.putString("uriTree", "");
            editor.apply();
        } 
    }

或者在应用设置页面点击取消访问权限手动删除(MIUI 11 上未发现此按钮):

在这里插入图片描述

本篇都是具体场景的的使用示例,完整的代码我已上传GitHub。可以去自行查看体验。

3.参考