关于 Android 7.0 适配中 FileProvider 部分的总结

因为 Android 7.0 或更高版本的系统在国内手机市场上的占比不是很高,不少 Android 开发人员并无作 7.0 适配工做,同时测试人员也容易忽视这方面的兼容问题。这致使 7.0 及以上版本的手机用户在使用到应用部分功能时可能出现 App 崩溃闪退。其中,大部分缘由都是由项目中使用到 file:// 类型的 URI 所引起的。本文咱们便来一探究竟。php

Android 7.0 权限变动


为了提升私有目录的安全性,防止应用信息的泄漏,从 Android 7.0 开始,应用私有目录的访问权限被作限制。具体表现为,开发人员不可以再简单地经过 file:// URI 访问其余应用的私有目录文件或者让其余应用访问本身的私有目录文件。html

备注:若是你对应用私有目录不太清楚的话,能够阅读个人这篇文章:了解 Android 应用的文件存储目录,掌握持久化数据的正确姿式java

同时,也是从 7.0 开始,Android SDK 中的 StrictMode 策略禁止开发人员在应用外部公开 file:// URI。具体表现为,当咱们在应用中使用包含 file:// URI 的 Intent 离开本身的应用时,程序会发生故障。android

开发中,若是咱们在使用 file:// URI 时忽视了这两条规定,将致使用户在 7.0 及更高版本系统的设备中使用到相关功能时,出现 FileUriExposedException 异常,致使应用出现崩溃闪退问题。而这两个过程的替代解决方案即是使用 FileProvidergit

FileProvider


做为四大组件之一的 ContentProvider,一直扮演着应用间共享资源的角色。这里咱们要使用到的 FileProvider,就是 ContentProvider 的一个特殊子类,帮助咱们将访问受限的 file:// URI 转化为能够受权共享的 content:// URI。程序员

第一步,注册一个 FileProvidergithub

做为系统四大组件之一的 ContentProvider,其子类FileProvider,也一样须要使用 元素在 Manifest 文件中添加注册信息,并按照要求设置相关属性值。 安全

<application>
    ...
    <provider android:name="android.support.v4.content.FileProvider" android:authorities="${applicationId}.yourname" android:exported="false" android:grantUriPermissions="true">
        ...
    </provider>
    ...
</application>复制代码

其中,android:authorities 属性值是一个由 build.gradle 文件中的 applicationId 值和自定义的名称组成的 Uri 字符串(这样写是约定俗成的)。其余属性值使用如上固定值便可。微信

第二步,添加共享目录app

在 res/xml 目录下新建一个 xml 文件,用于存放应用须要共享的目录文件。这个 xml 文件的内容相似这样:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <files-path name="my_images" path="images/"/>
    ...
</paths>复制代码

元素必须包含一到多个子元素。这些子元素用于指定共享文件的目录路径,必须是这些元素之一:

  • <files-path>:内部存储空间应用私有目录下的 files/ 目录,等同于 Context.getFilesDir() 所获取的目录路径;

  • <cache-path>:内部存储空间应用私有目录下的 cache/ 目录,等同于 Context.getCacheDir() 所获取的目录路径;

  • <external-path>:外部存储空间根目录,等同于 Environment.getExternalStorageDirectory() 所获取的目录路径;

  • <external-files-path>:外部存储空间应用私有目录下的 files/ 目录,等同于 Context.getExternalFilesDir(null) 所获取的目录路径;

  • <external-cache-path>:外部存储空间应用私有目录下的 cache/ 目录,等同于 Context.getExternalCacheDir();

能够看出,这五种子元素基本涵盖内外存储空间全部目录路径,包含应用私有目录。同时,每一个子元素都拥有 namepath 两个属性。

其中,path 属性用于指定当前子元素所表明目录下须要共享的子目录名称。注意:path 属性值不能使用具体的独立文件名,只能是目录名。

而 name 属性用于给 path 属性所指定的子目录名称取一个别名。后续生成 content:// URI 时,会使用这个别名代替真实目录名。这样作的目的,很显然是为了提升安全性。

若是咱们须要分享的文件位于同级别目录下不一样的子目录中,就须要添加多个子元素逐一指定要分享的文件目录,或者共享他们通用的父目录也行。

添加完共享目录后,再在 <provider> 元素中使用 <meta-data> 元素将 res/xml 中的 path 文件与注册的 FileProvider 连接起来:

<provider android:name="android.support.v4.content.FileProvider" android:authorities="${applicationId}.yourname" android:exported="false" android:grantUriPermissions="true">
    <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/yourfilename" />
