存储空间

应用保存数据的方式:

  • 应用专属存储空间:存储仅供应用使用的文件,可以存储到内部存储卷中的专属目录或外部存储空间中的其他专属目录。使用内部存储空间中的目录保存其他应用不应访问的敏感信息。
  • 共享存储:存储您的应用打算与其他应用共享的文件,包括媒体、文档和其他文件。
  • 偏好设置:以键值对形式通过SharePreference存储私有原始数据。
  • 数据库:使用 Room 持久性库将结构化数据存储在Sqlite数据库中。

下表汇总了这些选项的特点:

内容类型 访问方法 所需权限 其他应用是否可以访问? 卸载应用时是否移除文件?
应用专属文件 仅供您的应用使用的文件 - 从内部存储空间访问,可以使用 getFilesDir()getCacheDir() 方法 ;- 从外部存储空间访问,可以使用 getExternalFilesDir()getExternalCacheDir() 方法 从内部存储空间访问不需要任何权限;如果应用在搭载 Android 4.4(API 级别 19)或更高版本的设备上运行,从外部存储空间访问不需要任何权限
媒体 可共享的媒体文件(图片、音频文件、视频) MediaStore API 在 Android 11(API 级别 30)或更高版本中,访问其他应用的文件需要 READ_EXTERNAL_STORAGE;在 Android 10(API 级别 29)中,访问其他应用的文件需要 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE;在 Android 9(API 级别 28)或更低版本中,访问所有文件均需要相关权限 是,但其他应用需要 READ_EXTERNAL_STORAGE 权限
文档和其他文件 其他类型的可共享内容,包括已下载的文件 存储访问框架SAF 是,可以通过系统文件选择器访问
应用偏好设置 键值对 Jetpack Preferences
数据库 结构化数据 Room 持久性库

外部存储

以前的手机是存在SDcard的,但目前很多手机都取消了SDcard,Android上引入了映射机制来创建虚拟的SDcard,我们通过文件管理器看到的路径storage/emulated/0就是虚拟SDcard,也就是我们俗称的“外部存储空间”或者“公共存储空间”app申请的读写权限请求都是申请的外部存储空间权限

内部存储

内存存储也就是本app的专属目录,其他app是无法访问的,适合存储敏感文件。
通过系统api访问到的路径:/data/user/0/app_packageName/…
对应的真实目录:/data/date/app_packageName/…

分区存储

分区存储实际上就是外部存储空间中建一个app对应目录,本app无须申请权限就可以访问,如果申请读写权限,意味着申请外部空间所有访问权限,在Android10之前,分区存储目录是有可能被其他app访问到的。从Android11开始,其他app是无法访问的,也称之为沙盒模式。

权限的变化

Android 5.0 存储访问框架(SAF):引入了存储访问框架,用于提供一种更加安全和细粒度的文件访问方式。通过 SAF,用户可以选择授予应用访问特定文件或目录的权限。

Android 6.0 运行时权限:引入了运行时权限模型,应用需要在运行时动态请求存储权限,而不是在安装时获得。这进一步提高了用户的控制权。

Android 7.0 File URI 限制:文件URI被限制,应用不能直接通过file://访问其他应用的文件,必须使用ContentProvider来共享文件。

Android 10

分区存储(Scoped Storage):引入了分区存储,限制了应用对外部存储的访问。应用只能访问自己的应用专属目录和一些特定的公共目录(如DownloadPictures等)。

媒体文件访问权限:应用可以通过特定的媒体存储API访问和操作媒体文件(如图片、音频、视频),而不需要全局存储权限。

Android 11

进一步收紧分区存储:分区存储变得更加严格,应用对外部存储的访问进一步受限。

MANAGE_EXTERNAL_STORAGE 权限:该权限提供对应用专属目录和 MediaStore 之外文件的写入权限。引入了新的权限,允许某些应用访问所有的外部存储文件,但这个权限的使用受到严格限制,需要通过Google Play审核。

媒体存储访问权限:应用可以请求访问特定类型的媒体文件,更加细粒度的访问控制。

Android 12

特定作用域的媒体访问:为不同类型的媒体文件提供更细致的访问控制,实现了更细粒度的权限管理。

Android 13Android 14 倒是没有对权限进行大的改动,目前已经趋于稳定。

下载文件到SD卡的Download文件夹

方案一:申请权限写入

需要在AndroidManifest.xml中添加以下权限:

1
2
3
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />

对于Android 10(API 29)及以上版本:

  • 使用MediaStore API来创建文件。
  • 使用ContentResolver来获取输出流并写入文件。

对于Android 9(API 28)及以下版本:

  • 直接使用File API在Download目录创建文件。
  • 使用FileOutputStream写入文件。

使用线程进行网络操作和文件写入,避免阻塞主线程。

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
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.appcompat.app.AppCompatActivity
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL

