Android 文件工具类 FileUtils(超全封装版)

适用于:文件创建 / 删除 / 拷贝 / 读写 / 缓存目录管理 / 资源文件复制 / 图片缓存 / 分享等

支持 Android 10.0 +(Scoped Storage)

一、前言

在 Android 项目中,文件操作几乎是绕不开的基础能力,例如:

  • 文件下载保存
  • 日志写入
  • 缓存管理
  • 图片 / 视频文件处理
  • 文件复制、删除、重命名
  • 适配 Android 10+ 分区存储
  • 如果每个模块都写一套文件工具,不仅混乱,而且极易出错。

本文整理了一套 稳定、实用、工程级 FileUtils 工具类,已经在真实项目中长期使用。

二、功能概览

该 FileUtils 工具类主要包含以下功能:

📁 目录管理

  • 获取 App 文件目录 / 缓存目录
  • 自动创建多级目录
  • 判断读写权限

📄 文件操作

  • 创建 / 删除 / 移动 / 复制 文件
  • 批量删除文件
  • 读取 / 写入文件内容

📦 资源处理

  • 从 res/raw、drawable 拷贝资源文件
  • 将 Bitmap 保存为文件
  • 从 Uri 复制文件到本地

🧹 清理功能

  • 清空指定文件夹
  • 删除缓存目录

📷 多媒体支持

  • 文件大小计算
  • 文件扩展名解析
  • 支持视频 / 图片文件处理

三、核心 FileUtils 工具类源码

✅ 可直接复制使用

❗ 已适配 Android 10+ 分区存储

java 复制代码
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.text.TextUtils;
import androidx.annotation.AnyRes;
import com.xxx.xxx.base.MyApplication;
import com.xxx.xxx.bean.LocalFileNodeBean;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
import java.text.DecimalFormat;
import java.util.List;


public class FileUtils {

    private static final String TAG = "FileUtils";

    //Logcat文件夹
    public static final String LOGCAT = "logcat";

