1.背景
Google于 2019年9月3日发布了Android10 release版本,为了更好的保护用户数据并限制设备冗余文件增加,Android 10版本变更了设备外部存储访问方式,外部存储新特性称为分区存储(Scoped Storage), 分区存储遵循以下三个原则对外部存储文件访问方式重新设计,便于用户更好的管理外部存储文件
文件更好的归属: 系统记录文件由哪个应用创建,应用不需要存储权限即可以访问应用自己创建文件
应用数据保护: 添加外部存储应用私有目录文件访问限制, 应用即使申请了存储权限也不能访问其他应用外部存储私有目录文件
用户数据保护: 添加pdf、office、doc等文件的访问限制,用户即使申请了存储权限也不能访问其他应用创建的pdf、office、doc等文件
2.Android存储目录
Android的存储目录被分为内部存储和外部存储两个部分,对于外部存储进行了重新设计,外部存储被分为应用私有目录以及共享目录两个部分。

①内部存储:
-
getFilesDir() - 应用内部存储 放在data/data/packagename/files/ -
getCacheDir() - 应用内部存储 放在data/data/packagename/cache/ 主要用于存储应用的私密数据,用户在手机上是看不见这块区域的。在进行存储的过程中不需要存储权限,当应用被卸载后数据会被删除。应用内部存储目录访问方式与之前Android版本一致,可以通过file path获取资源
②外部存储的私有目录:
-
getExternalFilesDir - 放在外部存储Android/data/packagename/files/ 外部存储私有目录
- 应用卸载就会删除
- 5.0及以上不需要
WRITE_EXTERNAL_STORAGE READ_EXTERNAL_STORAGE - 不安全,别的应用可以写入数据到此目录
- Media扫描不出来,不会出现在相册
-
getExternalCacheDir - 存放临时缓存数据 放在外部存储Android/data/packagename/cache/ ? 对应设置选项设置->应用->应用详情里面的"清除数据"与"清除缓存“选项" ? 应用外部存储私有目录访问方式与Android10之前版本一致,可以通过file path获取资源
**③外部存储共享目录:**外部存储共享目录 除了外部存储的私有目录以外的目录,都是共享目录。程序保存在共享目录中的数据,在应用被删除后,仍然保留。存储其他应用可访问文件, 包含媒体文件、文档文件以及其他文件,对应设备DCIM、Pictures、Alarms, Music, Notifications,Podcasts, Ringtones、Movies、Download等目录
-
getExternalStorageDirectory - 在sd卡目录mnt/sdcard,在Android10 以前版本中 可以通过file path获取资源,获取资源的时候需要获取 存储权限。卸载后保留创建的文件 -
在Android10 版本之后,共享目录文件需要通过MediaStore API或者Storage Access Framework方式访问 ? MediaStore API在共享目录指定目录下创建文件或者访问应用自己创建文件,不需要申请存储权限 ? MediaStore API访问其他应用在共享目录创建的媒体文件(图片、音频、视频), 需要申请存储权限,未申请存储权限,通过 ? ContentResolver查询不到文件Uri,即使通过其他方式获取到文件Uri,读取或创建文件会抛出异常; ? MediaStore API不能够访问其他应用创建的非媒体文件(pdf、office、doc、txt等), 只能够通过Storage Access Framework方式访问;
3.文件迁移
3.1文件迁移是将应用共享目录文件迁移到应用私有目录或者Android10要求的media集合目录
①针对只有应用自己访问并且应用卸载后允许删除的文件,需要迁移文件到应用私有目录文件,可以通过File path方式访问文件资源,降低适配成本
②允许其他应用访问,并且应用卸载后不允许删除的文件,文件需要存储在共享目录,应用可以选择是否进行目录整改,将文件迁移到Android10要求的media集合目录

3.2 文件访问兼容性适配
共享目录文件不能够通过File path方式读取,需要使用MediaStore API或者Storage Access Framework框架进行访问

