A
第 25 章JAVA40 分钟

文件与存储:内部/外部存储、SAF

系统梳理 Android 内部/外部存储、分区存储、权限、FileProvider 与 Storage Access Framework 的实战方案。

学习目标

  • 理解内部存储、外部存储、分区存储的差异
  • 掌握 Android 10+ 分区存储的适配方案
  • 能够使用 FileProvider 暴露 content:// URI
  • 学会 SAF 选择、打开与创建文档
  • 理解存储权限的版本差异与请求方式

学习目标

  • 理解内部存储、外部存储、分区存储的差异与 API 选择。
  • 掌握 Android 10+ 分区存储(Scoped Storage)的适配方案。
  • 能够使用 FileProvider 暴露 content:// URI,让其他应用安全访问本应用文件。
  • 学会用 Storage Access Framework(SAF)让用户选择、打开、创建文档。
  • 理解存储权限的版本差异与请求方式。

文件与存储实战

1. 存储分区总览

类型 路径 权限 卸载是否清除
内部存储 /data/data/<pkg>/files、/data/data/<pkg>/cache 无需权限 是
外部持久专属 getExternalFilesDir(null) 即 /storage/emulated/0/Android/data/<pkg>/files API 18+ 不需权限 是
外部持久公共 /storage/emulated/0/Pictures 等 API 29+ 走 MediaStore / SAF 否
外部缓存 getExternalCacheDir() 即 /storage/emulated/0/Android/data/<pkg>/cache 无需权限 是

自 Android 10 起,/sdcard/ 任意路径访问被「分区存储」限制:应用只能自由访问「应用专属目录」与「MediaStore 公共集合」,其他位置须通过 SAF。

2. 内部存储

无需权限,文件路径与 cacheDir 由系统管理:

// files 目录:永久保存,应用卸载时清除
File filesDir = context.getFilesDir();
File logFile = new File(filesDir, "app.log");
try (FileWriter w = new FileWriter(logFile, true)) {
    w.append("hello\n");
} catch (IOException e) {
    Log.e("FS", "write log failed", e);
}

// cache 目录:系统空间不足时自动清除
File cacheDir = context.getCacheDir();
File tmpImage = new File(cacheDir, "thumb.tmp");
// ... 写入临时数据 ...

// 列举内部文件
String[] list = context.fileList();
for (String name : list) {
    Log.d("FS", "internal: " + name);
}

// 删除内部文件
context.deleteFile("app.log");

cache 目录是「廉价」存储,常用于 Glide、OkHttp 等第三方库的默认缓存路径;空间不足时系统会按 LRU 清除。

3. 外部存储:应用专属目录

getExternalFilesDir(null) 返回外部存储上的应用专属目录,无需申请权限:

File extFiles = context.getExternalFilesDir(null);          // /Android/data/<pkg>/files
File extPics  = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES);
File extCache = context.getExternalCacheDir();

// 写入文件
File target = new File(extFiles, "report.pdf");
try (OutputStream os = new FileOutputStream(target)) {
    os.write(content);
} catch (IOException e) {
    Log.e("FS", "write ext failed", e);
}

4. Android 10+ 分区存储

Android 10 引入分区存储,限制应用只能访问:

  • 应用专属目录(getExternalFilesDir、getExternalCacheDir)— 不需权限。
  • 通过 MediaStore 访问媒体集合(图片、视频、音频)。
  • 通过 Downloads 访问下载集合。
  • 其他位置需通过 SAF 用户授权。

向后兼容:targetSdkVersion <= 28 可通过 requestLegacyExternalStorage="true" 临时回退旧模型;targetSdkVersion 29 起强制分区存储。

android {
    compileSdk 34
    defaultConfig {
        targetSdkVersion 34
    }
}
<!-- AndroidManifest.xml -->
<application
    android:requestLegacyExternalStorage="false">
</application>

5. MediaStore 写入公共目录

