輕松適配Android 10 Scoped Storage 分區(qū)存儲

Android 10(API 級別 29)引入了多項功能和行為變更,目的是更好地保護(hù)用戶的隱私權(quán)。其中最重要的變化之一就是存儲訪問權(quán)限

Android 10中,Google針對外部存儲引入了一個新特性,它的名字叫:Scoped Storage,Google官方對它的翻譯為分區(qū)存儲,我們也可以把它叫做作用域存儲,至于為什么?

image-20200414211649994.png

本文中,我們還是將它翻譯為分區(qū)存儲。好了,我要開始提問了!

image-20200414212753228.png

問題一、外部存儲?內(nèi)部存儲?內(nèi)存?

1.1、內(nèi)存

有些朋友經(jīng)常將內(nèi)存和內(nèi)部存儲搞混,因為內(nèi)部存儲也可以被簡稱為內(nèi)存:

A:聽說你買了新手機(jī)?內(nèi)存多大的啊?后臺能開多少啊?

B:哈哈,256G,裝幾百個都不是問題!

A:呵呵

但實際上它們是兩個不一樣的東西。內(nèi)存(RAM)簡單理解就是程序運(yùn)行時臨時的數(shù)據(jù)存儲器,某個程序進(jìn)程結(jié)束后,關(guān)于此程序的所有內(nèi)存數(shù)據(jù)都會消失,而斷電后整個內(nèi)存里面的數(shù)據(jù)都會丟失。由于內(nèi)存經(jīng)常與CPU打交道,因此它的讀寫速度是相當(dāng)快的,內(nèi)存也是我們通常所說的隨機(jī)存取存儲器(Random Access Memory)

1.2、內(nèi)部存儲

內(nèi)部存儲顧名思義就是手機(jī)自帶的存儲空間,一般情況下,系統(tǒng)和應(yīng)用都是安裝在內(nèi)部存儲中的。在沒有root的情況下,普通用戶是無法查看內(nèi)部存儲中的文件的。對于Android開發(fā)者,最熟悉的應(yīng)該就是:/data/user/0/<package>這個路徑,其中0表示用戶ID(似乎Android 6.0以后開始支持多用戶)。而我們更熟悉的/data/data/<package>實際上是/data/user/<current_user_id>/<package>的一個鏈接,注意是current_user_id

這個路徑是應(yīng)用的內(nèi)部存儲私有目錄,保存到這個路徑下的文件是應(yīng)用的私有文件,其他應(yīng)用不能訪問這些文件(除非擁有 Root 訪問權(quán)限),非常適合保存用戶無需直接訪問的內(nèi)部應(yīng)用數(shù)據(jù)。

當(dāng)我們卸載應(yīng)用后,保存在私有路徑中的文件也會被刪除。因此,我們不應(yīng)該將那些希望應(yīng)用卸載以后還保留的數(shù)據(jù)文件放在私有路徑中。

主要有以下幾個常用的目錄:

  • files目錄

    完整路徑為:/data/data/<package>/files。用于保存應(yīng)用創(chuàng)建的文件,可以通過Context對象獲取其路徑:

    // java
    String path = getFilesDir().getAbsolutePath();
    
    // kotlin
    val path = filesDir.absolutePath
    
  • cache目錄

    完整路徑為:/data/data/<package>/cache。用于保存應(yīng)用的臨時緩存文件,可以通過Context對象獲取其路徑:

    // java
    String path = getCacheDir().getAbsolutePath();
    
    // kotlin
    val path = cacheDir.absolutePath
    
  • shared_prefs目錄

    完整路徑為:/data/data/<package>/shared_prefs。用于保存SharedPreferences的數(shù)據(jù)文件。

  • databses目錄

    完整路徑為:/data/data/<package>/databses。用于保存Sqlite數(shù)據(jù)庫文件。

1.3、外部存儲

