Android学习-数据存储

在Android开发的过程中, 数据的本地存储总是绕不开的一个场景. 从android 10版本开始, 谷歌为了规范Android开发过程中的数据存储, 推出了”分区存储”的概念. 这个变化出现后, 很多新人(也包括我)会突然发现文件存储的权限变得特别乱, 自己的脑海中没有一个清晰的体系架构在里面. 所以这篇文章我们就来系统地学习一下Android的数据存储.

存储空间的划分

内部存储和外部存储

首先我们要明白Android是怎么对存储空间进行划分的. Android将设备的存储空间简单分成了两大部分: 内部存储和外部存储. 这个概念提出的时候, 大多数手机的存储空间是分为两个部分的, 一个是手机内部自带的存储空间, 一个是我们可以自己扩展的MIcro SD卡. 这个我相信大家都有过使用按键手机和板砖机的经历, 应该都还有印象. 对于手机内部自带的存储空间, Android就将其称为内部存储; 对于Micro SD卡, Android就将其称为外部存储.

当然, 对于现在的手机来说, Micro SD卡基本上不常见了, 因为厂商将存储介质内置到了手机当中, 不给我们扩展的选择了. 但是, 这并不意味着就没有外部存储了, 内置存储卡虽然叫内置, 但实际上还是一个外部存储介质, 只不过它不对外暴露了, 所以它就是现在手机中的外部存储空间.

两个存储空间的存储路径

那么内部存储空间和外部存储空间的路径都是什么呢? 那就要从Android的文件系统来说了.

上面是Android的文件系统. 我们知道, Android是基于Linux的, 所以它的文件系统和Linux的文件系统一样, 都是树形结构. 从根节点往下, 有三个子节点——data、system和storage. 我们一个个说.

data节点往下的盘区就是Android的内部存储空间了. 所以一个app的内部存储目录的路径应该是: /data/user/0/app的包名.

system节点是系统所使用的存储空间. 严格来说, 它也是内部存储空间的一部分. 手机厂商预装的应用的apk就放在这里. Android会自动将这里存储的apk安装在手机中(所以系统应用基本上删不掉, 即使你删了它也会自动安装上去, 除非它在安装完成后自动把这里的apk删掉).

storage节点, 顾名思义, 就是外部存储空间的根节点. 这个节点下主要有两个节点——sdcard1和emulated. 对于使用外置Micro SD卡的手机来说, SD卡会挂载到sdcard1节点下; 对于使用内置存储卡的手机来说, 存储介质会挂载到emulated节点下. 一般来说, 设备的根节点就是0. 所以我们可以把0为根节点的子树整个看作是外部存储空间所存储的数据, 即外部存储空间的根目录为/storage/emulated/0.

顺便说一句, 在不root的情况下, 我们只能遍历0为根节点的子树, 也就是外部存储空间. data和system我们是无法在不root的情况下通过文件管理器看到的.

两个存储空间的特点

内部存储

内部存储最大的特点就是安全. 这个安全是随着Andoird的发展越来越严格的.

显然, 对于一个app来说, 它可以随意在内部存储进行数据读写. 通过下面两个api, app可以获得自己在内部存储的两个目录:

1
2
String fileFolderPath = context.getFilesDir(); // /data/user/0/app包名/files
String cacheFolderPath = context.getCacheDir(); // /data/user/0/app包名/cache

当然, 我们也可以只获取到app在内部存储空间根节点路径:

1
String dataDir = getApplicationInfo().dataDir;

在Android 7.0以前, app的内部存储空间也是可以对外开放的. 应用在内部存储空间创建一个文件时, 可以通过openFileOutPut()方法来对该文件的权限进行设置:

1
2
3
4
5
6
7
8
9
10
11
12
String filename = "myfile";
String fileContents = "Hello world!";
FileOutputStream outputStream;

