☰
Android存储体系与分区存储适配:MediaStore、SAF与权限实践
2026/10/31 5:39:22 网站建设 项目流程

1. 先搞清楚Android存储到底在解决什么问题

很多人第一次写Android存储相关代码时,脑子里只有一个模糊的印象:有个叫/sdcard的地方可以放文件,有个SharedPreferences可以存配置,还有个数据库叫SQLite。但真要动手做需求,立刻就懵了——用户换手机了数据还在不在?应用卸载了图片要不要留着?Android 10之后为什么我的/sdcard写不进去了?从相册选一张图片,回调里拿到的content://是什么东西,为什么File打不开?

这些问题的根源,其实都来自Android存储模型的一条核心分野:私有目录和应用外部可见的数据,走的是完全不同的路。前者是"我的地盘我做主",后者是"公共领地要守规矩"。凡是把这两件事混为一谈的代码,早晚要在某个系统版本上翻车。

我在实际项目里见过太多这种例子。某个做本地笔记的App,早期版本直接把笔记文件写到外部存储根目录,Android 9之前跑得好好的,升到Android 11之后,用户更新完应用,笔记全"消失"了——其实文件还在,只是应用没有权限读了。排查花了整整两天,最后定位到就是存储路径用错了。这类问题的成本极高,因为涉及用户数据,一旦出问题就是信任危机。

所以这个主题值得认真梳理一遍。下面我会从存储分区的物理结构讲起,把内部存储、外部存储、私有目录、共享媒体集合这几个概念的关系摆清楚,然后逐个拆解文件存储、SharedPreferences、SQLite、MediaStore、SAF文件选择器这几种主流方案各自的适用边界和坑点,最后给一套可复用的封装思路。代码以Kotlin为主,兼顾Java读者。适合刚入行、被存储权限绕晕的同学,也适合想系统梳理一遍的老手。

提醒一句:本文所有路径和API行为描述,都以原生Android框架为准;不同厂商ROM在文件管理器展示上可能有差异,但应用层的API语义是一致的。

2. 内部存储与外部存储:两套目录体系的实际差别

2.1 内部存储不是"手机内存",别被中文翻译带偏

中文里"内存"这个词太容易混淆。Android文档里的Internal Storage,指的是应用私有的、位于系统数据分区的目录,和手机的RAM(运行内存)完全是两码事。它的物理位置大致在/data/data/<包名>/(新版本实际映射到/data/user/0/<包名>/),普通用户没有root权限是看不到的。

内部存储最核心的特性是隔离性:其他应用访问不到,用户在系统文件管理器里也看不到,卸载应用时随应用一起清空。这意味着两件事——第一,放这里的文件天然安全,不用操心权限;第二,放这里的文件天然脆弱,用户一卸载就没了,而且系统在存储空间紧张时也可能清理缓存目录。

代码上拿到这个目录极其简单,一行就够:

val filesDir = context.filesDir // /data/user/0/<包名>/files val cacheDir = context.cacheDir // /data/user/0/<包名>/cache val noBackupDir = context.noBackupFilesDir

filesDir和cacheDir的区别值得单独说一下,因为很多人随手就用cacheDir存重要数据,结果线上出现偶发的数据丢失。系统在磁盘空间不足时会主动删除cacheDir下的内容,这是设计好的行为,不是bug。反过来,filesDir只有在用户卸载或手动"清除数据"时才会被清空。所以判断标准很直接:这份数据丢了用户会不会受影响?会,就放filesDir;不会,才放cacheDir。

还有一个容易被忽略的noBackupFilesDir。默认情况下,应用的filesDir会参与系统的自动备份(Auto Backup),用户换机时可能被恢复到新设备上。如果你的数据体积很大,或者包含不该跨设备迁移的内容(比如设备绑定的令牌),就应该放到noBackupFilesDir里,明确告诉系统"这个别备份"。

2.2 外部存储的"私有"与"共享"双层结构

外部存储(External Storage)的历史包袱最重,因为它经历了从"所有人随便读写"到"分区隔离"的剧烈演变。理解它的关键是记住:从Android 4.4(API 19)开始,外部存储就被拆成了两块。

