Android Compose大图加载碰壁之路

背景

近期新增了发布长图,核心诉求:支持双指缩放,下载原图。

整个app 以Compose为主,先前已经引入相关的三方库:

  1. github.com/coil-kt/coi...: 图片加载
  2. github.com/usuiat/Zoom...: 缩放

理所当然,选择基于上述三方库快速构建,但执行过程中发现有很多细节、坑需要处理。

初步接入

kotlin 复制代码
@Composable fun PhotoItem(
    uri: String,
    modifier: Modifier = Modifier,
) {
    AsyncImage(
        model = "https://example.com/image.jpg",
        contentDescription = null,
        modifier = Modifier.zoomable(rememberZoomState()),
    )
}

对于加载长图而言,该版本存在几个问题:

  1. 缺少loading效果
  2. maxScale无法动态适配网络图片宽高
  3. 图片放大后太糊

逐个击破

loading效果

使用SubcomposeAsyncImage

kotlin 复制代码
@Composable fun PhotoItem(
    uri: String,
    modifier: Modifier = Modifier,
) {
    SubcomposeAsyncImage(
        model = uri,
        loading = {
            CircularProgressIndicator()
        },
        modifier = Modifier.zoomable(rememberZoomState()),
    )
}

maxScale无法适配网络图片宽高

目前Zoomable最新版本1.6.1, maxScale作为ZoomState的构造参数,没法动态修改,如果在PhotoItem内部通过remember变量监听coil image result生成ZoomState,会导致图片无法交互的bug。

只能外围包裹一个ZoomablePhotoItem,代码如下:

kotlin 复制代码
@Composable
private fun ZoomablePhotoItem(uri: String) {
    var intrinsicSize by remember {
        mutableStateOf(Size.Zero)
    }
    var layoutSize by remember {
        mutableStateOf(IntSize.Zero)
    }
    key(intrinsicSize, layoutSize) {
        val zoomState: ZoomState = remember(intrinsicSize, layoutSize) {
            if (intrinsicSize == Size.Zero || layoutSize == IntSize.Zero) {
                ZoomState()
            } else {
                val maxScale = ZoomScaleUtil.getMaxZoomScale(
                    layoutWidth = layoutSize.width.toFloat(),
                    layoutHeight = layoutSize.height.toFloat(),
                    imageIntrinsicWidth = intrinsicSize.width,
                    imageIntrinsicHeight = intrinsicSize.height,
                )
                ZoomState(maxScale, intrinsicSize)
            }
        }
        PhotoItem(
            uri = uri,
            zoomState = zoomState,
            onIntrinsicSizeResult = { size ->
                intrinsicSize = size
            },
            onLayoutSizeResult = { intSize ->
                layoutSize = intSize
            },
        )
    }
}

@Composable
private fun PhotoItem(
    uri: String,
    zoomState: ZoomState,
    onIntrinsicSizeResult: (intrinsicSize: Size) -> Unit,
    onLayoutSizeResult: (IntSize) -> Unit,
    modifier: Modifier = Modifier,
) {
    SubcomposeAsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(uri)
            .listener { _, result ->
                val size = Size(
                    result.drawable.intrinsicWidth.toFloat(),
                    result.drawable.intrinsicHeight.toFloat(),
                )
                onIntrinsicSizeResult(size)
            }
            .build(),
        loading = {
            CircularProgressIndicator()
        },
        contentDescription = null,
        contentScale = ContentScale.Fit,
        modifier = modifier
            .fillMaxSize()
            .onSizeChanged(onLayoutSizeResult)
            .zoomable(zoomState),
    )
}

对于计算宽高比,提供了一个util方法:保证图片能放大到没有黑边

  1. scale=1 表示初始状态,同ImageView#centerInside
  2. maxScale=max(图片放大到没有黑边, 5)
kotlin 复制代码
internal object ZoomScaleUtil {
    private const val DEFAULT_MAX_SCALE = 5.0f

