Android IM 开发 - Coil 图片动态路径&视频首帧加载思路

612 阅读4分钟

背景

针对文件上传功能,我们有时会考虑上传到三方OSS服务,以便更好的处理文件稳定上传的能力,公司选择了使用阿里云OSS,鉴于我们使用了Kotlin 协程的异步方案,对于图片加载我们选用Coil图片加载库,其原理之前写过一篇图片加载库Coil源码浅析,有兴趣大家可以看一下。

阿里云OSS部分流程介绍

核心是希望将本地资源上传到阿里云服务中,因此针对OSS的路径我们可以本地定义上传路径,也可以由服务端代理生成,然后作为上传的ObjectKey,上传成功后,加载流程需要根据这个ObjectKey生成一个具备有效期的真实文件链接地址,在这个过程中需要校验凭证有效期。 详细请查阅官方文档

超时检查逻辑
class OssBuilderWithTokenManager private constructor() {

    private var tempOssBuilder : OssBuilder ?= null
    private var expirationTime = 0L
    private val locked = Mutex()

    @WorkerThread
    suspend fun getOssBuilder(): OssBuilder {
        return locked.withLock {
            if (tempOssBuilder == null || System.currentTimeMillis() >= expirationTime) {
                blockAndGetToken().apply {
                    tempOssBuilder = this
                }
            } else {
                tempOssBuilder!!
            }
        }
    }

    @OptIn(InternalCoroutinesApi::class)
    private suspend fun blockAndGetToken() = suspendCancellableCoroutine<OssBuilder> { continuation -> runBlocking {
                getStsToken().collect { result ->
                    when (result) {
                        is Result.Success -> {
                            handleResult(result, continuation)
                        }

                        is Result.Error -> {
                            continuation.tryResumeWithException(
                                result.exception ?: Exception("get sts token failed")
                            )
                        }

                        else -> {
                            // ignore...
                        }
                    }
                }
            }
        }

    private fun handleResult(result: Result.Success<StsTokenResponse>, continuation: CancellableContinuation<OssBuilder>) {
        // record deadline
        expirationTime = result.data.expiration
        // generate builder...
        OssBuilder.newBuilder()
            .setAccessKeyId(result.data.accessKeyId)
            .setAccessKeySecret(result.data.secretAccessKey)
            .setSecurityToken(result.data.securityToken)
            // .setEndpoint(if (BuildConfig.DEBUG) result.data.internalEndpoint else result.data.publicEndpoint)
            .setEndpoint(result.data.publicEndpoint)
            .setBucketName(result.data.bucketName)
            .run { continuation.safeResume(this) }
    }

    private suspend fun getStsToken(): Flow<Result<StsTokenResponse>> {
        val url = NetworkConstant.HTTP_HOST + GET_STS_TOKEN_URL
        val params: HashMap<String, Any> = HashMap()
        return executeFlow(
            fetchNetwork = {
                val responseType = object : TypeToken<ApiResponse<StsTokenResponse>>() {}
                NetworkClient.request(url, params, responseType)
            },
            requestUrl = url,
            requestBody = mapOf()
        )
    }

    @ReleasableSingleton
    companion object : Singleton<OssBuilderWithTokenManager>({ OssBuilderWithTokenManager() }) {
        private const val GET_STS_TOKEN_URL = "/oss/getToken.do"
        private const val TAG = "OssTokenManager"
    }
}

class OssBuilder private constructor() {
    var endpoint: String = ""
        private set
    var accessKeyId: String = ""
        private set
    var accessKeySecret: String = ""
        private set
    var securityToken: String = ""
        private set
    var bucketName: String = ""
        private set

    fun setEndpoint(endpoint: String): OssBuilder {
        this.endpoint = endpoint
        return this
    }

    fun setAccessKeyId(accessKeyId: String): OssBuilder {
        this.accessKeyId = accessKeyId
        return this
    }

    fun setAccessKeySecret(accessKeySecret: String): OssBuilder {
        this.accessKeySecret = accessKeySecret
        return this
    }

