Android 文件存储实战:从应用私有目录读写到网络文件落盘与公共存储接入

本文围绕一个完整的文件存储练习页面展开,先把应用内部私有目录和应用外部私有目录的读写流程跑通,再把网络图片下载到本地,最后接入公共存储空间。整篇内容按实际编码顺序推进,重点不是只给出 API 名称,而是把每一步为什么这样写、文件最终落到哪里、读写链路如何闭环说明清楚。

文件存储在 Android 中并不是"拿到一个路径就开始写文件"这么简单。存储位置决定了访问边界,系统版本决定了权限策略,下载与展示又会牵涉线程切换、流式写入和 MediaStore。把这些环节放在同一条主线上整理,能够更直观地看清内部私有、外部私有与公共存储之间的职责差异。

目录

  • [Android 文件存储实战:从应用私有目录读写到网络文件落盘与公共存储接入](#Android 文件存储实战:从应用私有目录读写到网络文件落盘与公共存储接入)
  • [1. Android 文件存储位置与权限边界](#1. Android 文件存储位置与权限边界)
  • [2. 文件读写页面的交互入口](#2. 文件读写页面的交互入口)
  • [3. 应用内部私有目录中的文件写入](#3. 应用内部私有目录中的文件写入)
  • [4. 应用内部私有目录中的文件读取](#4. 应用内部私有目录中的文件读取)
  • [5. 应用外部私有目录中的文件读写](#5. 应用外部私有目录中的文件读写)
    • [5.1 先检查外部存储是否可用](#5.1 先检查外部存储是否可用)
    • [5.2 将文本写入外部私有目录](#5.2 将文本写入外部私有目录)
    • [5.3 从外部私有目录读取文本](#5.3 从外部私有目录读取文本)
    • [5.4 删除外部私有目录中的目标文件](#5.4 删除外部私有目录中的目标文件)
  • [6. 将网络文件保存到应用私有目录](#6. 将网络文件保存到应用私有目录)
    • [6.1 添加网络下载依赖](#6.1 添加网络下载依赖)
    • [6.2 配置网络访问与明文传输能力](#6.2 配置网络访问与明文传输能力)
    • [6.3 下载图片并写入外部私有图片目录](#6.3 下载图片并写入外部私有图片目录)
    • [6.4 抽离通用下载工具类](#6.4 抽离通用下载工具类)
  • [7. 将图片写入公共存储空间](#7. 将图片写入公共存储空间)
    • [7.1 根据系统版本声明读取权限](#7.1 根据系统版本声明读取权限)
    • [7.2 搭建公共存储页面与交互入口](#7.2 搭建公共存储页面与交互入口)
    • [7.3 动态申请媒体访问权限](#7.3 动态申请媒体访问权限)
    • [7.4 使用 MediaStore 将网络图片落到公共目录](#7.4 使用 MediaStore 将网络图片落到公共目录)
  • [8. 不同文件存储位置的适用场景](#8. 不同文件存储位置的适用场景)
  • [9. 相关代码附录](#9. 相关代码附录)
    • [9.1 私有存储页面与布局](#9.1 私有存储页面与布局)
    • [9.2 网络下载工具与构建依赖](#9.2 网络下载工具与构建依赖)
    • [9.3 公共存储页面与相册展示](#9.3 公共存储页面与相册展示)

1. Android 文件存储位置与权限边界

在进入具体写文件代码之前,先把 Android 中文件可以落到哪里理清楚。文件存储位置不同,直接决定了数据是否只对当前应用可见、卸载应用后是否会被删除,以及系统是否要求额外权限。

清单文件中的权限声明也要围绕"最小能力申请"来做。对于 Android 13 及以上系统,如果只是访问图片、视频或音频,不应该继续笼统地申请整个外部存储的读取能力,而是优先申请更细粒度的媒体权限。

xml 复制代码
<!-- READ_EXTERNAL_STORAGE:读取外部存储的数据(在 Android 13 设备上,如果只是对媒体文件图片、视频、音频,那么可以使用更细分的媒体权限来代替该权限) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<!-- 写入外部存储数据 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

<!-- 访问图片文件 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

<!-- 访问视频文件 -->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

路径:FileAndDataByJavaProject/app/src/main/AndroidManifest.xml

  • 应用内部存储空间目录:如 data/data/包名,适合保存只允许当前应用访问的敏感数据、持久化文件和缓存数据。这类目录的隔离性最强,但容量通常更适合小体量、私有化的数据。
  • 应用外部存储空间目录:如 storage/emulated/0/Android/data/包名/files,更适合图片、视频或文档这类占用空间较大的文件。它们依然属于当前应用专属目录,应用卸载后也会一并删除。
  • 公共存储空间:如 storage/emulated/0/Picturesstorage/emulated/0/Movies 等,这类目录面向系统与其他应用共享,应用卸载后不会清理掉这里的公共文件。

从系统版本策略来看,公共存储的访问方式也在持续收紧:

  • Android 10 开始引入分区存储,应用不能再随意通过普通文件路径访问公共区域。
  • Android 11 继续强化这套边界,更推荐通过 MediaStore 处理公共媒体资源。
  • Android 13 把媒体读取权限进一步细分为图片、视频、音频三个方向,使权限申请与访问目的保持一致。

这也意味着一个很重要的结论:如果只是处理应用自己的文件,优先使用内部私有目录或外部私有目录;只有明确需要把文件暴露给系统相册或其他应用时,才进入公共存储链路。

2. 文件读写页面的交互入口

正式实现读写逻辑之前,先把页面交互入口准备好。这个布局把内部私有存储、外部私有存储和下载操作放到同一个页面里,便于按按钮逐步验证每一条文件链路。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".PrivateFileActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="16dp"
        android:text="应用内部私有存储相关操作"
        android:textSize="28sp" />

    <Button
        android:id="@+id/btn_internal_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="保存文件" />

    <Button
        android:id="@+id/btn_internal_read"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="读取文件" />


    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:layout_marginBottom="16dp"
        android:text="应用外部私有存储相关操作"
        android:textSize="28sp" />

    <Button
        android:id="@+id/btn_external_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="保存文件" />

    <Button
        android:id="@+id/btn_external_read"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="读取文件" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="30dp"
        android:text="==================我是分割线==================" />

    <EditText
        android:id="@+id/et_info"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:layout_marginTop="10dp"
        android:gravity="top"
        android:text="我想把这段内容保存到文件当中!!!" />

    <TextView
        android:id="@+id/tv_info"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="读取到了如下信息:\n" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="30dp"
        android:text="==================我是分割线==================" />


    <Button
        android:id="@+id/btn_download"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="下载文件" />
</LinearLayout>

路径:FileAndDataByJavaProject/app/src/main/res/layout/activity_private.xml

这个页面承担了三个职责:

  • btn_internal_savebtn_internal_read 负责验证内部私有目录的写入和读取。
  • btn_external_savebtn_external_read 用来切换到应用外部私有目录,观察路径变化和访问方式变化。
  • btn_download 用来验证网络流落盘,确认文件不仅能由本地文本生成,也能由网络响应体写入。

布局运行后的界面如下,后续所有按钮操作都会基于这个入口展开:

3. 应用内部私有目录中的文件写入

内部私有目录最适合拿来练习文件写入的基础流程,因为它不需要额外权限,路径也最稳定。这里要完成的事情很明确:把输入框中的文本内容写入 content.txt,并确认文件已经实际生成在应用内部目录里。

这一段代码先通过 getFilesDir() 拿到当前应用的内部文件目录,再用 new File(目录, 文件名) 构造目标文件对象。写入动作放到子线程中,是为了避免文件 I/O 直接阻塞主线程。真正执行写入时,FileOutputStream 负责字节输出,BufferedOutputStream 负责减少频繁磁盘写入带来的开销。

java 复制代码
findViewById(R.id.btn_internal_save).setOnClickListener(view -> {

    new Thread() {
        @Override
        public void run() {
            super.run();

            //getFilesDir():返回内部存储位置的目录
            //创建file对象,指定他的路径、文件名
            File file = new File(getFilesDir(), "content.txt");
            Log.i(TAG, "run: absolutePath:" + file.getAbsolutePath());
            //打开文件输出流
            try (FileOutputStream fos = new FileOutputStream(file);
                 //缓冲输出流
                 BufferedOutputStream bos = new BufferedOutputStream(fos)) {
                //获取输入框里的数据
                String content = etInfo.getText().toString().trim();
                byte[] bytes = content.getBytes();

                bos.write(bytes, 0, bytes.length);

            } catch (FileNotFoundException e) {
                throw new RuntimeException(e);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

        }
    }.start();

});

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PrivateFileActivity.java

这段实现里有几个关键点需要连起来看:

  • getFilesDir() 返回的是当前应用内部私有目录,外部应用不能直接访问这里的文件。
  • etInfo.getText().toString().trim() 先把页面输入内容转成字符串,再通过 getBytes() 变成字节数组,才能被输出流写入文件。
  • bos.write(bytes, 0, bytes.length) 明确指定从字节数组起始位置开始写入,并写入当前数组全部长度。
  • try-with-resources 负责在写入结束后自动关闭输出流,避免资源泄漏。

执行完成后,可以在设备文件目录中看到新生成的 content.txt

4. 应用内部私有目录中的文件读取

写入完成后,下一步就是把同一个文件重新读出来,并显示到界面上。这一步的目标不是简单读取字节,而是把"文件存在于内部目录中"这件事闭环验证出来。

读取流程依然放在子线程中执行。原因也很直接:文件读取属于耗时操作,不能把磁盘 I/O 直接放在主线程里。拿到 FileInputStream 后,再包上一层 InputStreamReaderBufferedReader,就能把原始字节流转成按行读取的字符流。

java 复制代码
//从内部私有读取文件
findViewById(R.id.btn_internal_read).setOnClickListener(view -> {

    new Thread() {
        @Override
        public void run() {
            super.run();

            //获取文件的file对象
            File file = new File(getFilesDir(), "content.txt");

            try (FileInputStream fileInputStream = new FileInputStream(file);
                 InputStreamReader isr = new InputStreamReader(fileInputStream);//fileInputStream字节流转为字符流
                 BufferedReader br = new BufferedReader(isr);//为isr提供缓冲读取的能力
            ) {

                StringBuilder builder = new StringBuilder();
                String line;

                while ((line = br.readLine()) != null) {
                    builder.append(line).append("\n");
                }

                String content = builder.toString();
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        tvInfo.setText(content);
                    }
                });


            } catch (FileNotFoundException e) {
                throw new RuntimeException(e);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }.start();

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PrivateFileActivity.java

这里的链路拆开来看更容易理解:

  • new File(getFilesDir(), "content.txt") 先定位到内部私有目录下已经写入过的目标文件。
  • FileInputStream 读取的是原始字节流,InputStreamReader 把字节流转换成字符流,方便按文本语义读取。
  • BufferedReaderreadLine() 会逐行读取内容,因此需要借助 StringBuilder 把每一行重新拼接起来。
  • 文件内容读完后,必须通过 runOnUiThread() 切回主线程,才能安全更新 tvInfo

这样一来,文本从输入框写入本地文件,再从本地文件读取回页面,内部私有目录这条读写链路就完整跑通了。

5. 应用外部私有目录中的文件读写

当文件体积更大,或者希望把文件放在设备外部存储区域但仍保持应用专属访问边界时,就应该切换到应用外部私有目录。它和公共存储的区别在于:路径位于外部存储区域,但目录仍然归当前应用独享,卸载应用时也会一起删除。

5.1 先检查外部存储是否可用

外部存储并不一定总是可写,因此在执行读写动作前,需要先判断当前介质状态。Environment.getExternalStorageState() 返回的就是系统对外部存储的当前判定结果。

java 复制代码
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state)) {
    // 外部存储可读写

} else if (Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
    // 外部存储只读
} else {
    //外部存储不可用
}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PrivateFileActivity.java

这一步的意义在于把后续写入动作建立在明确的前置条件上:

  • MEDIA_MOUNTED 表示当前既可读也可写,可以安全执行写文件逻辑。
  • MEDIA_MOUNTED_READ_ONLY 说明只能读取,不能继续写入。
  • 其余状态则意味着当前外部存储不可用,此时继续构造输出流没有意义。

5.2 将文本写入外部私有目录

确认外部存储可用后,写入流程与内部私有目录非常接近,真正变化的核心只有一个:文件路径不再来自 getFilesDir(),而是来自 getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)

这里传入 Environment.DIRECTORY_DOCUMENTS,表示要把文件放到应用外部私有目录下的文档分类子目录中。这样既能保持路径语义清晰,也能让系统按文档类型组织文件。

java 复制代码
//保存文件到外部私有
findViewById(R.id.btn_external_save).setOnClickListener(view -> {
    //判断外部扩展存储的状态
    String state = Environment.getExternalStorageState();
    Log.i(TAG, "onCreate: 外部存储状态:" + state);

    if (Environment.MEDIA_MOUNTED.equals(state)) {
        // 外部存储可读写

        new Thread() {
            @Override
            public void run() {
                super.run();

                //getExternalFilesDir():返回外部私有目录
                //创建file对象,指定他的路径、文件名
                File file = new File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "content.txt");
                //获取file的绝对路径
                String absolutePath = file.getAbsolutePath();
                Log.i(TAG, "run: absolutePath:" + absolutePath);
                //打开文件输出流
                try (FileOutputStream fos = new FileOutputStream(file);
                     //缓冲输出流
                     BufferedOutputStream bos = new BufferedOutputStream(fos)) {
                    //获取输入框里的数据
                    String content = etInfo.getText().toString().trim();
                    byte[] bytes = content.getBytes();

                    bos.write(bytes, 0, bytes.length);

                } catch (FileNotFoundException e) {
                    throw new RuntimeException(e);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }

            }
        }.start();

    } else if (Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
        // 外部存储只读
    } else {
        //外部存储不可用
    }
});

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PrivateFileActivity.java

这段代码和内部写入流程的差异主要集中在三个位置:

  • 文件目录来源从 getFilesDir() 切换成了 getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
  • 写入前增加了存储状态判断,避免在外部介质不可用时继续执行。
  • 通过 file.getAbsolutePath() 打印实际路径,方便确认文件已经落到应用外部私有文档目录,而不是内部目录。

运行后刷新目录,可以看到对应位置已经生成了目标文件:

5.3 从外部私有目录读取文本

外部私有目录的读取流程沿用内部私有目录的那套"字节流转字符流、按行拼接、切回主线程更新 UI"的结构,只是文件定位方式改成了外部私有路径。

java 复制代码
//从外部私有读取文件
findViewById(R.id.btn_external_read).setOnClickListener(view -> {

    new Thread() {
        @Override
        public void run() {
            super.run();
            //getExternalFilesDir():返回外部私有目录
            //创建file对象,指定他的路径、文件名
            File file = new File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "content.txt");

            try (FileInputStream fileInputStream = new FileInputStream(file);
                 InputStreamReader isr = new InputStreamReader(fileInputStream);//fileInputStream字节流转为字符流
                 BufferedReader br = new BufferedReader(isr);//为isr提供缓冲读取的能力
            ) {

                StringBuilder builder = new StringBuilder();
                String line;

                while ((line = br.readLine()) != null) {
                    builder.append(line).append("\n");
                }

                String content = builder.toString();
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        tvInfo.setText(content);
                    }
                });


            } catch (FileNotFoundException e) {
                throw new RuntimeException(e);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }.start();


});

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PrivateFileActivity.java

这里真正需要关注的是"路径切换后,读取模型本身没有变化"。也就是说:

  • 私有目录无论位于内部还是外部,本质上仍然是当前应用自己的文件。
  • 只要能准确拿到 File 对象,后续的输入流包装、逐行拼接和主线程更新 UI 流程都可以复用。
  • 这也是为什么在练习文件存储时,应该先把 File、输入输出流和线程切换的基础链路跑通,再去区分更复杂的公共存储访问方式。

5.4 删除外部私有目录中的目标文件

在高版本 Android 中,File 方式能够直接操作的范围,主要就是应用私有目录。因此,删除文件这一步最适合放在外部私有目录场景中验证。

java 复制代码
//getExternalFilesDir():返回外部私有目录
//创建file对象,指定他的路径、文件名
File file = new File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "content.txt");
if (file.exists()) {
    boolean delete = file.delete();
    if (delete) {
        Log.i(TAG, "onCreate: 删除成功");
    } else {
        Log.i(TAG, "onCreate: 删除失败");
    }
}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PrivateFileActivity.java

这个删除动作展示了两个判断点:

  • file.exists() 先确认文件确实存在,避免对不存在的目标执行无效删除。
  • file.delete() 的返回值直接表示本次删除是否成功,因此日志输出可以作为最直接的验证结果。

如果目标换成公共存储区域,高版本系统下就不能再简单依赖 File.delete() 这一类路径式操作了,访问模型需要切换到 MediaStore 或其他系统授权机制。

6. 将网络文件保存到应用私有目录

本地文本写入跑通后,下一步要解决的是另一个更常见的场景:从网络下载文件,再把响应体落到设备本地。这个过程的关键不在于"下载"本身,而在于把网络输入流稳定地转换为本地文件输出流。

6.1 添加网络下载依赖

先在构建脚本中加入 OkHttp。只有把网络请求能力引入工程,后续才能从远程地址拿到图片字节流。

groovy 复制代码
//Okhttp
implementation 'com.squareup.okhttp3:okhttp:4.12.0'

路径:FileAndDataByJavaProject/app/build.gradle

OkHttp 在这里承担的是"获取远程响应体输入流"的职责,文件写入仍然由本地 FileOutputStream 完成。也就是说,网络层和文件层会在响应处理阶段汇合。

6.2 配置网络访问与明文传输能力

如果下载地址是 HTTP 明文链接,那么除了普通网络权限,还要在 application 上显式允许明文流量,否则请求本身就会被系统拦截。

xml 复制代码
<uses-permission android:name="android.permission.INTERNET" />
<application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.FileAndDataByJavaProject"
        android:usesCleartextTraffic="true"
        tools:targetApi="31">

路径:FileAndDataByJavaProject/app/src/main/AndroidManifest.xml

这两个配置解决的是两类不同问题:

  • INTERNET 让应用具备发起网络请求的基础能力。
  • android:usesCleartextTraffic="true" 允许当前应用访问 HTTP 地址,否则示例中的图片链接不会被正常拉取。

6.3 下载图片并写入外部私有图片目录

下载网络图片到本地时,先要完成请求,再把响应体中的字节流分段写入目标文件。这里之所以仍然选择外部私有目录,是因为图片体积通常更大,放在 Environment.DIRECTORY_PICTURES 对应的应用专属图片目录更合适。

java 复制代码
findViewById(R.id.btn_download).setOnClickListener(view -> {
    String imgPath = "http://titok.fzqq.fun/uploads/20240826/b4ee80207d67072b2227034c496f7fce.jpeg";

    OkHttpClient okHttpClient = new OkHttpClient();

    Request request = new Request.Builder().url(imgPath).build();

    Call call = okHttpClient.newCall(request);
    call.enqueue(new Callback() {
        @Override
        public void onFailure(@NonNull Call call, @NonNull IOException e) {
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    Toast.makeText(PrivateFileActivity.this, "下载失败!", Toast.LENGTH_SHORT).show();
                }
            });
        }

        @Override
        public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {

            //直接调用工具类下载
						// String path = getExternalFilesDir(Environment.DIRECTORY_PICTURES) + "/" + "picture.jpeg";
						// DownloadUtils.downloadFileWithOkHttp(PrivateFileActivity.this, imgPath, path);


            // 下载成功后,保存数据到本地
            File file = new File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), "picture.jpeg");

            InputStream inputStream = response.body().byteStream();
            FileOutputStream fileOutputStream = new FileOutputStream(file);

            byte[] buffer = new byte[1024];
            int bytesRead;
            //分段写入
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                fileOutputStream.write(buffer, 0, bytesRead);
            }

            fileOutputStream.flush();


            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    Toast.makeText(PrivateFileActivity.this, "保存成功!", Toast.LENGTH_SHORT).show();
                }
            });
        }
    });
});

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PrivateFileActivity.java

这一段逻辑可以按链路拆成几步来看:

  • 先用图片 URL 构造 Request,再通过 OkHttpClient.newCall(request) 发起请求。
  • enqueue() 走的是异步回调模型,因此不会阻塞主线程。
  • 请求成功后,通过 response.body().byteStream() 拿到网络输入流。
  • 本地目标文件放在 getExternalFilesDir(Environment.DIRECTORY_PICTURES) 下,这样图片会进入应用外部私有图片目录。
  • 使用 byte[] buffer = new byte[1024] 作为分段缓冲区,循环读取网络输入流,再持续写入本地输出流。
  • 写入结束后调用 flush(),确保缓冲区中的内容真正落盘,最后回到主线程弹出成功提示。

这一步完成后,网络文件就不再只存在于内存中的响应体里,而是已经转换为设备上的本地图片文件:

6.4 抽离通用下载工具类

当下载图片、音乐、视频这类大体积文件的流程基本一致时,继续把网络请求和分段写入逻辑堆在页面里就没有必要了。更合理的做法是把这条链路抽成工具类,让页面只负责拼接目标路径和触发调用。

java 复制代码
public class DownloadUtils {


    /**
     * 使用OkHttp同步下载图片到本地
     *
     * @param fileUrl
     * @param destinationPath
     */
    public static void downloadFileWithOkHttp(Activity activity, String fileUrl, String destinationPath) {

        OkHttpClient client = new OkHttpClient();

        Request request = new Request.Builder().url(fileUrl).build();

        // 将响应体保存到文件
        File file = new File(destinationPath);
        String absolutePath = file.getAbsolutePath(); // 获取绝对路径
        Log.i("DownloadUtils", " downloadFileWithOkHttp absolutePath:" + absolutePath);

        client.newCall(request).enqueue(new Callback() {
            @Override
            public void onFailure(@NonNull Call call, @NonNull IOException e) {
                Log.i("DownloadUtils", " downloadFileWithOkHttp 文件下载失败");
                activity.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        Toast.makeText(activity, "文件下载失败!", Toast.LENGTH_SHORT).show();
                    }
                });
            }

            @Override
            public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
                Log.i("DownloadUtils", " downloadFileWithOkHttp 文件下载成功");

                InputStream inputStream = response.body().byteStream();
                FileOutputStream fos = new FileOutputStream(file);
                byte[] buffer = new byte[1024];
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    fos.write(buffer, 0, bytesRead);
                }
                fos.flush();

                activity.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        Toast.makeText(activity, "文件下载成功!", Toast.LENGTH_SHORT).show();
                    }
                });
            }
        });
    }


    /**
     * 使用OkHttp同步下载图片到本地
     *
     * @param fileUrl
     * @param destinationPath
     */
    public static void downloadFileWithOkHttp(String fileUrl, String destinationPath) {

        new Thread() {
            @Override
            public void run() {
                super.run();

                OkHttpClient client = new OkHttpClient();

                Request request = new Request.Builder().url(fileUrl).build();

                // 将响应体保存到文件
                File file = new File(destinationPath);
                String absolutePath = file.getAbsolutePath(); // 获取绝对路径
                Log.i("DownloadUtils", " downloadFileWithOkHttp absolutePath:" + absolutePath);
                try (Response response = client.newCall(request).execute();
                     InputStream inputStream = response.body().byteStream();
                     FileOutputStream fos = new FileOutputStream(file)) {

                    byte[] buffer = new byte[1024];
                    int bytesRead;
                    while ((bytesRead = inputStream.read(buffer)) != -1) {
                        fos.write(buffer, 0, bytesRead);
                    }
                    fos.flush();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }.start();

    }

}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/download/DownloadUtils.java

这个工具类保留了两种调用方式:

  • Activity 参数的版本适合页面直接调用,因为下载结果可以回到主线程弹出 Toast
  • 只接收路径参数的版本适合更纯粹的文件下载动作,它在内部起一个线程,执行同步请求并直接写入本地。

页面侧只需要先拼好本地保存路径,再调用工具方法即可:

java 复制代码
String path = getExternalFilesDir(Environment.DIRECTORY_PICTURES) + "/" + "picture.jpeg";
DownloadUtils.downloadFileWithOkHttp(PrivateFileActivity.this, imgPath, path);

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PrivateFileActivity.java

抽离之后,页面只需要关心"文件要存到哪里",下载工具负责处理"文件怎么从网络流落到本地文件流"。

7. 将图片写入公共存储空间

如果文件需要进入系统公共目录,让相册或其他应用也能访问,那么就不能继续停留在应用私有目录模型里。公共存储的核心变化有两个:一是权限策略受系统版本影响更明显,二是高版本系统更推荐通过 MediaStore 写入媒体资源。

7.1 根据系统版本声明读取权限

公共存储的权限声明要随系统版本变化而调整。低版本 Android 仍然使用外部存储读写权限,高版本则更强调按媒体类型精确申请。

xml 复制代码
<!-- 安卓13(api33版本以下) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

路径:FileAndDataByJavaProject/app/src/main/AndroidManifest.xml

xml 复制代码
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

路径:FileAndDataByJavaProject/app/src/main/AndroidManifest.xml

可以把权限选择原则整理成下面这条结论:

  • Android 13 以下版本,读取和写入公共外部文件仍然依赖 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE
  • Android 13 及以上版本,如果目标只是图片访问,应优先申请 READ_MEDIA_IMAGES 这类精确权限。
  • 内部私有目录与外部私有目录通常不需要这类公共存储权限,因此不要把公共存储权限误加到所有文件操作场景中。

7.2 搭建公共存储页面与交互入口

进入公共存储场景后,页面不仅要负责下载图片,还要负责读取指定图片并跳转相册页面验证结果。因此,这里的按钮职责与前面的私有存储页面不同。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".PublicFileActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="16dp"
        android:text="外部存储相关操作"
        android:textSize="28sp" />

    <Button
        android:id="@+id/btn_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="保存文件" />

    <Button
        android:id="@+id/btn_read"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="读取文件" />


    <ImageView
        android:id="@+id/image_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/btn_album"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="打开相册" />

</LinearLayout>

路径:FileAndDataByJavaProject/app/src/main/res/layout/activity_public_file.xml

配套的页面初始化代码如下,它把保存图片、读取图片和打开相册三个动作都绑定到了按钮上:

java 复制代码
/**
* 公共存储空间
*/
public class PublicFileActivity extends AppCompatActivity {

private ImageView imageView;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.activity_public_file);

      imageView = findViewById(R.id.image_view);

      //保存图片
      findViewById(R.id.btn_save).setOnClickListener(view -> {
          downloadFile();
      });
      //读取图片
      findViewById(R.id.btn_read).setOnClickListener(view -> {
          showPicture();
      });

      //打开相册
      findViewById(R.id.btn_album).setOnClickListener(view -> {
          startActivity(new Intent(this, AlbumActivity.class));
      });

      // ...

  }
}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PublicFileActivity.java

这段初始化代码把公共存储流程拆成了三个独立验证点:

  • downloadFile() 负责把网络图片写进公共目录。
  • showPicture() 负责按照文件名从媒体库中查询指定图片,并显示到 ImageView
  • AlbumActivity 则把系统媒体库中的图片批量读出来,以网格形式展示,验证文件是否已经进入可被系统相册识别的公共区域。

7.3 动态申请媒体访问权限

清单文件中的权限声明只解决"有哪些权限可能会被申请",真正执行公共媒体读取时,还需要在运行期向用户发起授权请求。

java 复制代码
@Override
protected void onCreate(Bundle savedInstanceState) {
    // ...
  	//打开相册
    findViewById(R.id.btn_album).setOnClickListener(view -> {
        startActivity(new Intent(this, AlbumActivity.class));
    });
  
  //动态获取外部存储区域的读写权限
    ActivityCompat.requestPermissions(this,
            new String[]{Manifest.permission.READ_EXTERNAL_STORAGE,
                    Manifest.permission.WRITE_EXTERNAL_STORAGE}, 100);
}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PublicFileActivity.java

在实际工程代码里,还额外根据系统版本做了区分:Android 11 及以上优先申请 READ_MEDIA_IMAGES,其余版本再回退到传统外部存储权限。这种分支判断的目的是让运行时权限和系统权限模型保持一致,而不是用一套老权限去覆盖所有版本。

7.4 使用 MediaStore 将网络图片落到公共目录

  • query(查询位置, 要查询信息数组, 返回的图片信息, 查询的条件, 条件中的 ? 占位值数组, 排序规则) 其中,后面三个参数传 null,表示没有条件,并且使用默认排序规则,只要在公共存储空间,都要返回;
  • 遍历 Cursor 对象的每一行 cursor.moveToNext() ,然后将每一行中的图片 uri 都放到列表中;
  • 以 uri 列表作为参数,实例化图片适配器,并且设置到指定了网格布局为排列规则的 recyclView 中;
  • 注意:当前代码没有实现分段下载图片,如果相册图片过多,可能会造成卡顿;
java 复制代码
@Override
protected void onCreate(Bundle savedInstanceState) {
    // ....
    recyclView.setLayoutManager(new GridLayoutManager(this, 3));

    Cursor cursor = getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,//指定的是公共图片
            new String[]{MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME},//返回id、name信息
            null, null, null);
    //移动到第一行
    if (cursor != null && cursor.moveToFirst()) {

        do {
            int idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID);
            //通过id的索引位置获取图像的id
            long id = cursor.getLong(idIndex);
            //通过图像的id获取图像的uri
            Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
            //添加到列表
            pictures.add(uri);

        } while (cursor.moveToNext());

        //把获取到的数据显示到RecyclerView
        ImageAdapter adapter = new ImageAdapter(pictures);
        recyclView.setAdapter(adapter);
    }
	}
}