第一块是应用专属的外部目录,路径形如/storage/emulated/0/Android/data/<包名>/files/。注意它虽然在外部存储上,但访问它不需要任何权限,从API 19起就是如此。这块目录在Android 11之前,其他应用只要拿到路径也能读;Android 11之后,其他应用再想访问就受限了。

第二块是共享存储空间,也就是用户能通过文件管理器看到的那些目录:Download、Pictures、DCIM、Music、Documents等。这块才是权限管控的主战场。

拿目录的代码是这样的:

// 应用专属外部目录,无需权限 val extFiles: Array<File>? = context.getExternalFilesDirs(null) val extCache: File? = context.externalCacheDir

这里有个细节必须强调:getExternalFilesDir()返回的是单个File,可能为null;而getExternalFilesDirs()返回的是数组,可能包含多个元素。为什么?因为设备可能同时挂载了内置存储和SD卡。数组的第0个元素通常是内置存储,后面的元素是外置卡。如果业务涉及大量媒体文件,且用户插了SD卡,多目录枚举是必要的。但大多数应用其实只关心第0个,直接取[0]也可以,只是要判空。

提示:getExternalFilesDir()在外部存储不可用(比如被挂载到电脑上)时会返回null。任何直接.absolutePath的写法都是隐患,先判空再使用。

2.3 一个表格理清四种目录的边界

目录类型典型路径需要权限卸载后是否保留适合放什么
内部私有/data/user/0/<包名>/files否否用户核心数据、配置、小文件
内部缓存/data/user/0/<包名>/cache否否可重建的临时文件
外部专属/storage/emulated/0/Android/data/<包名>/files否否大文件、媒体草稿、可被用户拷走的数据
共享空间/storage/emulated/0/Pictures等是(见后文)是需要用户可见、跨应用共享的成果

这张表建议截图存下来。我遇到过的路径选择错误,99%都能用这张表解释清楚。特别提醒第三行:外部专属目录虽然不需要权限,但它在Android 11之后变成了"应用沙箱"的一部分,其他应用即使知道路径也读不到,这跟早期的行为不一样了,做文件共享功能时要留意。

3. 分区存储上线后,共享目录的规则彻底变了

3.1 从"一个权限走天下"到"媒体集合分而治之"

Android 10(API 29)引入分区存储(Scoped Storage)时,社区怨声载道,因为改动太大。简单回顾一下前后对比:Android 9及以前,应用拿到WRITE_EXTERNAL_STORAGE权限,就可以往共享空间的任意目录写文件,包括别人应用的目录;Android 10开始,应用默认只能通过MediaStore往特定媒体集合里写,不能再直接用File操作共享目录的任意路径。

Android 11(API 30)又做了一轮调整,把规则打磨得更合理了。目前的稳定形态大致是这样:

  • 访问自己创建的媒体文件(图片、视频、音频),不需要任何权限;
  • 访问其他应用创建的媒体文件,读操作需要READ_EXTERNAL_STORAGE(Android 13起细分成了READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO);
  • 访问非媒体文件(比如PDF、Word、压缩包),无论谁创建的,基本都要走系统文件选择器(SAF);
  • 写入共享目录的媒体集合,通过MediaStore插入,不需要写权限。

这个规则看着复杂,但逻辑上其实很自洽:媒体交给MediaStore管,非媒体交给用户手动授权。因为它本质上是把"文件所有权"这个概念落到了实处——你创建的东西你能管,别人创建的东西得经过同意。

值得注意的是MANAGE_EXTERNAL_STORAGE这个"全文件访问"权限的存在。它有,但上架审核极其严格,文件管理器、杀毒软件这类应用才可能获批。普通App如果申请它,大概率被拒,而且用户看到那个授权弹窗也会心里发毛。我的建议很明确:除非你的应用核心功能就是文件管理,否则不要碰这个权限,老老实实走MediaStore和SAF。

3.2 requestLegacyExternalStorage这面旗子已经倒了

很多老项目里能看到android:requestLegacyExternalStorage="true"这个manifest属性。它在Android 10上确实能临时恢复到旧行为,但要注意两点:一是这个开关只在targetSdk为29时生效,一旦把targetSdk升到30及以上就失效了;二是系统升级到Android 11的设备上,即使targetSdk还是29,这个属性也不再起作用(对从旧版本升级上来的应用有过渡期豁免,但新安装的应用无效)。