    fun setSecurityToken(securityToken: String): OssBuilder {
        this.securityToken = securityToken
        return this
    }

    fun setBucketName(bucketName: String): OssBuilder {
        this.bucketName = bucketName
        return this
    }

    companion object {
        fun newBuilder(): OssBuilder {
            return OssBuilder()
        }
    }
}
真实地址生成方式
private suspend fun blockAndGetRealUrlWithSDK(): Pair<String, Long> = suspendCancellableCoroutine { continuation ->
    runBlocking {
            val ossBuilder = OssBuilderWithTokenManager.getInstance().getOssBuilder()
            initOss(ossBuilder)
            val request = GeneratePresignedUrlRequest(ossBuilder.bucketName, objectKey)
            request.expiration = System.currentTimeMillis() + ONE_HOUR
            mediaParams?.let { request.queryParameter = it } // x-oss-process=image/resize,w_{width}
            val url = oss?.presignConstrainedObjectURL(request) ?: return@runBlocking continuation.resumeWithException(NullPointerException("oss is null"))
            val result : Pair<String, Long> = url to request.expiration
            continuation.resumeWith(kotlin.Result.success(result))
    }
}

x-oss-process=image/resize,w_{width} 是对资源进行操作的配置。

动态图片地址加载流程

先上流程图

coil加载流程

由于我们需要维护外网图片资源与内网资源,因此需要针对链接进行校验,分不同情况处理。

拦截器处理

由于我们的ObjectKey与图片资源唯一绑定,因此可以修改图片缓存的KeyObjectKey,此时可以自定义拦截器。

class OssUrlInterceptor : Interceptor {
    @OptIn(ExperimentalCoilApi::class)
    override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
        val originalRequest = chain.request

        val newRequest = (originalRequest.data as? String)?.takeIf { ObjectKeyUtils.isObjectKey(it) }?.let { originalImageUrl ->
            val imageLoader = Coil.imageLoader(appContext)
            val ossParams = originalRequest.headers[OSS_MEDIA_PARAMS_COIL_KEY]
            val convertImageUrl = buildConvertedUrl(originalImageUrl, ossParams)

            val memoryCache = imageLoader.memoryCache?.get(MemoryCache.Key(convertImageUrl))
            val diskCache = imageLoader.diskCache?.get(convertImageUrl)

            if (memoryCache == null && diskCache == null) {
                withContext(Dispatchers.IO) {
                    val realUrl : String = OssDownloadEngine(originalImageUrl, ossParams?.let { mapOf(OSS_MEDIA_PARAMS_KEY to it) }).getDownloadUrl()
                    originalRequest.newBuilder()
                        .data(realUrl)
                        .memoryCacheKey(convertImageUrl)
                        .diskCacheKey(convertImageUrl)
                        .diskCachePolicy(CachePolicy.ENABLED) // 启动磁盘缓存
                        .memoryCachePolicy(CachePolicy.ENABLED) // 启动内存缓存
                        .build()
                }
            } else {
                originalRequest.newBuilder()
                    .data(convertImageUrl)
                    .build()
            }
        } ?: originalRequest
        val response = chain.proceed(newRequest)
        return response
    }

    private fun buildConvertedUrl(originalImageUrl: String, ossParams: String?): String {
        return if (ossParams != null) {
            "$originalImageUrl?$OSS_MEDIA_PARAMS_KEY=$ossParams"
        } else {
            originalImageUrl
        }
    }

    companion object {
        const val OSS_MEDIA_PARAMS_COIL_KEY = "x"
    }
}

同时我们需要对图片进行缩略图处理,比如先加载尺寸比较小的图片,按需加载原图。针对这个场景,阿里云OSS已经可以针对链接配置进行处理。 阿里云OSS 图片缩放文档

在图片URL末尾添加?x-oss-process=image/resize,w_{width}时,OSS实时处理该图片,并根据指定的宽度进行等比例缩小,返回处理后的结果。其中,image/resize表示进行缩放处理,w表示期望的图片宽度。w的取值范围为[1,16384]w的取值必须为正整数。

