Android 10適配要點,做用域存儲

本文同步發表於個人微信公衆號,在微信搜索「郭霖」便可關注,每一個工做日都有文章更新。android

距離 Android 10 系統正式發佈已通過去大半年左右的時間了,你的應用程序已經對它進行適配了嗎?git

在 Android 10 衆多的行爲變動當中,有一點是很是值得引發咱們重視的,那就是做用域存儲。這個新功能直接顛覆了長久以來咱們一直慣用的外置存儲空間的使用方式,所以大量 App 都將面臨着較多代碼模塊的升級。github

然而,對於做用域存儲這個新功能,官方的資料並很少,不少人也沒有搞明白它的用法。另外它也不屬於《第一行代碼》現有的知識架構體系,雖然我有想過在第 3 版中加入這部份內容的講解,但幾經思考以後仍是決定以一講單獨文章的方式來說解這部份內容,也算是做爲《第一行代碼 第 3 版》的內容擴展吧。瀏覽器

本篇文章對做用域存儲進行了比較全面的解析,相信看完以後你將可以輕鬆地完成 Android 10 做用域存儲的適配升級。安全

理解做用域存儲

Android 長久以來都支持外置存儲空間這個功能,也就是咱們常說的 SD 卡存儲。這個功能使用得極其普遍,幾乎全部的 App 都喜歡在 SD 卡的根目錄下創建一個本身專屬的目錄,用來存放各種文件和數據。微信

那麼這麼作有什麼好處嗎?我想了一下,大概有兩點吧。第一,存儲在 SD 卡的文件不會計入到應用程序的佔用空間當中,也就是說即便你在 SD 卡存放了 1G 的文件,你的應用程序在設置中顯示的佔用空間仍然可能只有幾十 K。第二,存儲在 SD 卡的文件,即便應用程序被卸載了,這些文件仍然會被保留下來,這有助於實現一些須要數據被永久保留的功能。markdown

然而,這些 「好處」 真的是好處嗎?或 許對於開發者而言這算是好處吧,但對於用戶而言,上述好處無異於一些流氓行爲。由於這會將用戶的 SD 卡空間搞得亂糟糟的,並且即便我卸載了一個徹底再也不使用的程序,它所產生的垃圾文件卻可能會一直保留在個人手機上。網絡

另外,存儲在 SD 卡上的文件屬於公有文件,全部的應用程序都有權隨意訪問,這也對數據的安全性帶來了很大的挑戰。架構

爲了解決上述問題,Google 在 Android 10 當中加入了做用域存儲功能。app

那麼到底什麼是做用域存儲呢?簡單來說,就是 Android 系統對 SD 卡的使用作了很大的限制。從 Android 10 開始,每一個應用程序只能有權在本身的外置存儲空間關聯目錄下讀取和建立文件,獲取該關聯目錄的代碼是:context.getExternalFilesDir()。關聯目錄對應的路徑大體以下:

/storage/emulated/0/Android/data/<包名>/files
複製代碼

將數據存放到這個目錄下,你將能夠徹底使用以前的寫法來對文件進行讀寫,不須要作任何變動和適配。但同時,剛纔提到的那兩個 「好處」 也就不存在了。這個目錄中的文件會被計入到應用程序的佔用空間當中,同時也會隨着應用程序的卸載而被刪除。

那麼有些朋友可能會問了,我就是須要訪問其餘目錄該怎麼辦呢?好比讀取手機相冊中的圖片,或者向手機相冊中添加一張圖片。爲此,Android 系統針對文件類型進行了分類,圖片、音頻、視頻這三類文件將能夠經過 MediaStore API 來進行訪問,而其餘類型的文件則須要使用系統的文件選擇器來進行訪問。

另外,咱們的應用程序向媒體庫貢獻的圖片、音頻或視頻,將會自動擁有其讀寫權限,不須要額外申請 READ_EXTERNAL_STORAGE 和 WRITE_EXTERNAL_STORAGE 權限。而若是你要讀取其餘應用程序向媒體庫貢獻的圖片、音頻或視頻,則必需要申請 READ_EXTERNAL_STORAGE 權限才行。WRITE_EXTERNAL_STORAGE 權限將會在將來的 Android 版本中廢棄。

好了,關於做用域存儲的理論知識就先講到這裏,相信你已經對它有了一個基本的瞭解了,那麼接下來咱們就開始上手操做吧。

我必定要升級嗎?

必定會有不少朋友關心這個問題,由於每當適配升級面臨着須要更改大量代碼的時候,大多數人的第一想法都是能不升就不升,或者能晚升就晚升。而在做用域存儲這個功能上面,恭喜你們,暫時確實是能夠不用升級的。

