Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Load very big image throw OOM #1349

Open
XuQK opened this issue Jul 7, 2022 · 19 comments
Open

Load very big image throw OOM #1349

XuQK opened this issue Jul 7, 2022 · 19 comments
Labels
help wanted Issues that are up for grabs + are good candidates for community PRs

Comments

@XuQK
Copy link

XuQK commented Jul 7, 2022

Describe the bug
Load a 107MB png image, sometimes throw OOM.

Logs/Screenshots
attached.
2022-07-07 14:36:25.093 4541-4541.txt

Version
coil-2.1.0
What library version are you using? Does this occur on a specific API level or Android device?

@colinrtwhite
Copy link
Member

colinrtwhite commented Jul 7, 2022

There isn’t enough information in this report. What code are you using to load the image? How can I reproduce this?

@XuQK
Copy link
Author

XuQK commented Jul 8, 2022

I success reproduce it in a empty project just now.

  1. Download images attached, copy them 3 times, now we have 15 images.
  2. Put 15 images to /sdcard/Pictures/bigpic.
  3. Install the demo, give it storage permission manual in Settings.
  4. Then Run the demo, you should see there are image list loaded, keep sliding up and down for a while, then the OOM occured.
  5. If not reproduce, copy the picture into 30 pieces, repeat.

Wait for your news.

This is demo:
CoilTest.zip

This is images:
https://drive.google.com/file/d/1-CNSN7AxCfsG7TX4PKU2CUvLeDLlu6rF/view?usp=sharing

@colinrtwhite
Copy link
Member

Thanks for the repro project. I’ll take a look soon.

@colinrtwhite colinrtwhite added the help wanted Issues that are up for grabs + are good candidates for community PRs label Jul 29, 2022
@spacecowboy
Copy link

Same issue in Coil 2.2.0 when trying to load this image (on an Android Emulator):

http://www.corsix.org/images/495f98eb0abe80037a8bfb0b3dfb943df9e04495.svg

Note that the image is only 14kb but coil is attempting to decode a 142mb file?