注意

由于没有很好的方案将缩略图的配置传递到拦截器中,我这边选择在请求Header中注入配置,但是如果Header中配置x-oss-process作为key,那么会导致最终加载真实的OSS Url失败报错,所以这里随意定了一个X,然后再将其对应的value取出,再交给阿里云OSS SDK生成最终的URL.

缓存处理

由于我们在中间过程中修改了URL,所以对缓存逻辑同样要进行二次判断,先判断缓存中是否存在ObjectKey所对应的资源,如果存在则直接加载,不存在则重新用有效期的URL请求。

针对之前的Coil源码分析,内存缓存命中判断逻辑是在EngineInterceptor中判断的

image.png

而磁盘缓存的判断逻辑比较靠后,是在HttpUriFetcher中即将发起网络请求前进行的判断,因此为了让这段逻辑纯粹一些,我重新定义了一个OssDiskFetcher,还有一个原因是我需要在OssDiskFetcher中对URL进行判断,或监控缓存命中率、加载耗时等核心指标。

image.png

/**
 * 如果 能命中这个fetcher,说明是oss的图片,并且存在磁盘缓存。
 */
class OssDiskCatchFetcherFactory : Fetcher.Factory<Uri> {
    override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
        val objectKey = data.toString()
        if (ObjectKeyUtils.isObjectKey(objectKey)) {
            return OssDiskCatchFetcher(objectKey)
        }
        // 如果是null 那么交个其他 fetcher 处理
        return null
    }
}

class OssDiskCatchFetcher(val objectKey: String) : Fetcher {
    @OptIn(ExperimentalCoilApi::class)
    override suspend fun fetch(): FetchResult? {
        val snapshot = Coil.imageLoader(appContext).diskCache?.get(objectKey) ?: return null
        val source = ImageSource(snapshot.data)
        val dataSource = DataSource.DISK
        return SourceResult(
            source = source,
            mimeType = null,
            dataSource = dataSource
        )
    }
}

综上,就是对图片中核心的URL切换请求逻辑得处理,不得不说Coil结合了Kotlin协程的语法糖写起来真的很爽。

视频截帧处理

IM消息处理中,同样想传入一个视频文件路径到图片库,然后对视频截帧处理。同样可以使用Coil暴露的Fetcher接口自定义视频帧获取的逻辑。

class VideoFrameFetcherFactory(val context: Context) : Fetcher.Factory<Any> {
    override fun create(data: Any, options: Options, imageLoader: ImageLoader): Fetcher? {
        val realData = (data as? File) ?: return null
        val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(realData.extension)
        return if (mimeType?.startsWith(MIME_VIDEO_TYPE) == true) {
            VideoFrameFetcher(context, Uri.fromFile(realData))
        } else {
            null
        }
    }

    companion object {
        private const val MIME_VIDEO_TYPE = "video/"
    }
}

class VideoFrameFetcher(val context : Context, val uri : Uri) : Fetcher {

    override suspend fun fetch(): FetchResult? {
        val cacheFile = createCacheFile()
        val retriever = MediaMetadataRetriever()

        try {
            when (uri.scheme) {
                "file" -> retriever.setDataSource(uri.path!!)
                else -> throw IllegalArgumentException("Unsupported URI scheme: ${uri.scheme}")
            }

            saveFrameToFile(retriever, cacheFile)
            return SourceResult(
                source =  ImageSource(cacheFile.absolutePath.toPath()),
                mimeType = null,
                dataSource = DataSource.DISK
            )

        } finally {
            retriever.release()
        }
    }

    private fun createCacheFile(): File {
        val fileName = "video_frame_${System.currentTimeMillis()}.jpg"
        return File(context.cacheDir, fileName).apply {
            createNewFile()
            deleteOnExit()
        }
    }

    private fun saveFrameToFile(retriever: MediaMetadataRetriever, file: File) {
        val bitmap = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST)
            ?: throw IOException("Failed to extract video frame")
        FileOutputStream(file).use { fos ->
            if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 85, fos)) {
                throw IOException("Failed to compress bitmap")
            }
            fos.flush()
        }
        bitmap.recycle()
    }

}