try {
outputStream = openFileOutput(filename, Context.MODE_WORLD_WRITEABLE);
outputStream.write(fileContents.getBytes());
outputStream.close();
}
catch (Exception e) {
e.printStackTrace();
}

Context的参数预设值包括MODE_WORLD_WRITEABLE、MODE_WORLD_READABLE和MODE_PRIVATE三个值, 分别允许文件可被其它app读写、被其它app读和禁止其它app访问.

从Android 7.0开始, MODE_WORLD_WRITEABLE、MODE_WORLD_READABLE两个参数被弃用, 如果再使用它们会直接抛出SecurityException. 作为替代, 如果一个app想将自己的内部存储文件共享给其它app, 需要通过FileProvider来实现. FileProvider是ContentProvider的一个实现, 具体使用方法这里就不多着墨了.

外部存储

外部存储空间的特点是大. 因为是外部存储介质, 空间是远比手机自带的存储空间大的. 对于体积比较大的应用, 资源紧张的内部存储空间可能不是一个特别好的选择, 那么我们就可以通过修改AndroidManifest.xml的manifest标签, 添加属性: android: installlocation = “auto”/“preferExternal”, 将其安装在外部存储空间.

当然, 因为是外部存储, 安全性自然就默认不如内部存储高. 外部空间的权限管理是一个比较大的内容, 我们在下面单独展开说.

外部存储的管理

共有目录和私有目录

Andoird系统对外部存储进一步做了划分, 将其分成了公共目录和私有目录两部分.

私有目录, 顾名思义, 是app保存在外部存储空间中私有的部分. 我们再回头看看上面那张文件系统图, /storage/emulated/0/Android目录下有一个data目录, 这个目录下的空间就是外部存储的私有目录区. 在这里, 每一个app都会有一个以包名命名的目录(即/storage/emulated/0/Android/data/app包名). 虽然叫私有目录, 它并不是私有的, 它允许别的应用进行访问和修改(前提是要知道这个应用的包名).

公共目录是直接位于/storage/emulated/0目录下的目录, 这些目录包括Android系统默认创建的DownLoad、Photo、Music、DCIM、Documents等目录, 也包括app在这里创建的目录.

公共目录和私有目录的区别在于, 当app被卸载时, app在私有目录下的数据都会被删除, 而公共目录下的数据则仍然存在(所以即使我们卸载了app, 我们还是会在手机中找到这些app的残留文件夹). 同时, 在app的系统设置界面, 我们可以通过点击“清除应用数据”键来人为清除app在私有目录下的files和cache两个文件夹的内容(事实上, 手机清理的原理就是挨个去清理每个app的私有目录. 因为针对的是私有目录, 所以无论你清了多少次, 公共目录下的应用残留还是会存在). 最后, 在私有目录下的文件不会被系统扫描到, 即如果把图片保存到了私有目录下, 除非手动通知系统将其同步, 否则我们不会在相册里看到它.

两个目录的访问

在Android 4.4以前, 应用无论是访问自己的私有目录还是设备的公共目录, 都需要申请权限, 就是我们都熟知的READ_EXTERNAL_STORAGE 或 WRITE_EXTERNAL_STORAGE. 从Android 4.4开始, 只有访问公共目录才需要申请这两个权限了.

检查外部存储状态

无论是访问公共目录还是私有目录, 在访问前最好要做一次外部存储的检查, 因为外部存储是可变的(比如一些手机的外置Micro SD卡卡槽也可以插SIM卡), 虽然现在绝大多数手机都是内置存储卡, 但是从逻辑上来说, 检查外部存储状态是有必要的.

1
String state = Environment.getExternalStorageState();

getExternalStorageState()这个函数的返回值有10种, 分别表示了外部存储的不同状态, 具体见下表:

| 返回值 | 意义 |
| :— | : — |
| MEDIA_BAD_REMOVAL | 存储设备在没有完成解挂的情况下被拔出, 类似于我们直接把USB从电脑上拔出来 |
| MEDIA_CHECKING | 顾名思义, 存储设备存在且正在被扫描 |
| MEDIA_EJECTING | 设备正在被弹出, 类似于电脑上的安全弹出设备 |
| MEDIA_MOUNTED | 设备已挂载, 可以正常读写 |
| MEDIA_MOUNTED_READ_ONLY | 设备已挂载, 但是只能读 |
| MEDIA_NOFS | 设备存在但是无法挂载, 通常是文件系统不兼容 |
| MEDIA_REMOVED | 设备不存在 |
| MEDIA_SHARED | 设备并非挂载在手机上, 而是通过USB连接 |
| MEDIA_UNKNOWN | 设备类型未知 |
| MEDIA_UNMOUNTABLE | 设备存在但是无法挂载, 通常是文件系统崩了 |
| MEDIA_UNMOUNTED | 设备存在但是还没有完成挂载 |

在检查了存储设备后, 就可以进行外部存储的读写了.

访问外部存储的私有目录

对于外部存储的私有目录, 可以说安全级别是不算低的. 一个应用想要访问另外一个应用的私有目录, 要么要获取到root权限, 要么要另外一个应用通过ContentProvider来共享. 但是应用自己在自己的私有目录下创建和管理文件还是非常方便的.

1
2
String fileFolderPath = context.getExternalFilesDir();
String cacheFolderPath = context.getExternalCacheDir();

上面是获取外部存储的私有目录路径的两个方法. 需要注意的是, 这两个方法返回的是手机内置的外部存储. 也就是说, 如果你的手机既有内置存储卡, 又扩展了一张Micro SD卡, 那么这两个方法是不会返回Micro SD卡的路径的. 如果你需要, 那么可以使用下面两个方法:

1
2
String fileFolderPath = context.getExternalFilesDirs();
String cacheFolderPath = context.getExternalCacheDirs();

这两个方法返回的是一个数组, 数组包括了所有外部存储的路径. 数组的第一个仍然是内置存储卡的路径, 后面的就是Micro SD卡的路径了.

访问外部存储的公共目录

对于外部存储的公共目录, 在Android 10以前, 安全级别是不高的, 所有的应用都可以通过file://+文件路径的形式在公共目录下随意访问文件夹和文件(所以公共目录下的东西十分混乱而且不安全).

从Android 10开始, 公共目录的读和写都做了严格的规范. Android 10开始, 规范的访问文件的途径只有MediaStore和SAF两种. 因为Android 10以后, 系统不再支持file://访问, 只支持uri访问. uri的来源就是这两种途径(其实也可以自己写程序手动实现绝对路径和uri的转换, 但是既然有现成的框架为啥不用呢). 这么做的意义是, 谷歌实际上是希望所有应用把公共目录下的数据都存到Image、Download等系统预创建的文件夹下, 保持公共目录的整洁(虽然基本上没有人按照谷歌想的走).

这里要说明的是, Android10和11目前都还支持兼容模式, 即可以用以前的file://+文件路径的方法随意创建文件, 只需要在AndroidManifest.xml文件的application标签中添加”requestLegacyExternalStorage=true”(Android 11是“preserveLegacyExternalStorage

=true”, 但是这和10还不一样. 如果你的应用卸载重装了, 就不能再用旧的方式了. )即可. 但是这是一时的办法, 后面的版本中随时会取消, 所以还是建议大家按照分区存储的规则来做.

首先是写入. 写入的权限很简单, 所有应用只能修改自己创建的文件, 想要修改别的应用创建的文件, 要么申请MANAGE_EXTERNAL_STORAGE权限(当然这是有限制的, 所有申请了此权限的应用在Google Play上架前都需要经过谷歌的审核. 不知道国内的平台有没有这个操作), 要么需要其它应用通过ContentProvider来分享文件.

然后是读取, Android 10同样在权限上做了规范. 现在, 除非应用是系统的默认应用, 否则它只能写入或删除自己创建的文件, 对于别的应用创建的文件只有读权限.