只显示一张图片(实际有八张)

相册照片

会出现这一现象,是因为高版本下的安卓权限造成的,我们在 PublicFileActivity 中动态获取权限代码如下:

  • 动态获取外部存储的读权限和写权限
java 复制代码
@Override
protected void onCreate(Bundle savedInstanceState) {
    // ...
  	//打开相册
    findViewById(R.id.btn_album).setOnClickListener(view -> {
        startActivity(new Intent(this, AlbumActivity.class));
    });
  
  //动态获取外部存储区域的读写权限
    ActivityCompat.requestPermissions(this,
            new String[]{Manifest.permission.READ_EXTERNAL_STORAGE,
                    Manifest.permission.WRITE_EXTERNAL_STORAGE}, 100);
}

但是在 Android 11 以上版本,对外部存储读写权限进行了更细致的分割,不需要直接申请 READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE,只需要申请外部存储空间的图片读写权限 WRITE_EXTERNAL_STORAGE 即可;

所以我们需要对 SDK 进行判断,来决定获取哪个权限:

  • Build.VERSION_CODES.R 表示 Android 11 版本声明;
  • Build.VERSION.SDK_INT 表示获取当前版本;
  • 动态获取的权限都需要在清单文件中提前声明;
java 复制代码
@Override
protected void onCreate(Bundle savedInstanceState) {
    // ...
  	//打开相册
    findViewById(R.id.btn_album).setOnClickListener(view -> {
        startActivity(new Intent(this, AlbumActivity.class));
    });
  
		//判断是否是安卓11以上
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        ActivityCompat.requestPermissions(this,
                new String[]{Manifest.permission.READ_MEDIA_IMAGES}, 100);
    } else {
        //动态获取外部存储区域的读写权限
        ActivityCompat.requestPermissions(this,
                new String[]{Manifest.permission.READ_EXTERNAL_STORAGE,
                        Manifest.permission.WRITE_EXTERNAL_STORAGE}, 100);
    }
}