4.Android10 以后使用MediaStore 进行文件的存储操作
MediaStore API 简介
MediaStore API
系统会自动扫描外部存储,添加文件到系统已定义的Images、Videos、Audio files、Downloaded files集合中,Android 10通过MediaStore.Images、MediaStore.Video、MediaStore.Audio、MediaStore.Downloads 访问共享目录文件资源
媒体类型 | Uri | uri 定义的常量 | 默认创建目录 | 允许创建目录 | Mime Type |
---|
Image | content://media/external/images/media | MediaStore.Images.Media.EXTERNAL_CONTENT_URI | Pictures | DCIM Pictures | Pictures图片(image/*) | Video | content://media/external/video/media | MediaStore.Video.Media.EXTERNAL_CONTENT_URI | Movies | DCIM, Movies | 视频(video/*) | Audio | content://media/external/audio/media | MediaStore.Audio.Media.EXTERNAL_CONTENT_URI | Music | Alarms, Music, Notifications, podcast
Ringtones | 音频(audio/*) | Download | content://media/external/downloads | MediaStore.Downloads.EXTERNAL_CONTENT_URI | Download | Download | NA | File | content://media/external/ | MediaStore.Files.getContentUri(“external”) | Documents | Documents | file/* |
4.1 创建/保存文件
通过 ContentResolver.insert(Uri url,ContentValues values) 插入到对应的目录中,该方法会返回一个 Uri,通过对该 Uri 进行文件流的操作。
① insert()方法的第一个参数表示你要插入哪一个目录的Uri ,就是上表中定义的Uri常量。
② insert()方法的第二个参数,ContentValues ,首先构造一个 ContentValues 对象,
-
ContentValues 的 key 值可以通过 MediaStore.XXX.Media.YYY 获取到 XXX: 对应的媒体类型 YYY: 对应的字段常量 -
RELATIVE_PATH 注意RELATIVE_PATH需要targetVersion>=29 ,表示在公共媒体路径下创建子目录relativePath。例如上文我们将这个图片保存到了 Pictures/DemoPicture 文件夹下,如果不设置这个值,则会被默认保存到对应的媒体类型的文件夹下,例如,图片文件(mimeType = image/*)会被保存到 Pictures(Environment#DIRECTORY_PICTURES) 中,需要注意的是,不能将文件放置到不对应的顶级文件夹下,比如将一个 mimeType 为 audio/mpeg 放大 Pictures 这样的行为是不被允许的,也就是如果设置 MIME_TYPE = audia/* 并将 RELATIVE_PATH 设置为 Environment#DIRECTORY_PICTURES 这样是会 Throw IllegalArgumentException 的 例如:
values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES +"/ucamera");
? Environment 类中定义了一些我们常用的目录的字段;
public static String DIRECTORY_MUSIC = "Music";
public static String DIRECTORY_PODCASTS = "Podcasts";
public static String DIRECTORY_RINGTONES = "Ringtones";
public static String DIRECTORY_ALARMS = "Alarms";
public static String DIRECTORY_NOTIFICATIONS = "Notifications";
public static String DIRECTORY_PICTURES = "Pictures";
public static String DIRECTORY_MOVIES = "Movies";
public static String DIRECTORY_DOWNLOADS = "Download";
public static String DIRECTORY_DCIM = "DCIM";
public static String DIRECTORY_DOCUMENTS = "Documents";
public static String DIRECTORY_SCREENSHOTS = "Screenshots";
public static String DIRECTORY_AUDIOBOOKS = "Audiobooks";
创建返回Uri示例代码如下:
/***
*
* @param context 上下文
* @param videoName 视频文件名字带有后缀名如 :"123.mp4"
* @param mineType 视频格式类型如:"video/mp4"
* @param subDir 创建的子目录如:"/ucam"
* @return uri
*/
public Uri getSaveToGalleryVideoUri(Context context, String videoName, String mineType, String subDir) {
ContentValues values = new ContentValues();
//视频的名字
values.put(MediaStore.Video.Media.DISPLAY_NAME, videoName);
//视频类型;
values.put(MediaStore.Video.Media.MIME_TYPE, mineType);
//修改时间
values.put(MediaStore.Video.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
//大于等于10 的版本设置子目录
values.put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + subDir);
}else{
//数据库表中列字段存储的是数据的绝对路径;android 10 之前,将绝对路径设置到这个key中;这个字段可能在后面的数据库查询可能会用到;
values.put(MediaStore.MediaColumns.DATA, getpath());
}
Uri mInsert = context.getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
return mInsert;
}
4.2使用我们插入之后的Uri 进行我们的一些存储操作
4.2.1 设置视频录制(MediaRecorder.setOutputFile(FileDescriptor fd)所需要的 FileDescriptor 实例对象
public static FileDescriptor getSaveToGalleryVideoOutputStream(Uri uri) throws FileNotFoundException {
ParcelFileDescriptor fileDescriptor = context.getContentResolver().openFileDescriptor(uri, "w");
FileDescriptor mfileDescriptor = fileDescriptor.getFileDescriptor();
return mfileDescriptor;
}
}
FileDescriptor fd = getSaveToGalleryVideoOutputStream(uri);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
mMediaRecorder.setOutputFile(fd);
}
4.2.2 Cursor query(@RequiresPermission.Read @NonNull Uri uri,@Nullable String[] projection, @Nullable String selection,@Nullable String[] selectionArgs, @Nullable String sortOrder) 方法查询数据库中的数据操作。
参数解释:
参数 | 类型 | 释义 |
---|
uri | Uri | 提供检索内容的 Uri,其 scheme 是content:// | projection | String[] | 返回所要查询的数据库列的集合,如果传递 null 则所有列都返回(效率低下) | selection | String | 过滤条件,即 SQL 中的 WHERE 语句(但不需要写 where 本身),如果传 null 则返回所有的数据 | selectionArgs | String[] | 如果你在 selection 的参数加了 ? 则会被本字段中的数据按顺序替换掉 | sortOrder | String | 用来对数据进行排序,即 SQL 语句中的 ORDER BY (单不需要写ORDER BY 本身),如果传 null 则按照默认顺序排序(可能是无序的) |
Cursor的一些理解:
Cursor是怎么一回事,怎么取它的数据,和它的数据为什么那么取。