2022-08-31 00:39:32.474 6398-6398/com.nononsenseapps.feeder.debug E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.nononsenseapps.feeder.debug, PID: 6398
    java.lang.RuntimeException: Canvas: trying to draw too large(142657200bytes) bitmap.
        at android.graphics.RecordingCanvas.throwIfCannotDraw(RecordingCanvas.java:266)
        at android.graphics.BaseRecordingCanvas.drawBitmap(BaseRecordingCanvas.java:94)
        at androidx.compose.ui.graphics.AndroidCanvas.drawImageRect-HPBpro0(AndroidCanvas.android.kt:271)
        at androidx.compose.ui.graphics.drawscope.CanvasDrawScope.drawImage-AZ2fEMs(CanvasDrawScope.kt:263)
        at androidx.compose.ui.node.LayoutNodeDrawScope.drawImage-AZ2fEMs(Unknown Source:40)
        at androidx.compose.ui.graphics.drawscope.DrawScope.drawImage-AZ2fEMs$default(DrawScope.kt:510)
        at androidx.compose.ui.graphics.painter.BitmapPainter.onDraw(BitmapPainter.kt:93)
        at androidx.compose.ui.graphics.painter.Painter.draw-x_KDEd0(Painter.kt:212)
        at coil.compose.AsyncImagePainter.onDraw(AsyncImagePainter.kt:210)
        at androidx.compose.ui.graphics.painter.Painter.draw-x_KDEd0(Painter.kt:212)
        at androidx.compose.ui.draw.PainterModifier.draw(PainterModifier.kt:281)
        at androidx.compose.ui.node.DrawEntity.draw(DrawEntity.kt:98)
        at androidx.compose.ui.node.LayoutNodeWrapper.drawContainedDrawModifiers(LayoutNodeWrapper.kt:336)
        at androidx.compose.ui.node.LayoutNodeWrapper.draw(LayoutNodeWrapper.kt:326)
        at androidx.compose.ui.node.ModifiedLayoutNode.performDraw(ModifiedLayoutNode.kt:242)
        at androidx.compose.ui.node.LayoutNodeWrapper.drawContainedDrawModifiers(LayoutNodeWrapper.kt:334)
        at androidx.compose.ui.node.LayoutNodeWrapper.access$drawContainedDrawModifiers(LayoutNodeWrapper.kt:64)
        at androidx.compose.ui.node.LayoutNodeWrapper$invoke$1.invoke(LayoutNodeWrapper.kt:358)
        at androidx.compose.ui.node.LayoutNodeWrapper$invoke$1.invoke(LayoutNodeWrapper.kt:357)
        at androidx.compose.runtime.snapshots.Snapshot$Companion.observe(Snapshot.kt:2118)
        at androidx.compose.runtime.snapshots.SnapshotStateObserver$observeReads$1$1.invoke(SnapshotStateObserver.kt:129)
        at androidx.compose.runtime.snapshots.SnapshotStateObserver$observeReads$1$1.invoke(SnapshotStateObserver.kt:125)
        at androidx.compose.runtime.SnapshotStateKt__DerivedStateKt.observeDerivedStateRecalculations(DerivedState.kt:336)
        at androidx.compose.runtime.SnapshotStateKt.observeDerivedStateRecalculations(Unknown Source:1)
        at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:125)
        at androidx.compose.ui.node.OwnerSnapshotObserver.observeReads$ui_release(OwnerSnapshotObserver.kt:120)
        at androidx.compose.ui.node.LayoutNodeWrapper.invoke(LayoutNodeWrapper.kt:357)
        at androidx.compose.ui.node.LayoutNodeWrapper.invoke(LayoutNodeWrapper.kt:64)
        at androidx.compose.ui.platform.RenderNodeApi29.record(RenderNodeApi29.android.kt:180)
        at androidx.compose.ui.platform.RenderNodeLayer.updateDisplayList(RenderNodeLayer.android.kt:298)
        at androidx.compose.ui.platform.AndroidComposeView.dispatchDraw(AndroidComposeView.android.kt:1005)
        at android.view.View.draw(View.java:23198)
        at android.view.View.updateDisplayListIfDirty(View.java:22062)
        at android.view.ViewGroup.recreateChildDisplayList(ViewGroup.java:4513)
        at android.view.ViewGroup.dispatchGetDisplayList(ViewGroup.java:4486)
        at android.view.View.updateDisplayListIfDirty(View.java:22018)
        at android.view.ViewGroup.recreateChildDisplayList(ViewGroup.java:4513)
        at android.view.ViewGroup.dispatchGetDisplayList(ViewGroup.java:4486)
        at android.view.View.updateDisplayListIfDirty(View.java:22018)
        at android.view.ViewGroup.recreateChildDisplayList(ViewGroup.java:4513)
        at android.view.ViewGroup.dispatchGetDisplayList(ViewGroup.java:4486)
        at android.view.View.updateDisplayListIfDirty(View.java:22018)
        at android.view.ViewGroup.recreateChildDisplayList(ViewGroup.java:4513)
        at android.view.ViewGroup.dispatchGetDisplayList(ViewGroup.java:4486)
        at android.view.View.updateDisplayListIfDirty(View.java:22018)
        at android.view.ThreadedRenderer.updateViewTreeDisplayList(ThreadedRenderer.java:682)
2022-08-31 00:39:32.474 6398-6398/com.nononsenseapps.feeder.debug E/AndroidRuntime:     at android.view.ThreadedRenderer.updateRootDisplayList(ThreadedRenderer.java:688)
        at android.view.ThreadedRenderer.draw(ThreadedRenderer.java:786)
        at android.view.ViewRootImpl.draw(ViewRootImpl.java:4579)
        at android.view.ViewRootImpl.performDraw(ViewRootImpl.java:4290)
        at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:3517)
        at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:2286)
        at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:8948)
        at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1231)
        at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1239)
        at android.view.Choreographer.doCallbacks(Choreographer.java:899)
        at android.view.Choreographer.doFrame(Choreographer.java:832)
        at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1214)
        at android.os.Handler.handleCallback(Handler.java:942)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at android.os.Looper.loopOnce(Looper.java:201)
        at android.os.Looper.loop(Looper.java:288)
        at android.app.ActivityThread.main(ActivityThread.java:7898)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)

Using the following code:

Image(
                                    painter = rememberAsyncImagePainter(
                                        model = ImageRequest.Builder(LocalContext.current)
                                            .data(imageUrl)
                                            .scale(Scale.FILL)
                                            .placeholder(placeHolder)
                                            .error(placeHolder)
                                            .precision(Precision.INEXACT)
                                            .size(1000)
                                            .build(),
                                        contentScale = ContentScale.Crop,
                                    ),
                                    contentScale = ContentScale.Crop,
                                    contentDescription = null,
                                    modifier = modifier
                                        .fillMaxWidth()
                                        .aspectRatio(16.0f / 9.0f)
                                )