向 Pictures、Movies、Downloads 等公共目录写入文件,Android 10+ 必须通过 MediaStore:

@RequiresApi(api = Build.VERSION_CODES.Q)
public static Uri saveImageToPictures(Context context, Bitmap bitmap, String displayName)
        throws IOException {
    ContentValues values = new ContentValues();
    values.put(MediaStore.Images.Media.DISPLAY_NAME, displayName);
    values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg");
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        // IS_PENDING 表示「正在写入」,完成后置 0
        values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/AppDemo");
        values.put(MediaStore.Images.Media.IS_PENDING, 1);
    }
    ContentResolver resolver = context.getContentResolver();
    Uri uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
    if (uri == null) throw new IOException("insert failed");

    try (OutputStream os = resolver.openOutputStream(uri)) {
        if (os == null) throw new IOException("open output failed");
        bitmap.compress(Bitmap.CompressFormat.JPEG, 90, os);
    }

    // 写入完成,标记 IS_PENDING=0 让其他应用可见
    values.clear();
    values.put(MediaStore.Images.Media.IS_PENDING, 0);
    resolver.update(uri, values, null, null);
    return uri;
}

6. 读取公共媒体

public static List<Uri> listImages(Context context) {
    List<Uri> result = new ArrayList<>();
    String[] projection = {
            MediaStore.Images.Media._ID,
            MediaStore.Images.Media.DISPLAY_NAME,
            MediaStore.Images.Media.DATE_ADDED
    };
    String sortOrder = MediaStore.Images.Media.DATE_ADDED + " DESC";
    try (Cursor cursor = context.getContentResolver().query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            projection, null, null, sortOrder)) {
        int idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID);
        while (cursor.moveToNext()) {
            long id = cursor.getLong(idCol);
            Uri uri = ContentUris.withAppendedId(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
            result.add(uri);
        }
    }
    return result;
}

7. 存储权限请求

读取公共媒体集合(其他应用创建的图片)在 Android 13+ 需细粒度权限;Android 12 及以下沿用 READ_EXTERNAL_STORAGE:

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
                 android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                 android:maxSdkVersion="29"/>
<!-- 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"/>
private static final int REQ_PERM = 0x101;

private void requestStorage() {
    String perm;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        perm = Manifest.permission.READ_MEDIA_IMAGES;
    } else {
        perm = Manifest.permission.READ_EXTERNAL_STORAGE;
    }
    if (ContextCompat.checkSelfPermission(this, perm) != PackageManager.PERMISSION_GRANTED) {
        ActivityCompat.requestPermissions(this, new String[]{perm}, REQ_PERM);
    } else {
        loadImages();
    }
}

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
                                       @NonNull int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    if (requestCode == REQ_PERM && grantResults.length > 0
            && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
        loadImages();
    } else {
        // 用户拒绝:引导到设置或提示 SAF 选择
        showPermissionDenied();
    }
}

Android 11+ 应用与目标 SDK 30+ 项目,调用 WRITE_EXTERNAL_STORAGE 已无意义,系统直接忽略;写公共目录请用 MediaStore。

8. FileProvider 暴露 content://

将本应用内部文件共享给其他应用(如调用系统分享、相机拍照保存),必须用 FileProvider 把 file:// 转为 content://,否则触发 FileUriExposedException。

8.1 AndroidManifest 注册

<application>
    <provider
        android:name="androidx.core.content.FileProvider"
        android:authorities="${applicationId}.fileprovider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_provider_paths"/>
    </provider>
</application>

8.2 路径配置 res/xml/file_provider_paths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <!-- 对应 getFilesDir -->
    <files-path name="internal" path="."/>
    <!-- 对应 getCacheDir -->
    <cache-path name="cache" path="."/>
    <!-- 对应 getExternalFilesDir(null) -->
    <external-files-path name="ext_files" path="."/>
    <!-- 对应 getExternalCacheDir() -->
    <external-cache-path name="ext_cache" path="."/>
    <!-- 对应 context.getExternalStorageDirectory() 子目录 -->
    <external-path name="external" path="."/>