在很久很久以前,幾乎所有的Android手機(jī)都可以插入一張micro SD卡,因為內(nèi)部存儲實在太小了,我第一款A(yù)ndroid手機(jī)是SONY LT18i,內(nèi)部存儲只有1GB,最大支持32GB的SD卡。我們所說的外部存儲,指的就是我們插入的那張SD卡。SD卡一般會被掛載到/storage/sdcard1,根據(jù)設(shè)備的不同,不一定叫sdcard1,比如在我的模擬器中,路徑為:/storage/1106-3A09

而現(xiàn)在,幾乎沒有Android手機(jī)再提供SD卡的插口。

那...為什么不提供了?不能插SD卡意味著我的手機(jī)沒有外部存儲了?那為什么我還能用文件管理器看到很多文件?不是說內(nèi)部存儲不root看不到嗎?

image-20200415213357961.png
1.3.1、為什么不再提供SD卡插口?

我個人覺得,有以下原因:

  • SD卡可隨時被用戶移出,因此,在嘗試訪問應(yīng)用外部存儲中的文件時,都需要檢查外部存儲目錄及嘗試訪問的文件是否可用。
  • SD卡讀寫速度慢,限制了應(yīng)用(比如游戲)的加載速度。拿頂級的micro SD卡來說,它的寫入速度只有90MB/s,讀取速度只有170MB/s,和現(xiàn)在手機(jī)內(nèi)置存儲的UFS 3.1芯片相比,太弱了。(UFS 3.1 寫入:700+MB/s,讀取:1700MB+/s)
  • 為了手機(jī)設(shè)計美觀,減少開孔
1.3.2、不能插SD卡意味著我的手機(jī)沒有外部存儲了嗎?

現(xiàn)在幾乎所有手機(jī)都會自帶比較可觀的內(nèi)部存儲容量,動不動上來就是128GB、256GB、512GB,起步都是128GB了,64GB都幾乎看不到了,不過64GB是真的不夠用,親身經(jīng)歷!

系統(tǒng)會將內(nèi)部存儲空間通過fuse技術(shù)掛載到/storage/emulated/0上,這個掛載點就是外部存儲,沒有SD卡一樣可以擁有外部存儲了,這個外部存儲嚴(yán)格意義上叫做內(nèi)置外部存儲,和內(nèi)部存儲共享空間,它有如下好處:

  • 永遠(yuǎn)在線,不可被用戶移出
  • 和內(nèi)置存儲共享空間,速度也快
1.3.3、外部存儲分私有目錄和公有目錄嗎?
  • 外部存儲私有目錄

    是的,外部存儲也有應(yīng)用的私有目錄,嚴(yán)格來說叫做外部存儲私有目錄,它的路徑位于:/storage/emulated/0/Android/data/<package>中,同樣擁有files文件夾和cache文件夾,使用如下代碼可以獲取相應(yīng)路徑:

    • files目錄

      // java
      File file = getExternalFilesDir("");
      String path = "";
      if (file != null){
          path = file.getAbsolutePath();
      }
      
      // kotlin
      val path = getExternalFilesDir("")?.absolutePath ?: ""
      

      getExternalFilesDir()是需要一個字符串參數(shù)的,如果我們傳入空字符串,則獲取到/storage/emulated/0/Android/data/<package>/files,如果我們傳入"test",則獲取到:/storage/emulated/0/Android/data/<package>/files/test。官方建議我們不要隨意命名,應(yīng)該使用Environment下定義好的名字:

image-20200415232030508.png
  • cache目錄

    // java
    File file = getExternalCacheDir("");
    String path = "";
    if (file != null){
        path = file.getAbsolutePath();
    }
    
    // kotlin
    val path = externalCacheDir?.absolutePath ?: ""
    

卸載應(yīng)用后,外部存儲私有路徑中的文件同樣會被清除。那...內(nèi)部存儲中的私有目錄和外部存儲中的私有目錄有什么區(qū)別呢?官方文檔的解釋如下:

盡管這些文件在技術(shù)上可被用戶和其他應(yīng)用訪問(因為它們存儲在外部存儲上),但它們不能為應(yīng)用之外的用戶提供價值。可以使用此目錄來存儲您不想與其他應(yīng)用共享的文件。

  • 公有目錄

    外部存儲中,除了私有目錄以外的目錄,都是公有目錄。程序保存在公有目錄中的數(shù)據(jù),在應(yīng)用被刪除后,仍然保留。

