作者:連續(xù)三屆村草
Android 10(Q)/11(R) 分區(qū)存儲適配
大部分應(yīng)用都會請求 ( READ_EXTERNAL_STORAGE ) ( WRITE_EXTERNAL_STORAGE ) 存儲權(quán)限,來做一些諸如在 SD 卡中存儲文件或者讀取多媒體文件等常規(guī)操作。這些應(yīng)用可能會在磁盤中存儲大量文件,即使應(yīng)用被卸載了還會依然存在。另外,這些應(yīng)用還可能會讀取其他應(yīng)用的一些敏感文件數(shù)據(jù)。
為此,google 終于下定決心在 Android 10 中引入了分區(qū)存儲,對權(quán)限進(jìn)行場景的細(xì)分,按需索取,并在 Android 11 中進(jìn)行了進(jìn)一步的調(diào)整。
Android 存儲分區(qū)情況
Android 中存儲可以分為兩大類:私有存儲和共享存儲
- 私有存儲 (Private Storage) : 每個應(yīng)用在都擁有自己的私有目錄,其它應(yīng)用看不到,彼此也無法訪問到該目錄:內(nèi)部存儲私有目錄 (/data/data/packageName) ;外部存儲私有目錄 (/sdcard/Android/data/packageName),
- 共享存儲 (Shared Storage) : 存儲其他應(yīng)用可訪問文件, 包含媒體文件、文檔文件以及其他文件,對應(yīng)設(shè)備DCIM、Pictures、Alarms、Music、Notifications、Podcasts、Ringtones、Movies、Download等目錄。
Android 10(Q) :
Android 10 中主要對共享目錄進(jìn)行了權(quán)限詳細(xì)的劃分,不再能通過絕對路徑訪問。
受影響的接口:

訪問不同分區(qū)的方式:
- 私有目錄:和以前的版本一致,可通過 File() API 訪問,無需申請權(quán)限。
- 共享目錄:需要通過MediaStore和Storage Access Framework API 訪問,視具體情況申請權(quán)限,下面詳細(xì)介紹。
其中,對共享目錄的權(quán)限進(jìn)行了細(xì)分:
- 無需申請權(quán)限的操作:
通過 MediaStore API對媒體集、文件集進(jìn)行媒體/文件的添加、對 自身App 創(chuàng)建的 媒體/文件 進(jìn)行查詢、修改、刪除的操作。 - 需要申請READ_EXTERNAL_STORAGE 權(quán)限:
通過 MediaStore API對所有的媒體集進(jìn)行查詢、修改、刪除的操作。 - 調(diào)用 Storage Access Framework API :
會啟動系統(tǒng)的文件選擇器向用戶申請操作指定的文件
新的訪問方式:

Android 11 (R):
Android 11 (R) 在 Android 10 (Q) 中分區(qū)存儲的基礎(chǔ)上進(jìn)行了調(diào)整
1. 新增執(zhí)行批量操作
為實現(xiàn)各種設(shè)備之間的一致性并增加用戶便利性,Android 11 向 MediaStore API 中添加了多種方法。對于希望簡化特定媒體文件更改流程(例如在原位置編輯照片)的應(yīng)用而言,這些方法尤為有用。
MediaStore API 新增的方法