所以正确的态度是:把它当作迁移期的临时拐杖,而不是长期方案。我在给老项目做存储改造时,第一步永远是检查targetSdk和这个属性,然后按"先适配、再升targetSdk、最后删属性"的顺序推进,中间用灰度发布观察崩溃率。直接一把梭升到最新targetSdk然后删属性,风险太大。

3.3 权限请求的现实写法

权限这块,Android 13(API 33)把存储读权限拆成了三个细粒度权限,代码要按版本分支判断。下面这段是我实际在用的写法,逻辑是:Android 13及以上用细分权限,Android 10到12用旧读权限,Android 9及以下读写都要:

private fun requiredStoragePermissions(): Array<String> { return when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> arrayOf( Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO ) Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> arrayOf( Manifest.permission.READ_EXTERNAL_STORAGE ) else -> arrayOf( Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE ) } }

写这段时有几个实操心得。第一,按需申请比一次性全申请体验好得多,用户看到一个只请求图片权限的弹窗,比看到一个笼统的"存储权限"弹窗更容易点同意。第二,Android 13的细分权限是可以分别申请的,只做图片选择就只申请READ_MEDIA_IMAGES,通过率明显更高。第三,别忘了处理"用户勾选了不再询问"的情况,这时候应该引导去设置页,而不是反复弹窗——反复弹窗会被系统静默拦截,用户会觉得你的应用坏了。

注意:从Google Play的政策角度看,权限申请必须与功能直接相关。申请了却不用,或者用完不解释,都可能被拒。这块在提交审核前最好自查一遍manifest。

4. 文件读写、SharedPreferences、SQLite:三条腿各有各的脾气

4.1 普通文件读写的原子性与编码陷阱

内部存储的读写API就是标准的Java IO,FileInputStream/FileOutputStream,没什么特别。但有几个坑反复出现,值得单独拎出来。

第一个是原子写。直接往目标文件写,如果写到一半进程被杀或者断电,文件就损坏了。正确做法是写临时文件,写完再renameTo目标文件。重命名在同一分区内是原子操作,能保证要么是旧内容,要么是新内容,不会出现半截文件:

fun atomicWrite(target: File, content: String) { val tmp = File(target.parentFile, "${target.name}.tmp") tmp.writeText(content, Charsets.UTF_8) if (!tmp.renameTo(target)) { target.delete() tmp.renameTo(target) } }

注意renameTo在某些文件系统上可能失败(比如目标已存在且平台不允许覆盖),所以失败后要兜底先删再重命名。这个兜底在Linux系文件系统上一般用不到,但加上不亏。

第二个是编码。File.readText()默认用UTF-8,但FileWriter的默认编码是平台相关的。如果你的数据里可能包含中文或emoji,务必显式指定Charsets.UTF_8,否则在某些环境下会读出乱码。我见过一次线上问题,用户在Windows上编辑的配置文件传进App后中文全是问号,就是编码没指定导致的。

第三个是大文件。readText()会把整个文件读进内存,几十MB的文件就可能OOM。大文件应该用流式处理,边读边解析。媒体文件更是应该直接拿路径或URI交给图片库,不要自己读进内存再转。

4.2 SharedPreferences的apply与commit不是随便选的

SharedPreferences本质是一个XML文件,全量加载进内存,所以它适合量小、读取频繁、结构简单的键值对。不适合存大JSON、不适合存列表、更不适合存图片。

关于写入,apply()和commit()的区别是高频面试题,但实际项目中真正理解的人不多。commit()是同步写磁盘,返回布尔值表示成功与否;apply()是同步更新内存、异步落盘,没有返回值。绝大多数场景应该用apply(),因为它在主线程调用不会阻塞。

但apply()有个隐蔽的问题:异步落盘意味着进程立刻被杀时,数据可能丢。所以如果是在onPause/onStop这种可能马上被系统回收的时机保存关键状态,理论上是应该用commit()的。不过实际上系统对apply()的落盘有一定保护机制,真正会丢的概率很低。我的经验是:普通配置用apply(),涉及"用户刚操作完就必须持久化"的关键状态(比如支付流程的中间态)用commit(),并且在后台线程调用。

