Android(Coil,Glide)大量图片加载缓存清理问题(二 Coil处理)

Android(Coil,Glide)大量图片加载缓存清理问题

Glide/Coil 加载大量图片后--->磁盘缓存(已满)-->请求网络资源重新下载.

App加载加载大量图片资源以后导致磁盘缓存已满,从而引发频繁的清理磁盘缓存和下载网络资源存入磁盘缓存,进而导致指定资源频繁请求.

1:处理方案

  • 扩大磁盘缓存,类似HashMap动态扩容(DiskLruCache,是固定容量,处理复杂)
  • 分类型分磁盘存储

我们选择了第二种方案,将图片资源分为日常存储资源和固定存储的资源.

2:功能实现

自定义缓存存储,创建固定缓存的对象,然后加载和存储的时候调用固定存储的方法,日常的调用日常方法

2.1 自定义缓存存储

kotlin 复制代码
package com.wkq.util.coil

import android.content.Context
import android.graphics.Bitmap
import android.util.LruCache
import android.widget.ImageView
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest

/**
 * CacheManager
 *
 * 功能:
 * 1. 管理 Coil 的 ImageLoader 缓存(内存/磁盘)
 * 2. 提供永久缓存机制(内存 + 磁盘),适合头像、礼物等频繁使用资源
 * 3. 支持 URL / 本地 File / resId
 * 4. 提供 ImageView 测量完成后生成 Bitmap 的工具,避免第一次加载模糊
 */
object CacheManager {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    // 永久内存缓存
    private var pinnedMemoryCache: LruCache<String, Bitmap>? = null

    // 永久磁盘缓存 (使用 Coil DiskCache 管理大小)
    private var pinnedDiskCache: DiskCache? = null

    // Coil ImageLoader 实例
    private var imageLoader: ImageLoader? = null

    private var isInitialized = false

    /**
     * 初始化缓存
     * @param context Context
     * @param pinnedMemoryMB 永久内存缓存大小 (MB)
     * @param memoryCachePercent Coil 内存缓存占比
     * @param pinnedDiskMB 永久磁盘缓存大小 (MB)
     * @param diskCacheMB Coil 磁盘缓存大小 (MB)
     */
    fun init(context: Context, pinnedMemoryMB: Int = 20, memoryCachePercent: Double = 0.25, pinnedDiskMB: Int = 200, diskCacheMB: Int = 200) {
        if (isInitialized) return

        val appContext = context.applicationContext
        // -------------------------------
        // 永久内存缓存,LruCache 按 Bitmap.byteCount 计算大小
        pinnedMemoryCache = object : LruCache<String, Bitmap>(pinnedMemoryMB * 1024 * 1024) {
            override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
        }
        // -------------------------------
        // 永久磁盘缓存 (LRU 管理)
        pinnedDiskCache = DiskCache.Builder()
            .directory(File(appContext.cacheDir, "pinned_disk_cache"))
            .maxSizeBytes(pinnedDiskMB * 1024L * 1024L)
            .build()
        // -------------------------------
        // Coil 缓存
        val coilDiskCache = DiskCache.Builder()
            .directory(File(appContext.cacheDir, "coil_image_cache"))
            .maxSizeBytes(diskCacheMB * 1024L * 1024L)
            .build()
        val loader = ImageLoader.Builder(appContext)
            .crossfade(true) // 渐入动画
            .components {
                add(OkHttpNetworkFetcherFactory()) // 注册 OkHttp 网络加载器
                add(coil3.gif.GifDecoder.Factory()) // 注册 GIF 解码器
            }
            .memoryCache {
                MemoryCache.Builder()
                    .maxSizePercent(appContext, memoryCachePercent)
                    .strongReferencesEnabled(true) // 启用强引用,提高缓存稳定性
                    .build()
            }
            .diskCache { coilDiskCache }
            .build()
        imageLoader = loader
        // 集成到 Coil 单例加载器
        coil3.SingletonImageLoader.setSafe { loader }
        isInitialized = true
    }

    /** 获取 Coil ImageLoader */
    fun getImageLoader(): ImageLoader = imageLoader ?: throw IllegalStateException("CacheManager not initialized. Call init(context) first.")

    // ===============================
    // 永久内存缓存操作
    fun putPinnedMemory(key: String, bitmap: Bitmap) {
        pinnedMemoryCache?.put(key, bitmap)
    }

    fun getPinnedMemory(key: String): Bitmap? = pinnedMemoryCache?.get(key)
    