SAF和MediaStore

SAF

SAF的全称为Storage Access Framework, 是Android提供一个访问文件的服务. 通过SAF, 我们可以访问公共目录下所有类型文件. 下面是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private void openFileWithSAF(){
int resultCode = 1; //设置返回码

Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); //指定操作类型为打开文件, 对于创建文件使用Intent.ACTION_CREATE_DOCUMENT
intent.addCategory(Intent.CATEGORY_OPENABLE); //设置过滤规则, 这里的意思是只显示能打开的文件
intent.setType("*/*"); //设置筛选后的文件类型, 这里是全选
startActivityForResult(intent, resultCode);
}

@Override
public void onActivityResult(int requestCode, int resultCode,Intent resultData) {
//判断响应码
if(requestCode == 1){
//返回结果判空
if(resultData != null){
try {
Uri uri = resultData.getData();
//获取输入输出流, 之后就可以读写文件了
InputStream inputStream = getContentResolver().openInputStream(uri);
OutputStream outputStream = getContentResolver().openOutputStream(uri);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
}
}

super.onActivityResult(requestCode, resultCode, resultData);
}

Android 5.0版本开始, SAF还提供了选择某一个文件夹的服务, 只需要将Intent.ACTION_OPEN_DOCUMENT改成Intent.ACTION_OPEN_DOCUMENT_TREE就可以了.

这里要说一下, SAF实际上是让系统针对特定的URI对应用授权. 这个授权是暂时的, 如果设备重启了, 那么授权就无效了. 想要获得长期的权限, 还需要添加下面的代码:

1
2
3
4
final int takeFlags = intent.getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(uri, takeFlags);

因为是针对URI的授权, 所以当文件移动了位置, URI发生了变化, 那么即使申请了长期权限也只能再重新获得授权.

MediaStore

MediaStore实际上是Android系统的一个数据库, 它不仅存储了多媒体文件的URI, 还存储了多媒体文件的一些属性值. 我们可以通过ContentResolver来对数据库进行查找, 获得我们需要的多媒体文件的URI. 当然, MediaStore所提供的结果可能是不完全的, 比如私有目录中的多媒体文件如果不认为同步的话, MediaStore是不会收录的. 除此之外, 还可以对在Photo、Audio、Vedio这些文件夹内创建.nomedia文件夹. 这个文件夹内的多媒体文件不会被MediaStore收录.

下面谷歌给出的一个使用MediaStore的例子, 场景为查找时长大于等于5分钟的视频:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// Need the READ_EXTERNAL_STORAGE permission if accessing video files that your
// app didn't create.

// Container for information about each video.
class Video {
private final Uri uri;
private final String name;
private final int duration;
private final int size;

public Video(Uri uri, String name, int duration, int size) {
this.uri = uri;
this.name = name;
this.duration = duration;
this.size = size;
}
}
List<Video> videoList = new ArrayList<Video>();

String[] projection = new String[] {
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.SIZE
};
String selection = MediaStore.Video.Media.DURATION +
" >= ?";
String[] selectionArgs = new String[] {
String.valueOf(TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES));
};
String sortOrder = MediaStore.Video.Media.DISPLAY_NAME + " ASC";

try (Cursor cursor = getApplicationContext().getContentResolver().query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)) {
// Cache column indices.
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID);
int nameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME);
int durationColumn =
cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION);
int sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE);

while (cursor.moveToNext()) {
// Get values of columns for a given video.
long id = cursor.getLong(idColumn);
String name = cursor.getString(nameColumn);
int duration = cursor.getInt(durationColumn);
int size = cursor.getInt(sizeColumn);

Uri contentUri = ContentUris.withAppendedId(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);

// Stores column values and the contentUri in a local object
// that represents the media file.
videoList.add(new Video(contentUri, name, duration, size));
}
}