文件与存储:内部/外部存储、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(见前几章) |
常见坑与最佳实践
Environment.getExternalStorageDirectory()在 Android 13+ 已废弃:写公共目录请用 MediaStore。file://URI 跨应用抛FileUriExposedException:必须用 FileProvider 转content://。ACTION_OPEN_DOCUMENT必须加CATEGORY_OPENABLE:否则返回的 URI 可能无法读取流。takePersistableUriPermission失败:Intent 必须带FLAG_GRANT_PERSISTABLE_URI_PERMISSION,且权限不能超过原始 Intent 授予的范围。- 重装应用丢失 SAF 权限:URI 字符串保留无意义,需引导用户重新授权。
- 写大文件到 MediaStore 失败:
IS_PENDING=1状态下其他应用不可见,写完务必置 0。 - 相机 Intent 找不到 Activity:在
targetSdk 30+项目下,intent.resolveActivity可能返回 null,可用Intent.createChooser或resolveActivity加PackageManager.MATCH_DEFAULT_ONLY。 - 文件路径硬编码
/sdcard/不可移植:不同 OEM 设备挂载点不同,统一用getExternalFilesDir/MediaStore。 - 缓存目录空间不足:定期清理过期缓存,使用
getCacheQuotaBytes()检查可用空间。 - 写入外部存储请求权限无效: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 组件、模块化、依赖注入与项目的可维护性建设。