动态获取权限后,再次打开相册,此时弹出申请访问外部图片的权限:

获取权限后,可以发现展示了外部存储空间的所有的图片:

总结:

  • 如果是在 Android11(API 30)以上 (Android 应用开发)
    • 内部私有、外部私有存储,不需要任何的读写权限
    • 由我们 APP 本身生成的媒体资源,并且哪怕是在外部公共区域,那也不需要权限
    • 如果是其他 APP 生成的、放在公共区域的媒体资源,那么就需要对应媒体类型的读权限
    • 如果是其他 APP 生成的、放在公共区域的媒体资源,想要写入,依然需要申请写入权限
  • 如果是在 Android11(API 30)以下(车载 、POS 机)
    • 写入应用私有目录,不需要添加写入外部空间的权限;
    • 读取外部公共空间存储文件,还是需要申请读取外部空间的权限;

8. 不同文件存储位置的适用场景

把整条文件存储链路跑完之后,再回头看各类目录的使用边界会更清晰:

  • 应用内部私有存储:适合敏感数据、配置文件、缓存或不希望其他应用访问的文本内容。路径稳定、权限成本低,是最适合做基础读写练习的位置。
  • 应用外部私有存储:适合体积更大的图片、文档、音视频等资源。文件仍然只属于当前应用,但更适合承载大文件。
  • 公共存储空间:适合明确要交给系统相册或其他应用访问的媒体资源。高版本 Android 推荐通过 MediaStore 完成写入与读取。