    fun clearPinnedMemory() {
        pinnedMemoryCache?.evictAll()
    }
    // 存储到磁盘
    fun putPinnedDisk(key: String, bitmap: Bitmap) {
        val diskCache = pinnedDiskCache ?: return
        scope.launch {
            val editor = diskCache.openEditor(key.md5()) ?: return@launch
            try {
                FileOutputStream(editor.data.toFile()).use { 
                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) 
                }
                editor.commit()
            } catch (e: Exception) {
                editor.abort()
                e.printStackTrace()
            }
        }
    }
    /** 获取磁盘缓存 */
    fun getPinnedDisk(key: String): File? {
        val diskCache = pinnedDiskCache ?: return null
        val snapshot = diskCache.openSnapshot(key.md5()) ?: return null
        return try {
            snapshot.data.toFile()
        } catch (e: Exception) {
            null
        } finally {
            snapshot.close()
        }
    }
    /** 清空磁盘缓存 */
    fun clearPinnedDisk() {
        scope.launch {
            pinnedDiskCache?.clear()
        }
    }

    // ===============================
    // Coil 内存/磁盘缓存清理
    fun clearMemoryCache() {
        imageLoader?.memoryCache?.clear()
    }

    fun clearDiskCache() {
        scope.launch {
            imageLoader?.diskCache?.clear()
        }
    }

    /** 清理所有缓存:永久内存/磁盘 + Coil 内存/磁盘 */
    fun clearAllCache() {
        clearPinnedMemory()
        clearPinnedDisk()
        clearMemoryCache()
        clearDiskCache()
    }

    // ===============================
    // 工具方法
    /** String -> MD5,用于生成唯一文件名 */
    private fun String.md5(): String {
        return try {
            val digest = MessageDigest.getInstance("MD5")
            val result = digest.digest(toByteArray())
            result.joinToString("") { "%02x".format(it) }
        } catch (e: Exception) {
            this.hashCode().toString()
        }
    }

    /**
     * 等待 ImageView 测量完成后生成 Bitmap
     * 避免第一次加载模糊
     */
    fun ImageView.postBitmap(drawableBitmap: Bitmap, onReady: (Bitmap) -> Unit) {
        if (width > 0 && height > 0) {
            onReady(drawableBitmap.resize(width, height))
        } else {
            post {
                onReady(drawableBitmap.resize(width.coerceAtLeast(1), height.coerceAtLeast(1)))
            }
        }
    }

    /** 按 ImageView 尺寸缩放 Bitmap */
    private fun Bitmap.resize(targetWidth: Int, targetHeight: Int): Bitmap {
        if (width == targetWidth && height == targetHeight) return this
        return Bitmap.createScaledBitmap(this, targetWidth, targetHeight, true)
    }
}

2.2 资源加载

kotlin 复制代码
package com.wkq.util.coil

import android.graphics.drawable.Drawable
import android.widget.ImageView
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import coil3.asDrawable
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.request.error
import coil3.request.placeholder
import coil3.request.target
import coil3.request.transformations
import coil3.size.Scale
import coil3.load
import coil3.request.Disposable
import coil3.transform.CircleCropTransformation
import coil3.transform.RoundedCornersTransformation
import coil3.transform.Transformation
import com.wkq.util.coil.CacheManager.postBitmap
import java.io.File

/**
 * ImageLoaderUtil
 *
 * 功能:
 * 1. 图片加载工具类,支持 URL / File / resId
 * 2. 支持普通加载和永久缓存加载
 * 3. 支持 GIF 动图加载,避免 OOM
 * 4. 自动按 ImageView 尺寸生成 Bitmap,避免第一次加载模糊
 * 5. 支持圆角、圆形、灰度等变换
 * 6. 提供 ImageView 扩展函数,使用更便捷
 */
object ImageLoaderUtil {

    /**
     * 基础加载方法
     * @return Disposable 可用于取消请求
     */
    fun load(
        imageView: ImageView,
        data: Any?,
        placeholder: Any? = null,
        error: Any? = null,
        isCircle: Boolean = false,
        isGrayscale: Boolean = false,
        radius: Float = 0f,
        scale: Scale = Scale.FILL,
        memoryCachePolicy: CachePolicy = CachePolicy.ENABLED,
        diskCachePolicy: CachePolicy = CachePolicy.ENABLED,
        onSuccess: ((Drawable) -> Unit)? = null,
        onError: ((Throwable) -> Unit)? = null,
        builder: ImageRequest.Builder.() -> Unit = {}
    ): Disposable? {
        if (data == null) return null
        
        // 使用 Coil 的单例 ImageLoader,它带有了正确的解码组件(GIF 等)
        val imageLoader = CacheManager.getImageLoader()
        
        // 直接使用扩展函数,它会自动处理 ImageView 的生命周期并设置加载结果
        return imageView.load(data, imageLoader) {
            (placeholder as? Int)?.let { placeholder(it) }
            (placeholder as? Drawable)?.let { placeholder(it) }
            (error as? Int)?.let { error(it) }
            (error as? Drawable)?.let { error(it) }
            
            scale(scale)
            crossfade(true)
            memoryCachePolicy(memoryCachePolicy)
            diskCachePolicy(diskCachePolicy)
            // 圆形圆角处理
            val transformationsList = mutableListOf<Transformation>()
            if (isCircle) {
                transformationsList.add(CircleCropTransformation())
            } else if (radius > 0) {
                transformationsList.add(RoundedCornersTransformation(radius))
            }
            // 判断灰度处理
            if (isGrayscale) {
                transformationsList.add(GrayscaleTransformation())
            }
            if (transformationsList.isNotEmpty()) {
                transformations(transformationsList)
            }

            // 这里监听加载结果,用于回调扩展
            listener(
                onSuccess = { _, _ ->
                    // 获取 ImageView 当前显示的 Drawable(Coil 已经设置进去的)
                    val currentDrawable = imageView.drawable
                    if (currentDrawable is android.graphics.drawable.Animatable) {
                        currentDrawable.start()
                    }
                    onSuccess?.invoke(currentDrawable!!)
                },
                onError = { _, result ->
                    onError?.invoke(result.throwable)
                }
            )
            
            apply(builder)
        }
    }

