文章

Android 文件系统与存储深度解析——跨平台开发者的数据持久化全指南

面向跨平台开发者的 Android 文件存储完全指南,从内部存储与外部存储的区别、Scoped Storage 限制策略、SharedPreferences vs DataStore 对比、Room 数据库缓存策略到 Cache 目录清理,系统讲解 Flutter/RN/鸿蒙开发中的数据持久化方案和文件互通问题。

Android 文件系统与存储深度解析——跨平台开发者的数据持久化全指南

一句话概括

Android 文件系统是应用数据持久化的基础设施——从简单的键值对存储(SharedPreferences)到结构化数据库(Room)、从内部私有文件到外部分享文件,Android 提供了多层级的存储方案,而 Android 10 引入的 Scoped Storage 更是从根本上改变了文件访问的方式;理解这些存储机制,是跨平台开发者在 Android 上正确处理用户数据的前提。

背景与意义

在移动应用开发中,数据持久化是一个无法回避的课题。用户的登录 Token 存哪里?下载的图片放哪个目录?本地缓存数据库用什么实现?这些问题看似简单,但在 Android 平台上,由于碎片化严重(不同厂商、不同 Android 版本),文件存储的实现比看上去要复杂得多。

对于 Flutter 和 React Native 开发者来说,你可能已经习惯了使用 shared_preferencespath_providersqflite 等现成的插件来管理文件和数据。当这些插件工作正常时,你不需要关心底层实现。但当以下情况发生时,理解 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/包名/)仍然可自由读写
  • 访问公共目录需要通过 MediaStoreSAF(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 的主要问题:

  1. 同步加载:首次读取时会在主线程同步读取整个 XML 文件,可能导致 UI 卡顿
  2. 类型不安全getString() 可能抛出 ClassCastException
  3. 内存消耗:整个文件被加载到内存中
  4. 原子性弱apply() 是异步的,写入后立即读取可能读到旧值
  5. 回调缺失:无法监听数据变化

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 的核心优势:

  1. 异步加载:使用协程和 Flow,不在主线程中执行 I/O
  2. 类型安全:不需要手动类型转换
  3. 事务性:所有操作都是原子性的
  4. 响应式:通过 Flow 监听数据变化
  5. 内存效率:不会一次性加载整个文件

对比总结

特性SharedPreferencesDataStore
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 生态中有两个主流的选择:

  1. sqflite:封装了 SQLite,功能类似于 Room,但运行在 Dart 层
  2. drift(原 moor):Flutter 端的 ORM 框架,功能更强大

对于跨平台开发者来说,建议:

  • 如果数据只在 Flutter 层使用 → 使用 sqflitedrift
  • 如果数据需要与原生层共享 → 使用 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 侧无法直接访问,反之亦然。

问题根源在于:

  1. Flutter/RN 和原生端使用不同的路径表示方式
  2. Android 10+ 的 Scoped Storage 返回 content:// URI,而非 file:// 路径
  3. 不同存储目录的访问权限不同

常见场景与解决方案

场景一:原生 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 缓存,避免每次打开都从网络加载。

实现方案

  1. 使用 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. 手动实现缓存清理
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() 之间的区别是什么?

特性getApplicationDocumentsDirectorygetTemporaryDirectory
对应路径/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 文件系统是一个多层次、多版本、多场景的存储体系。对于跨平台开发者来说,以下是最值得掌握的核心知识点:

  1. 内部存储 vs 外部存储:内部存储(data/data/包名)用于私有数据,外部存储(sdcard/)用于共享数据
  2. Scoped Storage:Android 10+ 文件访问的核心变化,必须通过 MediaStore 或 SAF 访问公共目录
  3. 键值对存储:SharedPreferences → DataStore 的演进,新项目建议使用 DataStore
  4. 本地数据库:Room(原生侧)或 sqflite/drift(Flutter 侧)作为缓存层
  5. 缓存管理:良好的缓存策略包括缓存大小监控、过期清理和系统触发清理的处理
  6. 路径互通:跨 Flutter/RN 和原生端的文件访问,通过 MethodChannel 传递路径而非硬编码

最后记住三条核心原则:

  • 文件存哪比怎么存更重要——选错存储位置可能导致数据丢失或安全漏洞
  • 不要假设路径格式——Android 不同版本和厂商的路径格式各不相同
  • Always test on Android 10+——如果你的应用需要读写文件,必须在一台 Android 10+ 的设备上测试,因为存储行为发生了根本性变化
本文由作者按照 CC BY 4.0 进行授权