有權限後,就可以取得音樂檔案啦,透過 ContentProvider 觀念介紹 介紹的觀念,取得音檔。
首先先來寫 query 的 function:
override suspend fun getSongs(): List<Song> {
val songs = mutableListOf<Song>()
/**
* Working with [ContentResolver]s can be slow, so we'll do this off the main
* thread inside a coroutine.
*/
withContext(Dispatchers.IO) {
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.ALBUM
)
val selection = null
val selectionArgs = null
val sortOrder = "${MediaStore.Audio.Media.DATE_ADDED} DESC"
......
因為讀取音檔資料會花費比較久的時間,需要在 Background thread 執行,這邊就使用 Coroutines 來處理。接著看到 projection,先拿我們畫面上需要的就好。從官方文件上可以得知,還有很多欄位可以拿,但也看到很多欄位在 Android 11 要被加入,有些要被棄用了(怕) XD,之後 App target 升級到 30 (Android 11),可能會有需要修改的部分,但目前就先用 Android 10 的方式來撰寫。
因為要拿全部的音檔,因此在 selection 和 selectionArgs 就不用給定數值。sortOrder 則是排序規則,設定成依據音檔加入的時間降續排列,最晚加入的會最先取得。
條件設定好了,就可以執行查詢了:
contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
val albumIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
Log.i(TAG, "Found ${cursor.count} songs")
查詢後,會拿到 db 的 cursor,可以透過這個 cursor 來取得資訊。上面的 idColumn 這些欄位是取得 column 的 index,可以看到這邊寫的 column 都是上面 projection 有寫到的。
接著就將資訊轉換成物件:
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val title = cursor.getString(titleColumn)
val artist = cursor.getString(artistColumn)
val albumId = cursor.getLong(albumIdColumn)
val album = cursor.getString(albumColumn)
val cover = getAlbumCoverPathFromAlbumId(contentResolver, albumId)
val contentUri: Uri =
ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)
val song = Song(
id = id,
title = title,
artistName = artist,
albumId = albumId,
albumName = album,
coverPath = cover,
contentUri = contentUri
)
songs += song
Log.v(TAG, "Added songs: $song")
}
Song 為自定義的資料結構,裡面存畫面上和播放所需的資料,供之後使用,除了有上面五個 projection 的欄位外,還看到多了兩個欄位分別是:
原本拿取音檔的方式為在 Projection 上加入 MediaStore.Audio.Media.DATA,但這個寫法在在 Android 10 引入了 Scope Storage 後被 deprecated 了,原本這個寫法拿到的是一個絕對路徑,ex: /storage/emulated/0/Music/xxx.mp3。
關於 coverPath 這邊雖然路徑拿到的是 content:// 開頭,但使用的路徑是寫死的路徑的(content://media/external/audio/albumart),加上 ALBUM_ART 欄位在 Android 10 被 deprecated 了,要使用的話,直接用 ContentResolver#loadThumbnail,來取得圖片,但這個 API 在 Android 10 才加入,那 Android 5 到 9 要用什麼方式處理呢? 目前還沒有找到比較好的方式,如果大家知道有比較好的方式來獲取 Cover 路徑,歡迎在下面留言!
private fun getAlbumCoverPathFromAlbumId(
contentResolver: ContentResolver,
albumId: Long
): String {
val albumArtUri =
Uri.parse("content://media/external/audio/albumart")
val coverUri = ContentUris.withAppendedId(albumArtUri, albumId)
return try {
val inputStream: InputStream? = contentResolver.openInputStream(coverUri)
inputStream?.close()
coverUri.toString()
} catch (e: IOException) {
""
} catch (e: IllegalStateException) {
""
}
}
以上就是撈取音檔的程式碼。
再來是和系統註冊事件,如果音檔有變化會通知,比方說有新的音檔下載好了,系統就會發出通知 App,可以去讀取新的歌曲資訊。
contentObserver = contentResolver.registerObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI ) {
loadSongs()
}
fun ContentResolver.registerObserver(
uri: Uri,
observer: (selfChange: Boolean) -> Unit
): ContentObserver {
val contentObserver = object : ContentObserver(Handler()) {
override fun onChange(selfChange: Boolean) {
observer(selfChange)
}
}
registerContentObserver(uri, true, contentObserver)
return contentObserver
}
因為有註冊,就需要反註冊,ViewModel 可以寫在 onCleared() 內:
override fun onCleared() {
contentObserver?.let {
contentResolver.unregisterContentObserver(it)
}
}
其他的部分就是程式的串接,使用 LiveData 和 Coroutines,DI 使用手工的方式(InjectorUtils),就不特別介紹了。
程式碼在這,分支名稱(day5_query_songs):Fancy/day5_query_songs
明天就介紹顯示撈取的音檔啦,不會只有在 log 上看到了 XD