系統(tǒng)在調(diào)用以上任何一個方法后,會構(gòu)建一個 PendingIntent 對象。應(yīng)用調(diào)用此 intent 后,用戶會看到一個對話框,請求用戶同意應(yīng)用更新或刪除指定的媒體文件。
2. 使用直接文件路徑和原生庫訪問文件
為了幫助您的應(yīng)用更順暢地使用第三方媒體庫,Android 11 允許您使用除 MediaStore API 之外的 API 訪問共享存儲空間中的媒體文件。不過,您也可以轉(zhuǎn)而選擇使用以下任一 API 直接訪問媒體文件:
File API。
原生庫,例如 fopen()。
簡單來說就是,可以通過 File() 等API 訪問有權(quán)限訪問的媒體集了。
性能:
通過 File () 等直接通過路徑訪問的 API 實際上也會映射為MediaStore API 。
按文件路徑順序讀取的時候性能相當(dāng);隨機(jī)讀取和寫入的時候則會更慢,所以還是推薦直接使用 MediaStoreAPI。
3. 新增權(quán)限
MANAGE_EXTERNAL_STORAGE : 類似以前的 READ_EXTERNAL_STORAGE + WRITE_EXTERNAL_STORAGE ,除了應(yīng)用專有目錄都可以訪問。
應(yīng)用可通過執(zhí)行以下操作向用戶請求名為所有文件訪問權(quán)限的特殊應(yīng)用訪問權(quán)限:
- 在清單中聲明 MANAGE_EXTERNAL_STORAGE 權(quán)限。
- 使用 ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION intent 操作將用戶引導(dǎo)至一個系統(tǒng)設(shè)置頁面,在該頁面上,用戶可以為您的應(yīng)用啟用以下選項:授予所有文件的管理權(quán)限。
- 在 Google Play 上架的話,需要提交使用此權(quán)限的說明,只有指定的幾種類型的 APP 才能使用。
Sample
- 使用 MediaStore 增刪改查媒體集
- 使用 Storage Access Framework 訪問文件集
1. 媒體集
1) 查詢媒體集(需要 READ_EXTERNAL_STORAGE 權(quán)限)
實際上 MediaStore 是以前就有的 API ,不同的是過去主要通過 MediaStore.Video.Media._DATA這個 colum 請求原始數(shù)據(jù),可以得到絕對Uri ,現(xiàn)在需要請求MediaStore.Video.Media._ID來得到相對Uri再進(jìn)行處理。
// Need the READ_EXTERNAL_STORAGE permission if accessing video files that your
// app didn't create.
// Container for information about each video.
data class Video(
val uri: Uri,
val name: String,
val duration: Int,
val size: Int
)
val videoList = mutableListOf<Video>()
val projection = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.SIZE
)
// Show only videos that are at least 5 minutes in duration.
val selection = "${MediaStore.Video.Media.DURATION} >= ?"
val selectionArgs = arrayOf(
TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES).toString()
)
// Display videos in alphabetical order based on their display name.
val sortOrder = "${MediaStore.Video.Media.DISPLAY_NAME} ASC"
val query = ContentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)
query?.use { cursor ->
// Cache column indices.
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
val nameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
val durationColumn =
cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)
while (cursor.moveToNext()) {
// Get values of columns for a given video.
val id = cursor.getLong(idColumn)
val name = cursor.getString(nameColumn)
val duration = cursor.getInt(durationColumn)
val size = cursor.getInt(sizeColumn)
val contentUri: Uri = ContentUris.withAppendedId(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
id
)
// Stores column values and the contentUri in a local object
// that represents the media file.
videoList += Video(contentUri, name, duration, size)
}
}
2)插入媒體集(無需權(quán)限)
// Add a media item that other apps shouldn't see until the item is
// fully written to the media store.
val resolver = applicationContext.contentResolver
// Find all audio files on the primary external storage device.
// On API <= 28, use VOLUME_EXTERNAL instead.
val audioCollection = MediaStore.Audio.Media
.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val songDetails = ContentValues().apply {
put(MediaStore.Audio.Media.DISPLAY_NAME, "My Workout Playlist.mp3")
put(MediaStore.Audio.Media.IS_PENDING, 1)
}
val songContentUri = resolver.insert(audioCollection, songDetails)
resolver.openFileDescriptor(songContentUri, "w", null).use { pfd ->
// Write data into the pending audio file.
}
// Now that we're finished, release the "pending" status, and allow other apps
// to play the audio track.
songDetails.clear()
songDetails.put(MediaStore.Audio.Media.IS_PENDING, 0)
resolver.update(songContentUri, songDetails, null, null)
3)更新自己創(chuàng)建的媒體集(無需權(quán)限)
刪除類似
// Updates an existing media item.
val mediaId = // MediaStore.Audio.Media._ID of item to update.
val resolver = applicationContext.contentResolver
// When performing a single item update, prefer using the ID
val selection = "${MediaStore.Audio.Media._ID} = ?"
// By using selection + args we protect against improper escaping of // values.
val selectionArgs = arrayOf(mediaId.toString())
// Update an existing song.
val updatedSongDetails = ContentValues().apply {
put(MediaStore.Audio.Media.DISPLAY_NAME, "My Favorite Song.mp3")
}
// Use the individual song's URI to represent the collection that's
// updated.
val numSongsUpdated = resolver.update(
myFavoriteSongUri,
updatedSongDetails,
selection,
selectionArgs)
4)更新/刪除其它媒體創(chuàng)建的媒體集
若已經(jīng)開啟分區(qū)存儲則會拋出 RecoverableSecurityException,捕獲并通過SAF請求權(quán)限
// Apply a grayscale filter to the image at the given content URI.
try {
contentResolver.openFileDescriptor(image-content-uri, "w")?.use {
setGrayscaleFilter(it)
}
} catch (securityException: SecurityException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val recoverableSecurityException = securityException as?
RecoverableSecurityException ?:
throw RuntimeException(securityException.message, securityException)
val intentSender =
recoverableSecurityException.userAction.actionIntent.intentSender
intentSender?.let {
startIntentSenderForResult(intentSender, image-request-code,
null, 0, 0, 0, null)
}
} else {
throw RuntimeException(securityException.message, securityException)
}
}
2. 文件集 (通過 SAF)
1)創(chuàng)建文檔
注:創(chuàng)建操作若重名的話不會覆蓋原文檔,會添加 (1) 最為后綴,如 document.pdf -> document(1).pdf
// Request code for creating a PDF document.
const val CREATE_FILE = 1
private fun createFile(pickerInitialUri: Uri) {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/pdf"
putExtra(Intent.EXTRA_TITLE, "invoice.pdf")
// Optionally, specify a URI for the directory that should be opened in
// the system file picker before your app creates the document.
putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
}
startActivityForResult(intent, CREATE_FILE)
}
2)打開文檔
建議使用 type 設(shè)置 MIME 類型
// Request code for selecting a PDF document.
const val PICK_PDF_FILE = 2
fun openFile(pickerInitialUri: uri) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/pdf"
// Optionally, specify a URI for the file that should appear in the
// system file picker when it loads.
putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
}
startActivityForResult(intent, PICK_PDF_FILE)
}
3)授予對目錄內(nèi)容的訪問權(quán)限
用戶選擇目錄后,可訪問該目錄下的所有內(nèi)容
Android 11 中無法訪問 Downloads
fun openDirectory(pickerInitialUri: Uri) {
// Choose a directory using the system's file picker.
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
// Provide read access to files and sub-directories in the user-selected
// directory.
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
// Optionally, specify a URI for the directory that should be opened in
// the system file picker when it loads.
putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
}
startActivityForResult(intent, your-request-code)
}
4)永久獲取目錄訪問權(quán)限
上面提到的授權(quán)是臨時性的,重啟后則會失效。可以通過下面的方法獲取相應(yīng)目錄永久性的權(quán)限
val contentResolver = applicationContext.contentResolver
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
// Check for the freshest data.
contentResolver.takePersistableUriPermission(uri, takeFlags)
5)SAF API 響應(yīng)
SAF API 調(diào)用后都是通過 onActivityResult來相應(yīng)動作
override fun onActivityResult(
requestCode: Int, resultCode: Int, resultData: Intent?) {
if (requestCode == your-request-code
&& resultCode == Activity.RESULT_OK) {
// The result data contains a URI for the document or directory that
// the user selected.
resultData?.data?.also { uri ->
// Perform operations on the document using its URI.
}
}
}
6) 其它操作
除了上面的操作之外,對文檔其它的復(fù)制、移動等操作都是通過設(shè)置不同的 FLAG 來實現(xiàn),見 Document.COLUMN_FLAGS
3. 批量操作媒體集
構(gòu)建一個媒體集的寫入操作 createWriteRequest()
val urisToModify = /* A collection of content URIs to modify. */
val editPendingIntent = MediaStore.createWriteRequest(contentResolver,
urisToModify)
// Launch a system prompt requesting user permission for the operation.
startIntentSenderForResult(editPendingIntent.intentSender, EDIT_REQUEST_CODE,
null, 0, 0, 0)
//相應(yīng)
override fun onActivityResult(requestCode: Int, resultCode: Int,
data: Intent?) {
...
when (requestCode) {
EDIT_REQUEST_CODE ->
if (resultCode == Activity.RESULT_OK) {
/* Edit request granted; proceed. */
} else {
/* Edit request not granted; explain to the user. */
}
}
}
createFavoriteRequest() createTrashRequest() createDeleteRequest() 同理