class DownloadActivity : AppCompatActivity() {

fun downloadFile(url: String, fileName: String) {
val thread = Thread {
try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connect()

if (connection.responseCode != HttpURLConnection.HTTP_OK) {
return@Thread
}

val inputStream: InputStream = connection.inputStream

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10及以上版本
val contentValues = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream")
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}

val resolver = contentResolver
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
resolver.openOutputStream(uri)?.use { outputStream ->
inputStream.copyTo(outputStream)
}
}
} else {
// Android 9及以下版本
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, fileName)
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
}

inputStream.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
thread.start()
}
}

注意,WRITE_EXTERNAL_STORAGE权限只在API 28及以下版本需要。对于API 29+,我们使用MediaStore API,不需要这个权限。
最后,记得在运行时请求WRITE_EXTERNAL_STORAGE权限(对于Android 6.0到Android 9.0)。

方案二:无需权限SAF选择目录写入

通过 Storage Access Framework (SAF) 先获取一个目录的 URI使用 DocumentFile 来实现文件下载。

这个方法的优点包括:

  1. 兼容性好:适用于所有 Android 版本。
  2. 支持可移动存储:用户可以选择 SD 卡上的目录。
  3. 无需特殊权限:不需要 WRITE_EXTERNAL_STORAGE 权限。
  4. 用户友好:用户可以选择他们想要保存文件的位置。

使用这种方法时,需要注意以下几点:

  1. 需要用户选择一个目录,这可能会增加一些用户交互。
  2. 你需要保存用户选择的目录 URI,以便在后续的下载中重用。
  3. 对于大文件,你可能需要考虑添加进度回调和取消功能。
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
import android.content.Context
import android.os.Build
import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL

class DocumentFileDownloader(private val context: Context) {

fun downloadFile(url: String, fileName: String) {
Thread {
try {
val file = getOrCreateFile(fileName)
if (file == null) {
println("Could not create or access file")
return@Thread
}

// 打开连接并下载
val connection = URL(url).openConnection() as HttpURLConnection
connection.connect()

if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw Exception("Server returned HTTP ${connection.responseCode}")
}

// 打开输入流和输出流
val inputStream = BufferedInputStream(connection.inputStream)
val outputStream = BufferedOutputStream(context.contentResolver.openOutputStream(file.uri))

// 复制数据
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}

// 关闭流
outputStream.flush()
outputStream.close()
inputStream.close()

println("File downloaded successfully: ${file.uri}")
} catch (e: Exception) {
e.printStackTrace()
}
}.start()
}

private fun getOrCreateFile(fileName: String): DocumentFile? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10 及以上版本
val downloadsDir = DocumentFile.fromTreeUri(context,
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toUri())
downloadsDir?.createFile("application/octet-stream", fileName)
} else {
// Android 9 及以下版本
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, fileName)
DocumentFile.fromFile(file)
}
}
}

如何使用这个下载器的示例:

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
class MainActivity : AppCompatActivity() {
private lateinit var downloader: DocumentFileDownloader
private var directoryUri: Uri? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

downloader = DocumentFileDownloader(this)

// 假设你有一个按钮来触发文件选择
findViewById<Button>(R.id.selectFolderButton).setOnClickListener {
selectFolder()
}

// 假设你有一个按钮来触发下载
findViewById<Button>(R.id.downloadButton).setOnClickListener {
directoryUri?.let {
downloader.downloadFile(
"https://example.com/file.zip",
"downloaded_file.zip",
it
)
} ?: run {
Toast.makeText(this, "Please select a folder first", Toast.LENGTH_SHORT).show()
}
}
}

private fun selectFolder() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, OPEN_DIRECTORY_REQUEST_CODE)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == OPEN_DIRECTORY_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
data?.data?.let { uri ->
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
directoryUri = uri
// 可以保存这个 URI 以便后续使用
}
}
}

companion object {
private const val OPEN_DIRECTORY_REQUEST_CODE = 1
}
}

方案三:无需权限无需选择目录 DocumentFile API写入

希望使用 DocumentFile API 直接创建指定的文件,而不需要用户选择目录。

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
import android.content.Context
import android.os.Build
import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL

class DocumentFileDownloader(private val context: Context) {

fun downloadFile(url: String, fileName: String) {
Thread {
try {
val file = getOrCreateFile(fileName)
if (file == null) {
println("Could not create or access file")
return@Thread
}

// 打开连接并下载
val connection = URL(url).openConnection() as HttpURLConnection
connection.connect()

if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw Exception("Server returned HTTP ${connection.responseCode}")
}

// 打开输入流和输出流
val inputStream = BufferedInputStream(connection.inputStream)
val outputStream = BufferedOutputStream(context.contentResolver.openOutputStream(file.uri))

// 复制数据
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}

// 关闭流
outputStream.flush()
outputStream.close()
inputStream.close()

println("File downloaded successfully: ${file.uri}")
} catch (e: Exception) {
e.printStackTrace()
}
}.start()
}

