getExternalFilesDirs(null) 可以返回所有SD卡的根目录的数组,第一个通常叫做 "内置存储" 或 "内部存储",是内置在手机里面的,出厂自带的,不可插拔。其它的则为 "外置 SD 卡", 即在手机外面就可以操作,用户可以自由插拔,有的像SIM卡一样,在手机外面有个卡槽可以拉出来插卡,或者在手机里面拨出电池就能看到SD卡的卡槽,在适配一些定制的Adnroid设备时,有些是带这些外置SD卡的,通常录像的时候我们会选择把视频保存到外置SD卡,方便用户以后需要复制视频时可以直接拨出SD卡,然后插入读卡器,在电脑上直接复制视频文件。
现在AI已经大行其道,而且Android studio自带的gemini有免费额度可以使用,于是我让AI帮我写了这个程序,真的是非常的棒,写的很好,又很快,自己写最少也得半小时,AI写几分钟搞定,项目地址:https://gitee.com/daizhufei/sdcard-test
应用截图如下:

用户专属私有目录路径:
- 内置存储通常为:/storage/emulated/0/Android/data/cn.android666.sdcardtest/files
- 外置存储通常为:/storage/XXXX-XXXX/Android/data/cn.android666.sdcardtest/files
getExternalFilesDirs(null) 是ContextWrapper上的方法,在API 19 (即Android 4.4)添加的,所以比较通用。
到API 24(即Adnroid 7)的时候,出了一个StorageVolume,可用于获取指定用户的共享/外部存储卷信息。文档说明中有提到:
一个设备总是有一个(且只有一个)主存储卷(primary storage volume),但它可以有额外的卷(extra volumes),比如SD卡和USB驱动器。该对象表示特定用户的存储卷(storage volume)的逻辑视图:不同的用户可能对相同的物理卷有不同的视图(例如,如果卷是内置的模拟存储(built-in emulated storage))。
官方文档还有许多信息值得细读。比如现在新版本使用分区存储,只能在应用的私有存储中保存数据,以及在一些公共位置存储数据,对于公共位置,比如图片、视频等,通过createAccessIntent(Environment.DIRECTORY_PICTURES)来访问公共的图片位置。要访问任何目录(及其子目录)则用Intent.ACTION_OPEN_DOCUMENT和Intent.ACTION_OPEN_DOCUMENT_TREE,这应该是指没有申请 "管理所有文件" 的权限的时候应该这样做。
从StorageVolume中可以获取到的信息如下:
kotlin
val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
storageManager.storageVolumes.forEach { storageVolume: StorageVolume ->
val path = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) "path=${storageVolume.directory}, " else ""
Log.i("MainActivity", "${path}isEmmulated=${storageVolume.isEmulated}, isPrimary=${storageVolume.isPrimary}, isRemovable=${storageVolume.isRemovable}, Uuid=${storageVolume.uuid}, mounted=${storageVolume.state == Environment.MEDIA_MOUNTED}")
}
运行结果如下:
path=/storage/emulated/0, isEmmulated=true, isPrimary=true, isRemovable=false, Uuid=null, mounted=true
path=/storage/3DC8-90E7, isEmmulated=false, isPrimary=false, isRemovable=true, Uuid=3DC8-90E7, mounted=true
从这里也可以看到,内置存储是不可移除的,而且内置存储就是主存储(isPrimary=true),且isEmmulated=true,内置存储没有uuid,因为它也不需要,它的路径为:/storage/emulated/0,这是不是意味着主存储也可以有多个,如果还有第2个,则路径上的0变成1,不过我也从来没见过有两个内置存储的情况,官方应该是做好了这种扩展的准备,只是没企业会这样做,必竟增加存储是要增加成本的,如果是容量不够,增加单张卡的容量即可,比如8G不够就用16G的,只要一个主存储即可,没必要用两张8G的主存储,多一个也会占用手机硬件上的空间。
外置存储则是可移除的,且isEmmulated=false,且有uuid,它的路径也会包含这个uuid:/storage/3DC8-90E7。
需要注意的是,storageVolume.directory是API 30(Android 11)才出的,所以不是很友好,那这个API 出来之前如何获取路径呢?可以使用它的隐藏方法 getPath(),通过反射调用:
kotlin
val path = StorageVolume::class.java.getMethod("getPath").invoke(storageVolume).toString()
但是也需要注意,从Android 9.0 开始,官方为了API的稳定性,开始禁止反射来调用隐藏或者未公开的API,getPath的源代码如下:
java
/**
* Returns the mount path for the volume.
*
* @return the mount path
* @hide
*/
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.Q, publicAlternatives = "{@link StorageVolume#getDirectory()}")
@TestApi
public String getPath() {
return mPath.toString();
}
可以看到,@hide 把这个方法隐藏了,如果你使用反射,@UnsupportedAppUsage则表明了它不允许App使用,但是有一个条件,当你的App的targetSdk 为 Q(即Android 10)或更低版本时,还是可以使用的,这是为了兼容那些以前写的App。一但targetSdk大于Android 10,意味着你这是新开发的App了,没必要给你特权了,你自己要去做适配了,不允许再调用这个方法了。而且,后面的publicAlternatives给开发者指了一条路:可以使用getDirectory()来实现。
所以还是通过 getExternalFilesDirs(null) 来获取路径比较通用。当有了一个File对象时,还可以通过 StorageManager的getStorageVolume(File file)来得到StorageVolume对象,以便获取该卷上的各种信息,还有接收Uri的重载方法。
在一台执法仪设备上,当切换到U盘模式时,只时外置存储在电脑上可读, 会在电脑中显示一个盘符,双击即可打开。这种情况下,getExternalFilesDirs(null)返回的依旧有两个元素,只是第二个元素为null,所以在使用时,一定要判断是否为null再使用,甚至做mounted状态判断后再使用,如果路径为null就不用判断了,肯定用不了,如果不为null时,再通过 StorageManager的getStorageVolume(File file)来得到StorageVolume对象,然后就可以获取mounted状态了。storageManager.storageVolumes中依旧有两个卷,打印信息如下:
path=/storage/emulated/0, isEmmulated=true, isPrimary=true, isRemovable=false, Uuid=null, mounted=true
path=null, isEmmulated=false, isPrimary=false, isRemovable=true, Uuid=3DC8-90E7, mounted=false
可以看到,外置存储的path为null,且mounted为false,所以在使用外置存储时,最好判断一下mounted状态。内置存储就不用判断,因为内置存储是不可移除的。
另外还有一个 "文件传输" 模式,切换到这个模式后,在我的电脑中有一个以执法仪设备命名的图标,它不像盘符,双击打开后可看到两个存储空间,且没有盘符,名称为:"Udisk"和"内部共享存储空间",这其实就对应了StorageVolume中的getDescription()的返回值。此时查看StorageVolume信息如下:
path=/storage/emulated/0, isEmmulated=true, isPrimary=true, isRemovable=false, Uuid=null, mounted=true
path=/storage/3DC8-90E7, isEmmulated=false, isPrimary=false, isRemovable=true, Uuid=3DC8-90E7, mounted=true
由此可见,切换到U盘模式时,对应的卷的mounted会变成false,变成不可读写状态,而如果是切换到文件传输模式,则不影响使用。
这些可用不可用就是使用前面AI写的那个Demo测试出来的,需要注意的时,当切换模式后,App也需要重启一下,以便重走onCreate中的逻辑,但是有些设备,在按返回后,并不会结束Activity,只是让Activity后台运行,当再次点开应用时不会走onCreate方法。所以我们需覆盖onBackPressed()方法:
kotlin
override fun onBackPressed() {
finish()
}
这确保在按返回键时能结束Activity。