</paths>

8.3 调用与授权

public static void shareFile(Context context, File file, String mime) {
    Uri uri = FileProvider.getUriForFile(context,
            context.getPackageName() + ".fileprovider", file);
    Intent intent = new Intent(Intent.ACTION_SEND);
    intent.setType(mime);
    intent.putExtra(Intent.EXTRA_STREAM, uri);
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
    context.startActivity(Intent.createChooser(intent, "分享到"));
}

8.4 调用系统相机拍照保存到本应用目录

public static final int REQ_CAMERA = 0x102;
private Uri photoUri;

private void takePhoto() throws IOException {
    File dir = new File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), "camera");
    if (!dir.exists() && !dir.mkdirs()) throw new IOException("mkdirs failed");
    File photo = File.createTempFile("IMG_", ".jpg", dir);

    // 通过 FileProvider 获取 content:// URI
    photoUri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", photo);

    Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
    // 给相机应用临时授权
    intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    if (intent.resolveActivity(getPackageManager()) != null) {
        startActivityForResult(intent, REQ_CAMERA);
    }
}

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == REQ_CAMERA && resultCode == RESULT_OK && photoUri != null) {
        // 直接使用 photoUri 显示图片
        Glide.with(this).load(photoUri).into(ivPreview);
    }
}

9. SAF:Storage Access Framework

SAF 让用户在系统文档选择器中授权访问任意目录与文件,无需申请存储权限。返回的 content:// URI 拥有持久权限。

9.1 打开单个文件

public static final int REQ_OPEN_DOC = 0x201;

private void pickDocument() {
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
    intent.addCategory(Intent.CATEGORY_OPENABLE);
    intent.setType("application/pdf");
    intent.setType("*/*");
    intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"application/pdf", "image/*"});
    startActivityForResult(intent, REQ_OPEN_DOC);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == REQ_OPEN_DOC && resultCode == RESULT_OK && data != null) {
        Uri uri = data.getData();
        if (uri == null) return;

        // 申请长期访问权限
        try {
            getContentResolver().takePersistableUriPermission(uri,
                    Intent.FLAG_GRANT_READ_URI_PERMISSION);
        } catch (SecurityException e) {
            Log.e("SAF", "take persist permission failed", e);
        }
        readFromUri(uri);
    }
}

private void readFromUri(Uri uri) {
    try (InputStream is = getContentResolver().openInputStream(uri)) {
        ByteArrayOutputStream buf = new ByteArrayOutputStream();
        byte[] b = new byte[4096];
        int n;
        while ((n = is.read(b)) > 0) buf.write(b, 0, n);
        byte[] content = buf.toByteArray();
    } catch (IOException e) {
        Log.e("SAF", "read failed", e);
    }
}

9.2 创建新文档

public static final int REQ_CREATE_DOC = 0x202;

private void createDocument() {
    Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
    intent.addCategory(Intent.CATEGORY_OPENABLE);
    intent.setType("text/plain");
    intent.putExtra(Intent.EXTRA_TITLE, "note.txt");
    startActivityForResult(intent, REQ_CREATE_DOC);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == REQ_CREATE_DOC && resultCode == RESULT_OK && data != null) {
        Uri uri = data.getData();
        try (OutputStream os = getContentResolver().openOutputStream(uri)) {
            os.write("hello".getBytes(StandardCharsets.UTF_8));
        } catch (IOException e) {
            Log.e("SAF", "write failed", e);
        }
    }
}

9.3 选择目录树

public static final int REQ_OPEN_TREE = 0x203;

