【Android】就想下载个文件到SD卡,怎就这么难?快把代码拿走吧

2,754 阅读9分钟

Android下载文件到SD卡的几种方式

前言

Android的版本更新算是跟权限管理犟上了,每次版本更新都是权限管理的改动,导致我现在就想简简单单的实现一个下载文件到 SD 卡怎么就一路坎坷呢。

本来之前我们的应用下载文件到沙盒很容易得,也不需要处理权限,但是沙盒中的文件用户看不到无法操作,所以我们需要改动为下载到外置 SD 卡让用户在设备自带的文件管理器中可以查看与操作。

不就改动一个路径的事情吗?又要处理权限组,然后申请权限,target33 的还不让申请内存卡权限,需要用多媒体权限替代...烦不甚烦。

641.gif

那么我们应该如何实现一个简单的下载文件到 SD 卡呢?

一、方案1.权限的变化与实现

大家可能或多或少的都知道Android的SD卡权限收紧,几个重大的节点:

Android 5.0 存储访问框架(SAF):引入了存储访问框架,用于提供一种更加安全和细粒度的文件访问方式。通过 SAF,用户可以选择授予应用访问特定文件或目录的权限。

Android 6.0 运行时权限:引入了运行时权限模型,应用需要在运行时动态请求存储权限,而不是在安装时获得。这进一步提高了用户的控制权。

Android 7.0 File URI 限制:文件URI被限制,应用不能直接通过file://访问其他应用的文件,必须使用ContentProvider来共享文件。

Android 10

分区存储(Scoped Storage):引入了分区存储,限制了应用对外部存储的访问。应用只能访问自己的应用专属目录和一些特定的公共目录(如Download、Pictures等)。

媒体文件访问权限:应用可以通过特定的媒体存储API访问和操作媒体文件(如图片、音频、视频),而不需要全局存储权限。

Android 11

进一步收紧分区存储:分区存储变得更加严格,应用对外部存储的访问进一步受限。

MANAGE_EXTERNAL_STORAGE 权限:引入了新的权限,允许某些应用访问所有的外部存储文件,但这个权限的使用受到严格限制,需要通过Google Play审核。

媒体存储访问权限:应用可以请求访问特定类型的媒体文件,更加细粒度的访问控制。

Android 12

特定作用域的媒体访问:为不同类型的媒体文件提供更细致的访问控制,实现了更细粒度的权限管理。

Android 13 与 Android 14 倒是没有对权限进行大的改动,目前已经趋于稳定。

顺便说一嘴 android:requestLegacyExternalStorage="true" 是为 Android 10 提供的一个临时适配方案,帮助应用在过渡到分区存储时继续使用传统的存储访问模式。开发者应当尽快适配分区存储,以确保应用在未来的Android版本上能够正常运行。在 Android 14 的年代了已经不需要用这种方案,请丢到历史的垃圾堆,老老实实的按谷歌标准来适配。

讲了这么多跟我下载一个文件到SD卡有毛关系,我就问我就要直接申请SD卡权限,就要下载到SD卡行不行,啰里八嗦的。

嗯,行也不行,看机器的系统版本。比如:

    File downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
                    String downloadPath = downloadDir.getAbsolutePath();

        MyOkhttpUtil.okHttpDownloadFile("http://www.baidu.com/income-eligibility-criteria.pdf",
                    new CallBackUtil.CallBackFile(downloadPath, fileName) {
                        @Override
                        public void onFailure(Call call, Exception e) {
                            LoadingDialogManager.get().dismissLoading();
                            YYLogUtils.e("downLoadMessageFile--onFailure");
                            ToastUtils.get().showFailText(CommUtils.getContext(), "File download failed");
                        }

                        @Override
                        public void onProgress(float progress, long total) {
                            super.onProgress(progress, total);
                        }

                        @Override
                        public void onResponse(Call call, File response) {
                            LoadingDialogManager.get().dismissLoading();
                            YYLogUtils.w("downLoadMessageFile--Success--path=" + response.getAbsolutePath());
                            ToastUtils.get().showSuccessText(CommUtils.getContext(),
                                    "File download successful, save path: " + response.getAbsolutePath());
                        }
                    });

我自己封装一个 OkHttp 的下载方法,直接下载到 download 文件夹。

大家觉得能下载成功吗?行还是不行?给大家10秒钟考虑。