目前 Android 10 系統對於做用域存儲適配的要求還不是那麼嚴格,畢竟以前傳統外置存儲空間的用法實在是太普遍了。若是你的項目指定的 targetSdkVersion 低於 29,那麼即便不作任何做用域存儲方面的適配,你的項目也能夠成功運行到 Android 10 手機上。

而若是你的 targetSdkVersion 已經指定成了 29,也沒有關係,假如你還不想進行做用域存儲的適配,只須要在 AndroidManifest.xml 中加入以下配置便可:

<manifest ... >
  <application android:requestLegacyExternalStorage="true" ...>
    ...
  </application>
</manifest>
複製代碼

這段配置表示,即便在 Android 10 系統上,仍然容許使用以前遺留的外置存儲空間的用法來運行程序,這樣就不用對代碼進行任何修改了。固然,這只是一種權宜之計,在將來的 Android 系統版本中,這段配置隨時均可能會失效(Android 11 中已強制啓用做用域存儲,這段配置在 Android 11 當中已再也不有效)。所以,咱們仍是很是有必要如今就來學習一下,到底該如何對做用域存儲進行適配。

另外,本篇文章中演示的全部示例,均可以到 ScopedStorageDemo 這個開源庫中找到其對應的源碼。

獲取相冊中的圖片

首先來學習一下如何在做用域存儲當中獲取手機相冊裏的圖片。注意,雖然本篇文章中我是以圖片來舉例的,可是獲取音頻、視頻的用法也是基本相同的。

不一樣於過去能夠直接獲取到相冊中圖片的絕對路徑,在做用域存儲當中,咱們只能藉助 MediaStore API 獲取到圖片的 Uri,示例代碼以下:

val cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, "${MediaStore.MediaColumns.DATE_ADDED} desc")
if (cursor != null) {
    while (cursor.moveToNext()) {
        val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
        val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
        println("image uri is $uri")
    }
	cursor.close()
}
複製代碼

上述代碼中,咱們先是經過 ContentResolver 獲取到了相冊中全部圖片的 id,而後再借助 ContentUris 將 id 拼裝成一個完整的 Uri 對象。一張圖片的 Uri 格式大體以下所示:

content://media/external/images/media/321
複製代碼

那麼有些朋友可能會問了,獲取到了 Uri 以後,我又該怎樣將這張圖片顯示出來呢?這就有不少種辦法了,好比使用 Glide 來加載圖片,它自己就支持傳入 Uri 對象來做爲圖片路徑:

Glide.with(context).load(uri).into(imageView)
複製代碼

而若是你沒有使用 Glide 或其餘圖片加載框架,想在不借助第三方庫的狀況下直接將一個 Uri 對象解析成圖片,可使用以下代碼:

val fd = contentResolver.openFileDescriptor(uri, "r")
if (fd != null) {
    val bitmap = BitmapFactory.decodeFileDescriptor(fd.fileDescriptor)
	fd.close()
    imageView.setImageBitmap(bitmap)
}
複製代碼

上述代碼中,咱們調用了 ContentResolver 的 openFileDescriptor() 方法,並傳入 Uri 對象來打開文件句柄,而後再調用 BitmapFactory 的 decodeFileDescriptor() 方法將文件句柄解析成 Bitmap 對象便可。

Demo 效果:

這樣咱們就將獲取相冊中圖片的方式掌握了,而且這種方式在全部的 Android 系統版本中都適用。

那麼接下來,咱們開始學習如何將一張圖片添加到相冊。

將圖片添加到相冊

將一張圖片添加到手機相冊要相對稍微複雜一點,由於不一樣系統版本之間的處理方式是不太同樣的。

咱們仍是經過一段代碼示例來直觀地學習一下,代碼以下所示:

fun addBitmapToAlbum(bitmap: Bitmap, displayName: String, mimeType: String, compressFormat: Bitmap.CompressFormat) {
    val values = ContentValues()
    values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
    values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
    } else {
        values.put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName")
    }
    val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
    if (uri != null) {
        val outputStream = contentResolver.openOutputStream(uri)
        if (outputStream != null) {
            bitmap.compress(compressFormat, 100, outputStream)
			outputStream.close()
        }
    }
}
複製代碼

這段代碼演示瞭如何將一個 Bitmap 對象添加到手機相冊當中,我來簡單解釋一下。