上述代码只是针对本地视频进行截帧,为了性能,在获取视频文件路径时,同样可以采用阿里云OSS的视频截帧方案,这个方案会有一定的费用,但是相对来说很便宜。视频截帧文档

使用fast模式截取视频7s处的内容,输出为JPG格式的图片,宽度为800,高度为600。 处理后的URL为:oss-console-img-demo-cn-hangzhou.oss-cn-hangzhou.aliyuncs.com/video.mp4?x…

Coil 配置
object ImageLoader {
    lateinit var instance: ImageLoader
    fun init(context: Context) {
        instance = ImageLoader.Builder(context)
            .okHttpClient { createInsecureOkHttpClient() }
            .diskCache(DiskCache.Builder().directory(context.filesDir.resolve("coil_image_cache")).maxSizePercent(0.2).build())
            .memoryCache { MemoryCache.Builder(context).maxSizePercent(0.25).build() }
            .components {
                if (android.os.Build.VERSION.SDK_INT >= 28) {
                    add(ImageDecoderDecoder.Factory())
                } else {
                    add(coil.decode.GifDecoder.Factory())
                }
                add(SvgDecoder.Factory())
                add(OssUrlInterceptor())
                add(OssDiskCatchFetcherFactory())
                add(VideoFrameFetcherFactory(context)) // 放在最后判断吧。
            }
            .build()
        Coil.setImageLoader(instance)
    }
}
Coil 使用
// 对ImageRequest的扩展
fun ImageRequest.Builder.applyThumbnail(width: Int?) {
    width?.let {
        this.addHeader(OssUrlInterceptor.OSS_MEDIA_PARAMS_COIL_KEY, "image/resize,w_$width")   // 指定缩略图宽度  也可以是高度 这里可以自定义
    }
}

// 使用,最终会在拦截器中发起对OSS服务的图片缩略图url的获取,使用方无需考虑。
binding.imageview.load(url, ImageLoader.instance, builder = {
            applyThumbnail(size)
        })


// 对ImageRequest的扩展
fun ImageRequest.Builder.applyVideoSnapshot(sizePercent: Int = 100, frameIndex: Int = 0) {
    this.addHeader(
        OssUrlInterceptor.OSS_MEDIA_PARAMS_COIL_KEY,
        "video/snapshot,t_$frameIndex,f_jpg,pw_$sizePercent,ph_$sizePercent"  //指定首帧图片的宽高对于原始视频尺寸的百分比,和帧的位置,默认首帧图像。
    )
}


// 使用,直接将objectKey 作为coil的url 传递即可
holder.ivFirstFrame.load(videoUrl) {
            applyVideoSnapshot()
            crossfade(true)
            transformations(RoundedCornersTransformation(8.dp.toFloat()))
        }

总结

统一资源管理

通过将文件存储与业务逻辑解耦,利用阿里云OSS实现高可用、可扩展的文件存储服务,结合动态URL生成机制,有效解决了文件生命周期管理、访问控制、CDN加速等核心问题。

高效加载优化

  • 动态URL转换

    通过自定义OssUrlInterceptor拦截器,将业务层使用的ObjectKey动态转换为带签名的有效URL,实现透明化资源访问。

  • 多级缓存策略

    利用Coil的内存缓存与磁盘缓存机制,结合自定义OssDiskCatchFetcher,确保高频访问资源快速响应,减少OSS请求次数。

  • 按需加载优化

    通过OSS图片处理参数(如x-oss-process=image/resize,w_300),实现图片尺寸的动态适配,降低带宽消耗,提升加载性能。

多媒体扩展能力

  • 视频首帧提取

    通过VideoFrameFetcher实现本地视频截帧,或利用OSS服务端处理能力生成预览图,满足IM消息、视频封面等场景需求。

  • 动态处理参数传递

    通过Header注入OSS处理指令(如缩放、格式转换),实现业务层与资源处理的解耦,提升灵活性。