还有一个常被忽略的点:SharedPreferences不支持多进程。如果应用有独立进程(比如推送进程、widget进程),两个进程各持有一份内存缓存,互相看不到对方的修改,会出现数据覆盖。Android官方后来提供了MODE_MULTI_PROCESS,但这个模式早就废弃了,行为不可靠。真要跨进程共享数据,用ContentProvider或者文件锁,别硬上SharedPreferences。

4.3 SQLite不是"重型方案",是数据量上来之后的必然选择

很多人一听数据库就觉得重,其实SQLite在Android上是内建的,体积极小,启动开销也不大。真正需要判断的是数据结构复杂度:如果数据是"很多条结构相同的记录",需要按条件查询、排序、分页、聚合,那就是SQLite的场子;如果是几个孤立的配置项,SharedPreferences更省事。

直接写SQLiteOpenHelper的话,代码会比较啰嗦,尤其是建表和升级逻辑。下面是一个最小可用版本,重点看onUpgrade的处理:

class NoteDbHelper(context: Context) : SQLiteOpenHelper(context, "note.db", null, 2) { override fun onCreate(db: SQLiteDatabase) { db.execSQL(""" CREATE TABLE note ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT, updated_at INTEGER NOT NULL ) """) db.execSQL("CREATE INDEX idx_updated ON note(updated_at DESC)") } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { if (oldVersion < 2) { db.execSQL("ALTER TABLE note ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0") } } }

onUpgrade这里必须用增量式写法:每个版本判断一次,只做该版本的变更。千万不要在里面DROP TABLE再重建——用户数据就没了。另外注意默认值,ALTER TABLE ADD COLUMN加非空列时必须给DEFAULT,否则老数据行会出问题。

写操作建议放在事务里,尤其是批量插入。不开事务时每条insert都是独立事务,每次都要fsync,一千条数据能慢到几秒;包在事务里能快一到两个数量级。这是实测过的差距,不是理论值。

至于要不要上Room,看团队情况。Room把编译期校验、实体映射、迁移验证都做了,能省掉大量模板代码和运行时错误,代价是引入注解处理器、增加构建时间。新项目我一般直接上Room;老项目如果已经有一堆裸SQL,强行迁移收益不大,可以局部替换。

5. MediaStore和SAF:跟系统"借道"访问共享空间

5.1 MediaStore的本质是一张系统维护的索引表

分区存储之后,往相册里存图片的标准姿势是走MediaStore。它的原理是:系统维护着一套媒体数据库,记录了所有媒体文件的元数据(路径、类型、时间、尺寸等)。应用通过ContentResolver往这个数据库插入一条记录,系统会分配一个URI,并创建对应的文件。

听起来绕,但代码其实不复杂。下面这段是保存图片到相册的完整流程,用的是IS_PENDING标记法:

fun saveImageToGallery(context: Context, bitmap: Bitmap, displayName: String): Uri? { val resolver = context.contentResolver val values = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, displayName) put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/MyApp") put(MediaStore.Images.Media.IS_PENDING, 1) } val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null resolver.openOutputStream(uri)?.use { out -> bitmap.compress(Bitmap.CompressFormat.JPEG, 95, out) } values.clear() values.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(uri, values, null, null) return uri }

IS_PENDING这个机制值得解释一下。插入时标记为1,表示"正在写入,别让别人看见";写完再更新为0,文件才正式对相册可见。这样做的好处是避免相册里出现半截图片。如果写入过程中失败,这个pending记录要么被系统清理,要么一直挂着占空间——所以要记得在异常分支里删除它。

RELATIVE_PATH是从Android 10才开始支持的,它决定了文件在相册里归到哪个子目录。老版本没有这个字段,需要配合DATA字段(实际路径)来指定,但DATA在新版本上已经不可写,所以这块必须做版本分支。

5.2 读取相册图片:为什么拿到的是content://

用系统相册选择器或者MediaStore查询拿到的都是content://media/...形式的URI,而不是文件路径。这是因为分区存储有意隐藏了真实路径,防止应用通过路径推测其他文件。

想把这个URI变成InputStream,用contentResolver.openInputStream(uri)就行。但如果某个第三方库只接受File或绝对路径(这类库现在还有不少),就麻烦了。常见的迂回办法是把内容拷贝到应用自己的缓存目录,再把临时文件路径传过去:

fun uriToTempFile(context: Context, uri: Uri): File? { return try { val input = context.contentResolver.openInputStream(uri) ?: return null val file = File(context.cacheDir, "tmp_${System.currentTimeMillis()}") input.use { ins -> file.outputStream().use { outs -> ins.copyTo(outs) } } file } catch (e: Exception) { null } }

这个做法有效但要注意清理。缓存目录里的临时文件用完就该删,否则用户手机里会堆积一堆看不见的垃圾。我一般会在应用启动时扫一遍cacheDir,删掉超过一定时间的临时文件。

另外还有一个细节:查询MediaStore时不要一次性把整张表拉出来。用ContentResolver.query传projection只取需要的列,用selection和sortOrder做过滤和排序。我见过有人直接query全表然后自己遍历筛选,几千张图片的表能卡几百毫秒。正确做法是让系统帮你筛。

5.3 SAF:非媒体文件的唯一正道

如果是让用户选一个PDF、一个zip、或者导出到用户指定的位置,那MediaStore不适用,必须用存储访问框架(SAF)。它的入口是两个Intent:ACTION_OPEN_DOCUMENT(选已有文件)和ACTION_CREATE_DOCUMENT(新建文件)。

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "application/pdf" } launcher.launch(intent)

回调里拿到的同样是content://开头的URI,用contentResolver.openInputStream读即可。写的话用ACTION_CREATE_DOCUMENT,回调拿到的URI可以直接写,不需要权限。

SAF最容易被忽略的点是URI的持久化。系统默认授予的URI权限是临时的,进程重启后就失效了。如果要把这个URI存到数据库里以后再用,必须主动调用:

context.contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION )

而且启动Intent时必须带上FLAG_GRANT_PERSISTABLE_URI_PERMISSION,否则拿不到可持久化的授权。这一对标志位缺一不可,我见过不少人在这一步反复试错,因为失败时不会有任何报错,只是后续读文件时报"Permission Denial"。

持久化权限也有数量上限(不同系统版本额度不同),存太多会被系统拒绝。所以如果用户选了上百个文件,不要全部持久化,建议只对"用户显式收藏"的那几个做持久化,其余的当场用完就丢。

6. 把这些知识落成一套可复用的封装

6.1 一个路径决策器,把选择逻辑集中起来

前面讲的都是分散的知识点,实际项目里最需要的是统一入口。我的做法是写一个StoragePaths工具类,把"根据数据类型选目录"这件事收敛到一处,避免每个业务模块各写一套:

object StoragePaths { fun privateFile(context: Context, name: String): File = File(context.filesDir, name).also { it.parentFile?.mkdirs() } fun privateCache(context: Context, name: String): File = File(context.cacheDir, name).also { it.parentFile?.mkdirs() } fun externalFile(context: Context, name: String): File { val base = context.getExternalFilesDir(null) ?: context.filesDir return File(base, name).also { it.parentFile?.mkdirs() } } }

注意externalFile里的兜底:外部存储不可用时回退到内部存储。这个兜底看起来简单,但能避免大量NPE。业务层不需要关心存储可用性,只管调方法拿File。

mkdirs()的调用位置也值得说。放在工具方法里做,而不是要求调用方自己记得做,能省掉一类"文件明明创建了却写不进去"的问题——因为父目录不存在时,FileOutputStream直接抛异常。

6.2 存储空间检查别等到IOException才反应过来

写大文件之前应该先检查剩余空间。Android提供了StorageManager来查:

fun hasEnoughSpace(context: Context, neededBytes: Long): Boolean { val sm = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val uuid = sm.getUuidForPath(context.filesDir) val available = sm.getAllocatableBytes(uuid) return available >= neededBytes * 1.2 // 留20%余量 }

getAllocatableBytes比直接拿File.usableSpace更准,它会考虑系统可以清理的部分(比如缓存)。留20%余量是经验值,因为系统本身需要空间做临时操作,卡着剩余空间写满容易引发各种奇怪问题。

提示:下载大文件前做这个检查,失败时给用户明确提示,比让用户看着进度条卡住再报错体验好得多。

6.3 删文件的坑:delete()返回false不代表没删掉

File.delete()的返回值经常被误用。它返回false可能意味着"文件不存在"、"没有权限"、"被占用"等多种情况,而且不同实现的行为不完全一致。对普通私有目录的文件,我的做法是删完之后用exists()再确认一次;对MediaStore管理的文件,必须通过ContentResolver.delete删记录,光删文件系统上的路径会导致索引残留。

还有一点:Android 10之前有个MediaStore的DATA列可以直接删文件,但新版本上这个列是只读的,改不了也删不了。老代码里如果还留着这种写法,在Android 11+上会静默失败,非常难查。

7. 几个真实踩过的坑和对应的验证手法

7.1 模拟器上一切正常,真机上权限弹窗不出现

这个现象我遇到过两次,原因不同。第一次是第二轮权限请求被系统拦截——用户点了"拒绝"后,应用立刻再请求一次,系统会直接返回DENIED而不弹窗。解决办法是记录请求次数,第二次拒绝后引导去设置页。

第二次更隐蔽:测试机是Android 13,只申请了READ_EXTERNAL_STORAGE,结果因为该权限在13上对媒体文件已失效,系统直接不弹窗。查日志能看到权限被自动拒绝的记录,但界面上毫无提示。验证方法:在多个API级别的设备上分别测权限流程,至少覆盖Android 9、11、13三个档位。只在最新款和模拟器上测是不够的。

7.2 导出的CSV在电脑上打开中文乱码

App导出CSV到Download目录,用户拷到电脑上用表格软件打开,中文全是乱码。原因有两个层面:一是编码用了UTF-8但没加BOM,某些表格软件靠BOM判断编码;二是写文件时没用SAF,直接往共享目录写。

修复方式是:编码写成带BOM的UTF-8(文件开头加\uFEFF),并且导出时走ACTION_CREATE_DOCUMENT让用户自己选位置。这样既解决了乱码,又完全避开共享目录的权限问题。这个方案我在三个项目里用过,用户反馈都不错。

7.3 老项目升级targetSdk后图片加载集体失效

这个坑最典型,链路是这样的:项目里用了一个老图片加载库,它内部把content://URI转成了本地路径再加载。分区存储开启后,content://转真实路径的系统方法返回null,于是所有图片加载失败。

排查过程分三步:第一步看日志,确认失败发生在加载库内部;第二步把库版本升到最新,发现新版支持直接传URI;第三步全局搜索代码里所有"路径转URI"和"URI转路径"的调用点,逐个替换。这次改造涉及二十多个文件,但好在逻辑一致,属于可批量处理的问题。

预防手段:在项目里禁止业务代码直接依赖"真实路径"这个概念,所有文件操作统一走Uri+ContentResolver,把File的使用限制在私有目录范围内。这条约束如果能提前定下来,能省掉后期大量返工。

8. 版本适配清单与日常检查习惯

8.1 一份能直接照着查的适配表

系统版本关键变化必须处理的点
Android 6 (23)运行时权限动态申请存储权限
Android 10 (29)分区存储、RELATIVE_PATH改用MediaStore,去掉共享目录直写
Android 11 (30)沙箱加强、批量媒体操作其他应用的外部专属目录不可访问
Android 13 (33)媒体权限细分按类型申请,更新权限判断逻辑
Android 14 (34)部分媒体访问细化检查所选照片访问相关行为

实际做适配时,我会在代码里维护一个StorageCompat对象,把所有版本分支集中进去,业务层只调用语义化方法,比如canWriteSharedDir()、askMediaPermission(type)。这样每次系统升级,改动只发生在一处,回归测试的范围也小。

8.2 我自己保持在用的三条习惯

第一,任何路径拼接都先确认父目录。写文件之前调parentFile?.mkdirs(),成本极低,能避免一类高频崩溃。

第二,临时文件统一放带前缀的命名空间。比如所有临时文件都以tmp_开头,并且统一放在cacheDir/tmp/下。这样清理逻辑只需要对着一个目录扫,不用满世界找残留。

第三,权限申请和功能入口绑定。用户点"保存到相册"才申请媒体权限,而不是应用一启动就弹一串权限。这个习惯带来的通过率提升是实打实的,也符合系统推荐的按需申请原则。

另外建议在开发阶段打开严格模式(StrictMode),它会把主线程上的磁盘读写、文件URI暴露等问题以日志形式打出来。存储相关的性能问题大多能在这一步提前发现,比等到线上卡顿再查要省事太多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询