問題二、Android 10的分區(qū)存儲究竟影響了什么?

相信每一臺Android手機(jī)的外部存儲根目錄都是亂得一塌糊涂,這是因為在Android 10以前,只要程序獲得了READ_EXTERNAL_STORAGE權(quán)限,就可以隨意讀取外部存儲的公有目錄;只要程序獲得了WRITE_EXTERNAL_STORAGE權(quán)限,就可以隨意在外部存儲的公有目錄上新建文件夾或文件。

于是Google終于開始動手了,在Android 10中提出了分區(qū)存儲,意在限制程序?qū)ν獠看鎯χ泄心夸浀臑樗麨椤?strong>分區(qū)存儲對 內(nèi)部存儲私有目錄 和 外部存儲私有目錄 都沒有影響。

簡而言之,在Android 10中,對于私有目錄的讀寫沒有變化,仍然可以使用File那一套,且不需要任何權(quán)限。而對于公有目錄的讀寫,則必須使用MediaStore提供的API或是SAF(存儲訪問框架),意味著我們不能再使用File那一套來隨意操作公有目錄了。

使用分區(qū)存儲的應(yīng)用對自己創(chuàng)建的文件始終擁有讀/寫權(quán)限,無論文件是否位于應(yīng)用的私有目錄內(nèi)。因此,如果您的應(yīng)用僅保存和訪問自己創(chuàng)建的文件,則無需請求獲得 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE權(quán)限。

若要訪問其他應(yīng)用創(chuàng)建的文件,則需要READ_EXTERNAL_STORAGE權(quán)限。并且仍然只能使用MediaStore提供的API或是SAF(存儲訪問框架)訪問。

需要注意的是,MediaStore提供的API只能訪問:圖片、視頻、音頻,如果需要訪問其它任意格式的文件,需要使用SAF(存儲訪問框架),它會調(diào)用系統(tǒng)內(nèi)置的文件瀏覽器供用戶自主選擇文件。

似乎沒WRITE_EXTERNAL_STORAGE什么事兒了?確實,聽說這個權(quán)限在后續(xù)會被廢除。

問題三、必須要適配嗎?

本來從Android 10開始,Google就決定強(qiáng)制采用分區(qū)存儲,但從預(yù)覽版的反饋來看,很多應(yīng)用都GG了,因此Google決定給開發(fā)者一段過渡時間,暫時不強(qiáng)制要求,但早晚會強(qiáng)制要求的。

如果我們將targetSdkVersion設(shè)置為低于29的值,那么即使不做任何關(guān)于Android 10的適配,我們的項目也可以成功運(yùn)行到Android 10手機(jī)上。

如果我們將targetSdkVersion設(shè)置為29了,但就是不想適配分區(qū)存儲,可以在清單文件中做如下設(shè)置:

<manifest ... >
    <application android:requestLegacyExternalStorage="true" ... >
        ...
    </application>
</manifest>

問題四、真的不能在外部存儲撒野了?我不信

首先我們將應(yīng)用的compileSdkVersiontargetSdkVersion都設(shè)置為29,并且授予程序讀寫的權(quán)限,然后編寫如下代碼:

val file = File(Environment.getExternalStorageDirectory(), "MyAppDir")
if (!file.exists()) {
    Log.d("createDir", file.mkdir().toString())
}

代碼很簡單,作用是在外部存儲的根目錄下創(chuàng)建一個名叫MyDir的文件夾。我們將這個程序運(yùn)行在Android 10的設(shè)備上,發(fā)現(xiàn)并沒有創(chuàng)建成功(file.mkdir().toString()false)。

同樣的程序,我們將它運(yùn)行在Android 10以下的設(shè)備,就可以成功創(chuàng)建這個文件夾。

問題五、那我應(yīng)該怎么操作外部存儲?