下面给出答案,安卓7的机器不行,安卓13的机器行。(Targert 33)

image.png

那我给他们加上权限申请呢

  PermissionEngine.get().requestPermission(activity, new PermissionEngine.OnSuccessCallback() {
            @Override
            public void onSuccess() {
            }
        }, Manifest.permission_group.STORAGE);

咦?不能这么用了?那我单独申请!

  PermissionEngine.get().requestPermission(activity, new PermissionEngine.OnSuccessCallback() {
            @Override
            public void onSuccess() {
            }
        },Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE);


image.png

我尼玛...我写个文件跟多媒体权限有半毛钱关系?

其实真没关系,其实我们读取文件和写入文件是不同的操作逻辑,并且多媒体文件与普通文件也是不同的操作逻辑。

其他的方式我们不提,因为今天的主题只是下载写入文件而已,跟多媒体的读取权限没关系,我们只需要申请写入文件的权限就可以兼容安卓10以下的版本。

二、直接申请SD卡权限

最简单的方案,我们直接申请写入文件就可以兼容安卓10以前和以后的机型。

 PermissionEngine.get().requestPermission(activity, new PermissionEngine.OnSuccessCallback() {
            @Override
            public void onSuccess() {

                //SD卡权限申请成功之后再次尝试
                if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
                    // 获取SD卡中Download目录的路径
                    File downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
                    String downloadPath = downloadDir.getAbsolutePath();
                    
                    YYLogUtils.w("准备下的路径为:" + downloadPath + fileName);

                    LoadingDialogManager.get().showLoading(activity);

                    MyOkhttpUtil.okHttpDownloadFile(downloadUrl, new CallBackUtil.CallBackFile(downloadPath, fileName) {
                        @Override
                        public void onFailure(Call call, Exception e) {
                            LoadingDialogManager.get().dismissLoading();
                            YYLogUtils.e("downLoadMessageFile--onFailure");
                            ToastUtils.get().showFailText(CommUtils.getContext(), "File download failed");
                        }

                        @Override
                        public void onProgress(float progress, long total) {
                            super.onProgress(progress, total);
                        }

                        @Override
                        public void onResponse(Call call, File response) {
                            LoadingDialogManager.get().dismissLoading();
                            YYLogUtils.w("downLoadMessageFile--Success--path=" + response.getAbsolutePath());
                            ToastUtils.get().showSuccessText(CommUtils.getContext(),
                                    "File download successful, save path: " + response.getAbsolutePath());
                        }
                    });

                } else {
                    YYLogUtils.e("SD card not available or not writable.");
                }


            }
        }, Manifest.permission.WRITE_EXTERNAL_STORAGE);

Android 13:

image.png

Android 7:

image.png

还初步测试了 Android 11,12,14等机型都没问题。

三、方案2.使用DocumentFile Api的方案

除了这个方法我们也能直接用 DocumentFile 的方案来实现,由于我们知道要下载到哪一个文件夹,我们直接手动的获取到路径,然后包装为 DocumentFile ,再通过 DocumentFile 的方式创建对应的子文件,并获取到 Uri,再通过 Uri 获取到 outputstream ,有了这个流不管是copy本地文件还是获取网络文件就能正常的写入了。

大致代码如下:

private void downloadFile(DocumentFile selectedDir, String fileName) {
    String url = "http://example.com/file.pdf";
    File parent = new File(selectedDir.getPath());
    DocumentFile documentFile = DocumentFile.fromFile(parent);
    DocumentFile subDocumentFile = documentFile.createFile("application/pdf", fileName);
    Uri uri = subDocumentFile.getUri();

    OkHttpClient client = new OkHttpClient();
    Request request = new Request.Builder()
        .url(url)
        .build();

    client.newCall(request).enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) {
            // 处理下载失败的情况
        }

        @Override
        public void onResponse(Call call, Response response) throws IOException {
            if (response.isSuccessful()) {
                InputStream inputStream = response.body().byteStream();
                OutputStream outputStream = null;
                try {
                    outputStream = getContentResolver().openOutputStream(uri);
                    if (outputStream != null) {
                        copyInputStreamToOutputStream(inputStream, outputStream);
                    } else {
                        // 处理输出流为空的情况
                    }
                } finally {
                    if (inputStream != null) {
                        inputStream.close();
                    }
                    if (outputStream != null) {
                        outputStream.close();
                    }
                }
            }
        }
    });
}

