Android Page3与Flow分页查媒体数据库展示宫格图片列表,Kotlin

Android Page3与Flow分页查媒体数据库展示宫格图片列表,Kotlin

Android中使用Paging3和Flow实现媒体库分页查询及图片展示的关键实现。主要包括:

  1. 权限配置:声明了存储访问和媒体读取权限
  2. Coil图片加载:通过自定义ImageLoader实现图片加载和缓存管理
  3. 数据库层:使用Room创建媒体数据库和DAO接口
  4. 分页实现:
    • 使用PagingSource从MediaStore分页查询媒体数据
    • 通过Pager配置分页参数
    • ViewModel中暴露Flow数据流
  5. 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" />
相关推荐
2501_9160074714 小时前
iOS开发中抓取HTTPS请求的完整解决方法与步骤详解
android·网络协议·ios·小程序·https·uni-app·iphone
lvronglee17 小时前
【数字图传第四步】Android App查看图传视频
android·音视频
90后的晨仔17 小时前
Android 程序入口与核心组件详解
android
90后的晨仔17 小时前
Kotlin 简介与开发环境搭建
android
BU摆烂会噶17 小时前
【LangGraph】House_Agent 实战(四):预定流程 —— 中断与人工干预
android·人工智能·python·langchain
AI玫瑰助手17 小时前
Python运算符:比较运算符(等于不等等于大于小于)与返回值
android·开发语言·python
new_dev18 小时前
Python实现Android自动化打包工具:加固、签名、多渠道一键完成
android·python·自动化
小孔龙18 小时前
Android `<activity-alias>` 指南:动态图标 · 多入口 · 重命名兼容
android·程序员·掘金·日新计划
QING61818 小时前
Kotlin inline 实战详解 —— 新手须知
android·kotlin·android jetpack
ElevenS_it18819 小时前
MySQL慢查询监控与告警实战:从slow_log采集到分钟级定位慢SQL的完整链路配置
android·sql·mysql