Android外部存储之DocumentProvider

前一段时间有不少用户反映客户端无法在外置SD上缓存视频,刚开始还怀疑是用户的SD卡自身损坏导致的,后来经调查才发现原来是Google
从4.4版本开始,Android开始限制第三方应用在外置SD卡中公共目录的写权限,当你的应用需要在外置SD卡公共目录中写入应用数据时,就必须使用Google提供的SAF存储访问框架进行访问。

虽然问题的根源找到了,但适配之路仍是非常的坎坷,特此记录下来与君共勉。

SAF

Storage Access Framework (SAF)是google在Android4.4开始引入的一项存储访问框架,其意是为了当用户需要访问文档,图片或其他文件信息时提供一个统一简单的访问入口,可以方便高效的访问不同文件内容提供商提供的文件信息。SAF共包含三个部分:

  • DocumentProvider,一个可以管理文件内容的存储服务,可以自定义管理文件的类型,目录等。
  • Client app,接收从DocumentProvider的返回的Uri进行文件操作,是完全自定义的。
  • Picker,一个系统提供的可配置的文件选择器,显示所有DocumentProvider的内容,并授权用户所选内容的读写权限,不可以自定义。

局限性

在使用SAF框架时除了需要兼容两套文件操作,还需要适配和解决SAF自身的一些局限性问题。

  1. 对Android 4.4支持有限
    google一开始在4.4+版本上引入的SAF并不是十分的完善。通过Picker UI获取写权限后,只能对已存在的文件执行删除和内容修改操作,无法创建文件,也不能对文件目录进行任何操作。原因主要在于:
- 在4.4版本上, SAF对外只提供了Intent.ACTION_OPEN_DOCUMENT (类似于ACTION_GET_CONTENT),  用户只能从Picker UI中选择文件而无法从Picker选择目录,从而导致第三方应用无法获取到文件目录的写权限。直至从5.0开始SAF提供了Intent.ACTION_OPEN_DOCUMENT_TREE 后才支持了从Picker UI中选择文件目录。
-  在4.4版本上,  SAF并不支持Intent.FLAG_GRANT_PREFIX_URI_PERMISSION即前缀匹配模式,若没有该属性,则每次授权都只能针对当前的选择对象,就算对同一目录下的文件进行写操作,每个文件都需要进行一次权限申请,非常的繁琐。
  • Picker UI是系统提供的统一交互界面,不能进行任何自定义行为,同时第三方应用和DocumentProvider之间只能通过PickerUI进行通信,无法直接通信,所以就算自定义DocumentProvider也无法绕过Picker UI的操作限制。
  1. 权限获取

    从5.0开始,SAF提供了Intent.FLAG_GRANT_PREFIX_URI_PERMISSION前缀匹配模式,同时支持了Intent.ACTION_OPEN_DOCUMENT_TREE,这样当第三方应用获取了某一个文件目录的操作权限后,对该目录下的子目录和文件都拥有了相同的操作权限。所以为了统一处理,第三方应用在使用SAF时必须引导用户选择在外置SD卡的根目录进行权限授予,这样第三方应用就获取了整个外置SD卡的读写权限并且只需要授权一次。
    但需要注意的是通过Intent.ACTION_OPEN_DOCUMENT,Picker UI返回的是一个DocumentUri, 而通过Intent.ACTION_OPEN_DOCUMENT_TREE,Picker UI返回的是一个TreeDocumentUri,而TreeDocumentUri只有才会被赋予Intent.FLAG_GRANT_PREFIX_URI_PERMISSION。

  @Override
  void onTaskFinished(Uri... uris) {
  ... ...
  if (mState.action == ACTION_GET_CONTENT) {
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
      } else if (mState.action == ACTION_OPEN_TREE) {
      intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
              | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
              |Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION                       
              |Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
      } else if (mState.action == ACTION_PICK_COPY_DESTINATION) {
          ... ...
      } else {
      intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
           | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
           |Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
      }
   ... ...
  }