想要將一張圖片添加到手機相冊,咱們須要構建一個 ContentValues 對象,而後向這個對象中添加三個重要的數據。一個是 DISPLAY_NAME,也就是圖片顯示的名稱,一個是 MIME_TYPE,也就是圖片的 mime 類型。還有一個是圖片存儲的路徑,不過這個值在 Android 10 和以前的系統版本中的處理方式不同。Android 10 中新增了一個 RELATIVE_PATH 常量,表示文件存儲的相對路徑,可選值有 DIRECTORY_DCIM、DIRECTORY_PICTURES、DIRECTORY_MOVIES、DIRECTORY_MUSIC 等,分別表示相冊、圖片、電影、音樂等目錄。而在以前的系統版本中並無 RELATIVE_PATH,因此咱們要使用 DATA 常量(已在 Android 10 中廢棄),並拼裝出一個文件存儲的絕對路徑才行。

有了 ContentValues 對象以後,接下來調用 ContentResolver 的 insert() 方法便可得到插入圖片的 Uri。但僅僅得到 Uri 仍然是不夠的,咱們還須要向該 Uri 所對應的圖片寫入數據才行。調用 ContentResolver 的 openOutputStream() 方法得到文件的輸出流,而後將 Bitmap 對象寫入到該輸出流當中便可。

以上代碼便可實現將 Bitmap 對象存儲到手機相冊當中,那麼有些朋友可能會問了,若是我要存儲的圖片並非 Bitmap 對象,而是一張網絡上的圖片,或者是當前應用關聯目錄下的圖片該怎麼辦呢?

其實方法都是類似的,由於無論是網絡上的圖片仍是關聯目錄下的圖片,咱們都能獲取到它的輸入流,只要不斷讀取輸入流中的數據,而後寫入到相冊圖片所對應的輸出流當中就能夠了,示例代碼以下:

fun writeInputStreamToAlbum(inputStream: InputStream, displayName: String, mimeType: String) {
    val values = ContentValues()
    values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
    values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
    } else {
        values.put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName")
    }
    val bis = BufferedInputStream(inputStream)
    val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
    if (uri != null) {
        val outputStream = contentResolver.openOutputStream(uri)
        if (outputStream != null) {
            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()
        }
    }
    bis.close()
}
複製代碼

這段代碼中只是將輸入流和輸出流的部分從新編寫了一下,其餘部分和以前存儲 Bitmap 的代碼是徹底一致的,相信很好理解。

Demo 效果:

好了,這樣咱們就將相冊圖片的讀取和存儲問題都解決了,下面咱們來探討另一個常見的需求,如何將文件下載到 Download 目錄。

下載文件到 Download 目錄

執行文件下載操做是一個很常見的場景,好比說下載 pdf、doc 文件,或者下載 APK 安裝包等等。在過去,這些文件咱們一般都會下載到 Download 目錄,這是一個專門用於存放下載文件的目錄。而從 Android 10 開始,咱們已經不能以絕對路徑的方式訪問外置存儲空間了,因此文件下載功能也會受到影響。

那麼該如何解決呢?主要有如下兩種方式。

第一種同時也是最簡單的一種方式,就是更改文件的下載目錄。將文件下載到應用程序的關聯目錄下,這樣不用修改任何代碼就可讓程序在 Android 10 系統上正常工做。但使用這種方式,你須要知道,下載的文件會被計入到應用程序的佔用空間當中,同時若是應用程序被卸載了,該文件也會一同被刪除。另外,存放在關聯目錄下的文件只能被當前的應用程序所訪問,其餘程序是沒有讀取權限的。

以上幾個限制條件若是不能知足你的需求,那麼就只能使用第二種方式,對 Android 10 系統進行代碼適配,仍然將文件下載到 Download 目錄下。

其實將文件下載到 Download 目錄,和向相冊中添加一張圖片的過程是差很少的,Android 10 在 MediaStore 中新增了一種 Downloads 集合,專門用於執行文件下載操做。但因爲每一個項目下載功能的實現都各不相同,有些項目的下載實現還十分複雜,所以怎麼將如下的示例代碼融合到你的項目當中是你本身須要思考的問題。

fun downloadFile(fileUrl: String, fileName: String) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        Toast.makeText(this, "You must use device running Android 10 or higher", Toast.LENGTH_SHORT).show()
        return
    }
    thread {
		try {
			val url = URL(fileUrl)
			val connection = url.openConnection() as HttpURLConnection
			connection.requestMethod = "GET"
			connection.connectTimeout = 8000
			connection.readTimeout = 8000
			val inputStream = connection.inputStream
			val bis = BufferedInputStream(inputStream)
			val values = ContentValues()
			values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
			values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
			val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
			if (uri != null) {
				val outputStream = contentResolver.openOutputStream(uri)
				if (outputStream != null) {
					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()
				}
			}
			bis.close()
		} catch(e: Exception) {
			e.printStackTrace()
		}
    }
}
複製代碼