    /**
     * 永久缓存加载图片(内存 + 磁盘)
     * 适合头像、礼物图标等小尺寸但高频显示的资源
     */
    fun loadPinned(
        imageView: ImageView,
        data: Any,
        placeholder: Any? = null,
        error: Any? = null,
        isCircle: Boolean = false,
        isGrayscale: Boolean = false,
        radius: Float = 0f,
        builder: ImageRequest.Builder.() -> Unit = {}
    ): Disposable? {
        val key = when (data) {
            is String -> data
            is File -> data.absolutePath
            is Int -> "res_$data"
            else -> data.toString()
        } + "_c${isCircle}_r${radius}_g${isGrayscale}"
        // 先检查内存缓存
        CacheManager.getPinnedMemory(key)?.let { bitmap ->
            if (!isCircle && radius <= 0f && !isGrayscale) {
                imageView.setImageBitmap(bitmap)
                return null
            }
        }

        // 检查磁盘缓存或网络加载
        val dataSource = CacheManager.getPinnedDisk(key) ?: data
        
        return load(
            imageView = imageView,
            data = dataSource,
            placeholder = placeholder,
            error = error,
            isCircle = isCircle,
            isGrayscale = isGrayscale,
            radius = radius,
            onSuccess = { drawable ->
                val bmp = drawable.toBitmap()
                CacheManager.putPinnedMemory(key, bmp)
                if (data is String || data is File) {
                    CacheManager.putPinnedDisk(key, bmp)
                }
            },
            builder = builder
        )
    }

    /**
     * GIF 动图加载
     */
    fun loadGif(
        imageView: ImageView,
        data: Any,
        placeholder: Any? = null,
        error: Any? = null,
        builder: ImageRequest.Builder.() -> Unit = {}
    ): Disposable? {
        return load(
            imageView = imageView,
            data = data,
            placeholder = placeholder,
            error = error,
            memoryCachePolicy = CachePolicy.DISABLED, // GIF 默认不存内存,防 OOM
            builder = builder
        )
    }

    /**
     * 预加载图片
     */
    fun preload(
        context: android.content.Context,
        data: Any?,
        builder: ImageRequest.Builder.() -> Unit = {}
    ): Disposable? {
        if (data == null) return null
        val request = ImageRequest.Builder(context)
            .data(data)
            .apply(builder)
            .build()
        return CacheManager.getImageLoader().enqueue(request)
    }

    /**
     * 灰度变换支持
     */
    private class GrayscaleTransformation : Transformation() {
        override val cacheKey: String = "GrayscaleTransformation"
        override suspend fun transform(input: android.graphics.Bitmap, size: coil3.size.Size): android.graphics.Bitmap {
            val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG or android.graphics.Paint.FILTER_BITMAP_FLAG)
            val colorMatrix = android.graphics.ColorMatrix()
            colorMatrix.setSaturation(0f)
            paint.colorFilter = android.graphics.ColorMatrixColorFilter(colorMatrix)

            val output = android.graphics.Bitmap.createBitmap(input.width, input.height, input.config ?: android.graphics.Bitmap.Config.ARGB_8888)
            val canvas = android.graphics.Canvas(output)
            canvas.drawBitmap(input, 0f, 0f, paint)
            return output
        }
    }
}

/**
 * ImageView 扩展函数
 */