現(xiàn)在我們已經(jīng)知道,在應(yīng)用確認(rèn)支持分區(qū)存儲之后,就不能再使用以前那一套來操作外部存儲了。那現(xiàn)在應(yīng)該怎么辦呢?Google官方推薦我們使用MediaStore提供的API訪問圖片、視頻、音頻資源,使用SAF(存儲訪問框架)訪問其它任意類型的資源。

5.1、使用MediaStore將圖片保存到Pictures目錄

Environment中我們能找到很多公有目錄文件夾的名字,其中Pictures這個文件夾就適合用來保存圖片數(shù)據(jù):

image-20200417020830849.png

在以前,我們經(jīng)常會根據(jù)目錄或文件的絕對路徑得到File對象,再將File對象傳給FileOutputStream得到輸出流,然后就可以愉快地寫入數(shù)據(jù)了。Android 10以后,我們要向這些公有目錄寫入數(shù)據(jù),必須要用MediaStore了。

下面,我們通過代碼學(xué)習(xí)如何將Bitmap保存到Pictures文件夾下:

const val APP_FOLDER_NAME = "ExternalScopeTestApp"

val bitmap = BitmapFactory.decodeResource(resources, R.drawable.die)
val displayName = "${System.currentTimeMillis()}.jpg"
val mimeType = "image/jpeg"
val compressFormat = Bitmap.CompressFormat.JPEG

private fun saveBitmapToPicturePublicFolder(
    bitmap: Bitmap,
    displayName: String,
    mimeType: String,
    compressFormat: Bitmap.CompressFormat
) {
    val contentValues = ContentValues()
    contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
    contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
    val path = getAppPicturePath()
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, path)
    } else {
        val fileDir = File(path)
        if (!fileDir.exists()){
            fileDir.mkdir()
        }
        contentValues.put(MediaStore.MediaColumns.DATA, path + displayName)
    }
    val uri =
    contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
    uri?.also {
        val outputStream = contentResolver.openOutputStream(it)
        outputStream?.also { os ->
            bitmap.compress(compressFormat, 100, os)
            os.close()
            Toast.makeText(this, "添加圖片成功", Toast.LENGTH_SHORT).show()
        }
    }
}

fun getAppPicturePath(): String {
    return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        // full path
        "${Environment.getExternalStorageDirectory().absolutePath}/" +
                "${Environment.DIRECTORY_PICTURES}/$APP_FOLDER_NAME/"
    } else {
        // relative path
        "${Environment.DIRECTORY_PICTURES}/$APP_FOLDER_NAME/"
    }
}

代碼為了方便展示做了位置調(diào)整,大家可以直接查看完整代碼。項目地址在文末給出。

首先我們創(chuàng)建了ContentValues對象,并往里面添加了三種信息:

  1. DISPLAY_NAME:圖片的名字,需要包含后綴名。在這里,我們使用的是當(dāng)前的時間戳命名

  2. MIME_TYPE:文件的mime類型。在這里,我們使用的是image/jpeg

  3. RELATIVE_PATH、DATA:文件的存儲路徑。在Android 10中,新增了RELATIVE_PATH,它表示文件存儲的相對路徑,可選值其實就是Environment里面那堆,比如PicturesMusic等。但是注意看我們的getAppPicturePath()中的代碼:"${Environment.DIRECTORY_PICTURES}/$APP_FOLDER_NAME/",后面還跟了一個$APP_FOLDER_NAME,表示在Pictures這個目錄下面 ,還要創(chuàng)建一個名叫:ExternalScopeTestApp的文件夾,這是因為如果所有應(yīng)用都將圖片保存到Pictures的根目錄,勢必會非常混亂,因此我們針對自己的應(yīng)用建立了二級文件夾,將圖片都保存到自己的二級文件夾中。

    DATA這個字段是Android 10以前使用的字段,在Android 10中已經(jīng)廢棄,但為了兼容老版本系統(tǒng), 我們還是要用。這個字段需要文件的絕對路徑。

ContentValues里面的值都設(shè)置完成后,我們就可以使用ContentResolverinsert()方法插入數(shù)據(jù)了,插入完成后會得到插入圖片的Uri,接下來我們要根據(jù)這個Uri得到OutputStream對象,通過:contentResolver.openOutputStream(uri)就可得到,剩下的就是將Bitmap寫入了。