如果只是为了把数据安全地落到本地,优先考虑内部私有或外部私有目录;只有在"需要共享给系统或其他应用"这个前提明确成立时,才切换到公共存储空间。

9. 相关代码附录

9.1 私有存储页面与布局

PrivateFileActivity 中,内部读写、外部读写和网络下载入口都集中在同一个页面里,适合按按钮逐步验证各条文件链路:

java 复制代码
public class PrivateFileActivity extends AppCompatActivity {
    private static final String TAG = "PrivateFileActivity";
    private EditText etInfo;
    private TextView tvInfo;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_private);

        etInfo = findViewById(R.id.et_info);
        tvInfo = findViewById(R.id.tv_info);

        // 内部私有存储:保存与读取
        // 外部私有存储:保存与读取
        // 网络文件:下载后写入图片目录
    }
}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PrivateFileActivity.java

xml 复制代码
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".PrivateFileActivity">

    <Button
        android:id="@+id/btn_internal_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="保存文件" />

    <Button
        android:id="@+id/btn_internal_read"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="读取文件" />

    <Button
        android:id="@+id/btn_external_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="保存文件" />

    <Button
        android:id="@+id/btn_external_read"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="读取文件" />

    <EditText
        android:id="@+id/et_info"
        android:layout_width="match_parent"
        android:layout_height="100dp" />

    <TextView
        android:id="@+id/tv_info"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/btn_download"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="下载文件" />