@spacecowboy
Copy link

Adding a simple try-catch ensures the app doesn't crash

diff --git i/coil-compose-base/src/main/java/coil/compose/AsyncImagePainter.kt w/coil-compose-base/src/main/java/coil/compose/AsyncImagePainter.kt
index 237b82da..67674e5d 100644
--- i/coil-compose-base/src/main/java/coil/compose/AsyncImagePainter.kt
+++ w/coil-compose-base/src/main/java/coil/compose/AsyncImagePainter.kt
@@ -3,6 +3,7 @@ package coil.compose
 import android.graphics.drawable.BitmapDrawable
 import android.graphics.drawable.ColorDrawable
 import android.graphics.drawable.Drawable
+import android.util.Log
 import androidx.compose.foundation.Image
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyRow
@@ -207,7 +208,11 @@ class AsyncImagePainter internal constructor(
         drawSize.value = size
 
         // Draw the current painter.
-        painter?.apply { draw(size, alpha, colorFilter) }
+        try {
+            painter?.apply { draw(size, alpha, colorFilter) }
+        } catch (e: Exception) {
+            Log.e("JCOIL", "Damn", e)
+        }
     }
 
     override fun applyAlpha(alpha: Float): Boolean {

but likely some kind of deeper redesign would be needed to return a proper error code since this happens AFTER coil already has returned the success status

@colinrtwhite
Copy link
Member

colinrtwhite commented Sep 4, 2022

@spacecowboy Your image is being decoded at a very large size, which is why it's throwing when it's drawn. We do want to throw the exception in this case instead of ignoring it as it could be the result of a misconfiguration.

Your SVG has the intrinsic dimensions width="101.484ex" height="2.843ex", which uses the ex unit (an uncommon type) so it's possible they're being interpreted incorrectly by the SVG rendering library. This would be a different issue - could you open a new bug for that?

@spacecowboy
Copy link

@spacecowboy Your image is being decoded at a very large size, which is why it's throwing when it's drawn. We do want to throw the exception in this case instead of ignoring it as it could be the result of a misconfiguration.

Your SVG has the intrinsic dimensions width="101.484ex" height="2.843ex", which uses the ex unit (an uncommon type) so it's possible they're being interpreted incorrectly by the SVG rendering library. This would be a different issue - could you open a new bug for that?

Yes I can do that but to reply to your "we want to throw" statement: that's very bad.

There is no possibility for my app to catch this error. Note In the stack trace that there is no mention of my app (com.nononsenseapps.feeder).

I use coil in an RSS reader app. all images come from the RSS feeds added by users so I have no control what images are going to be decoded. It can be an SVG like in this case but I've also seen the error with plain old bitmaps.

if this happens at the wrong place in the app, it is very possible to crash lock it and no way to stop the app from crashing on every startup except clearing the cache memory and opening it whilst in airplane mode.

so please do throw an exception but do so at a call site from which I can catch it please

@colinrtwhite
Copy link
Member

colinrtwhite commented Sep 6, 2022

@spacecowboy It is possible to catch the error. If you want different behaviour you can use a custom ImageView that wraps super.onDraw(canvas) to catch + ignore this exception. For context, Glide and Picasso also will also throw this exception in the same way.

@spacecowboy
Copy link

@spacecowboy It is possible to catch the error. If you want different behaviour you can use a custom ImageView that wraps super.onDraw(canvas) to catch + ignore this exception. For context, Glide and Picasso also will also throw this exception in the same way.

thanks! horrible to have to do that but it'll work

@spacecowboy
Copy link

@spacecowboy It is possible to catch the error. If you want different behaviour you can use a custom ImageView that wraps super.onDraw(canvas) to catch + ignore this exception. For context, Glide and Picasso also will also throw this exception in the same way.

I spoke too soon. Custom ImageView? My app is built with Jetpack Compose. Do you have a suggestion for how I can catch the error in compose?

@colinrtwhite
Copy link
Member

colinrtwhite commented Sep 9, 2022

@spacecowboy I'd create a custom Coil interceptor that throws if the drawable is too large. That has the benefit of triggering onError + the associated behaviour as well. Anything below 2500x2500 is pretty much always small enough to draw.

Also calling a solution "horrible" generally doesn't entice me to offer more help with your problem. If you have a plan for how to improve this that's consistent for both views and Compose I'm open.

@spacecowboy
Copy link

@spacecowboy I'd create a custom Coil interceptor that throws if the drawable is too large. That has the benefit of triggering onError + the associated behaviour as well. Anything below 2500x2500 is pretty much always small enough to draw.

Also calling a solution "horrible" generally doesn't entice me to offer more help with your problem. If you have a plan for how to improve this that's consistent for both views and Compose I'm open.

I apologize for my tone @colinrtwhite . I should have phrased it differently.

I'll give the interceptor a try.

@brianguertin
Copy link

brianguertin commented May 24, 2023

This is still an issue. Feels like it should be build into Coil to not crash the app...

Anyway, I think this interceptor will fix it:

add { chain ->
    val request = chain.request
    val result = chain.proceed(request)
    val bitmap = (result.drawable as? BitmapDrawable)?.bitmap
    if (bitmap != null && bitmap.byteCount >= MAX_BITMAP_SIZE) {
        ErrorResult(
            request.error,
            request,
            RuntimeException("Bitmap is too large (${bitmap.byteCount} bytes)")
        )
    } else {
        result
    }
}
        /**
         * Copied from RecordingCanvas.MAX_BITMAP_SIZE
         */
        private const val MAX_BITMAP_SIZE = 100 * 1024 * 1024 // 100 MB

@spacecowboy
Copy link

spacecowboy commented May 25, 2023

This the interceptor I ended up using

/**
 * Ensures an error is returned instead of rendering images that are likely to trigger memory errors
 * onDraw - but are not SO large as too cause a OOM exception during decode.
 */
class TooLargeImageInterceptor : Interceptor {
    override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
        return when (val result = chain.proceed(chain.request)) {
            is ErrorResult -> result
            is SuccessResult -> {
                val sumPixels = result.drawable.intrinsicWidth * result.drawable.intrinsicHeight

                if (sumPixels > MAX_PIXELS) {
                    return ErrorResult(
                        chain.request.error,
                        chain.request,
                        RuntimeException("Image was (probably) too large to render within memory constraints: ${result.drawable.intrinsicWidth} x ${result.drawable.intrinsicHeight} > 2500 x 2500"),
                    )
                } else {
                    result
                }
            }
        }
    }

    companion object {
        const val MAX_PIXELS = 2500 * 2500
    }
}

