android专题:深度好文!一文读懂android文件存储(支持最新的分区存储)

前言

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. 1. 默认仅能访问应用专属目录

  2. 2. 媒体文件需要通过MediaStore进行访问

  3. 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. 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("写入失败");
    }

}

然后在对应目录下查看,可以看到文件被成功写入了

Image

方法二:申请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源码

Image

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值