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

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

Android 7.0 权限变更


为了提高私有目录的安全性,防止应用信息的泄漏,从 Android 7.0 开始,应用私有目录的访问权限被做限制。具体表现为,开发人员不能够再简单地通过 file:// URI 访问其他应用的私有目录文件或者让其他应用访问自己的私有目录文件。

备注:如果你对应用私有目录不太清楚的话,可以阅读我的这篇文章:了解 Android 应用的文件存储目录,掌握持久化数据的正确姿势

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

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

FileProvider


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

第一步,注册一个 FileProvider

作为系统四大组件之一的 ContentProvider,其子类FileProvider,也同样需要使用 <provider> 元素在 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 字符串(这样写是约定俗成的)。其他属性值使用如上固定值即可。

第二步,添加共享目录

在 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>

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

  • <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 改变一定是在解决过去存在的系统问题,是一种进步的表现。遵循规范,是我们每个开发人员开发时都应铭记于心的格言。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容