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" />
相关推荐
xxjj998a8 小时前
Laravel4.x:PHP开发新纪元
android·数据库
Mr -老鬼8 小时前
EasyClick 安卓CLI全栈专家能力手册
android·自动化·ai编程·easyclick·易点云测
峥嵘life8 小时前
Android 不同的蓝牙音箱连接后声音突变问题分析解决
android·学习
JJay.8 小时前
Android BLE 里,MTU、分包和长数据发送到底该怎么处理
android
2501_915909068 小时前
iOS应用签名的三种方法全解析:从官方到第三方工具
android·ios·小程序·https·uni-app·iphone·webview
饭小猿人1 天前
Android 腾讯X5WebView如何禁止系统自带剪切板和自定义剪切板视图
android·java
_李小白1 天前
【android opencv学习笔记】Day 8: remap(像素位置重映射)
android·opencv·学习
美狐美颜SDK开放平台1 天前
多场景美颜SDK解决方案:直播APP(iOS/安卓)开发接入详解
android·人工智能·ios·音视频·美颜sdk·第三方美颜sdk·短视频美颜sdk
嗷o嗷o1 天前
Android BLE 里,MTU、分包和长数据发送到底该怎么处理
android