在 Android 应用开发领域,文件操作一直是一项至关重要的任务。随着 Android 系统的不断演进,文件操作的方式和相关权限机制也在持续变化,这给开发者带来了诸多挑战与困惑。本文将深入探讨 Android 中几种关键的文件操作方式,包括 File、DocumentFile、DocumentsProvider 以及 FileProvider,详细分析它们的功能、使用方法、适用场景以及相互之间的异同,旨在为广大 Android 开发者提供全面且深入的文件操作指南,助力大家在不同的开发需求和系统环境下,精准且高效地处理文件操作,确保应用的稳定性和用户数据的安全性。
2.1 分区存储特性
Android 系统在发展过程中,文件操作权限发生了显著变化。其中,Android 10 成为了一个关键的分水岭。自 Android 10(API 级别 29)起,应用的外部存储空间访问权限模式发生了重大转变,引入了分区存储特性。默认情况下仅能访问外部存储空间上的应用专属目录,以及本应用所创建的特定类型的媒体文件。这意味着应用之间的文件访问受到了更严格的隔离,无法随意访问其他应用的外部存储空间。这种限制虽然在一定程度上增加了开发的复杂性,但却有效提升了系统的安全性和稳定性,防止了应用之间的文件冲突和数据泄露风险。2.2 存储访问框架(SAF)
为了在保障安全的同时提供更灵活的文件操作方式,Android 4.4(API 级别 19)引入了存储访问框架(SAF)。SAF 为用户和开发者提供了一种统一且安全的文件操作接口。用户能够通过标准化的界面方便地浏览和打开各类文件,而无需关心文件的具体存储位置。对于开发者来说,SAF 使得客户端应用可以轻松地与各种存储提供程序集成,只需少量代码即可实现对文档的访问,大大提高了文件操作的便利性和通用性。2.3 本文聚焦问题
3.1 写入 SD 卡示例
为了深入理解 File 类的文件操作,我们通过一个具体示例来进行分析。假设我们要将 assets 目录下的文件写入 SD 卡,首先需要获取 SD 卡上的目标目录路径。在 Android 中,可以使用Environment.getExternalStoragePublicDirectory方法来获取系统默认的公共目录路径,例如Environment.DIRECTORY_DOWNLOADS表示下载目录。然后,通过File类的构造函数创建一个新的文件对象,指定文件名和路径。在写入文件之前,需要检查文件是否存在,如果不存在则使用createNewFile方法创建文件。最后,通过文件流将 assets 目录中的文件内容写入到新创建的文件中。示例代码如下:val downLoadPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
val isDirectory = parent.isDirectory
val canRead = parent.canRead()
val canWrite = parent.canWrite()
//获取文件流写入文件到File
val newFile = File(parent.absolutePath + "/材料清单PDF.pdf")
if (!newFile.exists()) {
newFile.createNewFile()
}
val inputStream = assets.open("材料清单PDF.pdf")
val inBuffer = inputStream.source().buffer()
newFile.sink(true).buffer().use {
it.writeAll(inBuffer)
inBuffer.close()
}
3.2 权限与版本限制
在 Android 10 之前,只要应用在AndroidManifest.xml文件中声明了WRITE_EXTERNAL_STORAGE权限,就可以相对自由地在 SD 卡上进行文件操作,包括写入自定义目录。然而,从 Android 10 开始,情况发生了显著变化。当以 Android 10 及更高版本为目标平台时,尝试写入自定义目录(如DownloadMyFiles)时,会抛出IOException异常,提示Operation not permitted。在 Android 10 以上系统中,File 类写入自定义文件夹的权限受到了严格限制。不过,系统对Download文件夹进行了特殊处理,在该文件夹下的写入操作仍然可行,但也并非毫无约束,不同设备可能对Download文件夹内的文件数量、大小等存在限制。
File 是 Java 类,DocumentFile 是 Android 中的一个类,那 Android10 以上的设备想写入自定义文件夹就要用到 DocumentFile 了。4.1 与 SAF 集成
DocumentFile 类与存储访问框架(SAF)紧密集成,为文件操作提供了新的途径。使用ACTION_OPEN_DOCUMENT_TREE意图,可以启动系统的文件选择界面,让用户手动选择要操作的文件夹。用户选择完成后,在onActivityResult回调中获取所选文件夹的Uri,然后通过DocumentFile.fromTreeUri方法将其转换为DocumentFile对象。一旦获得 DocumentFile 对象,就可以进行创建文件夹、文件以及写入内容等操作。例如,可以在所选文件夹内创建新的子文件夹,然后在子文件夹中创建文件,并通过 contentResolver.openOutputStream 方法获取文件输出流,将数据写入文件。override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == 2) {
//单独申请指定文件夹权限
resultData?.data?.let {
contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
//进行文件写入的操作
val documentFile: DocumentFile? = DocumentFile.fromTreeUri(mActivity, it)
documentFile?.run {
val findFile = createFile("application/pdf", "材料清单PDF")
findFile?.uri?.let {
val outs = contentResolver.openOutputStream(it)
val inBuffer = assets.open("材料清单PDF.pdf").source().buffer()
outs?.sink()?.buffer()?.use {
it.writeAll(inBuffer)
inBuffer.close()
}
}
}
}
}
}
4.2 直接转换操作
除了通过 SAF 集成的方式,DocumentFile 还可以从 File 对象直接转换。但需要注意的是,在 Android 10 以上版本中,这种直接转换方式对于写入自定义文件夹的操作存在一定限制。在进行直接转换之前,必须确保应用已获取READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限。extRequestPermission(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) {
val downLoadPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
val parent = File(downLoadPath)
val documentFile: DocumentFile? = DocumentFile.fromFile(parent)
documentFile?.createDirectory("DownloadMyFiles")?.apply {
createFile("text/plain", "test123")
val findFile = findFile("test123.txt")
findFile?.uri?.let {
contentResolver.openOutputStream(it)?.write("hello world".toByteArray())
}
}
}
转换后,虽然可以在某些系统特定文件夹(如Download文件夹)中进行一些操作,但相较于 SAF 集成方式,其灵活性和通用性较差,且同样受到设备相关因素(如文件夹权限设置、文件数量和大小限制等)的影响。并不比 File 有优势。4.3 写入自定义文件夹流程
在 Android 10 以上版本中,若要使用 DocumentFile 写入自定义文件夹,流程相对复杂。首先,需要手动构建指向目标自定义文件夹的Uri,并通过ACTION_OPEN_DOCUMENT_TREE意图启动文件选择界面,同时将构建的Uri作为初始Uri传递,并添加必要的权限标志(如FLAG_GRANT_READ_URI_PERMISSION、FLAG_GRANT_WRITE_URI_PERMISSION和FLAG_GRANT_PERSISTABLE_URI_PERMISSION)。用户选择文件夹并授权后,在onActivityResult回调中获取授权结果,使用contentResolver.takePersistableUriPermission方法获取持久化权限,然后通过DocumentFile.fromTreeUri获取DocumentFile对象,进而进行文件写入操作。但这一过程中,手动拼接Uri需要准确了解文件夹路径结构,且不同设备对Uri的处理可能存在差异,增加了操作的复杂性。 override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == 2) {
//单独申请指定文件夹权限
resultData?.data?.let {
contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
//进行文件写入的操作
val documentFile: DocumentFile? = DocumentFile.fromTreeUri(mActivity, it)
documentFile?.run {
val findFile = createFile("application/pdf", "材料清单PDF")
findFile?.uri?.let {
val outs = contentResolver.openOutputStream(it)
val inBuffer = assets.open("材料清单PDF.pdf").source().buffer()
outs?.sink()?.buffer()?.use {
it.writeAll(inBuffer)
inBuffer.close()
}
}
}
}
}
}
易用性上 Android10 之前的 File 的方式还是不能比,但是在 Android10 以上系统勉强可以用了,需要用户授权!如果有其他的方式也不推荐这种方式。所以当我们有文件存储的需求的时候,首先还是存沙盒中,其次存 SD 卡的Download 目录,最终考虑的才是存放在 SD 卡的自定义目录。4.4 对文件类型的限制
在 Android 10 以上版本中,使用普通 File API 可以正常读取Download目录中的所有文件和文件夹,包括各种格式的文件(如png、txt、doc、pdf等)。然而,在 Android 12 及更高版本中,当尝试读取 Download 目录时,只能获取到部分文件,如图片文件(png等)和文件夹,而其他类型的文档文件(如txt、doc、pdf等)则无法获取。随着 Android 系统版本的升级,分区存储特性和权限管理的变化对文件读取操作产生了重大影响,普通 File API 在处理高版本系统中的非媒体文件读取时不再可靠。推荐使用Intent启动存储访问框架(SAF)来选取文件。通过ACTION_OPEN_DOCUMENT意图,可以启动统一的文件选择界面,用户可以在其中方便地浏览和选择文件,而无需关心文件的具体存储位置。同时,可以通过setType方法或EXTRA_MIME_TYPES参数指定要选择的文件类型,实现更精准的文件筛选。
DocumentsProvider 与 FileProvider 对比5.1 FileProvider 功能与局限
FileProvider 是对 File 的一种补充,用于在不同应用间共享文件时提供更安全的方式,其核心功能是将文件的Uri转换为content://形式,以便其他应用能够安全地访问文件。在实际应用中,它适用于一些临时共享文件的场景,例如分享应用内生成的临时报告或图片等文件给其他应用进行查看或处理。然而,FileProvider 的功能相对单一,主要聚焦于文件共享过程中的Uri转换,对于文件存储结构的管理能力较弱,无法提供像 DocumentsProvider 那样的文档树结构展示和复杂的文件管理功能,因此在处理复杂的文件存储和管理需求时存在局限性。FileProvider 是 ContentProvider 的子类,使用方式也类似, 也需要先在 AndroidManifest 注册,本文省略代码示例。5.2 DocumentsProvider 功能与优势
DocumentsProvider 提供了更为强大和灵活的文件管理功能,主要用于长期存储和管理文件。它能够构建完整的文档树结构,使用户可以像操作本地文件管理器一样方便地浏览、搜索和操作文件。这种文档树结构使得文件管理更加直观和高效,特别适用于云盘存储应用或需要对文件进行深度管理的场景。此外,DocumentsProvider 具有高度的可定制性,开发者可以根据应用需求自定义返回字段,从而灵活控制文件信息的展示和操作逻辑,例如可以根据用户权限定制文件的操作选项,或者返回不同格式的文件链接等,满足了多样化的文件管理需求。5.3 DocumentsProvider 实现自定义文件选择器
要实现自定义文件选择器,需要创建一个继承自 DocumentsProvider 的类,如 SelectFileProvider。在这个类中,主要通过重写几个关键方法来实现功能。public class SelectFileProvider extends DocumentsProvider {
private final static String[] DEFAULT_ROOT_PROJECTION = new String[]{Root.COLUMN_ROOT_ID, Root.COLUMN_SUMMARY,
Root.COLUMN_FLAGS, Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_ICON,
Root.COLUMN_AVAILABLE_BYTES};
private final static String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME, Document.COLUMN_FLAGS, Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED};
public static final String AUTOHORITY = "com.guadou.kt_demo.selectfileprovider.authorities";
//是否有权限
private static boolean hasPermission(@Nullable Context context) {
if (context == null) {
return false;
}
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED) {
return true;
}
return false;
}
@Override
public Cursor queryRoots(String[] projection) throws FileNotFoundException {
return null;
}
@Override
public boolean isChildDocument(String parentDocumentId, String documentId) {
return documentId.startsWith(parentDocumentId);
}
@Override
public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {
// 判断是否缺少权限
if (!hasPermission(getContext())) {
return null;
}
// 创建一个查询cursor, 来设置需要查询的项, 如果"projection"为空, 那么使用默认项
final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
includeFile(result, new File(documentId));
return result;
}
@Override
public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException {
// 判断是否缺少权限
if (!hasPermission(getContext())) {
return null;
}
// 创建一个查询cursor, 来设置需要查询的项, 如果"projection"为空, 那么使用默认项
final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
final File parent = new File(parentDocumentId);
String absolutePath = parent.getAbsolutePath();
boolean isDirectory = parent.isDirectory();
boolean canRead = parent.canRead();
boolean canWrite = parent.canWrite();
File[] files = parent.listFiles();
if (isDirectory && canRead && files != null && files.length > 0) {
for (File file : parent.listFiles()) {
// 不显示隐藏的文件或文件夹
if (!file.getName().startsWith(".")) {
// 添加文件的名字, 类型, 大小等属性
includeFile(result, file);
}
}
}
return result;
}
private void includeFile(final MatrixCursor result, final File file) throws FileNotFoundException {
final MatrixCursor.RowBuilder row = result.newRow();
row.add(Document.COLUMN_DOCUMENT_ID, file.getAbsolutePath());
row.add(Document.COLUMN_DISPLAY_NAME, file.getName());
String mimeType = getDocumentType(file.getAbsolutePath());
row.add(Document.COLUMN_MIME_TYPE, mimeType);
int flags = file.canWrite()
? Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE | Document.FLAG_SUPPORTS_RENAME
| (mimeType.equals(Document.MIME_TYPE_DIR) ? Document.FLAG_DIR_SUPPORTS_CREATE : 0) : 0;
if (mimeType.startsWith("image/"))
flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
row.add(Document.COLUMN_FLAGS, flags);
row.add(Document.COLUMN_SIZE, file.length());
row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
}
@Override
public String getDocumentType(String documentId) throws FileNotFoundException {
if (!hasPermission(getContext())) {
return null;
}
File file = new File(documentId);
if (file.isDirectory()) {
//如果是文件夹-先返回再说
return Document.MIME_TYPE_DIR;
}
final int lastDot = file.getName().lastIndexOf('.');
if (lastDot >= 0) {
//如果文件有后缀-直接返回后缀名的类型
final String extension = file.getName().substring(lastDot + 1);
final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (mime != null) {
return mime;
}
}
return "application/octet-stream";
}
@Override
public ParcelFileDescriptor openDocument(String documentId, String mode, @Nullable CancellationSignal signal) throws FileNotFoundException {
return null;
}
@Override
public boolean onCreate() {
return true;
}
}
queryRoots 方法用于定义文件系统的根目录,开发者可以根据应用需求返回自定义的根目录信息。isChildDocument方法用于判断一个文档是否是另一个文档的子文档,这对于构建文档树结构至关重要。queryDocument方法根据文档的Id查询文档的详细信息,并以Cursor的形式返回,包括文件名称、大小、类型等。queryChildDocuments方法用于查询指定父文档下的子文档列表,需要先检查权限,然后根据父文档的Id获取对应的File对象,列出其下的文件和文件夹信息并添加到MatrixCursor中返回。在构建MatrixCursor时,需要为每个文件或文件夹添加详细信息,如文档Id、显示名称、MIME类型、标志位、大小和最后修改时间等。通过重写这些方法,可以构建出一个功能强大、符合应用需求的自定义文件选择器,为用户提供更加个性化和便捷的文件操作体验。
6.1 文件存储建议
在进行文件存储时,应优先考虑将文件存储在应用沙盒中。应用沙盒为每个应用提供了独立的存储空间,数据安全性较高,且无需担心外部存储权限的变化和其他应用的干扰。其次,可以选择将文件存储在 SD 卡的Download目录下,但需要注意高版本系统的兼容问题。在 Android 10 及以上版本中,虽然Download目录有一定的写入权限,但仍可能受到设备限制,并且使用 File 类直接操作可能会出现问题,因此最好使用 FileProvider 获取URI的方式来访问和操作文件,以确保兼容性和稳定性。最后,只有在确实无法满足需求的情况下,才考虑将文件存储在 SD 卡的自定义目录中,并且要清楚这需要用户手动选择文件夹并授权,操作相对繁琐,且不同设备的授权流程和用户体验可能存在差异。6.2 文件获取建议
对于文件获取,在 Android 10 以下版本中,可以优先使用 File API 进行操作,它能够满足大多数常见的文件读取需求。然而,对于 Android 10 及以上版本,由于权限管理的变化,推荐使用存储访问框架(SAF)来获取文件。SAF 提供了统一的文件选择界面,能够适应不同版本系统的权限变化,并且可以方便地指定文件类型,提高文件选择的准确性和效率。如果应用对文件选择的 UI 界面有特殊要求,且必须实现统一的设计效果,那么可以考虑使用 DocumentsProvider 配合 FileProvider 来实现自定义的文件数据获取。虽然这种方式相对复杂,但可以通过自定义 DocumentsProvider 来满足特定的 UI 和功能需求,为用户提供更加一致和个性化的文件选择体验。6.3 整体注意事项
在整个文件操作过程中,开发者需要密切关注 Android 系统版本的差异,特别是 Android 10 引入的分区存储特性和相关权限变化。针对不同版本的系统,合理选择文件操作方式,确保应用在各种设备上都能稳定运行。同时,要充分考虑用户体验,尽量简化文件操作流程,避免因复杂的权限获取和文件操作导致用户困惑或不满。此外,对于文件路径的处理要格外小心,尤其是在手动构建Uri或处理文件目录时,确保路径的准确性和兼容性,以避免出现文件找不到、权限不足等问题。通过综合考虑这些因素,开发者能够更好地应对 Android 文件操作中的各种挑战,实现高效、安全和用户友好的文件管理功能。