上面的表代表的是一个cursor对象。当我们用query方法查询到的是一个指向cursor对象第一行的前面一行数据,即上图中的空行。所以我们使用cursor.moveToNext()方法时,是指向cursor的第一行数据,而不是第二行。如何得到cursor中的数据呢?当我们使用了cursor.movetonext()后,cursor指向了第一行数据,由于是数据库中的表,所以每一行每一列都有对应的数据类型和列名。当我们要取出cursor对象中的一个对应的数据,首先我们要先确定它的数据类型,用cursor.getxxx()。xxx代表的是你要取出的数据的数据类型,当前我们要取出第一行的“列名2”的数据。就可以用cursor.getString()。然后我们确定了数据类型后,肯定要确定列名,所以我们要得到cursor对象中的列名,用cursor.getColumnIndex(“列名”)来获得。这样我们要取出第一行第二列的数据时,就可以用cursor.getString(cursor.getColumnIndex(“列名2”))来获得。取第二行的数据只要继续cursor.moveToNext(),到了第二行后,跟取第一行的数据是一样的。
关于 Cursor
在你理解和使用 Android Cursor 的时候你必须先知道关于 Cursor 的几件事情:
Cursor 是每行的集合。
使用 moveToFirst() 定位第一行。
你必须知道每一列的名称。
你必须知道每一列的数据类型。
Cursor 是一个随机的数据源。
所有的数据都是通过下标取得。
关于 Cursor 的重要方法:
close()
关闭游标,释放资源
copyStringToBuffer(int columnIndex, CharArrayBuffer buffer)
在缓冲区中检索请求的列的文本,将将其存储
getColumnCount()
返回所有列的总数
getColumnIndex(String columnName)
返回指定列的名称,如果不存在返回-1
getColumnIndexOrThrow(String columnName)
从零开始返回指定列名称,如果不存在将抛出IllegalArgumentException 异常。
getColumnName(int columnIndex)
从给定的索引返回列名
getColumnNames()
返回一个字符串数组的列名
getCount()
返回Cursor 中的行数
moveToFirst()
移动光标到第一行
moveToLast()
移动光标到最后一行
moveToNext()
移动光标到下一行
moveToPosition(int position)
移动光标到一个绝对的位置
moveToPrevious()
移动光标到上一行
查询代码的示例:
public void query(ContentResolver cr) {
String sortOrder = MediaStore.Images.Media.DATE_MODIFIED + " DESC";
Cursor c = cr.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, null, null, null, sortOrder);
int colnum = c.getColumnCount();
StringBuilder res = new StringBuilder();
for (int i = 0; i < colnum; i++) {
res.append(c.getColumnName(i)).append(c.getColumnIndex(c.getColumnName(i))).append("\t");
}
res.append("\n");
while (c.moveToNext()) {
for (int i = 0; i < colnum; i++) {
try {
res.append(c.getString(i)).append("\t");
} catch (Exception e) {
res.append(c.getType(i)).append("\t");
}
}
res.append("\n");
}
c.moveToFirst();
Log.d("deng", "zzzh createCursor: addImage " + cr + " count " + c.getCount() + " \n" + res);
}
通过整个的查询, 我截取了数据库表的一部分,可以看到数据库列字段 _data 中存放的是绝对路径, 其所在的列索引为33;