</provider>复制代码

第三步,生成 Content URI

在 Android 7.0 出现以前,咱们一般使用 Uri.fromFile() 方法生成一个 File URI。这里,咱们须要使用 FileProvider 类提供的公有静态方法 getUriForFile 生成 Content URI。好比:

Uri contentUri = FileProvider.getUriForFile(this,
                BuildConfig.APPLICATION_ID + ".myprovider", myFile);复制代码

须要传递三个参数。第二个参数即是 Manifest 文件中注册 FileProvider 时设置的 authorities 属性值,第三个参数为要共享的文件,而且这个文件必定位于第二步咱们在 path 文件中添加的子目录里面。

举个例子:

String filePath = Environment.getExternalStorageDirectory() + "/images/"+System.currentTimeMillis()+".jpg";
File outputFile = new File(filePath);
if (!outputFile.getParentFile().exists()) {
    outputFile.getParentFile().mkdir();
}
Uri contentUri = FileProvider.getUriForFile(this,
        BuildConfig.APPLICATION_ID + ".myprovider", outputFile);复制代码

生成的 Content URI 是这样的:

content://com.yifeng.samples.myprovider/my_images/1493715330339.jpg复制代码

其中,构成 URI 的 host 部分为 <provider> 元素的 authorities 属性值(applicationId + customname),path 片断 my_images 为 res/xml 文件中指定的子目录别名(真实目录名为:images)。

第四步,授予 Content URI 访问权限

生成 Content URI 对象后,须要对其受权访问权限。受权方式有两种:

第一种方式,使用 Context 提供的 grantUriPermission(package, Uri, mode_flags) 方法向其余应用受权访问 URI 对象。三个参数分别表示受权访问 URI 对象的其余应用包名,受权访问的 Uri 对象,和受权类型。其中,受权类型为 Intent 类提供的读写类型常量:

  • FLAG_GRANT_READ_URI_PERMISSION

  • FLAG_GRANT_WRITE_URI_PERMISSION

或者两者同时受权。这种形式的受权方式,权限有效期截止至发生设备重启或者手动调用 revokeUriPermission() 方法撤销受权时。

第二种方式,配合 Intent 使用。经过 setData() 方法向 intent 对象添加 Content URI。而后使用 setFlags() 或者 addFlags() 方法设置读写权限,可选常量值同上。这种形式的受权方式,权限有效期截止至其它应用所处的堆栈销毁,而且一旦受权给某一个组件后,该应用的其它组件拥有相同的访问权限。

第五步,提供 Content URI 给其它应用

拥有授予权限的 Content URI 后,即可以经过 startActivity() 或者 setResult() 方法启动其余应用并传递受权过的 Content URI 数据。固然,也有其余方式提供服务。

若是你须要一次性传递多个 URI 对象,可使用 intent 对象提供的 setClipData() 方法,而且 setFlags() 方法设置的权限适用于全部 Content URIs。

常见使用场景


前面介绍的内容都是理论部分,在 开发者官方 FileProvider 部分 都有所介绍。接下来咱们看看,实际开发一款应用的过程当中,会常常碰见哪些 FileProvider 的使用场景。

自动安装文件

版本更新完成时打开新版本 apk 文件实现自动安装的功能,应该是最多见的使用场景,也是每一个应用必备功能之一。常见操做为,通知栏显示下载新版本完毕,用户点击或者监听下载过程自动打开新版本 apk 文件。适配 Android 7.0 版本以前,咱们代码多是这样:

File apkFile = new File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "app_sample.apk");

Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
installIntent.setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive");
startActivity(installIntent);复制代码

如今为了适配 7.0 及以上版本的系统,必须使用 Content URI 代替 File URI。

在 res/xml 目录下新建一个 file_provider_paths.xml 文件(文件名自由定义),并添加子目录路径信息:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">

    <external-files-path name="my_download" path="Download"/>

</paths>复制代码

而后在 Manifest 文件中注册 FileProvider 对象,并连接上面的 path 路径文件:

<provider android:name="android.support.v4.content.FileProvider" android:authorities="com.yifeng.samples.myprovider" android:exported="false" android:grantUriPermissions="true">

    <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_provider_paths"/>

</provider>复制代码

修改 java 代码,根据 File 对象生成 Content URI 对象,并受权访问:

File apkFile = new File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "app_sample.apk");
Uri apkUri = FileProvider.getUriForFile(this,
        BuildConfig.APPLICATION_ID+".myprovider", apkFile);

Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
installIntent.setDataAndType(apkUri, "application/vnd.android.package-archive");
startActivity(installIntent);复制代码