@XuQK
Copy link
Author

XuQK commented May 26, 2023

I found that if it is just to prevent crashes, it is only necessary to capture the OOM exception in the interceptor。

class OOMInterceptor : Interceptor {
    override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
        return try {
            chain.proceed(chain.request)
        } catch (e: OutOfMemoryError) {
            ErrorResult(null, chain.request, e)
        }
    }
}

@spacecowboy
Copy link

I found that if it is just to prevent crashes, it is only necessary to capture the OOM exception in the interceptor。

You are missing an edge case there @XuQK . It is possible to get images with just the right size that won't cause an OOM exception during the decode, but will crash the app when you try to display it

@vitorpamplona
Copy link

vitorpamplona commented Nov 20, 2023

It would be great to have a Size.MAX_ALLOWED that would check the device's limits from RecordingCanvas and use the maximum allowable size if the image exceeds it, avoiding exceptions. Otherwise, it would behave as Size.ORIGINAL.

@nateridderman
Copy link

Anything below 2500x2500 is pretty much always small enough to draw.

For my app, running on a Zebra TC57 on Android 10, the magic number was 2350x2350. And for images that were more rectangular than square would work if the total # of pixels was less than 2350x2350.

I should mention my app applies a transformImage modifier in order to pan/zoom the image. When it fails, it does not crash, but instead shows a blank image. This is using the compose version of Coil.

@colinrtwhite
Copy link
Member

It would be great to have a Size.MAX_ALLOWED that would check the device's limits from RecordingCanvas and use the maximum allowable size if the image exceeds it, avoiding exceptions. Otherwise, it would behave as Size.ORIGINAL.

Coil 3.0 has a new maxBitmapSize property which puts a hard limit on the decoded image's dimensions. Even if you request Size.ORIGINAL for a very large images maxBitmapSize will not load larger than 4096x4096. This should guard against draw-time OOMs in almost all newer devices, but if you need a smaller size you can set ImageRequest.Builder.maxBitmapSize to a smaller value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Issues that are up for grabs + are good candidates for community PRs
Projects
None yet
Development

No branches or pull requests

6 participants