適配和兼容
在 targetSDK = 29 APP 中,在 AndroidManifes 設(shè)置 requestLegacyExternalStorage="true" 啟用兼容模式,以傳統(tǒng)分區(qū)模式運(yùn)行。
<manifest ... >
<!-- This attribute is "false" by default on apps targeting
Android 10 or higher. -->
<application android:requestLegacyExternalStorage="true" ... >
...
</application>
</manifest>
注意:如果某個應(yīng)用在安裝時啟用了傳統(tǒng)外部存儲,則該應(yīng)用會保持此模式,直到卸載為止。無論設(shè)備后續(xù)是否升級為搭載 Android 10 或更高版本,或者應(yīng)用后續(xù)是否更新為以 Android 10 或更高版本為目標(biāo)平臺,此兼容性行為均適用。
意思就是在新系統(tǒng)新安裝的應(yīng)用才會啟用,覆蓋安裝會保持傳統(tǒng)分區(qū)模式,例如:
- 系統(tǒng)通過 OTA 升級到 Android 10/11
- 應(yīng)用通過更新升級到 targetSdkVersion >= 29
補(bǔ)充
Q:之前討論過一些問題,APP 無需權(quán)限可以訪問自己創(chuàng)建的媒體,那么系統(tǒng)如何進(jìn)行判斷?
A:創(chuàng)建媒體時系統(tǒng)會給媒體打上 packageName tag,應(yīng)用被卸載則會清除 tag ,所以不會存在使用同樣 packageName 進(jìn)行欺騙的情況。
Q:我可以在媒體集文件夾下創(chuàng)建文檔,就可以避開權(quán)限的問題了?
A:官方文檔上寫了只能創(chuàng)建相應(yīng)類型的媒體/文件,具體如何限制的,沒有說明。
總結(jié)
從 Android 10提出分區(qū)存儲之后到現(xiàn)在已經(jīng)一年多了,所以Google 從強(qiáng)制推行的態(tài)度到現(xiàn)在 targetSDK >=30 才強(qiáng)制啟用分區(qū)存儲來看,Google 還是漸漸地選擇給開發(fā)者留更多的時間。缺點當(dāng)然是不強(qiáng)制啟用的話,國內(nèi) APP 適配進(jìn)度估計得延后了。不過好消息是在查資料的時候,看到了國內(nèi)大廠的相關(guān)適配文章,至少說明大廠在跟進(jìn)了。
去年(19年)的文檔描述是無論 targetSDK 多少,明年(20年)高版本強(qiáng)制啟用。