private void copyInputStreamToOutputStream(InputStream inputStream, OutputStream outputStream) throws IOException {
    byte[] buffer = new byte[4096];
    int len;
    while ((len = inputStream.read(buffer)) != -1) {
        outputStream.write(buffer, 0, len);
    }
}


咦?为什么要用 DocumentFile ?直接用 File 不行吗 ?还真不行有兼容性问题,具体可以看看我早期的文章【Android操作文件也太难了趴,File vs DocumentFile 的异同】。这是我刚接触博客写的文章,现在看来很粗糙了,大家见谅。

总结来说就是 Android10 以上的系统是无法使用 File 来写入的,除了一些特殊文件夹才能写入。Android10 以上的设备想写入自定义文件夹中的文件,还是推荐使用 DocumentFile 的方案。

四、方案3.使用SAF的选择

你这都是指定了下载文件的路径,如果我想让用户自己选择使用哪一个文件夹呢?这就引出了第三种方案,SAF让用户自己选择文件保存路径。

我们先用 DOCUMENT_TREE 的方式打开 SAF:

    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
    activity.startActivityForResult(intent, 1027);

此时我们会进入文件选择的系统页面,每一个机型和系统样式会有不同,不过操作都是大同小异。

image.png

image.png

接下来等用户选择/创建文件夹之后,我们就能获取到这个 Uri 了:

  @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
         if (requestCode == 1027 && resultCode == Activity.RESULT_OK) {
            if (data != null) {
                Uri treeUri = data.getData();
                DocumentFile pickedDir = DocumentFile.fromTreeUri(this, treeUri);
                if (pickedDir != null) {
                    // 使用pickedDir来下载文件
                    Uri uri = pickedDir.getUri();
                    String name = pickedDir.getName();
                    boolean canRead = pickedDir.canRead();
                    boolean canWrite = pickedDir.canWrite();
                    YYLogUtils.w("uri:" + uri + " name:" + name + " canRead:" + canRead + " canWrite:" + canWrite);
                }
            }
        }
    }

有了 Uri 了还需要我再说了吗,把上面的代码套过来,直接输入流和输出流对接就保存文件了。

总结

本文简单的介绍了 Android 系统权限收紧的介绍,并且对于如何写入文件方面做了几种适配案的探讨。

再次强调,本文只针对文件的写入,对于文件的读取是不适用的,那是另外的事情,对于多媒体图片视频等文件的写入和读取又是另另外的事了,万不可混为一谈。

对于文件的写入其实除了以上的方案还可以用 MANAGE_DOCUMENTS 的权限,但是但是个人强烈不推荐使用 android.permission.MANAGE_DOCUMENTS 这样的危险权限方案。上架 Google Play 会受限需要声明安全不说,而且随着越来越收紧的权限,后面难免也要再次改。并且危险权限是会回收的,过一段时间需要用户再次去确定,用户的使用感受上来说也并不好。系统对于这些危险权限给出很多提示,搞得用户也害怕,所以如非必要不要用这个权限。

那么再次总结一下,下载到指定目录可以考虑直接动态申请写入SD卡权限是最简单的,其次我们可以使用 DocumentFile 的 Api 去创建指定的文件,可以通过流的方式存储文件,再此基础上如果想让用户自己选择存放的目录则需要 ACTION_OPEN_DOCUMENT_TREE 的方式打开 SAF 的目录选择,并存储文件。

目前这三种方案都是可以适配到Android的各版本并且有各自的使用场景,如果你只想下载一个文件到目录直接用方案1,如果你想下载到更深的目录,那么需要你自己创建文件夹和文件可以考虑用方案2,如果你想让用户自己选择文件夹去存储那么久使用方案3。

对于相关的 DocumentFile 文件详细操作可以参考我早前的文章,链接在文章内容中,而本文只给出了简单的 Demo,相信大家看完之后都会如何处理下载文件到外置SD卡目录了。

那么文章到处就告一段落了,当然如果你有其他的更多的更好的实现方式,也希望大家能评论区交流一起学习进步。如果我的文章有错别字,不通顺的,或者代码、注释、有错漏的地方,同学们都可以指出修正。

如果感觉本文对你有一点的启发和帮助,还望你能点赞支持一下,你的支持对我真的很重要。

Ok,这一期就此完结。