如此这般,便完成了应用中调用系统功能打开 apk 文件的 7.0 适配工做。

调用系统拍照

调用系统拍照功能时也须要传递一个 Uri 对象,用于保存图片至指定目录,这里也须要适配 7.0 版本。其余步骤再也不赘述,核心 java 代码以下(路径不一样,注意添加 res/xml 中的 path 文件子目录):

String filePath = Environment.getExternalStorageDirectory() + "/images/"+System.currentTimeMillis()+".jpg";
File outputFile = new File(filePath);
if (!outputFile.getParentFile().exists()) {
    outputFile.getParentFile().mkdir();
}
Uri contentUri = FileProvider.getUriForFile(this,
        BuildConfig.APPLICATION_ID + ".myprovider", outputFile);

Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, contentUri);
startActivityForResult(intent, REQUEST_TAKE_PICTURE);复制代码

调用系统裁剪

调用系统裁剪的过程当中涉及到两个 Uri 对象:inputUri 和 outputUri,较为复杂一些。一般,调用系统裁剪的来源为调用系统拍照或选择系统相册。前者返回的是一个 File URI 对象,后者返回的是一个 Content URI 对象。做为裁剪源,咱们要作的就是对其作进一步处理。可是不能像上面那样使用 getUriForFile() 方法,这个并不难理解,由于若是是选择系统相册所得的图片,自己也不必定属于咱们本身的应用。正确处理方式是这样:

private Uri getImageContentUri(String path){
    Cursor cursor = getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            new String[]{MediaStore.Images.Media._ID},
            MediaStore.Images.Media.DATA + "=? ",
            new String[]{path}, null);
    if (cursor != null && cursor.moveToFirst()) {
        int id = cursor.getInt(cursor.getColumnIndex(MediaStore.Images.Media._ID));
        Uri baseUri = Uri.parse("content://media/external/images/media");
        return Uri.withAppendedPath(baseUri, ""+id);
    }else {
        ContentValues contentValues = new ContentValues(1);
        contentValues.put(MediaStore.Images.Media.DATA, path);
        return getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues);
    }
}复制代码

拿到正确的 Content URI 后,做为 inputUri,传递给 Intent 对象:

Intent intent = new Intent("com.android.camera.action.CROP");
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(inputUri, "image/*");
intent.putExtra("crop", "true");
intent.putExtra("aspectX", 1);
intent.putExtra("aspectY", 1);
intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(outputFile));
startActivityForResult(intent, REQUEST_PICK);复制代码

注意:这里的 outputUri 并无改变,仍然使用的是 Uri.fromFile() 方法获取的 File URI 类型!这是很奇怪的一点,可是不得不这么作。事实上,使用这种方式调用系统裁剪功能自己就是有问题的!常见问题如:在部分机型上,调用系统裁剪并返回前一个页面时,在 onActivityResult() 方法中获得的 resultCode 值不等于 RESULT_OK。Crop Intent 在官方文档中原本就无迹可寻,自己就是一种不推荐的用法!取而代之的是,咱们可使用 GitHub 上的一些开源库实现应用内的图片裁剪功能,好比 uCropcropper 等。

历史版本问题


说了这么多,还有一个你们比较关心的问题就是:哪些已经上线的旧版本应用没有作 7.0 适配工做怎么办?关于这个问题,Google 已经提早帮咱们想好解决方案啦。

还记得 6.0 运行时权限问题吗?若是你不想处理运行时权限事宜的话,只须要在 build.gradle 文件中将 targetSdkVersion 的值设为 23 如下便可。

一样的,只要 targetSdkVersion 值小于 24,File URI 的使用依旧能够出如今 7.0 及以上版本的设备中。不过须要注意的是,如前面所述,调用系统裁剪功能比较特殊,可能会出现一些问题。

虽然 Google 在每次发布新版 Android 系统时,都提供这种设置 targetSdkVersion 的方式兼容旧版本,但只是一种临时解决方案,并不推荐你们使用这种技巧绕开新版本的适配问题。要知道,新出现的 API 改变必定是在解决过去存在的系统问题,是一种进步的表现。遵循规范,是咱们每一个开发人员开发时都应铭记于心的格言。

关于我:亦枫,博客地址:yifeng.studio/,新浪微博:IT亦枫

微信扫描二维码,欢迎关注个人我的公众号:安卓笔记侠

不只分享个人原创技术文章,还有程序员的职场遐想

相关文章
相关标签/搜索