背景
针对文件上传功能,我们有时会考虑上传到三方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}
是对资源进行操作的配置。
动态图片地址加载流程
先上流程图
由于我们需要维护外网图片资源与内网资源,因此需要针对链接进行校验,分不同情况处理。
拦截器处理
由于我们的ObjectKey
与图片资源唯一绑定,因此可以修改图片缓存的Key
为ObjectKey
,此时可以自定义拦截器。
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
中判断的
而磁盘缓存的判断逻辑比较靠后,是在HttpUriFetcher
中即将发起网络请求前进行的判断,因此为了让这段逻辑纯粹一些,我重新定义了一个OssDiskFetcher
,还有一个原因是我需要在OssDiskFetcher
中对URL
进行判断,或监控缓存命中率、加载耗时等核心指标。
/**
* 如果 能命中这个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
处理指令(如缩放、格式转换),实现业务层与资源处理的解耦,提升灵活性。