为了可以直接利用Intent.FLAG_GRANT_PREFIX_URI_PERMISSION带来的便利,SAF提供了一组特定类型的tree uri,使用规则相当于:
java content://provider的authority/tree/treeDocumentId/document/documentId 即 content://com.example/tree/12/document/24/children
其中treeDocumentId是之前已授权目录的TreeDocumentUri中的documentId,document后的dcoumentId则是实际访问的documentId, 因为已授权目录的TreeDocumentUri是已知的,所以treeDocumentId可以通过DocumentsContract.getTreeDocumentId方法得到。 但对于需实际访问的documentId,则仍需要我们自己去构建。

  1. TreeDocumentFile
    DocumentFile为DocumentProvider模仿一套和传统File一样的操作接口,便于兼容传统File操作接口。其中TreeDocumentFile是专为TreeDocumentUri设计的,相当于是一个Directory,可以方便的对文件目录进行相关操作。
    并且TreeDocumentFile只能通过DocumentFile.fromTreeUri(Context context, Uri treeUri)方法来进行构造。源码片段如下:

        // TreeDocumentFile.java
        TreeDocumentFile(DocumentFile parent, Context context, Uri uri) {
        super(parent);
        mContext = context;
        mUri = uri;
    }
        //DocumentFile.java
        public static DocumentFile fromTreeUri(Context context, Uri treeUri) {
        final int version = Build.VERSION.SDK_INT;
        if (version >= 21) {
            return new TreeDocumentFile(null, context, DocumentsContractApi21.prepareTreeUri(treeUri));
        } else {
            return null;
        }
        //DocumentsContractApi21.java
        public static Uri prepareTreeUri(Uri treeUri) {
        return DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri));
        }
    }
    

    需要注意上面的DocumentsContractApi21.prepareTreeUri方法,该方法在内部调用了DocumentsContract.buildDocumentUriUsingTree()方法,但它传进去的documentId参数却仅是treeUri的TreeDocumentId,这意味如果treeUri是根据上节权限获取所述方式构建出的Uri,则得到TreeDocumentFile永远只表示了外置SD卡的根目录, 所以一般情况下TreeDocumentFile并不可用。

  2. 两套File操作
    考虑到android中主存储是可以通过动态权限申请 ( 6.0以下在AndroidManifest中 ) 就可以获取读写权限并且DocumengProvider获取权限的要求略微复杂,所有这样就不可避免的需要在代码中维护两套File操作(同一套api),这样在一定程度上增加了维护成本和构建成本。
    同时由于TreeDocumentFile我们自己难以构造,而SingleDocumentFile对于Directory属性的方法都不支持,所以有些常用的api就需要我们自己封装了。

    • mkdirs
      DocumengProvider虽然提供了createDocument方法,但需要注意的是调用createDocument方法的前提是父目录必须存在,否在会创建失败,所以我们封装mkdirs时需要从根目录开始层层判断父目录是否存在,这一点尤为的麻烦。
         @Override
    public String createDocument(String docId, String mimeType, String displayName)
            throws FileNotFoundException {
        displayName = FileUtils.buildValidFatFilename(displayName);
        final File parent = getFileForDocId(docId);
        if (!parent.isDirectory()) {
            throw new IllegalArgumentException("Parent document isn't a directory");
        }
     ... ...   
    }    
    
    • renameTo
      DocumengProvider虽然直接提供了renameDocument的方法,但renameTo的对象只能在同级目录下,不能更换父目录,因为输入参数只有displayName,这个是和File的rename不一样的,使用时需要格外的注意。
        @Override
    public String renameDocument(String docId, String displayName) throws FileNotFoundException {
        displayName = FileUtils.buildValidFatFilename(displayName);
        final File before = getFileForDocId(docId);
        final File after = FileUtils.buildUniqueFile(before.getParentFile(), displayName);
        if (!before.renameTo(after)) {
            throw new IllegalStateException("Failed to rename to " + after);
        }
       ... ... 
    }
    
  3. 国产ROM
    国产ROM有时候会修改System Picker UI的交互,越改越难用,典型的就是华为的xxxROM,把原生系统的选择按钮改成了全选,不明所以。

    Android外部存储之DocumentProvider_第1张图片
    enter image description here

    国产ROM有时候还有修改SAF存储框架,在华为xxx机型上居然不需要通过SAF申请任何权限,仅通过存储权限申请就是可以在外置SD卡的任意目录读写,而有时在某系机型上会突然性的出现在外置SD卡的默认包名下没有写权限,只能通过SAF申请权限后才能进行正常写入。
    所以这些都是我们在适配时都需要注意的,除了做好SAF权限授权引导,还是尽量避免申请SAF权限,毕竟操作还是十分繁琐的。


欢迎查看 个人博客.

你可能感兴趣的:(Android外部存储之DocumentProvider)