我們發(fā)現(xiàn),以前我們可以通過真實路徑得到輸出流,而現(xiàn)在只能通過Uri得到了。

如果我們不是將Bitmap保存到公有目錄,而是網(wǎng)絡(luò)上的圖片呢?其實原理都是一樣的,網(wǎng)絡(luò)上的圖片我們肯定是可以得到輸入流的,輸出流還是通過Uri獲取,然后讀取輸入流寫入輸出流不就行了嗎?

到此,保存圖片就學(xué)習(xí)完了,保存音頻、視頻都類似。注意,這些操作都是不需要權(quán)限的。

5.2、使用MediaStore獲取媒體庫中的圖片

我們向Pictures中添加了圖片文件,怎么才能獲取到呢?也必須通過MediaStore。如果我們沒有獲得存儲空間權(quán)限,那么我們只能通過MediaStore獲取到自己應(yīng)用創(chuàng)建的圖片;如果我們獲取了存儲空間權(quán)限,那么我們就可以獲取到其它應(yīng)用創(chuàng)建的圖片了。

我們通過代碼來學(xué)習(xí):

val cursor = contentResolver.query(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    null,
    null,
    null,
    "${MediaStore.MediaColumns.DATE_ADDED} desc"
)

cursor?.also {
    while (it.moveToNext()) {
        val id = it.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
        val displayName =
        it.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME))
        val uri =
        ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
    }
}
cursor?.close()

代碼很簡單,最終通過Uri與圖片文件id的組合,得到了圖片文件的Uri。得到了這個圖片的Uri后,怎么顯示出來呢?可以使用Glide,因為Glide原生支持Uri:

Glide.with(this).load(uri).into(ivPicture)

如果沒有使用Glide呢?可以這樣來做,得到一個Bitmap:

val openFileDescriptor = contentResolver.openFileDescriptor(uri, "r")
openFileDescriptor?.apply {
    val bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
    ivPicture.setImageBitmap(bitmap)
}
openFileDescriptor?.close()

但是需要注意,如果圖片分辨率高,bitmap會很占用內(nèi)存,而實際要顯示的區(qū)域可能比原圖小得多,需要自己控制下bitmap的像素。

在沒有獲取存儲空間權(quán)限對情況下,我們只能獲取到應(yīng)用自己創(chuàng)建的圖片:

mediaStoreReadSelf.gif

下面我們預(yù)先在手機(jī)中放置幾張圖片,模擬它們是其它應(yīng)用創(chuàng)建的,這幾張圖片如下:

image-20200417034542458.png

它們有兩張在DCIM/Camera,有一張在Download/OtherApp_01目錄下。現(xiàn)在我們授予應(yīng)用存儲權(quán)限,看看是否能獲取到這三張圖片:

mediaStoreReadAll.gif

果然顯示出來了。大家可能有一個疑問:應(yīng)用是怎么在擁有權(quán)限的情況下只顯示自己保存的圖片的?不是一旦有權(quán)限后,就是查詢的所有嗎?答案就是增加WHERE語句,過濾DATA和RELATIVE_PATH:

private fun queryImages(queryAll: Boolean = false): List<ImageBean> {
    var pathKey = ""
    var pathValue = ""
    if (!queryAll) {
        pathKey = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            MediaStore.MediaColumns.DATA
        } else {
            MediaStore.MediaColumns.RELATIVE_PATH
        }
        // RELATIVE_PATH會在路徑的最后自動添加/
        pathValue = getAppPicturePath()
    }
    val dataList = mutableListOf<ImageBean>()
    val cursor = contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        null,
        if (pathKey.isEmpty()) {
            null
        } else {
            "$pathKey LIKE ?"
        },
        if (pathValue.isEmpty()) {
            null
        } else {
            arrayOf("%$pathValue%")
        },
        "${MediaStore.MediaColumns.DATE_ADDED} desc"
    )

    cursor?.also {
        while (it.moveToNext()) {
            val id = it.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
            val displayName = it.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME))
            val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
            dataList.add(ImageBean(id, uri, displayName))
        }
    }
    cursor?.close()
    return dataList
}