</LinearLayout>

路径:FileAndDataByJavaProject/app/src/main/res/layout/activity_private.xml

9.2 网络下载工具与构建依赖

网络文件落盘依赖 OkHttp,而下载工具类则负责把网络响应体稳定写入本地目标文件:

groovy 复制代码
dependencies {
    implementation libs.appcompat
    implementation libs.material
    implementation libs.activity
    implementation libs.constraintlayout

    implementation "androidx.room:room-runtime:2.6.1"
    annotationProcessor "androidx.room:room-compiler:2.6.1"

    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
}

路径:FileAndDataByJavaProject/app/build.gradle

java 复制代码
public class DownloadUtils {

    public static void downloadFileWithOkHttp(Activity activity, String fileUrl, String destinationPath) {
        OkHttpClient client = new OkHttpClient();
        Request request = new Request.Builder().url(fileUrl).build();
        File file = new File(destinationPath);

        client.newCall(request).enqueue(new Callback() {
            @Override
            public void onFailure(@NonNull Call call, @NonNull IOException e) {
                activity.runOnUiThread(() ->
                        Toast.makeText(activity, "文件下载失败!", Toast.LENGTH_SHORT).show());
            }

            @Override
            public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
                InputStream inputStream = response.body().byteStream();
                FileOutputStream fos = new FileOutputStream(file);
                byte[] buffer = new byte[1024];
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    fos.write(buffer, 0, bytesRead);
                }
                fos.flush();

                activity.runOnUiThread(() ->
                        Toast.makeText(activity, "文件下载成功!", Toast.LENGTH_SHORT).show());
            }
        });
    }
}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/download/DownloadUtils.java

