前言
android系统发展了这么多年,存储系统的安全策略也经历多次变革,而这些逐渐也成为众多开发者踩坑雷区。
本系列主要包括以下三个部分:
- 存储体系:内部存储、外部存储、分区存储
- 权限处理:READ_EXTERNAL_STORAGE权限在媒体文件访问中的戏剧性变迁
- 文件处理:常用的文件处理方式
存储体系
在android中对文件的操作主要包括内部存储(Internal Storage)和外部存储(External Storage)。
- 内部存储
在/data/data//目录下,没有被root的手机用户是看不到这个目录的,一般用于敏感业务数据的存储,在应用卸载时数据会被清除。
- 外部存储
外部存储又分为应用专属存储和公共存储,设备无论是否被root,该目录都对用户可见。
应用专属存储路径一般在/sdcard/Android/data/<包名>/files目录下,存储在其中的数据在应用卸载时会被自动清除,其中sdcard的名字有可能被各设备厂商修改,在使用时需要通过API动态获取,不可使用硬编码。
公共存储即直接操作用户设备上用户可见的任意目录,包括手机自带存储、SD卡和USB存储设备等。
权限及文件处理
特性维度 |
内部存储 (Internal) |
应用专属外部存储 (External App-Specific) |
公共存储 (Public) |
路径示例 |
/data/data/<包名>/files |
/sdcard/Android/data/<自身包名>/files |
/sdcard/ |
访问权限 |
默认拥有读写权限 |
Android 4.4+ 默认拥有权限 |
需READ_EXTERNAL_STORAGE等权限 |
数据隔离性 |
完全私有(其他应用不可见) |
应用卸载时自动清除 |
永久存储(需用户主动删除) |
清除策略 |
随应用卸载自动清除 |
可保留Context.MODE_PRESERVE_APP_DATA |
永久保留 |
适用场景 |
敏感数据(如用户凭证) |
临时缓存/应用专属大文件 |
用户可见文件(图片/文档) |
如表格所示,开发者可根据文件的使用场景来选择不同的存储类型。
内部存储
从上表中我们知道,内部存储不需要任何权限,我们在需要操作内部存储的文件时,可以通过Context对象来操作。
例如,打开一个文件,并写入内容。
/**
* 将文件写入私有目录
* @param context
*/
private void writeInternalFile(Context context){
String fileName="privateFileTemp";//文件名称
String content="这是要写入的内容";
try {
FileOutputStream fos=context.openFileOutput(fileName, Context.MODE_PRIVATE);
fos.write(content.getBytes(StandardCharsets.UTF_8));
fos.flush();
fos.close();
toast("文件被保存至私有目录下");
} catch (FileNotFoundException e) {
Log.e(TAG, "savePrivateFile: ",e );
} catch (IOException e) {
Log.e(TAG, "savePrivateFile: ",e );
}
}
其中openFileOutput() 是 Context 类提供的方法,用于操作应用私有文件:
- fileName:文件名(不可包含路径分隔符 /),文件默认存储在 /data/data/<应用包名>/files/ 目录下。若文件不存在,系统会自动创建 ;
- Context.MODE_PRIVATE:文件操作模式,表示文件为应用私有,其他应用无法访问。写入时会覆盖原文件内容
文件操作模式类型
- MODE_PRIVATE:默认模式,覆盖写入,适用于需要保护敏感数据的场景(如用户配置或缓存)
- MODE_APPEND:追加写入,保留原内容
- MODE_WORLD_READABLE/WRITEABLE:允许其他应用读写(Android 7.0后废弃)
同理,我们可以读取私有目录下的文件。
/**
* 读取私有文件内容
* @param context
*/
private void readInternalFile(Context context){
String fileName="privateFileTemp";//文件名称
//获取/data/data/包名/file下的文件列表
String [] fileList=context.fileList();
//如果文件列表中不存在,则不继续执行
if (!Arrays.asList(fileList).contains(fileName)){
toast("文件不存在");
return;
}
try{
FileInputStream fis = context.openFileInput(fileName);
byte[] bytes = new byte[fis.available()];
fis.read(bytes);
String content=new String(bytes, StandardCharsets.UTF_8);
fis.close();
toast("文件内容为:"+content);
} catch (IOException e) {
Log.e(TAG, "readInternalFile: ",e );
}
}
应用专属外部存储
操作 |
所需权限 |
读写应用专属外部存储 |
无需任何权限 |
访问其他应用专属目录 |
Android 11+ 禁止访问 |
该存储路径在/Android/data// 下,在应用卸载时会自动清除数据,应用访问自身包名目录下的文件时无需任何权限。
注意,在android11之后,禁止访问其他应用的专属目录。
另外,在某些古早的android版本中(Android 4.1-4.3),可能还需要在清单文件中声明WRITE_EXTERNAL_STORAGE权限,否则getExternalFilesDir()会返回null
路径获取规范
在操作文件时,不能直接使用“/sdcard/Android/data/<自身包名>/files”硬编码的形式操作文件,因为各个厂商有可能会自定义sdcard的名称和目录,应该使用context对象的getExternalFilesDir方法来获取路径。
//获取应用专属外部存储根目录(通常对应/sdcard/Android/data/pkg/files)
File appRootPath=context.getExternalFilesDir(null);
Log.i(TAG, "应用专属外部存储根目录: "+appRootPath.getAbsolutePath() );
//获取指定类型子目录,不存在时会自动创建
File docFile=context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS);
Log.i(TAG, "指定类型子目录: "+docFile.getAbsolutePath());
文件写入示例
/**
* 写入文本到应用专属外部存储
*
* @return 成功写入返回true
*/
private boolean writeTextFile(Context context) {
String dirType = Environment.DIRECTORY_DOCUMENTS;
String fileName = "test.txt";
String content = "这是写入应用专属外部存储的内容";
File targetDir = context.getExternalFilesDir(dirType);
if (targetDir == null || !targetDir.exists() && !targetDir.mkdirs()) {
return false;
}
File targetFile = new File(targetDir, fileName);
try (FileWriter writer = new FileWriter(targetFile)) {
writer.write(content);
return true;
} catch (IOException e) {
Log.e(TAG, "文件写入失败: ", e);
return false;
}
}
文件读取示例
/**
* 读取应用专属外部存储的文件
* @param context
*/
private void externalAppSpecificRead(Context context) {
String dirType = Environment.DIRECTORY_DOCUMENTS;
String fileName = "test.txt";
File targetDir = context.getExternalFilesDir(dirType);
if (targetDir == null || !targetDir.exists() && !targetDir.mkdirs()) {
toast("路径不存在");
return;
}
File targetFile = new File(targetDir, fileName);
if (!targetFile.exists()){
toast("文件不存在");
return;
}
String content="";
try {
BufferedReader reader=new BufferedReader(new FileReader(targetFile));
String line;
while ((line=reader.readLine())!=null){
content=content+line+"\n";
}
reader.close();
toast("应用专属外部存储读取的内容:"+content);
} catch (FileNotFoundException e) {
Log.e(TAG, "externalAppSpecificRead: ",e );
} catch (IOException e) {
Log.e(TAG, "externalAppSpecificRead: ",e );
}
}
公共存储
公共存储是历次变化最多最难处理的一部分,它的数据特性是用户可见、多应用共享、永久存储。
在android 9及之前的版本中,在获取文件读写权限后,可直接通过路径访问文件,在android 10中引入了分区存储的安全机制,进一步的限制应用随意访问公共区域。
分区存储的关键特性包括:
-
1. 默认仅能访问应用专属目录
-
2. 媒体文件需要通过MediaStore进行访问
-
3. 文档类型文件需要使用SAF(存储访问框架)进行访问
操作类型 |
Android 9- |
Android 10-12 |
Android 13+ |
读取媒体文件 |
READ_EXTERNAL_STORAGE |
READ_EXTERNAL_STORAGE |
READ_MEDIA_IMAGES/VIDEO/AUDIO |
写入媒体文件 |
WRITE_EXTERNAL_STORAGE |
自动获得写入权限(通过MediaStore) |
同上 |
访问非媒体文件 |
路径访问+权限 |
必须使用SAF |
必须使用SAF |
获取权限
-
1. 在AndroidManifest.xml申明权限
<!-- 兼容Android 13以下版本 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Android 9及以下需要 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- Android 13+ 细分权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
2、在代码中动态获取权限,这里注意,要做好系统版本的判断,否则一些低版本的设备上会报错。
/**
* 在请求权限时要注意对各个android版本的判断,如果在低版本的系统中请求高版本才有的权限将会抛出异常
*
* @param activity
*/
private void requestPermission(Activity activity) {
//这里可以使用一个集合,根据android版本动态的添加需要请求的权限
List<String> permissions = new ArrayList<>();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
//android13 细分权限,根据需要申请权限(图片、音频、视频),如果要操作文件,需要使用SAF框架,
permissions.add(Manifest.permission.READ_MEDIA_IMAGES);
permissions.add(Manifest.permission.READ_MEDIA_AUDIO);
permissions.add(Manifest.permission.READ_MEDIA_VIDEO);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
//android10~12需要READ_EXTERNAL_STORAGE权限
permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
//android6~9
permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE);
permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
}
if (!permissions.isEmpty()) {
activity.requestPermissions(permissions.toArray(new String[0]), REQUEST_PERMISSION_CODE);
}
}
在onRequestPermissionsResult中处理权限的授予情况。
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_PERMISSION_CODE) {
List<String> deniedList = new ArrayList<>();
for (int i = 0; i < grantResults.length; i++) {
int result = grantResults[i];
if (result == PackageManager.PERMISSION_DENIED) {
deniedList.add(permissions[i]);
}
}
if (deniedList.size() == 0) {
toast("权限都被授予");
} else {
toast("有" + deniedList.size() + "个权限被拒绝");
//此时和弹出对话框,对用户说明该权限的重要性,并且在此申请所需的权限
}
}
}
在确定获取权限后,再进行获取图片的操作,如果没有获取权限的话,这里不一定会发生异常,但是Cursor会返回为空。
/**
* 加载相册中的图片
*/
private void loadPublicImg(){
String[] projection = {
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_ADDED
};
Cursor cursor = getContentResolver().query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
null, null,
MediaStore.Images.Media.DATE_ADDED + " DESC" // 按时间倒序
);
if (cursor != null) {
Uri imageUri;
while (cursor.moveToNext()) {
long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID));
String name = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME));
long dateAdded = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED));
imageUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
img.setImageURI(imageUri);
break;
}
cursor.close(); // 必须关闭游标防止内存泄漏[1,8](@ref)
}else {
toast("未获取到图片,请检查是否获得了权限");
}
}
🎨
分区存储内容较多,至于更多文件类型的读写,在后续会单独写一个专题,如果我忘记了,记得催催我~
特例
看到这儿,你可能就想了,我的老项目中,可能有这样或者那样的原因,比如文件管理器、浏览器等应用,必须要和之前一样能够访问全部的文件,那咋办?
要想解决上面的问题,有两种方案:
1、降低目标版本(不推荐,但好用)
2、获取文件管理权限(推荐,但是需要引导用户手动开启此权限)
方法一:降低目标版本
在清单文件中设置requestLegacyExternalStorage属性来临时绕过分区存储,不过此方案仅适用于TargetSDK=29的情况,因此如果不上应用商店的话,我们就可以采取这个临时方案。
第一步
在清单文件(AndroidManifest.xml)中申明权限,并在application节点中,申明requestLegacyExternalStorage,另外targetApi也要降低到29或以下。
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
……
android:preserveLegacyExternalStorage="true"
android:requestLegacyExternalStorage="true"
tools:targetApi="29">
第二步
在app的build.gradle文件中,找到targetSdk,将其降到29或以下
android {
defaultConfig {
……
minSdk 24
targetSdk 29
}
……
}
第三步
动态申请权限,也仅仅需要申请下面两个权限即可
private void requestPermission(Activity activity) {
String [] permission=new String[]{
android.Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
};
activity.requestPermissions(permission, REQUEST_PERMISSION_CODE);
}
处理权限授予结果
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_PERMISSION_CODE) {
List<String> deniedList = new ArrayList<>();
for (int i = 0; i < grantResults.length; i++) {
int result = grantResults[i];
if (result == PackageManager.PERMISSION_DENIED) {
deniedList.add(permissions[i]);
}
}
if (deniedList.size() == 0) {
toast("权限都被授予");
} else {
toast("有" + deniedList.size() + "个权限被拒绝");
//此时和弹出对话框,对用户说明该权限的重要性,并且在此申请所需的权限
}
}
}
第四步
哪有什么第四步,到这里,就可以和之前一样,直接通过文件路径操作文件就好。
写入一个txt文件试试
private void writeTxt() {
String fileName = "test.txt";
String content = "这是写入外部存储的内容";
//获取外部存储根目录
String rootPath=Environment.getExternalStorageDirectory().getAbsolutePath();
File targetDir = new File(rootPath+"/testDir/");
if (targetDir == null || !targetDir.exists() && !targetDir.mkdirs()) {
toast("路径不存在");
return;
}
File targetFile = new File(targetDir, fileName);
try (FileWriter writer = new FileWriter(targetFile)) {
writer.write(content);
toast("写入成功,可在" + targetFile.getAbsolutePath() + "中查看");
} catch (IOException e) {
Log.e(TAG, "文件写入失败: ", e);
toast("写入失败");
}
}
然后在对应目录下查看,可以看到文件被成功写入了
方法二:申请MANAGE_EXTERNAL_STORAGE权限
MANAGE_EXTERNAL_STORAGE权限是在android11(API30)引入的一个权限,允许应用绕过分区存储的限制,全局访问设备的存储文件,包括非媒体文件,但是该权限需要用户手动授予。
第一步
在清单文件(AndroidManifest.xml)中申明权限
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
第二步
检查是否已经授权,这里可以使用Environment.isExternalStorageManager()来判断是否已经授权,另外在android10及以下无需申请此权限。
public boolean hasManageStoragePermission(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return Environment.isExternalStorageManager();
}
return true; // Android 10以下无需此权限
}
第三步:
请求权限,该权限级别很高,需要引导用户跳转至设置页面手动开启
public void requestManageStoragePermission(Activity activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
intent.setData(Uri.parse("package:" + activity.getPackageName()));
activity.startActivityForResult(intent, REQUEST_PERMISSION_CODE);
}
}
处理权限授权结果,注意,这里是在onActivityResult方法中进行处理。
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_PERMISSION_CODE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Environment.isExternalStorageManager()) {
// 权限已授予
toast("权限已授予");
} else {
// 用户拒绝授权
toast("用户拒绝");
}
}
}
}
到这里,无需再额外申请任何权限,就可以操作文件。
注意!方法二仅限于android11及以上的系统,如果运行在低版本中,仍然无法操作文件,如何解决?看完整版方法。
完整版写法
第一步,申明权限
在清单文件(AndroidManifest.xml)中申明权限,这里注意,不要和方法一一样去设置application中的其他属性,保持最新版本就好。
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
第二步,检查授权
这里要对系统版本做判断,不同的版本权限不同
private boolean hasPermission(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return Environment.isExternalStorageManager();
} else {
String[] permissions = new String[]{
android.Manifest.permission.READ_EXTERNAL_STORAGE,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
};
boolean haveDenied = false;
for (String permission : permissions) {
if (ContextCompat.checkSelfPermission(this, permission)
== PackageManager.PERMISSION_DENIED) {
haveDenied=false;
break;
}
}
return haveDenied;
}
}
第三步,申请权限
根据不同版本使用不同的申请方式
/**
* 请求权限
*
* @param activity
*/
public void requestPermission(Activity activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
intent.setData(Uri.parse("package:" + activity.getPackageName()));
activity.startActivityForResult(intent, REQUEST_PERMISSION_CODE);
}else {
String[] permissions = new String[]{
android.Manifest.permission.READ_EXTERNAL_STORAGE,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
};
activity.requestPermissions(permissions,REQUEST_PERMISSION_CODE);
}
}
至此,应用就获取了文件的操作权限,即可以通过文件的全路径操作文件。
联系我,可投稿,可获取demo源码