首頁>技術>

Android 10(Q)/11(R) 分割槽儲存適配

大部分應用都會請求 ( READ_EXTERNAL_STORAGE ) ( WRITE_EXTERNAL_STORAGE ) 儲存許可權,來做一些諸如在 SD 卡中儲存檔案或者讀取多媒體檔案等常規操作。這些應用可能會在磁碟中儲存大量檔案,即使應用被解除安裝了還會依然存在。另外,這些應用還可能會讀取其他應用的一些敏感檔案資料。

為此,Google 終於下定決心在 Android 10 中引入了分割槽儲存,對許可權進行場景的細分,按需索取,並在 Android 11 中進行了進一步的調整。

Android 儲存分割槽情況

Android 中儲存可以分為兩大類:私有儲存和共享儲存

私有儲存 (Private Storage) : 每個應用在都擁有自己的私有目錄,其它應用看不到,彼此也無法訪問到該目錄:內部儲存私有目錄 (/data/data/packageName) ;外部儲存私有目錄 (/sdcard/Android/data/packageName),共享儲存 (Shared Storage) : 儲存其他應用可訪問檔案, 包含媒體檔案、文件檔案以及其他檔案,對應裝置DCIM、Pictures、Alarms、Music、Notifications、Podcasts、Ringtones、Movies、Download等目錄。Android 10(Q) :

Android 10 中主要對共享目錄進行了許可權詳細的劃分,不再能通過絕對路徑訪問。

受影響的介面:

訪問不同分割槽的方式:

私有目錄:和以前的版本一致,可通過 File() API 訪問,無需申請許可權。共享目錄:需要通過MediaStore和Storage Access Framework API 訪問,視具體情況申請許可權,下面詳細介紹。

其中,對共享目錄的許可權進行了細分:

無需申請許可權的操作:通過 MediaStore API對媒體集、檔案集進行媒體/檔案的新增、對 自身APP 建立的 媒體/檔案 進行查詢、修改、刪除的操作。需要申請READ_EXTERNAL_STORAGE 許可權:通過 MediaStore API對所有的媒體集進行查詢、修改、刪除的操作。呼叫 Storage Access Framework API :會啟動系統的檔案選擇器向用戶申請操作指定的檔案

新的訪問方式:

Android 11 (R):

Android 11 (R) 在 Android 10 (Q) 中分割槽儲存的基礎上進行了調整

1. 新增執行批量操作

為實現各種裝置之間的一致性並增加使用者便利性,Android 11 向 MediaStore API 中添加了多種方法。對於希望簡化特定媒體檔案更改流程(例如在原位置編輯照片)的應用而言,這些方法尤為有用。

MediaStore API 新增的方法

系統在呼叫以上任何一個方法後,會構建一個 PendingIntent 物件。應用呼叫此 intent 後,使用者會看到一個對話方塊,請求使用者同意應用更新或刪除指定的媒體檔案。

2. 使用直接檔案路徑和原生庫訪問檔案

為了幫助您的應用更順暢地使用第三方媒體庫,Android 11 允許您使用除 MediaStore API 之外的 API 訪問共享儲存空間中的媒體檔案。不過,您也可以轉而選擇使用以下任一 API 直接訪問媒體檔案:

File API。原生庫,例如 fopen()。

簡單來說就是,可以通過 File() 等API 訪問有許可權訪問的媒體集了。

效能:

通過 File () 等直接通過路徑訪問的 API 實際上也會對映為MediaStore API 。按檔案路徑順序讀取的時候效能相當;隨機讀取和寫入的時候則會更慢,所以還是推薦直接使用 MediaStoreAPI。

3. 新增許可權

MANAGE_EXTERNAL_STORAGE : 類似以前的 READ_EXTERNAL_STORAGE + WRITE_EXTERNAL_STORAGE ,除了應用專有目錄都可以訪問。

應用可通過執行以下操作向用戶請求名為所有檔案訪問許可權的特殊應用訪問許可權:

在清單中宣告 MANAGE_EXTERNAL_STORAGE 許可權。使用 ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION intent 操作將使用者引導至一個系統設定頁面,在該頁面上,使用者可以為您的應用啟用以下選項:授予所有檔案的管理許可權。在 Google Play 上架的話,需要提交使用此許可權的說明,只有指定的幾種型別的 APP 才能使用。Sample使用 MediaStore 增刪改查媒體集使用 Storage Access Framework 訪問檔案集