private void pickTree() {
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
                  | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
                  | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
    startActivityForResult(intent, REQ_OPEN_TREE);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == REQ_OPEN_TREE && resultCode == RESULT_OK && data != null) {
        Uri tree = data.getData();
        // 持久化授权
        getContentResolver().takePersistableUriPermission(tree,
                Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        // 用 DocumentFile 操作整个目录树
        DocumentFile root = DocumentFile.fromTreeUri(this, tree);
        for (DocumentFile f : root.listFiles()) {
            Log.d("SAF", "file: " + f.getName() + ", mime=" + f.getType());
        }
    }
}

9.4 DocumentFile 写文件

DocumentFile root = DocumentFile.fromTreeUri(this, tree);
DocumentFile file = root.createFile("text/plain", "demo.txt");
try (OutputStream os = getContentResolver().openOutputStream(file.getUri())) {
    os.write("hi".getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
    Log.e("SAF", "write failed", e);
}

SAF 持久 URI 在 App 重启后仍有效,但「应用被卸载重装」会失效,需用户重新授权。保存到 SharedPreferences 时记录 URI 字符串。

10. 关键差异与选型

场景 推荐方案
应用私有配置、缓存 内部存储 filesDir / cacheDir
应用专属媒体数据 外部 getExternalFilesDir
拍照、下载图片到相册 MediaStore + RELATIVE_PATH
分享文件给其他应用 FileProvider
用户选择文件并长期持有 SAF + takePersistableUriPermission
用户选择整个目录树 SAF ACTION_OPEN_DOCUMENT_TREE + DocumentFile
大数据关系型持久化 Room(见上一章)
轻量 KV SharedPreferences(见前几章)

常见坑与最佳实践

  1. Environment.getExternalStorageDirectory() 在 Android 13+ 已废弃:写公共目录请用 MediaStore。
  2. file:// URI 跨应用抛 FileUriExposedException:必须用 FileProvider 转 content://。
  3. ACTION_OPEN_DOCUMENT 必须加 CATEGORY_OPENABLE:否则返回的 URI 可能无法读取流。
  4. takePersistableUriPermission 失败:Intent 必须带 FLAG_GRANT_PERSISTABLE_URI_PERMISSION,且权限不能超过原始 Intent 授予的范围。
  5. 重装应用丢失 SAF 权限:URI 字符串保留无意义,需引导用户重新授权。
  6. 写大文件到 MediaStore 失败:IS_PENDING=1 状态下其他应用不可见,写完务必置 0。
  7. 相机 Intent 找不到 Activity:在 targetSdk 30+ 项目下,intent.resolveActivity 可能返回 null,可用 Intent.createChooser 或 resolveActivity 加 PackageManager.MATCH_DEFAULT_ONLY。
  8. 文件路径硬编码 /sdcard/ 不可移植:不同 OEM 设备挂载点不同,统一用 getExternalFilesDir / MediaStore。
  9. 缓存目录空间不足:定期清理过期缓存,使用 getCacheQuotaBytes() 检查可用空间。
  10. 写入外部存储请求权限无效:Android 10+ 应用专属目录无需权限,公共目录请走 MediaStore。

章节小结

  • 内部存储用于私有配置、缓存,无需权限;外部应用专属目录 (getExternalFilesDir) 也是。
  • Android 10+ 分区存储限制了对公共目录的直接访问,应通过 MediaStore + RELATIVE_PATH 写入媒体文件。
  • FileProvider 将 file:// 转为 content://,是跨应用共享文件的标准方式,必须注册 provider 与 path 配置。
  • SAF(ACTION_OPEN_DOCUMENT / ACTION_OPEN_DOCUMENT_TREE)让用户主动授权,无需申请存储权限;持久 URI 可在应用重启后继续访问。
  • 不同存储方案选型:私有 → 内部/外部专属目录;公共媒体 → MediaStore;用户文件 → SAF;关系型数据 → Room;轻量 KV → SharedPreferences。

下一章预告

Part 4 数据与网络至此告一段落。Part 5《架构与工程化》将进入更宏大的话题:从 MVC / MVP / MVVM 的演进到 Jetpack 组件、模块化、依赖注入与项目的可维护性建设。