资讯中心

Android存储基础:分层模型、权限适配与MediaStore写入实践

📅 2026/10/31 16:56:24
Android存储基础:分层模型、权限适配与MediaStore写入实践
做 Android 这几年只要项目里冒出“保存个文件”“选张图片”“缓存点数据”这类需求团队里第一句话几乎都是——放哪儿看着简单真动手就容易踩坑某台机型写进去读不出来、升级系统后老代码突然崩、用户投诉相册里多出一堆删不掉的垃圾图。这些问题背后指向的其实是同一件事就是 Android 存储的分层模型和权限演进。这篇就围绕 Android 存储基础这个主题把内部存储、外部存储、私有目录、共享媒体目录、分区存储、权限适配、MediaStore 写入、SAF、FileProvider、容量查询这些内容一次性捋清楚。不管你是刚接手的初级开发还是被历史代码折磨过的老手都能在这里找到能直接复制使用的路径选型和代码骨架。我尽量不写教科书式的定义多讲“为什么这么设计”“不这么写会出什么事”。1. 先搞清楚 Android 存储到底分几层1.1 内部存储与外部存储的真实边界很多人背概念的时候记得“内部存储小、外部存储大”“内部存储不可卸载、外部存储可以拔 SD 卡”这套说法放到十年前是对的放在今天已经严重失真。现在绝大多数手机没有可插拔的存储卡模拟外部存储实际就在/data分区下面具体位置是/data/media/0再通过挂载视图映射到/storage/emulated/0给应用看到。换句话说你往内部存储目录写的文件和往“外部存储”写的文件物理上大概率躺在同一块闪存上只是权限模型、生命周期和访问方式不同。真正的区别在访问约束和生命周期。内部存储对应Context.getFilesDir()、getCacheDir()这类路径实际落在/data/data/包名/之下其他应用无论怎么折腾都碰不到属于沙箱里的私产卸载应用时系统直接连目录一起删干净不留痕迹。外部存储则是共享视图路径形如/storage/emulated/0/历史上所有应用都能读能写正因为如此才催生了后来的分区存储。搞清这一点你在做容量规划时就不会犯“内部存储用来放大文件、外部存储用来放小文件”这种想当然的错误——它们本来就共享同一块空间。还有一层容易被忽略的多用户和双开场景。/storage/emulated/0里的那个0就是用户 ID工作资料、分身应用拿到的是/storage/emulated/1路径完全不同。如果你的代码里硬编码了/sdcard/或/storage/emulated/0/在多用户环境下必然翻车。这也是我在代码评审里看到硬编码路径就直接打回的原因之一明明Environment.getExternalStorageDirectory()都废弃了还手写字符串风险只会更大。1.2 私有目录、共享目录、系统目录的三类归属把设备上所有目录按“谁能访问”分类其实只有三类。第一类是应用私有目录内部存储的files、cache、databases、shared_prefs以及外部存储上的Android/data/包名/和Android/media/包名/。这一类不需要任何存储权限读写自由缺点是卸载即清空且从 Android 11 开始其他应用包括第三方文件管理器基本无法访问Android/data想靠这个目录做跨应用数据交换的路子已经堵死。第二类是共享媒体目录也就是DCIM、Pictures、Download、Music、Movies这些用户可见的公共位置。它们是相册、文件管理器的数据源必须走MediaStore或者 SAF 才能安全写入。特点是用户可见、持久、卸载应用后数据还在——这既是优点也是坑用户卸载后发现相册里躺着一堆你生成的图片那种体验非常糟糕后面我会专门讲清理策略。第三类是系统目录/system、/vendor、/proc这些普通应用只有只读权限不需要也不应该去碰。有人可能会想通过读/proc下的信息做设备指纹或者性能采集这类做法在不同版本和机型上表现极不稳定而且极易触碰隐私合规红线工程上不建议采用。三类目录的边界不是“建议”是硬约束。分区存储落地之后你越界访问共享目录会直接拿到FileNotFoundException或者SecurityException而且不同厂商 ROM 的报错信息还不一样排查起来非常费劲。养成“先想清楚这份数据属于哪一类再决定路径”的习惯能省掉后面一大半的调试时间。1.3 分区存储到底改了什么分区存储Scoped Storage是这几代 Android 存储演进里最重要的一次改动。分界线在 Android 10API 29从这一版开始应用对外部存储的访问被收窄到“自己的私有目录 通过 MediaStore 管理的媒体集合”。Android 11API 30把开关彻底关掉requestLegacyExternalStorage这个兼容属性对 targetSdkVersion 30 及以上的应用完全失效没有回旋余地。它到底改了什么说白了就是把“文件系统路径”这一层抽象换成了“内容 URI 媒体集合”的抽象。以前你可以new File(/sdcard/DCIM/a.jpg)直接读现在这条路走不通了得先向MediaStore查询拿到一个content://media/...的 URI再通过ContentResolver打开流。看起来麻烦了但换来的是明确的归属和权限你知道这个 URI 对应的文件是谁创建的、用户是否授权、什么时候会被清理。实践中有个常见误解需要澄清分区存储并不限制你往Pictures目录写文件只是写的方式变了。在 Android 10 及以上应用完全可以直接往公共媒体目录插入自己的图片而且不需要任何存储权限。只有当你去读别的应用创建的媒体文件时才需要READ_EXTERNAL_STORAGE或 Android 13 之后的细分媒体权限。这个区别非常关键很多团队一上来就申请存储权限其实根本不需要白白降低了权限授予率还给上架审核添麻烦。2. 路径选型不同数据该放哪个目录2.1 目录速查与选型决策表我习惯在项目里放一张选型表新人接手时少走弯路。下面这张表是我几个项目里沉淀下来的版本覆盖了绝大多数场景。数据类型推荐路径需要权限卸载后是否保留用户是否可见配置、登录态、轻量 KVgetFilesDir()/ DataStore否否否临时图片、网络缓存getCacheDir()否否否大体积离线资源模型、音色包getExternalFilesDir(null)否否否Android 11 更难访问用户主动导出的文件MediaStore.Downloads或 SAF否写自己的是是拍照 / 保存到相册的图片MediaStore.ImagesRELATIVE_PATH否是是数据库getDatabasePath()/ Room否否否跨应用分享的文件FileProvider 包装私有目录文件否否否这张表里最容易踩错的是“大体积离线资源”。不少团队图省事把几百兆的资源丢进getFilesDir()结果低端机内部存储告急用户连系统更新都装不上卸载率飙升。正确做法是放getExternalFilesDir()虽然物理上可能还是同一块闪存但至少被纳入了系统的存储统计和清理提示体系里用户可以在设置里看到“应用占用”清理时也更直观。还有一类很微妙的情况用户主动“导出”的文件。这种数据的所有权已经转移给用户了你就不能放在私有目录里否则用户换手机、重装应用就找不到了。应该走MediaStore.Downloads或者 SAF 让用户自己选位置。判断标准很简单——这份数据是“应用需要的”还是“用户想要的”前者进私有目录后者进共享目录。2.2 缓存目录与持久目录的取舍逻辑getCacheDir()和getFilesDir()之间的选择核心在于“这份数据丢了会不会影响功能”。缓存目录的契约是系统在存储空间紧张时可以随时清空用户也可以在设置里一键清理。所以任何放进去的东西代码都必须能在缺失时自动重建否则就会出现“用户清完缓存应用白屏”的经典事故。我见过一个反面案例某个项目的首页配置是从网络拉取的 JSON为了“加快启动速度”缓存到了getCacheDir()但没有在读取失败时兜底重新请求结果部分用户清理缓存后打开应用直接卡在加载页。这类问题的根因不是缓存目录用错了而是兜底逻辑缺失。缓存必须被当作“可能不存在”来对待这是设计原则不是可选项。容量管理上Android 对缓存目录没有硬性配额但系统会根据可用空间做清理判定。我的经验做法是在getCacheDir()下再建一层带版本号的子目录比如cache/v3/版本升级时整目录丢弃避免新旧格式混在一起读取报错。同时给缓存设一个总量上限比如 200MB超过后按 LRU 淘汰。这套机制自己实现大概一百行代码但能避免很多“缓存越滚越大把用户存储吃满”的投诉。持久目录则相反写进去的东西默认要长期存在所以写入必须保证原子性。常见的坑是写一半断电或者进程被杀留下半个文件下次读取时 JSON 解析失败。标准做法是先写临时文件再重命名File.renameTo()在同一分区内是原子操作能有效防止半截文件。这个技巧在所有“覆盖写配置”的场景下都适用。2.3 大文件与媒体文件的落点选择大文件的定义因人而异我一般按 20MB 划界。超过这个体积的数据优先考虑放外部私有目录getExternalFilesDir()理由是内部存储的/data/data目录在很多机型上受系统分区大小限制写爆了会直接影响应用运行甚至引发系统级异常。外部私有目录虽然也在同一物理盘上但归属在“用户数据”区域系统做空间回收时的策略更宽容一些。媒体文件则必须走MediaStore。这里有个细节值得展开从 Android 10 开始MediaStore支持通过RELATIVE_PATH指定子目录比如Pictures/我的应用这样应用生成的图片会整齐地出现在相册的一个子文件夹里用户想清理时一目了然。如果不指定所有图片散落在Pictures根目录那体验就非常糟糕了。指定子目录这件事成本极低但对用户观感的影响很大属于典型的“低成本高收益”优化。另外一个必须处理的问题是卸载后的残留。你通过MediaStore创建的图片属于用户数据卸载应用时系统不会删除。这就导致用户装了又卸相册里堆着一堆不明来源的图片。合规的做法是在应用内提供“清理已导出内容”的入口或者在首次导出时明确告知用户文件位置。有些团队试图在onTerminate里做清理但这个回调在真机上几乎不会被调用完全不可靠。3. 权限适配从 Android 10 到 Android 14 的版本断层3.1 媒体权限细分与按需申请Android 13API 33把原来一个READ_EXTERNAL_STORAGE拆成了READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO三个。这个变化看似只是多了几个常量实际影响很大以前一次申请拿到所有媒体读权限现在必须按业务真实需要分项申请。一个只做头像上传的应用去申请音频权限用户看到弹窗会觉得莫名其妙权限授予率自然上不去。Android 14API 34又往前推了一步引入了“选择部分照片”的授权模式对应READ_MEDIA_VISUAL_USER_SELECTED。用户可以选择只给你看某几张图而不是整个相册。这意味着你不能再假设“拿到权限就等于能枚举所有照片”必须处理“只授权部分”的分支。实现在onRequestPermissionsResult里判断授予结果是全部还是部分如果只有部分权限那么查询MediaStore时只能拿到用户选中的那些条目UI 上要给出“继续添加更多照片”的引导入口。适配写法上我一般封装一个权限请求工具按Build.VERSION.SDK_INT分支处理private fun requiredMediaPermissions(): ArrayString { return when { Build.VERSION.SDK_INT Build.VERSION_CODES.UPSIDE_DOWN_CAKE - arrayOf( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED ) Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU - arrayOf( Manifest.permission.READ_MEDIA_IMAGES ) else - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) } }这里有个实用的判断技巧如果你只需要让用户“选一张图上传”压根不需要申请读权限直接用系统照片选择器Photo Picker就够了。它通过ACTION_PICK_IMAGES拉起用户选完返回一个 URI应用拿到的是临时授权用完即失效。这条路子在 Android 11 及以上通过兼容库都能用权限弹窗一次都不用出体验和审核通过率都更好。我在新项目里已经默认用它替代自己写相册列表了。3.2 所有文件访问权限的边界与慎用理由MANAGE_EXTERNAL_STORAGE这个权限经常被误用。它的能力确实很诱人——拿到之后几乎可以像老版本那样遍历整个外部存储。但它不是运行时权限而是需要跳转到系统设置页由用户手动打开申请路径是val intent Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION).apply { data Uri.parse(package:$packageName) } startActivity(intent)判断是否已授权用Environment.isExternalStorageManager()。这套流程本身不复杂真正的问题在于上架审核。这个权限的使用场景被严格限定在文件管理器、备份还原、杀毒类工具等少数几类应用普通工具类应用申请大概率在审核环节被打回要求你说明为什么无法用MediaStore或 SAF 满足需求。我的建议是除非你的核心功能就是“浏览和管理用户设备上的任意文件”否则不要碰它。绝大多数需求都能用MediaStore加 SAF 组合解决。曾经有个项目想用它来“批量导入本地音频”被我否掉了改成 SAF 选目录之后功能照常还免去了审核扯皮。审慎使用这类高权限不只是合规问题也是对自己产品边界的一种约束。还有一点需要提醒MANAGE_EXTERNAL_STORAGE也不是万能的。从 Android 11 开始其他应用的Android/data和Android/obb目录即使有这个权限也访问不了系统层面做了隔离。所以指望“拿到全权限就能随便读别人数据”的想法在这个版本之后彻底行不通了。3.3 SAF 的使用姿势与持久化授权SAFStorage Access Framework是我个人最推荐的跨应用文件方案。它把“选文件”这件事交给系统 UI应用只拿到一个 URI不需要任何存储权限。核心是两个 IntentACTION_OPEN_DOCUMENT用于读取ACTION_CREATE_DOCUMENT用于新建ACTION_OPEN_DOCUMENT_TREE用于选整个目录。拿到 URI 之后关键一步是持久化授权contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION )发起 Intent 时也必须带上FLAG_GRANT_PERSISTABLE_URI_PERMISSION否则重启应用后授权就失效了。这一步漏掉的话表现是“第一次选完能读杀进程再进就报SecurityException”非常典型。持久化授权有数量上限一般每个应用几百个所以不要把用户每次选择都存下来应该在不再需要时主动releasePersistableUriPermission。读取方式上ContentResolver.openInputStream(uri)配合BufferedInputStream就够用。写入同理openOutputStream(uri, wt)里的wt表示截断写入不加的话是追加模式覆盖写场景下不加这个参数会导致文件越来越长、内容错乱。这个细节很多人不知道我第一次遇到时也是盯着文件大小变化愣了半天。SAF 的另一个优势是天然支持云盘和第三方文件提供者用户可以从网盘直接选文件应用侧完全无感。这一点在“导入文档”这类场景里体验提升明显比自己实现文件浏览器的成本低得多。4. 代码落地文件读写与 MediaStore 写入实操4.1 私有目录文件读写的最小可靠封装私有目录的读写本身不难难的是把异常分支和原子性处理干净。我一般在项目里放一个FileStore工具类把“路径解析、临时文件、原子重命名、异常兜底”都封装进去。核心思路是写入走xxx.tmp写完renameTo目标文件读取时如果目标不存在就返回 null让上层决定是重建还是提示。fun writeAtomic(target: File, writer: (OutputStream) - Unit): Boolean { val tmp File(target.parentFile, ${target.name}.tmp) return try { tmp.parentFile?.mkdirs() FileOutputStream(tmp).use { out - BufferedOutputStream(out).use(writer) out.fd.sync() // 落盘防止掉电丢数据 } tmp.renameTo(target) } catch (e: IOException) { tmp.delete() false } }out.fd.sync()这一行值得单独说。它的作用是把内核缓冲区刷到存储介质代价是有性能开销。对于配置文件这种“写一次影响全局”的场景值得加上对于高频写入的日志就别每次都 sync改用批量刷盘。我踩过一次坑某天测试机突然断电重启配置文件变成空文件应用启动直接崩加了 sync 之后这类问题再没复现过。另外提醒一个老生常谈的点MODE_WORLD_READABLE和MODE_WORLD_WRITEABLE从 Android 7API 24开始就会直接抛SecurityException了。这些参数在远古代码里还有残留升级 targetSdk 时一定要全局搜一遍清理掉否则在某些路径上会莫名其妙崩溃。4.2 MediaStore 插入图片的完整流程往相册写图片Android 10 及以上推荐用IS_PENDING机制保证写入过程中其他应用比如相册不会扫描到半成品文件。完整步骤如下。第一步构造插入参数val values ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, IMG_${System.currentTimeMillis()}.jpg) put(MediaStore.Images.Media.MIME_TYPE, image/jpeg) put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyApp) put(MediaStore.Images.Media.IS_PENDING, 1) } val collection if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) } else { MediaStore.Images.Media.EXTERNAL_CONTENT_URI } val uri contentResolver.insert(collection, values) ?: throw IOException(insert failed)第二步写入数据contentResolver.openOutputStream(uri)?.use { out - bitmap.compress(Bitmap.CompressFormat.JPEG, 92, out) }第三步清除 pending 标记让文件对相册真正可见values.clear() values.put(MediaStore.Images.Media.IS_PENDING, 0) contentResolver.update(uri, values, null, null)三步里最容易漏的是第三步。漏了之后文件确实写进去了但相册里就是看不到用户以为保存失败。我排查过一个线上反馈就是这个问题日志里一切正常最后发现是IS_PENDING忘了复位。建议把这三步合成一个方法内部兜底 ensure 复位别让调用方去记。压缩质量这里也有讲究。92 是我实测下来肉眼几乎无差别的临界值往下降到 85 时大图在手机上还能看但放到平板或者投屏上会明显感觉到色块。如果对体积敏感更好的办法是先按目标分辨率缩放再压缩而不是一味降质量。缩放用Bitmap.createScaledBitmap加采样率inSampleSize两段式处理能有效避免内存溢出。4.3 分享文件之 FileProvider 配置从 Android 7 开始file://形式的 URI 通过 Intent 传给其他应用会直接抛FileUriExposedException。分享私有目录文件必须走FileProvider。配置分两步先声明provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider再配置可共享的路径范围paths files-path namefiles path. / cache-path namecache path. / external-files-path nameext_files path. / /pathspath用.表示整个目录可共享虽然方便但范围偏大。更稳妥的做法是只开放必要的子目录比如cache-path nameshare pathshare/ /把要分享的文件先复制到cache/share/下再生成 URI。这样即便 URI 泄露也不会波及整个私有目录。生成 URI 的写法val uri FileProvider.getUriForFile( context, ${context.packageName}.fileprovider, file ) val intent Intent(Intent.ACTION_SEND).apply { type image/jpeg putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } startActivity(Intent.createChooser(intent, null))FLAG_GRANT_READ_URI_PERMISSION别忘了加否则接收方应用打开时会报权限不足。这个 flag 加在 Intent 上即可不需要手动调用grantUriPermission除非是发给某个指定包名的场景。4.4 存储空间查询与容量水位判断在写入大文件前判断剩余空间是个很容易被忽略但收益很高的动作。做法是用StatFsfun availableBytes(path: File): Long { return try { val stat StatFs(path.absolutePath) stat.availableBytes } catch (e: Exception) { -1L } }StatFs在不同 API 级别上有availableBlocks和availableBytes两套接口前者需要自己乘块大小后者是后加的便捷方法。内部存储和外部存储要用对应的路径分别查外部存储路径建议用context.getExternalFilesDir(null)比硬编码/sdcard稳妥得多。水位判断上我的经验是留出一个安全余量。假设文件大小是 S剩余空间是 A那么在A - S 200MB时就应该提示用户清理。因为写入过程中还会有临时文件、日志、数据库 WAL 增长等额外开销卡着线写很容易写到一半失败。提示文案上避免冷冰冰的“存储空间不足”可以说“设备空间紧张建议清理后重试”并给一个跳转系统存储设置的按钮转化率会好一些。5. 常见问题与排查技巧实录5.1 高频报错速查表下面这些是我在不同项目里反复遇到的按报错整理成表出问题时可以先对号入座。现象 / 报错常见原因处理方式FileNotFoundException: /storage/emulated/0/...分区存储下直接 new File 访问公共目录改用 MediaStore 或 SAFSecurityException: Permission Denial权限未申请或持久化授权未保存检查权限分支、takePersistableUriPermissionFileUriExposedException用file://分享文件改用 FileProvider保存成功但相册不显示IS_PENDING未复位写入完成后 update 为 0重启后 URI 失效未申请持久化授权发起 Intent 加 persistable flag多用户环境下路径不存在硬编码/storage/emulated/0用 Context 提供的目录方法配置文件内容为空写一半被杀进程临时文件 rename 原子写SecurityException: MODE_WORLD_READABLE使用了过时的文件模式删除该参数走 FileProvider这张表放在团队文档里新人自查一遍能解决八成问题。剩下的两成通常是厂商 ROM 的定制行为导致的需要真机验证。5.2 我踩过的几个坑第一个坑是权限的“已拒绝且不再询问”。用户勾选了不再询问之后再次请求权限会立即返回拒绝不会弹窗。这时候如果代码没有判断shouldShowRequestPermissionRationale就会表现为“点了按钮没反应”。正确做法是检测到这个状态后引导用户去应用设置页手动开启跳转用Settings.ACTION_APPLICATION_DETAILS_SETTINGS配合包名 URI。第二个坑是getExternalFilesDir()返回 null。理论上这个方法可能因为存储不可用而返回 null比如 USB 大容量存储模式挂载、存储损坏但代码里几乎没人判空一崩就是空指针。加个判空降级到getFilesDir()成本一行代码。第三个坑跟卸载残留有关。早期项目把用户头像存在getExternalFilesDir()里结果用户反馈“换手机后头像没了”。原因很直接私有目录不参与云备份也不会跟着账号走。要跨设备保留的数据必须走服务端同步本地存储只是缓存。这个认知偏差在新人身上特别普遍值得在评审时点出来。第四个坑是MediaStore查询的排序。默认排序是按_id升序也就是最早的照片在前。做“最近照片”列表时如果不显式指定orderBy用户看到的会是几年前的图。正确写法是MediaStore.Images.Media.DATE_ADDED DESC。这类问题不会报错只会让功能“看起来怪怪的”靠自动化测试也很难覆盖只能靠人眼发现。5.3 缓存清理与空间治理的实践缓存治理这件事做得好是加分项做不好就是投诉源。我的基本策略是三层第一层按类型分目录图片缓存、网络响应缓存、临时文件各占一个子目录第二层设总量阈值超过后按最后访问时间淘汰第三层在应用设置里提供“清理缓存”按钮并且明确告知清理不会删除用户数据。淘汰算法用 LRU 就够不必上复杂的权重模型。实现上维护一个按lastModified排序的文件列表从最旧的开始删直到总量低于阈值的 80%。留出 20% 的缓冲是为了避免频繁触发淘汰导致缓存命中率剧烈波动。还有一个容易被忽略的点getExternalCacheDir()和getCacheDir()是两个不同的目录前者在外部存储、后者在内部存储。有些系统清理工具只扫外部缓存的Android/data/包名/cache内部缓存反而不容易被清。所以如果缓存数据量不大放内部反而更“安全”一些数据量大再放外部但要接受被系统清理的概率更高。这个权衡没有标准答案取决于你的缓存重建成本。6. 轻量存储方案SharedPreferences、DataStore 与数据库怎么选6.1 各自适用边界诚然文件读写不是万能的键值对和小型结构化数据用文件存反而麻烦。SharedPreferences的优势是简单、同步读取快适合存几十个以内的配置项。但它的缺点也很明显apply()是异步落盘进程被强杀时有极小概率丢失commit()是同步的在主线程调用会卡顿跨进程访问完全不可靠MODE_MULTI_PROCESS早已废弃。所以它只适合“单进程、数据量小、丢失可容忍”的场景比如引导页是否看过、上次选择的 Tab。DataStore是官方后来推的替代方案分 Preferences DataStore 和 Proto DataStore 两种基于协程和 Flow天然支持异步和事务跨进程一致性也更好。代价是读取不再是同步的需要runBlocking或者挂起函数配合。如果应用已经全面拥抱协程迁移过去收益明显如果是老旧项目还在用回调迁移成本不低可以按模块逐步替换。结构化数据就用数据库Room 是我现在默认的选择。它把 SQL 校验放到编译期字段改动后忘了改查询会直接编译失败这个体验非常好。唯一要注意的是数据库文件也在私有目录里走getDatabasePath()或者 Room 的默认路径同样遵循“卸载即清除”的规则。6.2 数据库落盘与 WAL 的注意事项Room 默认开启 WALWrite-Ahead Logging会在数据库旁边生成-wal和-shm两个附属文件。这三个文件必须一起备份、一起迁移只拷.db会导致数据不完整。我见过一个做数据导出的需求开发只 copy 了主文件导出的数据库在另一台机器上打开时缺了最近几天的记录排查了半天才发现是这个原因。WAL 模式的好处是读写并发能力强多个读不会阻塞写。但在“数据库特别大、写入特别频繁”的场景下WAL 文件会持续膨胀需要定期 checkpoint。Room 内部有自动 checkpoint 机制一般不需要手动干预但如果你的应用长时间高频写入可以在合适的时机执行PRAGMA wal_checkpoint(TRUNCATE)主动收缩。另外一个实践建议数据库文件不要放在外部存储。虽然技术上能做到但外部存储的访问速度、可靠性都不如内部存储而且从 Android 11 开始Android/data目录的访问限制越来越严放在那里等于给自己挖坑。老老实实放默认位置需要导出时通过备份接口或者 SAF 生成副本这才是稳妥的做法。最后再分享一个我自己的小习惯。项目启动阶段我会在Application初始化完成之后立刻打印一份“存储体检报告”内容包括内部存储可用空间、外部存储可用空间、各缓存目录当前体积、数据库文件大小、是否存在孤儿临时文件。这份日志在灰度阶段帮我提前发现了两次缓存失控的问题成本不到五十行代码。对于长期迭代的应用这类“看不见的基础设施”上的投入回报往往比加一个新功能更高。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案