1. 媒體集

1) 查詢媒體集(需要 READ_EXTERNAL_STORAGE 許可權)

實際上 MediaStore 是以前就有的 API ,不同的是過去主要通過 MediaStore.Video.Media._DATA這個 colum 請求原始資料,可以得到絕對Uri ,現在需要請求MediaStore.Video.Media._ID來得到相對Uri再進行處理。

// 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)插入媒體集(無需許可權)

// 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)更新自己建立的媒體集(無需許可權)

// 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 IDval 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)更新/刪除其它媒體建立的媒體集

若已經開啟分割槽儲存則會丟擲 RecoverableSecurityException,捕獲並通過SAF請求許可權

// 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)建立文件

注:建立操作若重名的話不會覆蓋原文件,會新增 (1) 最為字尾,如 document.pdf -> document(1).pdf

// Request code for creating a PDF document.const val CREATE_FILE = 1private 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 設定 MIME 型別

// Request code for selecting a PDF document.const val PICK_PDF_FILE = 2fun 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)授予對目錄內容的訪問許可權

使用者選擇目錄後,可訪問該目錄下的所有內容

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)永久獲取目錄訪問許可權

上面提到的授權是臨時性的,重啟後則會失效。可以通過下面的方法獲取相應目錄永久性的許可權

val contentResolver = applicationContext.contentResolverval 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 響應

SAF API 呼叫後都是通過 onActivityResult來相應動作

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) 其它操作

除了上面的操作之外,對文件其它的複製、移動等操作都是通過設定不同的 FLAG 來實現,見 Document.COLUMN_FLAGS

3. 批量操作媒體集

構建一個媒體集的寫入操作 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)//相應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 設定 requestLegacyExternalStorage="true" 啟用相容模式,以傳統分割槽模式執行。

意思就是在新系統新安裝的應用才會啟用,覆蓋安裝會保持傳統分割槽模式,例如:

系統通過 OTA 升級到 Android 10/11應用通過更新升級到 targetSdkVersion >= 29

補充

A:建立媒體時系統會給媒體打上 packageName tag,應用被解除安裝則會清除 tag ,所以不會存在使用同樣 packageName 進行欺騙的情況。

Q:我可以在媒體集資料夾下建立文件,就可以避開許可權的問題了?

A:官方文件上寫了只能建立相應型別的媒體/檔案,具體如何限制的,沒有說明。

總結

從 Android 10提出分割槽儲存之後到現在已經一年多了,所以Google 從強制推行的態度到現在 targetSDK >=30 才強制啟用分割槽儲存來看,Google 還是漸漸地選擇給開發者留更多的時間。缺點當然是不強制啟用的話,國內 APP 適配進度估計得延後了。不過好訊息是在查資料的時候,看到了國內大廠的相關適配文章,至少說明大廠在跟進了。

去年(19年)的文件描述是無論 targetSDK 多少,明年(20年)高版本強制啟用。

今年(20)文件描述是 targetSDK >=30 才強制啟用

關於適配的難度:

對絕對路徑相關介面依賴比較深的 APP 適配還是改動挺多的;其次許可權的劃分很細,什麼時候需要什麼許可權以及呼叫哪個介面,理解起來需要一定時間;MediaStore API SAF API 這類介面以前就設計好了,我也覺得也不算特別友好;最後測試也需要重新進行。

所以雖然明年才會強制執行分割槽儲存,但還是建議儘早理解和 review 專案中需要適配的程式碼。

文末附上大廠學長給我的資料,內容包含:Android學習PDF+架構視訊+面試文件+原始碼筆記,高階架構技術進階腦圖、Android開發面試專題資料,高階進階架構資料 這幾塊的內容

這些都是我現在閒暇還會反覆翻閱的精品資料。裡面對近幾年的大廠面試高頻知識點都有詳細的講解。相信可以有效的幫助大家掌握知識、理解原理。

分享給大家,非常適合近期有面試和想在技術道路上繼續精進的朋友。也是希望可以幫助到大家提升進階

最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • Python介面(GUI)程式設計PyQt5窗體小部件