這段代碼整體來說仍是比較好理解的,主要就是添加了一些 Http 請求的代碼,並將 MediaStore.Images.Media 改爲了 MediaStore.Downloads,其餘部分幾乎是沒有變化的,我就再也不多加解釋了。

注意,上述代碼只能在 Android 10 或更高的系統版本上運行,由於 MediaStore.Downloads 是 Android 10 中新增的 API。至於 Android 9 及如下的系統版本,請你仍然使用以前的代碼來進行文件下載。

Demo 效果:

使用文件選擇器

若是咱們要讀取 SD 卡上非圖片、音頻、視頻類的文件,好比說打開一個 PDF 文件,這個時候就不能再使用 MediaStore API 了,而是要使用文件選擇器。

可是,咱們不能再像以前的寫法那樣,本身寫一個文件瀏覽器,而後從中選取文件,而是必需要使用手機系統中內置的文件選擇器。示例代碼以下:

const val PICK_FILE = 1

private fun pickFile() {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
    intent.addCategory(Intent.CATEGORY_OPENABLE)
    intent.type = "*/*"
    startActivityForResult(intent, PICK_FILE)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
        PICK_FILE -> {
            if (resultCode == Activity.RESULT_OK && data != null) {
                val uri = data.data
                if (uri != null) {
                    val inputStream = contentResolver.openInputStream(uri)
					
                }
            }
        }
    }
}
複製代碼

這裏在 pickFile() 方法當中經過 Intent 去啓動系統的文件選擇器,注意 Intent 的 action 和 category 都是固定不變的。而 type 屬性能夠用於對文件類型進行過濾,好比指定成 image/ 就能夠只顯示圖片類型的文件,這裏寫成 /* 表示顯示全部類型的文件。注意 type 屬性必需要指定,不然會產生崩潰。

而後在 onActivityResult() 方法當中,咱們就能夠獲取到用戶選中文件的 Uri,以後經過 ContentResolver 打開文件輸入流來進行讀取就能夠了。

Demo 效果:

閱讀完了本篇文章以後,相信你對 Android 10 做用域存儲的用法和適配基本上都已經掌握了。然而咱們在實際的開發工做當中還可能會面臨一個很是頭疼的問題,就是我本身的代碼固然能夠進行適配,可是項目中使用的第三方 SDK 還不支持做用域存儲該怎麼辦呢?

這個狀況確實是存在的,好比我以前使用的七牛雲 SDK,它的文件上傳功能要求你傳入的就是一個文件的絕對路徑,而不支持傳入 Uri 對象,你們應該也會碰到相似的問題。

因爲咱們是沒有權限修改第三方 SDK 的,所以最簡單直接的辦法就是等待第三方 SDK 的提供者對這部分功能進行更新,在那以前咱們先不要將 targetSdkVersion 指定到 29,或者先在 AndroidManifest 文件中配置一下 requestLegacyExternalStorage 屬性。

然而若是你不想使用這種權宜之計,其實還有一個很是好的辦法來解決此問題,就是咱們本身編寫一個文件複製功能,將 Uri 對象所對應的文件複製到應用程序的關聯目錄下,而後再將關聯目錄下這個文件的絕對路徑傳遞給第三方 SDK,這樣就能夠完美進行適配了。這個功能的示例代碼以下:

fun copyUriToExternalFilesDir(uri: Uri, fileName: String) {
    val inputStream = contentResolver.openInputStream(uri)
    val tempDir = getExternalFilesDir("temp")
    if (inputStream != null && tempDir != null) {
        val file = File("$tempDir/$fileName")
        val fos = FileOutputStream(file)
        val bis = BufferedInputStream(inputStream)
        val bos = BufferedOutputStream(fos)
        val byteArray = ByteArray(1024)
        var bytes = bis.read(byteArray)
        while (bytes > 0) {
            bos.write(byteArray, 0, bytes)
            bos.flush()
            bytes = bis.read(byteArray)
        }
        bos.close()
        fos.close()
    }
}
複製代碼

好的,關於 Android 10 做用域存儲的重要知識點就講到這裏,相信你已經能夠徹底掌握了。下篇文章中咱們會繼續學習 Android 10 適配,講一講深色主題的功能,詳見鏈見: Android 10 適配要點,深色主題

注:本篇文章中演示的全部示例,均可以到 ScopedStorageDemo 這個開源庫中找到其對應的源碼。

開源庫地址是:github.com/guolindev/S…

本篇文章是《第一行代碼 第 3 版》的配套擴展文章,目前《第一行代碼 第 3 版》已經出版,Kotlin、Jetpack、MVVM,你所關心的知識點都在這裏,詳情點擊這裏查看

關注個人技術公衆號「郭霖」,天天都有優質技術文章推送。

相關文章
相關標籤/搜索