    fun getMaxZoomScale(
        layoutWidth: Float,
        layoutHeight: Float,
        imageIntrinsicWidth: Float,
        imageIntrinsicHeight: Float,
    ): Float {
        val layoutRatio = layoutWidth / layoutHeight
        val imageRatio = imageIntrinsicWidth / imageIntrinsicHeight

        val maxScale = if (layoutRatio > imageRatio) {
            // layoutHeight as image default displayHeight
            // displayWidth/displayHeight = imageIntrinsicWidth/imageHeight,  displayHeight = layoutHeight
            val displayWidth = layoutHeight * imageIntrinsicWidth / imageIntrinsicHeight
            layoutWidth / displayWidth
        } else {
            // layoutWidth as image default displayWidth
            // displayWidth/displayHeight = imageIntrinsicWidth/imageHeight,  displayWidth = layoutWidth
            val displayHeight = layoutWidth / (imageIntrinsicWidth / imageIntrinsicHeight)
            layoutHeight / displayHeight
        }

        return maxScale.toBigDecimal().setScale(2, RoundingMode.FLOOR).toFloat().coerceAtLeast(DEFAULT_MAX_SCALE)
    }
}

图片太糊

这是因为coil默认根据视图大小对原图进行了处理,zoomable只对布局进行放大。 所以只需要强制coil加载原图即可。

kotlin 复制代码
...
    SubcomposeAsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(uri)
            .size(coil.size.Size.ORIGINAL), // ORIGINAL表示原图
        ...
    )
...

本以为一切就好了,一运行直接crash了。 原图:

原图 crash日志

173185920 = 1440 x 30067 x 4,好家伙这张图直接干到165M了! 根据日志 可以追踪到RecordingCanvas中上限是100M。

大图处理

可以使用经典的三方库:github.com/davemorriss... 出于以下原因,没有引入该库:

  1. 项目中对三方包的审核较为严格,引入流程很繁琐
  2. 大部分图片<10M(上传限制了10M)

新写了一个 coil Transformation,保证展示的Bitmap内存占用小于100M

kotlin 复制代码
...
    SubcomposeAsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(uri)
            .size(coil.size.Size.ORIGINAL), // ORIGINAL表示原图
            .transformations(LargeImageTransformation()) // 图片压缩至80M以内
            .allowHardware(false)  // 部分图片hardware失败
        ...
    )
...

class LargeImageTransformation : Transformation {
    override val cacheKey: String
        get() = LargeImageTransformation::class.java.name

    override suspend fun transform(input: Bitmap, size: coil.size.Size): Bitmap {
        return input.extractThumbnail(input.byteCount, BITMAP_BYTE_COUNT_MAX_LIMIT)
    }

    companion object {
        // App will crash once Bitmap#getByteCount > 100M when displaying
        // @see android.graphics.RecordingCanvas.throwIfCannotDraw
        private const val BITMAP_BYTE_COUNT_MAX_LIMIT = 80 * 1024 * 1024
    }
}

private fun Bitmap.extractThumbnail(currentSize: Int, limitSize: Int): Bitmap {
    if (currentSize < limitSize) return this
    val ratio = sqrt(currentSize.toFloat() / limitSize) + 0.01
    val width = (this.width / ratio).toInt()
    val height = (this.height / ratio).toInt()
    return Bitmap.createScaledBitmap(this, width, height, true)
}

最终效果:

超宽图 超长图

总结

本文通过Coil和Zoomable实现了Compose大图预览,遇到了几个问题,并逐一给出解决方案。

相关推荐
Dnelic-7 小时前
Kotlin | Android Provider 的实现案例
android·数据库·kotlin·provider·自学笔记
walkskyer10 小时前
Golang strconv包详解:高效类型转换实战
android·开发语言·golang
氦客10 小时前
Android Compose 显示底部对话框 (ModalBottomSheet),实现类似BottomSheetDialog的效果
android·dialog·ui·compose·modal·bottomsheet·底部对话框
我命由我1234510 小时前
Android Room 构建问题:There are multiple good constructors
android·开发语言·java-ee·android studio·android jetpack·android-studio·android runtime
c小旭10 小时前
Android SystemUI——服务启动流程(二)
android·systemui
zhangphil11 小时前
Android ValueAnimator根据屏幕刷率动态出帧/刷新,Kotlin
android·kotlin
芝士就是力量啊 ೄ೨11 小时前
Kotlin 循环语句详解
android·java·开发语言·kotlin
Alex老夫子11 小时前
kotlin sortedBy 与sortedWith的区别
android·开发语言·kotlin
Android采码蜂11 小时前
SurfaceFlinger07-Layer销毁流程
android
程序员老石16 小时前
ClickHouse-CPU、内存参数设置
android·java·clickhouse