今年(20)文檔描述是 targetSDK >=30 才強(qiáng)制啟用

關(guān)于適配的難度:
對絕對路徑相關(guān)接口依賴比較深的 APP 適配還是改動挺多的;其次權(quán)限的劃分很細(xì),什么時候需要什么權(quán)限以及調(diào)用哪個接口,理解起來需要一定時間;MediaStore API SAF API 這類接口以前就設(shè)計好了,我也覺得也不算特別友好;最后測試也需要重新進(jìn)行。
所以雖然明年才會強(qiáng)制執(zhí)行分區(qū)存儲,但還是建議盡早理解和 review 項目中需要適配的代碼。
文末附上大廠學(xué)長給我的資料,內(nèi)容包含:Android學(xué)習(xí)PDF+架構(gòu)視頻+面試文檔+源碼筆記,高級架構(gòu)技術(shù)進(jìn)階腦圖、Android開發(fā)面試專題資料,高級進(jìn)階架構(gòu)資料 這幾塊的內(nèi)容
這些都是我現(xiàn)在閑暇還會反復(fù)翻閱的精品資料。里面對近幾年的大廠面試高頻知識點都有詳細(xì)的講解。相信可以有效的幫助大家掌握知識、理解原理。
分享給大家,非常適合近期有面試和想在技術(shù)道路上繼續(xù)精進(jìn)的朋友。也是希望可以幫助到大家提升進(jìn)階