Android 文件系统与存储深度解析——跨平台开发者的数据持久化全指南
面向跨平台开发者的 Android 文件存储完全指南,从内部存储与外部存储的区别、Scoped Storage 限制策略、SharedPreferences vs DataStore 对比、Room 数据库缓存策略到 Cache 目录清理,系统讲解 Flutter/RN/鸿蒙开发中的数据持久化方案和文件互通问题。
一句话概括
Android 文件系统是应用数据持久化的基础设施——从简单的键值对存储(SharedPreferences)到结构化数据库(Room)、从内部私有文件到外部分享文件,Android 提供了多层级的存储方案,而 Android 10 引入的 Scoped Storage 更是从根本上改变了文件访问的方式;理解这些存储机制,是跨平台开发者在 Android 上正确处理用户数据的前提。
背景与意义
在移动应用开发中,数据持久化是一个无法回避的课题。用户的登录 Token 存哪里?下载的图片放哪个目录?本地缓存数据库用什么实现?这些问题看似简单,但在 Android 平台上,由于碎片化严重(不同厂商、不同 Android 版本),文件存储的实现比看上去要复杂得多。
对于 Flutter 和 React Native 开发者来说,你可能已经习惯了使用 shared_preferences、path_provider、sqflite 等现成的插件来管理文件和数据。当这些插件工作正常时,你不需要关心底层实现。但当以下情况发生时,理解 Android 文件系统就变得至关重要:
- 应用文件在设备上找不到(”我下载的文件存哪了?”)
- Android 10+ 设备上文件读写失败(”之前好好用的,升级 Android 后就读不出来了”)
- 应用卸载重装后数据丢失(”怎么之前登录的用户信息全没了?”)
- 跨 Flutter 和原生模块的文件路径转换问题
更现实的问题是:当你的 Flutter 应用中集成了某个需要读写文件的第三方原生 SDK 时,你需要知道这个 SDK 把文件写到了哪里,以及 Flutter 的 Dart 侧能否访问到这个路径。
本文将系统性地解析 Android 文件系统的分层结构,从传统存储模型到 Scoped Storage 时代的变化,从键值对存储到关系型数据库,并在每个环节聚焦跨平台开发者的实际需求。
核心知识点拆解
Android 存储体系概述
Android 的存储体系可以分为两大维度:
按设备位置分:
- 内部存储(Internal Storage):设备内置的非易失性存储区域
- 外部存储(External Storage):可移除的存储介质(SD 卡)或设备内置的共享存储空间
按访问范围分:
- 私有存储:仅应用自身可以访问
- 共享存储:所有应用可访问(如相册、 Downloads 目录)
Android 10(API 29)之前,应用可以自由读写外部存储的任意路径。Android 10 引入 Scoped Storage 后,对共享存储的访问策略发生了根本性变化。
内部存储 vs 外部存储
内部存储(data/data/包名/)
内部存储是每个应用私有的文件系统区域,位于 /data/data/包名/ 目录下。这个区域:
- 默认不可被其他应用访问(除非设备已 root)
- 应用卸载时自动删除
- 不需要任何权限声明
- 空间有限(设备内部存储空间)
内部存储的常用目录:
1
2
3
4
5
6
7
8
9
10
11
// 应用内部存储根目录
context.getFilesDir() → /data/data/com.example.app/files/
// 应用内部缓存目录
context.getCacheDir() → /data/data/com.example.app/cache/
// 数据库存储目录
context.getDatabasePath("mydb") → /data/data/com.example.app/databases/mydb
// SharedPreferences 存储目录
// (无公共 API,通常位于 data/data/包名/shared_prefs/)
内部存储的操作方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 写入文件
String fileName = "notes.txt";
String content = "Hello, Android Storage!";
try (FileOutputStream fos = context.openFileOutput(fileName, Context.MODE_PRIVATE)) {
fos.write(content.getBytes());
}
// 读取文件
try (FileInputStream fis = context.openFileInput(fileName)) {
byte[] buffer = new byte[fis.available()];
fis.read(buffer);
String content = new String(buffer);
}
// 列出文件
String[] files = context.fileList();
// 删除文件
context.deleteFile(fileName);
外部存储(sdcard/)
外部存储是指 Android 设备上的共享存储区域,通常挂载在 /sdcard/ 或 /storage/emulated/0/ 路径下。这个区域:
- 所有应用(有权限的)都可以读写
- 容量通常较大
- 应用卸载时,公共目录下的文件不删除,应用私有外部存储目录会被删除(Android 10+)
外部存储的两种路径:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 应用在外部存储的私有目录
context.getExternalFilesDir(null)
→ /storage/emulated/0/Android/data/com.example.app/files/
// 2. 外部存储的公共目录(所有应用可见)
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
→ /storage/emulated/0/Download/
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
→ /storage/emulated/0/Pictures/
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
→ /storage/emulated/0/DCIM/
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
→ /storage/emulated/0/Movies/
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC)
→ /storage/emulated/0/Music/
对于跨平台开发者的实际意义
在 Flutter 项目中,使用 path_provider 插件获取各种路径:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import 'package:path_provider/path_provider.dart';
// 获取应用内部存储的 documents 目录
Directory appDocDir = await getApplicationDocumentsDirectory();
// Android 上对应: /data/data/com.example.app/files/
// 获取应用缓存目录
Directory tempDir = await getTemporaryDirectory();
// Android 上对应: /data/data/com.example.app/cache/
// 获取外部存储的应用私有目录
Directory? extDir = await getExternalStorageDirectory();
// Android 上对应: /storage/emulated/0/Android/data/com.example.app/files/
// 获取下载目录
Directory? downloadsDir = await getDownloadsDirectory();
// Android 上对应: /storage/emulated/0/Download/
// 获取应用支持的目录
Directory? appSupportDir = await getApplicationSupportDirectory();
在 React Native 项目中,使用 react-native-fs 获取各种路径:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import RNFS from 'react-native-fs';
// 应用文档目录
console.log(RNFS.DocumentDirectoryPath);
// /data/data/com.example.app/files/
// 缓存目录
console.log(RNFS.CachesDirectoryPath);
// /data/data/com.example.app/cache/
// 外部存储目录
console.log(RNFS.ExternalDirectoryPath);
// /storage/emulated/0/Android/data/com.example.app/files/
// 外部存储公共目录
console.log(RNFS.ExternalStorageDirectoryPath);
// /storage/emulated/0/
Scoped Storage(Android 10+ 文件访问限制)
什么是 Scoped Storage?
Scoped Storage(分区存储)是 Android 10(API 29)引入的一项重大存储策略变更。它的核心理念是:应用只能访问自己的私有目录和明确的公共目录,不能随意访问外部存储的其他路径。
变更的具体内容
Android 10(API 29):
- 默认启用 Scoped Storage(targetSdkVersion ≥ 29)
- 应用可以继续通过
requestLegacyExternalStorage声明来使用旧的存储模型(过渡期方案) - 应用的外部存储私有目录(
Android/data/包名/)仍然可自由读写 - 访问公共目录需要通过
MediaStore或SAF(Storage Access Framework)
Android 11(API 30):
- Scoped Storage 强制启用——不再支持
requestLegacyExternalStorage - 应用不能访问其他应用的
Android/data/目录 - 新增
MANAGE_EXTERNAL_STORAGE权限(特殊权限,需 Google Play 审批) - 批量文件操作通过
MediaStore的批量 API 进行
对跨平台开发的冲击
对于需要读写文件的 Flutter/RN 应用,Scoped Storage 的影响体现在以下方面:
影响一:文件下载功能
在 Android 10 之前,应用可以直接将文件保存到 Download/ 目录。现在需要通过 MediaStore API,或者使用 SAF 让用户选择目录:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Android 10+ 推荐方式:使用 MediaStore
ContentValues values = new ContentValues();
values.put(MediaStore.Downloads.TITLE, "report.pdf");
values.put(MediaStore.Downloads.DISPLAY_NAME, "report.pdf");
values.put(MediaStore.Downloads.MIME_TYPE, "application/pdf");
values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/MyApp");
Uri uri = context.getContentResolver().insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
try (OutputStream out = context.getContentResolver().openOutputStream(uri)) {
// 将文件内容写入 outputStream
out.write(fileData);
}
影响二:图片选择功能
使用 image_picker 插件时,插件内部已经处理了 Scoped Storage 的适配。但如果你手动实现图片选择逻辑,需要注意 Android 10+ 返回的是 content:// URI,而不是 file:// URI。
1
2
3
4
// Flutter 中使用 image_picker(已自动适配)
final XFile? photo = await picker.pickImage(source: ImageSource.gallery);
// Android 10+ 返回 content:// URI
// Android 9- 返回 file:// URI
影响三:文件共享
应用之间共享文件时,Android 10+ 应使用 FileProvider:
1
2
3
4
5
6
7
8
9
10
// 使用 FileProvider 生成安全的 content URI
Uri fileUri = FileProvider.getUriForFile(context,
"com.example.app.fileprovider",
new File(filePath));
Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("image/*");
shareIntent.putExtra(Intent.EXTRA_STREAM, fileUri);
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(Intent.createChooser(shareIntent, "分享图片"));
FileProvider 配置
在 AndroidManifest.xml 中声明:
1
2
3
4
5
6
7
8
9
<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_paths" />
</provider>
定义共享路径(res/xml/file_paths.xml):
1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path path="images/" name="internal_images" />
<cache-path path="downloads/" name="cache_downloads" />
<external-files-path path="photos/" name="external_photos" />
<external-path path="Download/" name="downloads" />
</paths>
SharedPreferences 与 DataStore 对比
SharedPreferences 的问题
SharedPreferences 是 Android 中最早的键值对存储方案。它使用 XML 文件保存数据,在 API 级别 1 就已经存在。
1
2
3
4
5
6
7
8
9
10
11
12
// 写入
SharedPreferences prefs = getSharedPreferences("my_prefs", Context.MODE_PRIVATE);
prefs.edit()
.putString("username", "张三")
.putInt("age", 28)
.putBoolean("isLoggedIn", true)
.apply(); // 异步写入
// 读取
String username = prefs.getString("username", "");
int age = prefs.getInt("age", 0);
boolean isLoggedIn = prefs.getBoolean("isLoggedIn", false);
SharedPreferences 的主要问题:
- 同步加载:首次读取时会在主线程同步读取整个 XML 文件,可能导致 UI 卡顿
- 类型不安全:
getString()可能抛出ClassCastException - 内存消耗:整个文件被加载到内存中
- 原子性弱:
apply()是异步的,写入后立即读取可能读到旧值 - 回调缺失:无法监听数据变化
DataStore 的优势
DataStore 是 Jetpack 提供的现代化键值对存储方案,它是 SharedPreferences 的替代品。有两种实现:
Preferences DataStore:使用键值对存储,类似 SharedPreferences Proto DataStore:使用 Protocol Buffers 定义数据结构,类型安全
Preferences DataStore 的使用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建 DataStore
val Context.dataStore by preferencesDataStore(name = "settings")
// 写入
suspend fun saveUserSettings(context: Context, name: String, age: Int) {
context.dataStore.edit { preferences ->
preferences[USER_NAME_KEY] = name
preferences[USER_AGE_KEY] = age
}
}
// 读取
val userNameFlow: Flow<String> = context.dataStore.data.map { preferences ->
preferences[USER_NAME_KEY] ?: ""
}
private val USER_NAME_KEY = stringPreferencesKey("user_name")
private val USER_AGE_KEY = intPreferencesKey("user_age")
DataStore 的核心优势:
- 异步加载:使用协程和 Flow,不在主线程中执行 I/O
- 类型安全:不需要手动类型转换
- 事务性:所有操作都是原子性的
- 响应式:通过 Flow 监听数据变化
- 内存效率:不会一次性加载整个文件
对比总结
| 特性 | SharedPreferences | DataStore |
|---|---|---|
| API 级别 | 1+ | 需要 AndroidX |
| 加载方式 | 同步(可能卡 UI) | 异步(协程) |
| 类型安全 | 否 | 是(Proto DataStore) |
| 原子性 | 部分(commit 同步/apply 异步) | 完全 |
| 数据监听 | 无原生支持 | Flow 响应式 |
| 复杂数据 | 仅简单键值对 | Proto DataStore 支持对象 |
| 引入复杂度 | 低 | 中(需要学习协程) |
对于跨平台开发者的选择建议
- Flutter 项目:使用
shared_preferences插件(它封装的是 SharedPreferences),对于小量配置数据完全够用 - RN 项目:使用
@react-native-async-storage/async-storage,功能类似 - 原生插件开发:新项目建议使用 DataStore,已使用 SharedPreferences 的可以迁移
Room 数据库基础
Room 是什么?
Room 是 Android 官方提供的 ORM(对象关系映射)数据库框架,它在 SQLite 之上提供了一层抽象。相比直接使用 SQLite 或 SQLiteOpenHelper,Room 提供了:
- 编译时 SQL 验证
- 自动表创建和迁移
- 与 LiveData / Flow / RxJava 的集成
- 协程支持(异步查询)
Room 的三要素
1. Entity(实体):定义数据库表结构
1
2
3
4
5
6
7
@Entity(tableName = "cached_data")
data class CachedData(
@PrimaryKey val id: String,
@ColumnInfo(name = "data") val jsonData: String,
@ColumnInfo(name = "expires_at") val expiresAt: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)
2. DAO(数据访问对象):定义数据操作方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Dao
interface CachedDataDao {
@Query("SELECT * FROM cached_data WHERE id = :id")
suspend fun getById(id: String): CachedData?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(data: CachedData)
@Query("DELETE FROM cached_data WHERE expires_at < :timestamp")
suspend fun deleteExpired(timestamp: Long)
@Query("DELETE FROM cached_data")
suspend fun clearAll()
}
3. Database(数据库类):定义数据库配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Database(entities = [CachedData::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun cachedDataDao(): CachedDataDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_cache.db"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
return instance
}
}
}
}
在跨平台项目中使用 Room
Room 只能在 Android 原生端(Java/Kotlin)使用。在 Flutter 或 RN 项目中,如果要使用 Room,需要通过 MethodChannel 调用:
Flutter 侧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class NativeCache {
static const _channel = MethodChannel('com.example.app/cache');
static Future<void> saveCache(String key, String data, int expirySeconds) async {
await _channel.invokeMethod('saveCache', {
'key': key,
'data': data,
'expirySeconds': expirySeconds,
});
}
static Future<String?> getCache(String key) async {
return await _channel.invokeMethod('getCache', {'key': key});
}
}
Android 原生侧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class CachePlugin implements FlutterPlugin, MethodCallHandler {
private AppDatabase database;
@Override
public void onMethodCall(MethodCall call, Result result) {
switch (call.method) {
case "saveCache":
String key = call.argument("key");
String data = call.argument("data");
int expirySeconds = call.argument("expirySeconds");
GlobalScope.launch(Dispatchers.IO) {
CachedData cachedData = new CachedData(
key, data,
System.currentTimeMillis() + expirySeconds * 1000L
);
database.cachedDataDao().insert(cachedData);
GlobalScope.launch(Dispatchers.Main) {
result.success(true);
}
};
break;
}
}
}
本地缓存策略
对于跨平台网络数据,一个典型的本地缓存策略是:先读缓存→显示→异步更新→缓存到期后重新请求。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class CacheManager(private val dao: CachedDataDao) {
// 缓存优先策略
suspend fun getWithCache(
key: String,
expiryMillis: Long,
fetchRemote: suspend () -> String
): String {
// 1. 尝试读取缓存
val cached = dao.getById(key)
if (cached != null && !isExpired(cached, expiryMillis)) {
return cached.jsonData
}
// 2. 缓存不存在或已过期,从网络获取
return try {
val remoteData = fetchRemote()
// 3. 更新缓存
val newCached = CachedData(
id = key,
jsonData = remoteData,
expiresAt = System.currentTimeMillis() + expiryMillis
)
dao.insert(newCached)
remoteData
} catch (e: Exception) {
// 4. 网络失败时,返回过期缓存作为降级方案
cached?.jsonData ?: throw e
}
}
private fun isExpired(data: CachedData, expiryMillis: Long): Boolean {
return System.currentTimeMillis() > data.expiresAt
}
}
Flutter 的替代方案
如果不想在 Flutter 项目中使用原生 Room + MethodChannel 的架构,Flutter 生态中有两个主流的选择:
- sqflite:封装了 SQLite,功能类似于 Room,但运行在 Dart 层
- drift(原 moor):Flutter 端的 ORM 框架,功能更强大
对于跨平台开发者来说,建议:
- 如果数据只在 Flutter 层使用 → 使用
sqflite或drift - 如果数据需要与原生层共享 → 使用 Room(通过 MethodChannel 桥接)
- 如果只是简单的键值对 → 使用
shared_preferences
Cache 目录的清理策略
Android 的缓存目录
Android 为每个应用提供了两个缓存目录:
1
2
3
4
5
6
7
// 内部缓存
context.getCacheDir()
// /data/data/com.example.app/cache/
// 外部缓存
context.getExternalCacheDir()
// /storage/emulated/0/Android/data/com.example.app/cache/
系统自动清理
Android 系统会在存储空间不足时自动清理应用的缓存文件。但清理时机不可控——系统可能在任何时候清空这些目录。
手动清理策略
良好的实践是在应用中实现缓存清理功能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class CacheCleaner {
// 获取缓存大小
public static long getCacheSize(Context context) {
long size = 0;
size += getDirSize(context.getCacheDir());
File externalCache = context.getExternalCacheDir();
if (externalCache != null) {
size += getDirSize(externalCache);
}
return size;
}
// 清理全部缓存
public static void clearAllCache(Context context) {
deleteDir(context.getCacheDir());
File externalCache = context.getExternalCacheDir();
if (externalCache != null) {
deleteDir(externalCache);
}
}
// 清理过期缓存(保留最近 N 天的缓存)
public static void clearExpiredCache(Context context, int keepDays) {
long cutoff = System.currentTimeMillis() - keepDays * 24L * 60 * 60 * 1000;
clearExpiredFiles(context.getCacheDir(), cutoff);
File externalCache = context.getExternalCacheDir();
if (externalCache != null) {
clearExpiredFiles(externalCache, cutoff);
}
}
private static long getDirSize(File dir) {
long size = 0;
if (dir.isDirectory()) {
for (File file : dir.listFiles()) {
size += file.isDirectory() ? getDirSize(file) : file.length();
}
}
return size;
}
private static boolean deleteDir(File dir) {
if (dir.isDirectory()) {
for (File file : dir.listFiles()) {
deleteDir(file);
}
}
return dir.delete();
}
private static void clearExpiredFiles(File dir, long cutoff) {
if (dir.isDirectory()) {
for (File file : dir.listFiles()) {
if (file.isDirectory()) {
clearExpiredFiles(file, cutoff);
} else if (file.lastModified() < cutoff) {
file.delete();
}
}
}
}
}
Flutter 中的缓存清理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import 'package:path_provider/path_provider.dart';
import 'dart:io';
class CacheManager {
static Future<int> getCacheSize() async {
Directory tempDir = await getTemporaryDirectory();
return _getDirSize(tempDir);
}
static Future<void> clearCache() async {
Directory tempDir = await getTemporaryDirectory();
await _deleteDir(tempDir);
await tempDir.create(); // 重新创建目录
}
static Future<int> _getDirSize(Directory dir) async {
int size = 0;
await for (var entity in dir.list(recursive: true)) {
if (entity is File) {
size += await entity.length();
}
}
return size;
}
static Future<void> _deleteDir(Directory dir) async {
if (await dir.exists()) {
await dir.delete(recursive: true);
}
}
}
与 Flutter/RN 的文件读写互通问题
路径转换问题
跨平台开发中最常见的文件互通问题是:原生端获取的文件路径,在 Flutter/RN 侧无法直接访问,反之亦然。
问题根源在于:
- Flutter/RN 和原生端使用不同的路径表示方式
- Android 10+ 的 Scoped Storage 返回 content:// URI,而非 file:// 路径
- 不同存储目录的访问权限不同
常见场景与解决方案
场景一:原生 SDK 写入文件,Flutter 需要读取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Flutter 侧
class FileBridge {
static const _channel = MethodChannel('com.example.app/files');
static Future<String> getSharedFilePath() async {
// 从原生 SDK 获取文件路径
final nativePath = await _channel.invokeMethod('getSdkFilePath');
// 将原生路径转换成 Flutter 可访问的路径
// 如果原生路径是 /data/data/com.example.app/files/sdk/data.db
// Flutter 的 getApplicationDocumentsDirectory() 返回的是同一目录
return nativePath;
}
}
// Android 原生侧
@JavascriptInterface
public void onSdkFileReady(String relativePath) {
String fullPath = new File(context.getFilesDir(), relativePath).getAbsolutePath();
// 通过 MethodChannel 返回给 Flutter
channel.invokeMethod("onFileReady", fullPath);
}
场景二:Flutter 下载文件,RN 需要读取(同一应用内)
如果 Flutter 和 RN 混编(比如在 RN 项目中集成 Flutter 模块),文件互通通过共享的文件路径实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// RN 侧读取 Flutter 写入的文件
const RNFS = require('react-native-fs');
// 使用应用的公共外部存储目录
const sharedPath = `${RNFS.ExternalDirectoryPath}/shared_data.json`;
// 读取文件
RNFS.readFile(sharedPath, 'utf8')
.then(content => {
console.log('读取 Flutter 写入的文件:', content);
})
.catch(err => {
console.error('文件读取失败:', err);
});
场景三:Android 10+ 的 content:// URI 转文件路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 将 content:// URI 转换为可读的文件路径
// 注意:并非所有 content:// URI 都能成功转换
public static String getRealPathFromUri(Context context, Uri contentUri) {
String[] projection = {MediaStore.Images.Media.DATA};
Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, null);
if (cursor != null) {
int columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
cursor.moveToFirst();
String path = cursor.getString(columnIndex);
cursor.close();
return path;
}
// 如果无法通过 MediaStore 获取路径,尝试直接复制文件
return copyFileToCache(context, contentUri);
}
// 最佳实践:不依赖路径转换,直接复制文件
private static String copyFileToCache(Context context, Uri contentUri) {
try (InputStream input = context.getContentResolver().openInputStream(contentUri)) {
File cacheFile = new File(context.getCacheDir(), "temp_" + System.currentTimeMillis());
try (OutputStream output = new FileOutputStream(cacheFile)) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = input.read(buffer)) != -1) {
output.write(buffer, 0, bytesRead);
}
}
return cacheFile.getAbsolutePath();
} catch (IOException e) {
return null;
}
}
实战案例
案例一:Flutter 应用实现图片缓存
需求:应用需要展示大量网络图片,需要实现一个本地 LRU 缓存,避免每次打开都从网络加载。
实现方案:
- 使用 cached_network_image 插件(推荐):
1
2
3
4
5
6
7
8
9
10
11
import 'package:cached_network_image/cached_network_image.dart';
CachedNetworkImage(
imageUrl: "https://example.com/large_image.jpg",
placeholder: (context, url) => CircularProgressIndicator(),
errorWidget: (context, url, error) => Icon(Icons.error),
maxWidthDiskCache: 200, // 磁盘缓存最大宽度
maxHeightDiskCache: 200, // 磁盘缓存最大高度
memCacheWidth: 200, // 内存缓存宽度
memCacheHeight: 200, // 内存缓存高度
);
该插件底层使用 path_provider 获取缓存目录,将图片缓存到 getTemporaryDirectory()/libCachedImages/ 下。
- 手动实现缓存清理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ImageCacheCleaner {
static Future<void> clearImageCache() async {
// 清理 cached_network_image 的缓存
await DefaultCacheManager().emptyCache();
// 清理 Flutter 图片缓存
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
// 清理临时文件
Directory tempDir = await getTemporaryDirectory();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
await tempDir.create();
}
}
static Future<int> getImageCacheSize() async {
final cacheInfo = await DefaultCacheManager().getStore().getTotalSize();
return cacheInfo ?? 0;
}
}
案例二:React Native 应用实现离线缓存
需求:RN 应用需要缓存 API 响应数据,支持离线访问。
实现方案(使用 AsyncStorage):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import AsyncStorage from '@react-native-async-storage/async-storage';
class OfflineCache {
static CACHE_PREFIX = '@offline_cache_';
static async cacheResponse(key, data, ttlMinutes = 30) {
const cacheItem = {
data,
timestamp: Date.now(),
ttl: ttlMinutes * 60 * 1000,
};
await AsyncStorage.setItem(
`${this.CACHE_PREFIX}${key}`,
JSON.stringify(cacheItem),
);
}
static async getCachedResponse(key) {
const raw = await AsyncStorage.getItem(`${this.CACHE_PREFIX}${key}`);
if (!raw) return null;
const cacheItem = JSON.parse(raw);
const isExpired = Date.now() - cacheItem.timestamp > cacheItem.ttl;
if (isExpired) {
await AsyncStorage.removeItem(`${this.CACHE_PREFIX}${key}`);
return null;
}
return cacheItem.data;
}
static async getCacheSize() {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(k => k.startsWith(this.CACHE_PREFIX));
let totalSize = 0;
for (const key of cacheKeys) {
const value = await AsyncStorage.getItem(key);
totalSize += value ? value.length : 0;
}
return totalSize;
}
static async clearExpiredCache() {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(k => k.startsWith(this.CACHE_PREFIX));
for (const key of cacheKeys) {
const raw = await AsyncStorage.getItem(key);
if (raw) {
const cacheItem = JSON.parse(raw);
if (Date.now() - cacheItem.timestamp > cacheItem.ttl) {
await AsyncStorage.removeItem(key);
}
}
}
}
static async clearAllCache() {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(k => k.startsWith(this.CACHE_PREFIX));
await AsyncStorage.multiRemove(cacheKeys);
}
}
// 使用示例
async function fetchWithCache(url) {
const cached = await OfflineCache.getCachedResponse(url);
if (cached) {
console.log('返回缓存数据');
return cached;
}
const response = await fetch(url);
const data = await response.json();
await OfflineCache.cacheResponse(url, data, 30); // 缓存 30 分钟
return data;
}
案例三:处理 Android 10+ 的文件访问兼容性
需求:应用需要兼容 Android 6~14 的所有版本,包括 Scoped Storage 的变化。
实现策略:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 统一文件保存方法
public class StorageCompat {
public static Uri saveFile(Context context, String fileName, byte[] data, String mimeType) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+:使用 MediaStore
return saveViaMediaStore(context, fileName, data, mimeType);
} else {
// Android 9-:直接写入外部存储
return saveDirectly(context, fileName, data);
}
}
@RequiresApi(api = Build.VERSION_CODES.Q)
private static Uri saveViaMediaStore(Context context, String fileName, byte[] data, String mimeType) {
ContentValues values = new ContentValues();
values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
values.put(MediaStore.Downloads.MIME_TYPE, mimeType);
values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/MyApp");
Uri uri = context.getContentResolver().insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
if (uri != null) {
try (OutputStream out = context.getContentResolver().openOutputStream(uri)) {
out.write(data);
} catch (IOException e) {
e.printStackTrace();
}
}
return uri;
}
private static Uri saveDirectly(Context context, String fileName, byte[] data) {
File downloadsDir = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS);
File appDir = new File(downloadsDir, "MyApp");
if (!appDir.exists()) appDir.mkdirs();
File file = new File(appDir, fileName);
try (FileOutputStream fos = new FileOutputStream(file)) {
fos.write(data);
} catch (IOException e) {
e.printStackTrace();
}
return Uri.fromFile(file);
}
}
常见问题
Q1:getExternalStoragePublicDirectory() 已废弃,如何使用替代方案?
getExternalStoragePublicDirectory() 在 Android 10+ 已废弃。替代方案是使用 MediaStore:
1
2
3
4
5
6
7
8
9
10
11
// 将文件保存到公共目录
ContentValues values = new ContentValues();
values.put(MediaStore.Downloads.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS + "/MyApp");
values.put(MediaStore.Downloads.IS_PENDING, true); // Android 11+ 标记为"处理中"
Uri uri = getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
// 写入文件内容...
values.clear();
values.put(MediaStore.Downloads.IS_PENDING, false);
getContentResolver().update(uri, values, null, null);
推荐使用 path_provider(Flutter)或 react-native-fs(RN)插件,它们已经处理了版本兼容。
Q2:为什么应用卸载重装后 SharedPreferences 数据还在?
情况一:应用使用 Android:allowBackup=true(默认值),且用户在恢复数据时选择了恢复。Android 的自动备份功能会在应用卸载后保留数据,重新安装时恢复到设备。
解决方案:如果不想保留数据,设置 Android:allowBackup="false" 或使用 android:fullBackupContent 指定不需要备份的数据。
情况二:数据保存在外部存储的公共目录,不会被卸载删除。
Q3:path_provider.getApplicationDocumentsDirectory() 和 getTemporaryDirectory() 之间的区别是什么?
| 特性 | getApplicationDocumentsDirectory | getTemporaryDirectory |
|---|---|---|
| 对应路径 | /data/data/包名/files/ | /data/data/包名/cache/ |
| 系统清理 | 不会 | 存储空间不足时会被清理 |
| 用途 | 持久化用户数据 | 临时缓存文件 |
| 备份 | 会被备份(默认) | 不会被备份 |
Q4:什么是 Room 的 fallbackToDestructiveMigration()?为什么有风险?
当 Room 数据库版本升级但没有提供迁移策略时,fallbackToDestructiveMigration() 会直接删除旧数据库并创建新数据库。
风险:用户的所有本地数据会丢失。
推荐做法:提供具体的 Migration 方案:
1
2
3
4
5
6
7
8
9
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE cached_data ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
Q5:Flutter 如何读取 Android 原生端写入的文件?
需要知道原生端文件写入的完整路径。最可靠的方式是通过 MethodChannel 从原生端获取路径,而不是在 Flutter 侧拼接路径:
1
2
3
4
5
6
7
8
// ✅ 推荐:通过 MethodChannel 获取路径
final nativePath = await channel.invokeMethod('getDataFilePath');
final file = File(nativePath);
final content = await file.readAsString();
// ❌ 不推荐:手动拼接路径
// final wrongPath = '/data/data/com.example.app/files/data.json';
// 这样的硬编码路径在不同设备上可能不同
总结
Android 文件系统是一个多层次、多版本、多场景的存储体系。对于跨平台开发者来说,以下是最值得掌握的核心知识点:
- 内部存储 vs 外部存储:内部存储(data/data/包名)用于私有数据,外部存储(sdcard/)用于共享数据
- Scoped Storage:Android 10+ 文件访问的核心变化,必须通过 MediaStore 或 SAF 访问公共目录
- 键值对存储:SharedPreferences → DataStore 的演进,新项目建议使用 DataStore
- 本地数据库:Room(原生侧)或 sqflite/drift(Flutter 侧)作为缓存层
- 缓存管理:良好的缓存策略包括缓存大小监控、过期清理和系统触发清理的处理
- 路径互通:跨 Flutter/RN 和原生端的文件访问,通过 MethodChannel 传递路径而非硬编码
最后记住三条核心原则:
- 文件存哪比怎么存更重要——选错存储位置可能导致数据丢失或安全漏洞
- 不要假设路径格式——Android 不同版本和厂商的路径格式各不相同
- Always test on Android 10+——如果你的应用需要读写文件,必须在一台 Android 10+ 的设备上测试,因为存储行为发生了根本性变化