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大图预览,遇到了几个问题,并逐一给出解决方案。

相关推荐
simplepeng3 小时前
我的天,我真是和androidx的字体加载杠上了
android
小猫猫猫◍˃ᵕ˂◍4 小时前
备忘录模式:快速恢复原始数据
android·java·备忘录模式
CYRUS_STUDIO6 小时前
使用 AndroidNativeEmu 调用 JNI 函数
android·逆向·汇编语言
梦否6 小时前
【Android】类加载器&热修复-随记
android
徒步青云7 小时前
Java内存模型
android
今阳7 小时前
鸿蒙开发笔记-6-装饰器之@Require装饰器,@Reusable装饰器
android·app·harmonyos
-优势在我12 小时前
Android TabLayout 实现随意控制item之间的间距
android·java·ui
hedalei12 小时前
android13修改系统Launcher不跟随重力感应旋转
android·launcher
Indoraptor13 小时前
Android Fence 同步框架
android
峥嵘life13 小时前
DeepSeek本地搭建 和 Android
android