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" />
相关推荐
HLC++18 小时前
Linux的进程间通信
android·linux·服务器
爱笑鱼1 天前
Binder(二):AIDL 生成的 Proxy、Stub 和 Parcel 到底在做什么?
android
爱笑鱼1 天前
Binder(一):一次方法调用,究竟怎样跨进程执行?
android
btown1 天前
Kotlin 版 MyBatis-Plus 查询优雅解决方案
kotlin
壮哥_icon1 天前
【Android 系统开发】使用 BAT 脚本高效自动化管理 /system/priv-app/ 系统应用(安装与卸载)
android·运维·自动化
用户69371750013841 天前
Claude Code终端日志Token占用实测:一个过滤器砍掉60%-90%
android·前端·后端
蝉蜕日记1 天前
AccumuPDF高级版 v2.57 | 视图文转换PDF,合并分割压缩
android·智能手机·pdf·生活·软件需求
吐了啊取名字太难1 天前
美颜系统AI修图本地跑并支持Mac、win、安卓、iOS不卡顿
android·人工智能·windows·数码相机·mac·ai编程
小二·1 天前
车载数字座舱实战:用魔珐星云打造下一代智能驾乘助手(附 Android/Kotlin 完整代码)
开发语言·kotlin
阿pin1 天前
Android随笔-Retrofit
android·retrofit