用LIKE的原因是DATA中存儲的是圖片的絕對路徑,我們需要匹配應(yīng)用自己圖片路徑下的所有圖片。到此,獲取媒體庫中的圖片就學(xué)習(xí)完畢。

5.3、使用MediaStore刪除媒體庫中的圖片

同樣的,刪除自己創(chuàng)建的圖片不需要任何權(quán)限,但是刪除或者修改其它應(yīng)用創(chuàng)建的圖片就需要權(quán)限了,而且即使我們擁有了存儲權(quán)限,也不能修改或刪除其它APP的資源,需要由MediaProvider彈出彈框給用戶選擇是否允許APP修改或刪除圖片、視頻、音頻文件。用戶操作的結(jié)果,將通過onActivityResult回調(diào)返回到APP。如果用戶允許,APP將獲得該uri的修改權(quán)限,直到設(shè)備下一次重啟。我們先來學(xué)習(xí)刪除自己應(yīng)用的圖片:

val row = contentResolver.delete(imageUri, null, null)
if (row > 0) {
    Toast.makeText(this, "刪除成功", Toast.LENGTH_SHORT).show()
}

很簡單,圖片從ContentResolver中查詢出來的時候,我們可以獲取到id,圖片的uri就是通過MediaStore.Images.Media.EXTERNAL_CONTENT_URI與圖片的id組合而來。現(xiàn)在只需要通過uri進(jìn)行刪除即可。我們來看下在沒有存儲權(quán)限時,刪除應(yīng)用創(chuàng)建圖片的效果,當(dāng)然如果有存儲權(quán)限,也是一樣的:

mediaStoreDeleteSelf.gif

但是這段代碼是不夠嚴(yán)謹(jǐn)?shù)模驗楫?dāng)我們刪除的是其它應(yīng)用的資源,程序會閃退,并拋出:RecoverableSecurityException異常。因此我們需要捕獲這個異常,提示用戶給予此uri修改或刪除的權(quán)限:

companion object {
    const val REQUEST_DELETE_PERMISSION = 1
}

private var pendingDeleteImageUri: Uri? = null
private var pendingDeletePosition: Int = -1

private fun deleteImage(imageUri: Uri, adapterPosition: Int) {
    var row = 0
    try {
        // Android 10+中,如果刪除的是其它應(yīng)用的Uri,則需要用戶授權(quán)
        // 會拋出RecoverableSecurityException異常
        row = contentResolver.delete(imageUri, null, null)
    } catch (securityException: SecurityException) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val recoverableSecurityException =
                securityException as? RecoverableSecurityException
                    ?: throw securityException
            pendingDeleteImageUri = imageUri
            pendingDeletePosition = adapterPosition
            // 我們可以使用IntentSender向用戶發(fā)起授權(quán)
            requestRemovePermission(recoverableSecurityException.userAction.actionIntent.intentSender)
        } else {
            throw securityException
        }
    }

    if (row > 0) {
        Toast.makeText(this, "刪除成功", Toast.LENGTH_SHORT).show()
        pictureAdapter.deletePosition(adapterPosition)
    }
}

private fun requestRemovePermission(intentSender: IntentSender) {
    startIntentSenderForResult(intentSender, REQUEST_DELETE_PERMISSION, 
        null, 0, 0, 0, null)
}

private fun deletePendingImageUri(){
    pendingDeleteImageUri?.let {
        pendingDeleteImageUri = null
        deleteImage(it,pendingDeletePosition)
        pendingDeletePosition = -1
    }
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (resultCode == Activity.RESULT_OK &&
        requestCode == REQUEST_DELETE_PERMISSION
    ) {
        // 執(zhí)行之前的刪除邏輯
        deletePendingImageUri()
    }
}

簡單解釋下,當(dāng)修改或刪除非本應(yīng)用創(chuàng)建的文件uri時,在Android 10+的系統(tǒng)中,會拋出RecoverableSecurityException,我們捕獲到這個異常后,從異常中獲得了IntentSender,并使用它來向用戶索取該uri的修改、刪除權(quán)限。代碼都很簡單,不再贅述,效果如下:

mediaStoreDeleteAll.gif

至此,刪除媒體庫中的圖片就學(xué)習(xí)完成了。

問題六、我想讀取Download文件夾下的某個非媒體文件怎么辦?

拿PDF舉例,顯然,PDF不屬于音頻、視頻、圖片,因此我們不能使用MediaStore來獲取。對于這種其它類型的文件,我們一般使用SAF(存儲訪問框架)讓用戶選擇:

private fun selectPdfUseSAF() {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        type = "application/pdf"
        // 我們需要使用ContentResolver.openFileDescriptor讀取數(shù)據(jù)
        addCategory(Intent.CATEGORY_OPENABLE)
    }
    startActivityForResult(intent, REQUEST_OPEN_PDF)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
        REQUEST_OPEN_PDF -> {
            if (resultCode == Activity.RESULT_OK) {
                data?.data?.also { documentUri ->
                    val fileDescriptor =
                        contentResolver.openFileDescriptor(documentUri, "r") ?: return
                    // 現(xiàn)在,我們可以使用PdfRenderer等類通過fileDescriptor讀取pdf內(nèi)容
                    Toast.makeText(this, "pdf讀取成功", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}

注意,ACTION_OPEN_DOCUMENT用于打開文件。

問題七、我想創(chuàng)建任意類型文件怎么辦?

也是使用SAF(存儲訪問框架)讓用戶去創(chuàng)建。其Intent Action為:ACTION_CREATE_DOCUMENT,我們可以使用Intent的putExtra()來指定文件的名字:intent.putExtra(Intent.EXTRA_TITLE, "Android.pdf"),有點類似于"另存為"功能。其它的用法差不多,就不多說了。

問題八、我想將文件下載到Download目錄怎么辦?

拿下載app為例,在Android 10以前,只要獲取到了File對象,就能得到輸入流,而由于我們適配了Android 10中的分區(qū)存儲,因此不能這樣做了。MediaStore中提供了一種Downloads集合,專門用于執(zhí)行文件下載操作。它的使用和添加圖片是幾乎一樣的:

private fun downloadApkAndInstall(fileUrl: String, apkName: String) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        Toast.makeText(this, "請使用原始方式", Toast.LENGTH_SHORT).show()
        return
    }
    thread {
        try {
            val url = URL(fileUrl)
            val connection = url.openConnection() as HttpURLConnection
            connection.requestMethod = "GET"
            val inputStream = connection.inputStream
            val bis = BufferedInputStream(inputStream)
            val values = ContentValues()
            values.put(MediaStore.MediaColumns.DISPLAY_NAME, apkName)
            values.put(MediaStore.MediaColumns.RELATIVE_PATH, getAppDownloadPath())
            val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
            uri?.also {
                val outputStream = contentResolver.openOutputStream(uri) ?: return@thread
                val bos = BufferedOutputStream(outputStream)
                val buffer = ByteArray(1024)
                var bytes = bis.read(buffer)
                while (bytes >= 0) {
                    bos.write(buffer, 0, bytes)
                    bos.flush()
                    bytes = bis.read(buffer)
                }
                bos.close()
                runOnUiThread {
                    installAPK(uri)
                }
            }
            bis.close()

        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

private fun installAPK(uri: Uri) {
    val intent = Intent(Intent.ACTION_VIEW)
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    intent.setDataAndType(uri, "application/vnd.android.package-archive")
    startActivity(intent)
}

由于我們獲取到的這個uri本來就是content://開頭,所以不需要使用FileProvider。對于應(yīng)用私有目錄的文件,我們可以使用FileProvider進(jìn)行分享。

項目地址

本文的所有代碼都可以在項目中查看。傳送門

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,836評論 6 540
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,275評論 3 428
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,904評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,633評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 72,368評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,736評論 1 328
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,740評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,919評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,481評論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,235評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,427評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,968評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,656評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,055評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,348評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,160評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 48,380評論 2 379

推薦閱讀更多精彩內(nèi)容