Android Page3与Flow分页查媒体数据库展示宫格图片列表,Kotlin
Android中使用Paging3和Flow实现媒体库分页查询及图片展示的关键实现。主要包括:
- 权限配置:声明了存储访问和媒体读取权限
- Coil图片加载:通过自定义ImageLoader实现图片加载和缓存管理
- 数据库层:使用Room创建媒体数据库和DAO接口
- 分页实现:
- 使用PagingSource从MediaStore分页查询媒体数据
- 通过Pager配置分页参数
- ViewModel中暴露Flow数据流
- UI展示:
- RecyclerView配合GridLayoutManager实现宫格布局
- MediaPagingAdapter处理图片加载和展示
- 使用Coil进行图片异步加载和缓存
核心特点是结合AndroidX Paging3库实现高效分页,配合Coil图片加载库优化图片显示性能,整体架构清晰,适合处理大量媒体数据的展示需求。
XML
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
Kotlin
import android.content.Context
import android.os.Environment
import android.util.Log
import coil3.EventListener
import coil3.ImageLoader
import coil3.decode.Decoder
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.fetch.Fetcher
import coil3.imageDecoderEnabled
import coil3.memory.MemoryCache
import coil3.request.CachePolicy
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.request.Options
import java.io.File
class MyCoilMgr {
companion object {
const val TAG = "fly/MyCoilMgr"
val INSTANCE by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) { MyCoilMgr() }
}
private var mImageLoader: ImageLoader? = null
private constructor() {
Log.d(TAG, "constructor")
}
fun init(ctx: Context): ImageLoader {
if (mImageLoader != null) {
return mImageLoader!!
}
Log.d(TAG, "初始化ImageLoader")
//初始化加载器。
mImageLoader = ImageLoader.Builder(ctx)
.imageDecoderEnabled(true)
.memoryCachePolicy(CachePolicy.ENABLED)
.memoryCache(initMemoryCache())
.diskCachePolicy(CachePolicy.ENABLED)
.diskCache(initDiskCache())
.eventListener(object : EventListener() {
override fun fetchStart(request: ImageRequest, fetcher: Fetcher, options: Options) {
//Log.d(TAG, "fetchStart ${request.data}")
}
override fun decodeStart(request: ImageRequest, decoder: Decoder, options: Options) {
//Log.d(TAG, "decodeStart ${request.data}")
}
})
.components {
}.build()
return mImageLoader!!
}
private fun initMemoryCache(): MemoryCache {
//内存缓存。
val memoryCache = MemoryCache.Builder()
.maxSizeBytes(1024 * 1024 * 1024 * 2L) //2GB
.build()
return memoryCache
}
private fun initDiskCache(): DiskCache {
//磁盘缓存。
val diskCacheFolder = Environment.getExternalStorageDirectory()
val diskCacheName = "fly_disk_cache"
val cacheFolder = File(diskCacheFolder, diskCacheName)
if (cacheFolder.exists()) {
Log.d(TAG, "${cacheFolder.absolutePath} exists")
} else {
if (cacheFolder.mkdir()) {
Log.d(TAG, "${cacheFolder.absolutePath} create OK")
} else {
Log.e(TAG, "${cacheFolder.absolutePath} create fail")
}
}
val diskCache = DiskCache.Builder()
.maxSizeBytes(1024 * 1024 * 1024 * 2L) //2GB
.directory(cacheFolder)
.build()
Log.d(TAG, "cache folder = ${diskCache.directory.toFile().absolutePath}")
return diskCache
}
fun enqueue(request: ImageRequest): Disposable? {
return mImageLoader?.enqueue(request)
}
fun loader(): ImageLoader {
return mImageLoader!!
}
}
Kotlin
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [MediaEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun mediaDao(): MediaDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"media_db"
).build().also { INSTANCE = it }
}
}
}
}
Kotlin
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
/**
* 不一定需要
*/
@Dao
interface MediaDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(list: List<MediaEntity>)
}
Kotlin
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "media")
data class MediaEntity(
@PrimaryKey
val id: Long,
val uri: String,
val name: String,
val path: String?, // 这里存 RELATIVE_PATH + DISPLAY_NAME
val mimeType: String?,
val size: Long,
val duration: Long,
val dateAdded: Long,
val type: String // IMAGE / VIDEO
)
Kotlin
import android.content.Context
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil3.asDrawable
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.size.Scale
import com.ppdemo.R
import com.ppdemo.coil.MyCoilMgr
class MediaPagingAdapter(val mCtx: Context) : PagingDataAdapter<MediaEntity, MediaPagingAdapter.MediaViewHolder>(DIFF) {
companion object {
const val TAG = "fly/MediaAdapter"
val DIFF = object : DiffUtil.ItemCallback<MediaEntity>() {
override fun areItemsTheSame(oldItem: MediaEntity, newItem: MediaEntity): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: MediaEntity, newItem: MediaEntity): Boolean {
return oldItem == newItem
}
}
}
private var disposable: Disposable? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder {
val view = LayoutInflater.from(mCtx).inflate(R.layout.item_media, parent, false)
return MediaViewHolder(view)
}
override fun onBindViewHolder(holder: MediaViewHolder, position: Int) {
holder.imageView.setImageResource(android.R.drawable.gallery_thumb)
val item = getItem(position) ?: return
val req = ImageRequest.Builder(mCtx)
.data(item.uri)
.size(PageActivity.IMG_SIZE)
.scale(Scale.FIT)
.listener(object : ImageRequest.Listener {
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
holder.imageView.setImageDrawable(result.image.asDrawable(mCtx.resources))
}
override fun onStart(request: ImageRequest) {
holder.imageView.setImageResource(android.R.drawable.ic_menu_gallery)
}
}).build()
disposable = MyCoilMgr.INSTANCE.enqueue(req)
}
class MediaViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val imageView: ImageView = itemView.findViewById(R.id.iv)
}
}
Kotlin
import android.content.Context
import androidx.paging.Pager
import androidx.paging.PagingConfig
class MediaRepository(
private val context: Context,
private val db: AppDatabase
) {
fun pager() = Pager(
config = PagingConfig(
pageSize = PageActivity.PAGE_SIZE,
initialLoadSize = PageActivity.PAGE_INIT_SIZE,
prefetchDistance = PageActivity.PAGE_DISTANCE,
enablePlaceholders = false
),
pagingSourceFactory = {
MediaStorePagingSource(
context = context,
mediaDao = db.mediaDao() // 可选缓存
)
}
).flow
}
Kotlin
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
class MediaStorePagingSource(
private val context: Context,
private val mediaDao: MediaDao? = null
) : PagingSource<Int, MediaEntity>() {
companion object {
const val TAG = "fly/MediaStorePagingSource"
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MediaEntity> {
return try {
val page = params.key ?: 0
val offset = page * PageActivity.PAGE_SIZE
Log.d(TAG, "load begin...")
val t = System.currentTimeMillis()
val list = queryMediaPage(offset = offset, limit = PageActivity.PAGE_SIZE)
Log.d(TAG, "load end ${System.currentTimeMillis() - t}ms size=${list.size}")
// 可选,不必要:边读边缓存到 Room
mediaDao?.insertAll(list)
LoadResult.Page(
data = list,
prevKey = if (page == 0) null else page - 1,
nextKey = if (list.size < PageActivity.PAGE_SIZE) null else page + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, MediaEntity>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val page = state.closestPageToPosition(anchorPosition)
page?.prevKey?.plus(1) ?: page?.nextKey?.minus(1)
}
}
private fun queryMediaPage(offset: Int, limit: Int): List<MediaEntity> {
val result = mutableListOf<MediaEntity>()
val resolver = context.contentResolver
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns.MIME_TYPE,
MediaStore.Files.FileColumns.SIZE,
MediaStore.Files.FileColumns.DATE_ADDED,
MediaStore.Files.FileColumns.MEDIA_TYPE,
MediaStore.Video.VideoColumns.DURATION,
MediaStore.MediaColumns.RELATIVE_PATH
)
val selection = """
${MediaStore.Files.FileColumns.MEDIA_TYPE}=? OR
${MediaStore.Files.FileColumns.MEDIA_TYPE}=?
""".trimIndent()
val selectionArgs = arrayOf(
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(),
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString()
)
val queryArgs = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection)
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs)
putStringArray(
ContentResolver.QUERY_ARG_SORT_COLUMNS,
arrayOf(MediaStore.Files.FileColumns.DATE_ADDED)
)
putInt(
ContentResolver.QUERY_ARG_SORT_DIRECTION,
ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
)
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
}
resolver.query(collection, projection, queryArgs, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE)
val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE)
val dateCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
val typeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.DURATION)
val relativePathCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH)
while (cursor.moveToNext()) {
val id = cursor.getLong(idCol)
val name = cursor.getString(nameCol) ?: ""
val mimeType = cursor.getString(mimeCol)
val size = cursor.getLong(sizeCol)
val dateAdded = cursor.getLong(dateCol)
val mediaType = cursor.getInt(typeCol)
val duration = if (cursor.isNull(durationCol)) 0L else cursor.getLong(durationCol)
val relativePath = cursor.getString(relativePathCol)
val path = buildPath(relativePath, name)
val contentUri = when (mediaType) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE ->
ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO ->
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> null
} ?: continue
result.add(
MediaEntity(
id = id,
uri = contentUri.toString(),
name = name,
path = path,
mimeType = mimeType,
size = size,
duration = duration,
dateAdded = dateAdded,
type = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) {
"IMAGE"
} else {
"VIDEO"
}
)
)
}
}
return result
}
private fun buildPath(relativePath: String?, name: String): String? {
if (relativePath.isNullOrBlank() && name.isBlank()) return null
if (relativePath.isNullOrBlank()) return name
return if (relativePath.endsWith("/")) {
relativePath + name
} else {
"$relativePath/$name"
}
}
}
Kotlin
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.cachedIn
class MediaViewModel(application: Application) : AndroidViewModel(application) {
private val repository = MediaRepository(
context = application,
db = AppDatabase.getInstance(application)
)
val mediaFlow = repository.pager().cachedIn(viewModelScope)
}
Kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.ppdemo.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class PageActivity : AppCompatActivity() {
companion object {
const val TAG = "fly/PageActivity"
const val GRID = 4
const val IMG_SIZE = 400
const val PAGE_SIZE = 100
const val PAGE_INIT_SIZE = PAGE_SIZE
const val PAGE_DISTANCE = PAGE_SIZE / 2
}
private var recyclerView: RecyclerView? = null
private var adapter: MediaPagingAdapter? = null
private var viewModel: MediaViewModel? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_page)
viewModel = ViewModelProvider(this)[MediaViewModel::class.java]
recyclerView = findViewById(R.id.rv)
recyclerView?.layoutManager = GridLayoutManager(this, GRID)
adapter = MediaPagingAdapter(this)
recyclerView?.adapter = adapter
}
override fun onResume() {
super.onResume()
collectPagingData()
}
private fun collectPagingData() {
lifecycleScope.launch(Dispatchers.IO) {
viewModel?.mediaFlow?.collect { pagingData ->
adapter?.submitData(pagingData)
}
}
}
}
Kotlin
import android.app.Application
import com.ppdemo.coil.MyCoilMgr
class MyApp : Application(){
companion object {
const val TAG = "fly/MyApp"
}
override fun onCreate() {
super.onCreate()
MyCoilMgr.INSTANCE.init(this)
}
}
item_media.xml:
XML
<?xml version="1.0" encoding="utf-8"?>
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/iv"
android:layout_width="300px"
android:layout_height="300px"
android:scaleType="centerCrop" />