    /**
     * 获取应用的文件存储路径,兼容 Android 6.0 以上版本
     * Android 版本	推荐路径说明
     * Android 10+ (Q / API 29)	getExternalFilesDir(...) 是 app 私有目录,不需要权限,更安全。
     * Android 9 及以下(API < 29)	Environment.getExternalStorageDirectory() 是外部存储根目录,需要权限。
     */
    public static String getFilePathDir(Context context) {
        if(context == null) {
            context = MyApplication.getContext();
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            return context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS).getAbsolutePath();  // 适用于 Android 10及以上
        }
        return Environment.getExternalStorageDirectory().getPath();  // 适用于 Android 4.4 及以下
    }

    /**
     * 获取应用的缓存路径,兼容 Android 6.0 以上版本
     * <p>
     * - Android 10+ 使用 getExternalCacheDir(),无需权限,自动清理。
     * - Android 9 及以下可回退到外部存储根目录,但推荐仍使用 getExternalCacheDir。
     */
    public static String getFileCachePathDir(Context context) {
        if(context == null) {
            context = MyApplication.getContext();
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            File cacheDir = context.getExternalCacheDir();  // app外部缓存路径(如:/storage/emulated/0/Android/data/包名/cache)
            if (cacheDir != null) {
                return cacheDir.getAbsolutePath();
            }
        }
        return Environment.getExternalStorageDirectory().getPath();
    }

    /**
     * Logcat存储路径
     * (Math.random() * 10) 随机数避免重复文件
     */
    public static String getLogcatPathDir(Context context) {
        File dir = new File(getFilePathDir(context), FileUtils.LOGCAT);
        if (!dir.exists()) {
            dir.mkdirs(); // 创建目录(已存在会忽略)
        }
        return dir.getAbsolutePath();
    }
    

    /**
     * 应用下载文件夹(自动创建)
     */
    public static String getCustomApkUploadFilePathDir(Context context) {
        File dir = new File(getFileCachePathDir(context), FileUtils.APK_UPLOAD);
        if (!dir.exists()) {
            dir.mkdirs(); // 创建目录(已存在会忽略)
        }
        return dir.getAbsolutePath();
    }
    
    /**
     * 创建文件夹和文件
     */
    public static boolean createFileAndPath(String filePath) {
        File file = new File(filePath);
        // 获取文件的父目录路径
        File parentDir = file.getParentFile();

        // 确保父目录存在
        if (!parentDir.exists()) {
            // 逐级创建目录
            if (!parentDir.mkdirs()) {
                LogUtils.e(TAG, "Failed to create directory: " + parentDir.getAbsolutePath());
                return false;
            }
        }

        try {
            // 如果文件不存在,创建文件
            if (!file.exists()) {
                if (file.createNewFile()) {
                    LogUtils.d(TAG, "File created: " + filePath);
                    return true;
                } else {
                    LogUtils.e(TAG, "Failed to create file: " + filePath);
                    return false;
                }
            }
        } catch (IOException e) {
            LogUtils.e(TAG, "Error creating file: " + e.getMessage());
        }

        return true; // 如果文件已经存在,则直接返回 true
    }

    public static boolean createFileAndPath(File file) {

        // 获取文件的父目录路径
        File parentDir = file.getParentFile();

        // 确保父目录存在
        if (!parentDir.exists()) {
            // 逐级创建目录
            if (!parentDir.mkdirs()) {
                LogUtils.e(TAG, "Failed to create directory: " + parentDir.getAbsolutePath());
                return false;
            }
        }

        try {
            // 如果文件不存在,创建文件
            if (!file.exists()) {
                if (file.createNewFile()) {
                    LogUtils.d(TAG, "File created: " + file.getAbsolutePath());
                    return true;
                } else {
                    LogUtils.e(TAG, "Failed to create file: " + file.getAbsolutePath());
                    return false;
                }
            }
        } catch (IOException e) {
            LogUtils.e(TAG, "Error creating file: " + e.getMessage());
        }

        return true; // 如果文件已经存在,则直接返回 true
    }


    /**
     * 创建一个文件夹,逐级创建文件路径
     */
    public static boolean createFilePath(String path) {
        File file = new File(path);
        if (!file.exists()) {
            // 逐级创建文件夹路径
            if (file.mkdirs()) {
                LogUtils.d(TAG, "Created file path: " + path);
                return true;
            } else {
                LogUtils.e(TAG, "Failed to create file path: " + path);
                return false;
            }
        }
        return true;
    }

    public static boolean createFilePath(File file) {
        if (!file.exists()) {
            // 逐级创建文件夹路径
            if (file.mkdirs()) {
                LogUtils.d(TAG, "Created file path: " + file.getAbsolutePath());
                return true;
            } else {
                LogUtils.e(TAG, "Failed to create file path: " + file.getAbsolutePath());
                return false;
            }
        }
        return true;
    }

    /**
     * 创建一个文件
     */
    public static boolean createFile(String path) {
        File file = new File(path);
        if (!file.exists()) {
            try {
                return file.createNewFile();
            } catch (IOException e) {
                LogUtils.e(TAG, "Error creating file: " + e.getMessage());
            }
        }
        return false;
    }


    /**
     * 删除单个文件或文件夹(递归)
     *
     * @param path 文件路径
     * @return 是否删除成功
     */
    public static boolean deleteFile(String path) {
        if (path == null) return false;

        File file = new File(path);
        if (!file.exists()) return false;

        // 如果是文件夹,递归删除内部文件
        if (file.isDirectory()) {
            File[] children = file.listFiles();
            if (children != null) {
                for (File child : children) {
                    deleteFile(child.getAbsolutePath());
                }
            }
        }

        return file.delete(); // 删除自身
    }

    /**
     * 复制文件
     */
    public static boolean copyFile(String sourcePath, String destPath) {
        File sourceFile = new File(sourcePath);
        File destFile = new File(destPath);
        try (FileInputStream fis = new FileInputStream(sourceFile);
             FileOutputStream fos = new FileOutputStream(destFile);
             FileChannel sourceChannel = fis.getChannel();
             FileChannel destChannel = fos.getChannel()) {

            long size = sourceChannel.size();
            sourceChannel.transferTo(0, size, destChannel);
            return true;
        } catch (IOException e) {
            LogUtils.e(TAG, "Error copying file: " + e.getMessage());
        }
        return false;
    }

    /**
     * 批量删除多个文件或文件夹
     *
     * @param paths 文件路径列表
     * @return 成功删除的数量
     */
    public static int deleteFiles(List<String> paths) {
        if (paths == null || paths.isEmpty()) return 0;

        int successCount = 0;
        for (String path : paths) {
            if (deleteFile(path)) {
                successCount++;
            }
        }
        return successCount;
    }

    /**
     * 清空指定文件夹内的所有文件和子文件夹,但保留文件夹本身
     *
     * @param folderPath 文件夹路径
     * @return 成功删除的数量
     */
    public static int clearFolder(String folderPath) {
        if (folderPath == null || folderPath.isEmpty()) return 0;

        File folder = new File(folderPath);
        if (!folder.exists() || !folder.isDirectory()) return 0;

        int successCount = 0;
        File[] files = folder.listFiles();
        if (files != null) {
            for (File file : files) {
                if (file.isDirectory()) {
                    successCount += clearFolder(file.getAbsolutePath()); // 递归清空子文件夹
                    if (file.delete()) successCount++; // 删除空子文件夹
                } else {
                    if (file.delete()) successCount++;
                }
            }
        }
        return successCount;
    }

    /**
     * 批量删除多个文件或文件夹
     *
     * @param paths 文件路径列表
     * @return 成功删除的数量
     */
    public static int deleteSpecialFiles(List<LocalFileNodeBean> paths) {
        if (paths == null || paths.isEmpty()) return 0;

        int successCount = 0;
        for (LocalFileNodeBean path : paths) {
            if (deleteFile(path.getFilePath())) {
                successCount++;
            }
        }
        return successCount;
    }

    /**
     * 移动文件
     */
    public static boolean moveFile(String sourcePath, String destPath) {
        File sourceFile = new File(sourcePath);
        File destFile = new File(destPath);
        return sourceFile.renameTo(destFile);
    }

    /**
     * 读取文件内容(返回字节流)
     */
    public static byte[] readFile(String path) {
        File file = new File(path);
        if (file.exists()) {
            try (InputStream is = new FileInputStream(file)) {
                byte[] bytes = new byte[(int) file.length()];
                is.read(bytes);
                return bytes;
            } catch (IOException e) {
                LogUtils.e(TAG, "Error reading file: " + e.getMessage());
            }
        }
        return null;
    }

    /**
     * 写入文件内容(字节流)
     */
    public static boolean writeFile(String path, byte[] data) {
        File file = new File(path);
        if (!file.exists()) {
            try {
                file.createNewFile();
            } catch (IOException e) {
                LogUtils.e(TAG, "Error creating file: " + e.getMessage());
                return false;
            }
        }

        try (OutputStream os = new FileOutputStream(file)) {
            os.write(data);
            os.flush();
            return true;
        } catch (IOException e) {
            LogUtils.e(TAG, "Error writing file: " + e.getMessage());
        }
        return false;
    }

    /**
     * 获取文件大小
     */
    public static String getFileSize(String path) {
        File file = new File(path);
        if (file.exists()) {
            long size = file.length();
            return formatSize(size);
        }
        return "File not found";
    }

    /**
     * 格式化文件大小,适应不同单位(B,KB,MB,GB)
     */
    private static String formatSize(long size) {
        DecimalFormat df = new DecimalFormat("#.00");
        String fileSizeString;
        if (size < 1024) {
            fileSizeString = df.format(size) + " B";
        } else if (size < 1048576) {
            fileSizeString = df.format(size / 1024.0) + " KB";
        } else if (size < 1073741824) {
            fileSizeString = df.format(size / 1048576.0) + " MB";
        } else {
            fileSizeString = df.format(size / 1073741824.0) + " GB";
        }
        return fileSizeString;
    }

    /**
     * 判断是否具有写入权限
     */
    public static boolean canWrite(String path) {
        File file = new File(path);
        return file.exists() && file.canWrite();
    }

    /**
     * 判断是否具有读取权限
     */
    public static boolean canRead(String path) {
        File file = new File(path);
        return file.exists() && file.canRead();
    }

    /**
     * 获取文件名(包含扩展名),例如:/storage/emulated/0/Movies/test.mp4 → test.mp4
     */
    public static String getFileNameWithExtension(String path) {
        if (path == null || path.isEmpty()) return "";
        return new File(path).getName();
    }

    /**
     * 获取文件名(不包含扩展名),例如:/storage/emulated/0/Movies/test.mp4 → test
     */
    public static String getFileNameWithoutExtension(String path) {
        if (path == null || path.isEmpty()) return "";
        String fileName = new File(path).getName();
        int dotIndex = fileName.lastIndexOf('.');
        if (dotIndex > 0 && dotIndex < fileName.length()) {
            return fileName.substring(0, dotIndex);
        }
        return fileName;
    }

    /**
     * 获取文件扩展名,例如:/storage/emulated/0/Movies/test.mp4 → mp4
     */
    public static String getFileExtension(String path) {
        if (path == null || path.isEmpty()) return "";
        String fileName = new File(path).getName();
        int dotIndex = fileName.lastIndexOf('.');
        if (dotIndex >= 0 && dotIndex < fileName.length() - 1) {
            return fileName.substring(dotIndex + 1);
        }
        return "";
    }

    /**
     * 获取文件扩展名(包含点),例如:/storage/emulated/0/Movies/test.mp4 → .mp4
     */
    public static String getFileExtensionWithDot(String path) {
        if (path == null || path.isEmpty()) return "";
        String fileName = new File(path).getName();
        int dotIndex = fileName.lastIndexOf('.');
        if (dotIndex >= 0 && dotIndex < fileName.length() - 1) {
            return fileName.substring(dotIndex); // 保留点号
        }
        return "";
    }

    /**
     * 将用户选择的 Uri 文件复制到 app 的缓存目录中,
     * 返回 FFmpeg 可识别的本地文件路径。
     *
     * @param uri 用户选择的图片 Uri(content://)
     * @return 本地缓存文件的绝对路径,失败时返回 null
     */
    public static File copyUriToFile(Context context, Uri uri, String filePath, String customFile) {
        try {
            InputStream inputStream = context.getContentResolver().openInputStream(uri);
            if (inputStream == null) return null;

            File outFile = new File(filePath, customFile);

            if (outFile.exists()) {
                outFile.delete(); // 可选:删除旧文件
            }

            OutputStream outputStream = new FileOutputStream(outFile);

            byte[] buffer = new byte[4096];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
            }

            outputStream.flush();
            outputStream.close();
            inputStream.close();

            return outFile;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
    
    /**
     * 将 drawable/raw 中的图片资源保存为本地缓存文件
     *
     * @param context  上下文
     * @param resId    drawable 中的资源 ID(如 R.drawable.xxx)
     * @param fileParent 生成的文件根目录
     * @param fileName 生成的文件名(如 header.jpg)
     * @return 返回 File 文件,可用于上传或传给 FFmpeg
     */
    public static File copyResToFile(Context context, @AnyRes int resId, String fileParent, String fileName) {
        try {
            @SuppressLint("ResourceType") InputStream inputStream = context.getResources().openRawResource(resId);

            File outFile = new File(fileParent, fileName);
            OutputStream outputStream = new FileOutputStream(outFile);

            byte[] buffer = new byte[4096];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
            }

            outputStream.flush();
            outputStream.close();
            inputStream.close();

            return outFile;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 确保将资源文件只复制一次到本地指定目录下,如果文件已存在则直接返回
     *
     * @param context   上下文
     * @param resId     资源 ID(如 R.raw.xxx 或 R.drawable.xxx)
     * @param fileParent 目标文件夹绝对路径
     * @param fileName  目标文件名(如 "video.mp4")
     * @return 返回 File 对象,失败时返回 null
     */
    public static File ensureResFileCopiedOnce(Context context, @AnyRes int resId, String fileParent, String fileName) {
        try {
            @SuppressLint("ResourceType") InputStream inputStream = context.getResources().openRawResource(resId);

            File outFile = new File(fileParent, fileName);

            // 如果文件已存在,直接返回
            if (outFile.exists() && outFile.length() > 0) {
                return outFile;
            }

            OutputStream outputStream = new FileOutputStream(outFile);

            byte[] buffer = new byte[4096];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
            }

            outputStream.flush();
            outputStream.close();
            inputStream.close();

            return outFile;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 将 Bitmap 保存到 app 的外部缓存目录中,返回文件对象
     *
     * @param context  上下文
     * @param bitmap   要保存的图片
     * @param fileName 保存的文件名(如:screenshot.png)
     */
    public static File saveBitmapToCache(Context context, Bitmap bitmap, String filePath, String fileName) {
        try {

            // 创建文件
            File file = new File(filePath, fileName);

            if (file.exists()) {
                file.delete(); // 可选:删除旧文件
            }

            // 使用输出流将 Bitmap 压缩为 PNG 并写入文件
            FileOutputStream fos = new FileOutputStream(file);
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);
            fos.flush();
            fos.close();

            return file;  // 返回文件路径
        } catch (IOException e) {
            e.printStackTrace();
            return null;  // 出错返回 null
        }
    }

    /**
     * 异步复制相册图片到指定路径(目标路径为字符串),带成功/失败回调
     *
     * @param sourcePath  相册源文件路径
     * @param targetPath  目标文件完整路径(如 /data/data/.../temp_watermark.png)
     * @param listener    复制结果回调:onSuccess(String path) / onFailure(String msg)
     */
    public static void copyAlbumImageAsync(String sourcePath, String targetPath, EventListener<String> listener) {
        if (TextUtils.isEmpty(sourcePath)) {
            if (listener != null) listener.onAction(EnumUtils.EventListenerAction.FAILURE, "源文件路径为空");
            return;
        }

        File srcFile = new File(sourcePath);
        if (!srcFile.exists()) {
            if (listener != null) listener.onAction(EnumUtils.EventListenerAction.FAILURE, "源文件不存在: " + sourcePath);
            return;
        }

        if (TextUtils.isEmpty(targetPath)) {
            if (listener != null) listener.onAction(EnumUtils.EventListenerAction.FAILURE, "目标路径为空");
            return;
        }

        File targetFile = new File(targetPath);
        File parentDir = targetFile.getParentFile();
        if (parentDir != null && !parentDir.exists()) {
            parentDir.mkdirs();
        }

        new Thread(() -> {
            try (InputStream in = new FileInputStream(srcFile);
                 OutputStream out = new FileOutputStream(targetFile)) {

                byte[] buffer = new byte[8192];
                int bytesRead;
                while ((bytesRead = in.read(buffer)) != -1) {
                    out.write(buffer, 0, bytesRead);
                }
                out.flush();

                LogUtils.d(TAG, "图片复制成功: " + targetFile.getAbsolutePath());
                if (listener != null) {
                    listener.onAction(EnumUtils.EventListenerAction.SUCCESS, targetFile.getAbsolutePath());
                }

            } catch (IOException e) {
                LogUtils.e(TAG, "复制失败: " + e.getMessage());
                if (listener != null) {
                    listener.onAction(EnumUtils.EventListenerAction.FAILURE, "复制失败: " + e.getMessage());
                }
            }
        }).start();
    }


    /**
     * 获取其他文件夹下某个文件的完整路径,并自动创建目录
     *
     * <p>
     * 使用场景:
     * 1. 存放地球动画文件
     * 2. AI编辑生成的封面图
     * 3. 其他需要存放的资源文件
     * </p>
     *
     * @param fileName  文件名,例如 "my_video.mp4" 或 "cover.png"
     * @return          完整文件路径,例如:
     *                  /storage/emulated/0/Android/data/com.yourapp/files/other_files/my_video.mp4
     */
    public static String getOtherFileFullPath(String filePath, String fileName) {

        // 4. 使用 File 拼接文件路径,避免手动拼接字符串出错
        File targetFile = new File(filePath, fileName);

        // 5. 返回完整路径字符串
        return targetFile.getAbsolutePath();
    }


}

LocalFileNodeBean 类

java 复制代码
/**
 * Author: Su
 * Date: 2025/12/20
 * Description: Description
 */

public class LocalFileNodeBean implements Comparable<LocalFileNodeBean> {

    private String filePath;
    private String fileName;
    private long fileSize;
    private long lastModified;
    private EnumUtils.MediaFileInfoType fileType;
    public boolean isEditState;   //编辑状态
    public boolean isSelect;   //选中状态
    public String progress;   //下载进度

    public LocalFileNodeBean(File file, EnumUtils.MediaFileInfoType type) {
        this.filePath = file.getAbsolutePath();
        this.fileName = file.getName();
        this.fileSize = file.length();
        this.lastModified = file.lastModified();
        this.fileType = type;
    }

    public LocalFileNodeBean(String filePath, String fileName, long fileSize, long lastModified, EnumUtils.MediaFileInfoType type) {
        this.filePath = filePath;
        this.fileName = fileName;
        this.fileSize = fileSize;
        this.lastModified = lastModified;
        this.fileType = type;
    }


    public String getFilePath() {
        return filePath;
    }

    public void setFilePath(String filePath) {
        this.filePath = filePath;
    }

    public String getFileName() {
        return fileName;
    }

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }

    public long getFileSize() {
        return fileSize;
    }

    public void setFileSize(long fileSize) {
        this.fileSize = fileSize;
    }

    public EnumUtils.MediaFileInfoType getFileType() {
        return fileType;
    }

    public void setFileType(EnumUtils.MediaFileInfoType fileType) {
        this.fileType = fileType;
    }

    public long getLastModified() {
        return lastModified;
    }

    public void setLastModified(long lastModified) {
        this.lastModified = lastModified;
    }

    public boolean isEditState() {
        return isEditState;
    }

    public void setEditState(boolean editState) {
        isEditState = editState;
    }

    public boolean isSelect() {
        return isSelect;
    }

    public void setSelect(boolean select) {
        isSelect = select;
    }

    public String getProgress() {
        return progress;
    }

    public void setProgress(String progress) {
        this.progress = progress;
    }

    // --- 支持按时间倒序、名称升序排序 ---
    @Override
    public int compareTo(LocalFileNodeBean other) {
        int result = Long.compare(other.lastModified, this.lastModified); // 倒序:新时间在前

        if (result == 0 && !this.fileName.equals(other.fileName)) {
            return this.fileName.compareToIgnoreCase(other.getFileName()); // 名称升序
        }

        return result;
    }
}

四、总结

✔ 覆盖文件操作 90% 使用场景

✔ 兼容 Android 10+ Scoped Storage

✔ 项目级通用工具类

✔ 适合中大型项目直接复用

相关推荐
rchmin2 小时前
ThreadLocal内存泄漏机制解析
java·jvm·内存泄露
黎雁·泠崖2 小时前
Java 方法栈帧深度解析:从 JIT 汇编视角,打通 C 与 Java 底层逻辑
java·c语言·汇编
java资料站2 小时前
springBootAdmin(sba)
java
AscendKing2 小时前
接口设计模式的简介 优势和劣势
java
Vincent_Vang2 小时前
多态 、抽象类、抽象类和具体类的区别、抽象方法和具体方法的区别 以及 重载和重写的相同和不同之处
java·开发语言·前端·ide
qualifying2 小时前
JavaEE——多线程(3)
java·开发语言·java-ee
Fate_I_C2 小时前
Kotlin 中的 suspend(挂起函数)
android·开发语言·kotlin
花卷HJ2 小时前
Android 下载管理器封装实战:支持队列下载、取消、进度回调与自动保存相册
android·java
wanghowie2 小时前
01.01 Spring核心|IoC容器深度解析
java·后端·spring