9.3 公共存储页面与相册展示

公共存储页面负责把图片写入系统媒体库,再通过图片查询和相册列表做结果验证:

java 复制代码
public class PublicFileActivity extends AppCompatActivity {

    private ImageView imageView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_public_file);

        imageView = findViewById(R.id.image_view);

        findViewById(R.id.btn_save).setOnClickListener(view -> downloadFile());
        findViewById(R.id.btn_read).setOnClickListener(view -> showPicture());
        findViewById(R.id.btn_album).setOnClickListener(view ->
                startActivity(new Intent(this, AlbumActivity.class)));
    }
}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/PublicFileActivity.java

java 复制代码
public class AlbumActivity extends AppCompatActivity {

    private RecyclerView recyclView;
    ArrayList<Uri> pictures = new ArrayList<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_album);

        recyclView = findViewById(R.id.recycler_view);
        recyclView.setLayoutManager(new GridLayoutManager(this, 3));

        Cursor cursor = getContentResolver().query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                new String[]{MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME},
                null, null, null);

        if (cursor != null && cursor.moveToFirst()) {
            do {
                int idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID);
                long id = cursor.getLong(idIndex);
                Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
                pictures.add(uri);
            } while (cursor.moveToNext());

            ImageAdapter adapter = new ImageAdapter(pictures);
            recyclView.setAdapter(adapter);
        }
    }
}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/AlbumActivity.java