fun ImageView.loadUrl(
    url: String?,
    placeholder: Any? = null,
    error: Any? = null,
    isCircle: Boolean = false,
    isGrayscale: Boolean = false,
    radius: Float = 0f,
    memoryCachePolicy: CachePolicy = CachePolicy.ENABLED,
    diskCachePolicy: CachePolicy = CachePolicy.ENABLED,
    onSuccess: ((Drawable) -> Unit)? = null,
    onError: ((Throwable) -> Unit)? = null,
    builder: ImageRequest.Builder.() -> Unit = {}
) = ImageLoaderUtil.load(
    this, url, placeholder, error, isCircle, isGrayscale, radius, 
    memoryCachePolicy = memoryCachePolicy, 
    diskCachePolicy = diskCachePolicy, 
    onSuccess = onSuccess, onError = onError, builder = builder
)

fun ImageView.loadRes(
    resId: Int,
    placeholder: Any? = null,
    error: Any? = null,
    isCircle: Boolean = false,
    isGrayscale: Boolean = false,
    radius: Float = 0f,
    memoryCachePolicy: CachePolicy = CachePolicy.ENABLED,
    diskCachePolicy: CachePolicy = CachePolicy.ENABLED,
    onSuccess: ((Drawable) -> Unit)? = null,
    onError: ((Throwable) -> Unit)? = null,
    builder: ImageRequest.Builder.() -> Unit = {}
) = ImageLoaderUtil.load(
    this, resId, placeholder, error, isCircle, isGrayscale, radius, 
    memoryCachePolicy = memoryCachePolicy, 
    diskCachePolicy = diskCachePolicy, 
    onSuccess = onSuccess, onError = onError, builder = builder
)

fun ImageView.loadFile(
    file: File?,
    placeholder: Any? = null,
    error: Any? = null,
    isCircle: Boolean = false,
    isGrayscale: Boolean = false,
    radius: Float = 0f,
    memoryCachePolicy: CachePolicy = CachePolicy.ENABLED,
    diskCachePolicy: CachePolicy = CachePolicy.ENABLED,
    onSuccess: ((Drawable) -> Unit)? = null,
    onError: ((Throwable) -> Unit)? = null,
    builder: ImageRequest.Builder.() -> Unit = {}
) = ImageLoaderUtil.load(
    this, file, placeholder, error, isCircle, isGrayscale, radius, 
    memoryCachePolicy = memoryCachePolicy, 
    diskCachePolicy = diskCachePolicy, 
    onSuccess = onSuccess, onError = onError, builder = builder
)

3.调用

kotlin 复制代码
fun test() {
    // 1. 普通加载 (使用扩展函数)
    binding.ivNormal.loadUrl(testImg)

    // 2. 圆形裁剪
    binding.ivCircle.loadUrl(avatarImg, isCircle = true)

    // 3. 圆角加载 (30dp)
    binding.ivRounded.loadUrl(testImg, radius = 60f) // 这里的单位是 px,demo 使用 60 示意

    // 4. 灰度模式
    binding.ivGrayscale.loadUrl(testImg, isGrayscale = true)

    // 5. GIF 动图 (自动支持,也可以显式调用以禁用内存缓存防 OOM)
    ImageLoaderUtil.loadGif(binding.ivGif, gifUrl)

    // 6. 永久缓存加载 (Pinned Cache)
    // 适合高频显示的头像、小图标,即使应用清理缓存也不会丢失(除非手动清空永久缓存)
   val load= ImageLoaderUtil.loadPinned(binding.ivPinned, avatarImg, isCircle = true)
    load?.dispose()
}

总结

Coil处理磁盘已满情况下,高频情况下资源频繁请求网络问题.Glide和逻辑和这个差不多,按照这种思路修改一样能够实现.

相关推荐
乌萨达1 小时前
手机模拟器安卓怎么用?电脑安装应用、玩手游与账号同步指南
android·智能手机·电脑·雷电模拟器
美狐美颜SDK开放平台1 小时前
Android与iOS直播APP美颜有什么区别?视频美颜SDK开发详解
android·ios·音视频·视频美颜sdk
春猿火3 小时前
某市-2026【网安·论道】misc-afterimage_note-wp
android
恋猫de小郭4 小时前
Shopify 回应为什么从 RN 回到原生,为什么不用 KMP ?
android·前端·flutter
fundroid6 小时前
Android AI 开发,真正拉开差距的是工程闭环
android·ai·大模型·agent
脚踏实地,坚持不懈!6 小时前
Android 面试题全解(结合 AOSP 源码 · 截至 Android 17 / API 37)
android
换元不配限7 小时前
Android 架构演进实战:MVC → MVP → MVVM → MVI
android·架构·mvc·mvvm·mvp·mvi
mmsx8 小时前
Android Dialog 插槽模式:用一套壳统一全应用的弹窗风格
android·java·dialog·开发语言
码上有光9 小时前
Linux:进程间通信——匿名管道通信、命名管道通信
android·java·linux·linux通信·匿名通信·命名通信
爱笑鱼10 小时前
Android 系统启动机制(九):system_server 已经运行,为什么还不能说 Android 启动完成?
android