4.23通过uri 去获取缩略图
private String getVideoFilePath(Context context) {
final File dir = context.getExternalFilesDir(null);
return (dir == null ? "" : (dir.getAbsolutePath() + "/"))
+ System.currentTimeMillis() + ".mp4";
}
public Bitmap getVideoThumbnail(ContentResolver cr, Uri uri) {
query(cr);
Cursor cursor = null;
String path;
try {
String[] proj = {MediaStore.Video.Media.DATA};
cursor = cr.query(uri, proj, null, null, null);
StringBuilder res = new StringBuilder();
int column_index = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA);
res.append(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)+"").append("\t\t");
cursor.moveToFirst();
res.append("\n");
path = cursor.getString(column_index);
res.append(cursor.getString(column_index)).append("\t\t");
Log.e("deng", "createCursor: getvideothumbnail " + cr + " count " + cursor.getCount() + " \n" + res);
} finally {
if (cursor != null) {
cursor.close();
}
}
return ThumbnailUtils.createVideoThumbnail(path, MediaStore.Images.Thumbnails.FULL_SCREEN_KIND);
}
4.2.4通过uri可以得到Bitmap
public Bitmap getBitmap(ContentResolver rs ,Uri uri) throws FileNotFoundException {
ParcelFileDescriptor fd = rs.openFileDescriptor(uri, "r");
Bitmap bitmap = BitmapFactory.decodeFileDescriptor(fd.getFileDescriptor());
return bitmap;
}
4.2.5 删除文件
如果需要编辑修改甚至删除其他应用的文件,则需要申请 WRITE_EXTERNAL_STORAGE 权限。如果当应用没有 WRITE_EXTERNAL_STORAGE 权限时,去修改其他 App 的文件时,则会 throw java.lang.SecurityException: xxxx has no access to content://media/external/images/media/243 的异常
当应用拥有了 WRITE_EXTERNAL_STORAGE 权限后,当修改其他 App 的文件时,会 throw 另一个 Exception android.app.RecoverableSecurityException: xxxxxx has no access to content://media/external/images/media/243
如果我们将这个 RecoverableSecurityException 给 Catch 住,并向用户申请修改该图片的权限,用户操作后,我们就可以在 onActivityResult 回调中拿到结果进行操作了
4.2.5.1 删除应用自己创建的文件, 不需要权限,
public void delete(ContentResolver contentResolver,String name) {
try {
String sortOrder = MediaStore.Images.Media.DATE_MODIFIED + " DESC";
String selection = MediaStore.Images.Media.DISPLAY_NAME + "='" + name + "'";
cursor = contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, new String[]{MediaStore.Video.VideoColumns._ID,MediaStore.Images.Media.DISPLAY_NAME}, selection, null, sortOrder);
if (cursor != null ) {
int columnId = cursor.getColumnIndex(MediaStore.Images.Media._ID);
cursor.moveToFirst();
int mediaId = cursor.getInt(columnId);
Uri uri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, mediaId);
contentResolver.delete(uri,null,null);
}
}catch (Exception e){
e.toString();
}
cursor.close();
}
4.2.5.2 删除其他应用所创建的文件;
在 Android 11 上运行请使用以下方法:
- 使用MediaStore.createWriteRequest() 和MediaStore.createTrashRequest()为应用的写入或删除请求创建待定 intent,然后通过调用该 intent 提示用户授予修改一组文件的权限。
- 评估用户的响应:
- 如果授予了权限,请继续修改或删除操作。
- 如果未授予权限,请向用户说明您的应用中的功能为何需要该权限。
详细了解如何使用 Android 11 中提供的这些方法执行批量操作。
在 Android 10 上运行
如果您的应用以 Android 10(API 级别 29)为目标平台,请停用分区存储,继续使用适用于 Android 9 及更低版本的方法来执行此操作。
在 Android 9 或更低版本上运行
请使用以下方法:
- 按照请求应用权限中所述的最佳做法,请求
WRITE_EXTERNAL_STORAGE 权限。 - 使用
MediaStore API 修改或删除媒体文件。
参考文档
4.2.6 存储图片
public static void SavePic(Uri uri, byte[] bytes) {
if (uri != null) {
OutputStream outputStream = null;
try {
outputStream = MyApplication.getContext().getContentResolver().openOutputStream(uri);
outputStream.write(bytes);
outputStream.flush();
outputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
测试示例
|