java 复制代码
public class ImageAdapter extends RecyclerView.Adapter<ImageAdapter.ViewHolder> {

    private final ArrayList<Uri> mPictures;

    public ImageAdapter(ArrayList<Uri> pictures) {
        mPictures = pictures;
    }

    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        Uri uri = mPictures.get(position);
        holder.imageView.setImageURI(uri);
    }
}

路径:FileAndDataByJavaProject/app/src/main/java/com/ls/fileanddatabyjavaproject/ImageAdapter.java

相关推荐
茶本无香2 小时前
JVM调优介绍 + 面试题标准答案(高级)
java·jvm·面试
小王不爱笑1322 小时前
TCP/IP 协议族
网络·网络协议·tcp/ip
创梦流浪人2 小时前
soli-admin一款开箱即用的RBAC后台项目
java·spring boot·vue3·springsecurity
恋猫de小郭2 小时前
Android Studio Panda 2 ,支持 AI 用 Vibe Coding 创建项目
android·前端·flutter
夜猫子ing2 小时前
《UNIX高级环境编程》 第十四章 高级I/O(一文读懂UNIX下高级I/O)
运维·服务器·网络
南山love2 小时前
spring-boot多线程并发执行任务
java·开发语言
希望永不加班2 小时前
SpringBoot 配置 HTTPS(自签名证书+正式证书)
java·spring boot·后端·spring·https
yv_302 小时前
wireshark用法及流量分析知识点
网络·测试工具·wireshark
zhouping@2 小时前
[极客大挑战 2020]Greatphp
android·ide·web安全·android studio