private fun getOrCreateFile(fileName: String): DocumentFile? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10 及以上版本
val downloadsDir = DocumentFile.fromTreeUri(context,
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toUri())
downloadsDir?.createFile("application/octet-stream", fileName)
} else {
// Android 9 及以下版本
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, fileName)
DocumentFile.fromFile(file)
}
}
}

这个实现有以下几个特点:

  1. 它直接使用公共下载目录,不需要用户选择目录。
  2. 对于 Android 10 及以上版本,它使用 DocumentFile API 来创建文件。
  3. 对于 Android 9 及以下版本,它直接在下载目录创建文件,然后将其包装为 DocumentFile。

使用这个下载器的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MainActivity : AppCompatActivity() {
private lateinit var downloader: DocumentFileDownloader

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

downloader = DocumentFileDownloader(this)

findViewById<Button>(R.id.downloadButton).setOnClickListener {
downloader.downloadFile(
"https://example.com/file.zip",
"downloaded_file.zip"
)
}
}
}

需要注意的是:

  1. 对于 Android 9 及以下版本,你仍然需要 WRITE_EXTERNAL_STORAGE 权限。你可以在 AndroidManifest.xml 中这样声明:

    1
    2
    3
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />
  2. 对于 Android 6.0 到 Android 9.0,你需要在运行时请求 WRITE_EXTERNAL_STORAGE 权限。

  3. 这种方法在 Android 10 及以上版本不需要特殊权限,因为它使用了 MediaStore API 来访问下载目录。

  4. 如果你的应用目标 SDK 是 Android 10 或更高,并且你想要访问应用专用目录之外的文件,你可能需要在 AndroidManifest.xml 中添加 android:requestLegacyExternalStorage="true"。但请注意,这只是一个临时解决方案,在 Android 11 及以上版本中不再有效。

关于DocumentFile权限的情况:

Android 10(API 29)及以上:

  • 使用 DocumentFile 访问应用专属目录或通过 SAF(Storage Access Framework)访问的目录时,不需要额外的存储权限。
  • 对于下载目录等公共目录,也不需要 WRITE_EXTERNAL_STORAGE 权限。

Android 9(API 28)及以下:

  • 如果使用 DocumentFile.fromFile() 来访问外部存储上的文件,仍然需要 WRITE_EXTERNAL_STORAGE 权限。

考虑到这一点,我们可以优化之前的代码,使其在所有 Android 版本上都不需要请求 WRITE_EXTERNAL_STORAGE 权限。我们可以使用 MediaStore API 来实现这一点:

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
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.net.HttpURLConnection
import java.net.URL

class FileDownloader(private val context: Context) {

fun downloadFile(url: String, fileName: String) {
Thread {
try {
val uri = getOutputUri(fileName)
if (uri == null) {
println("Could not create output URI")
return@Thread
}

// 打开连接并下载
val connection = URL(url).openConnection() as HttpURLConnection
connection.connect()

if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw Exception("Server returned HTTP ${connection.responseCode}")
}

// 打开输入流和输出流
val inputStream = BufferedInputStream(connection.inputStream)
val outputStream = BufferedOutputStream(context.contentResolver.openOutputStream(uri))

// 复制数据
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}

// 关闭流
outputStream.flush()
outputStream.close()
inputStream.close()

println("File downloaded successfully: $uri")
} catch (e: Exception) {
e.printStackTrace()
}
}.start()
}

private fun getOutputUri(fileName: String): Uri? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream")
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
} else {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
Uri.fromFile(java.io.File(downloadsDir, fileName))
}
}
}

这个实现有以下优点:

  1. 它在所有 Android 版本上都不需要 WRITE_EXTERNAL_STORAGE 权限。
  2. 对于 Android 10 及以上版本,它使用 MediaStore API 来创建文件。
  3. 对于 Android 9 及以下版本,它仍然使用 File API,但通过 ContentResolver 来写入文件,这样不需要存储权限。

使用这个下载器的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MainActivity : AppCompatActivity() {
private lateinit var downloader: FileDownloader

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

downloader = FileDownloader(this)

findViewById<Button>(R.id.downloadButton).setOnClickListener {
downloader.downloadFile(
"https://example.com/file.zip",
"downloaded_file.zip"
)
}
}
}

在 AndroidManifest.xml 中,你只需要声明网络权限:

1
<uses-permission android:name="android.permission.INTERNET" />

这种方法的优点是:

  1. 兼容性好:适用于所有 Android 版本。
  2. 不需要存储权限:在所有版本上都不需要 WRITE_EXTERNAL_STORAGE 权限。
  3. 简单直接:不需要用户选择目录或处理 SAF。

需要注意的是,这种方法将文件下载到公共的下载目录。如果你需要将文件保存到应用的私有目录,或者需要更多的文件操作灵活性,可能还是需要考虑使用 SAF 或其他方法。

Reference

聊一聊Android存储行为的变化

就想下载个文件到SD卡,怎就这么难?快把代码拿走吧