airbnb/lottie-android · error · IllegalStateException

OffscreenBitmap: finish() call without matching start()

Error message

OffscreenBitmap: finish() call without matching start()

What it means

Thrown by OffscreenLayer.finish() when any of the internal session fields (parentCanvas, op, preExistingTransform, targetRect) are null at line 350. This means finish() was called without a prior successful start(), or the start() did not complete initialization. The finish() method expects a fully initialized session and cannot safely clean up a partial or nonexistent one.

Source

Thrown at lottie/src/main/java/com/airbnb/lottie/utils/OffscreenLayer.java:351

        renderNode.setHasOverlappingRendering(true);
        renderNode.setPosition((int)scaledBounds.left, (int)scaledBounds.top, (int)scaledBounds.right, (int)scaledBounds.bottom);

        childCanvas = renderNode.beginRecording((int) scaledBounds.width(), (int) scaledBounds.height());
        childCanvas.setMatrix(OffscreenLayer.IDENTITY_MATRIX);
        childCanvas.scale(pixelScaleX, pixelScaleY); // Replicate scaling applied by parentCanvas
        childCanvas.translate(-bounds.left, -bounds.top); // So that the image begins at the top-left of the bitmap
        break;

      default:
        throw new RuntimeException("Invalid render strategy for OffscreenLayer");
    }

    return childCanvas;
  }

  public void finish() {
    if (parentCanvas == null || op == null || preExistingTransform == null || targetRect == null) {
      throw new IllegalStateException("OffscreenBitmap: finish() call without matching start()");
    }

    switch (currentStrategy) {
      case DIRECT:
        parentCanvas.restore();
        break;

      case SAVE_LAYER:
        parentCanvas.restore();
        break;

      case BITMAP:
        if (bitmap == null) {
          throw new IllegalStateException("Bitmap is not ready; should've been initialized at start() time");
        }

        if (op.hasShadow()) {
          // Composing the shadow first and then the content like this will be incorrect in the

View on GitHub (pinned to 05ea92e903)

Solutions

  1. Track the start/finish state with a boolean flag and only call finish() if start() succeeded and hasn't been finished yet
  2. Ensure finish() is called exactly once per start() — use a try/finally where start() is outside the try, or guard with a flag
  3. If using try/finally, structure it so finish() is only called when start() completed: set a boolean after successful start()

Example fix

// before:
// try {
//   layer.start(canvas, bounds, op);
//   draw(layer.getCanvas());
// } finally {
//   layer.finish();  // called even if start() threw, or called twice
// }

// after: guard finish() with a state flag
boolean started = false;
try {
  layer.start(canvas, bounds, op);
  started = true;
  draw(layer.getCanvas());
} finally {
  if (started) {
    layer.finish();
  }
}
Defensive patterns

Strategy: validation

Validate before calling

// Guard finish() against missing or double calls
boolean started = false;

Canvas safeStart(OffscreenLayer layer, Canvas canvas, RectF bounds, ComposeOp op) {
  Canvas result = layer.start(canvas, bounds, op);
  started = true;
  return result;
}

void safeFinish(OffscreenLayer layer) {
  if (!started) return; // no-op if start() was never called or already finished
  layer.finish();
  started = false;
}

Try / catch

// Guarded try/finally pattern
boolean started = false;
try {
  layer.start(canvas, bounds, op);
  started = true;
  drawContent(layer.getCanvas());
} finally {
  if (started) layer.finish();
}

Prevention

When it happens

Trigger: Calling offscreenLayer.finish() without having called start() first, or calling finish() twice (the second call finds parentCanvas null because the first finish() cleared it at line 405). Also possible if start() itself failed partway through initialization due to an exception, leaving fields partially set. The throw is at line 351.

Common situations: A double finish() call — finish() is called, clears parentCanvas (line 405), then finish() is called again in a finally block or cleanup path. Calling finish() in a finally block when start() was never called (e.g., start() was conditionally skipped). Cleanup code that unconditionally calls finish() regardless of whether start() succeeded. Exception in start() before all fields are set.

Related errors


AI-assisted analysis of airbnb/lottie-android@05ea92e903 (2026-08-14). Data as JSON: /api/errors/0e712a776e0fe4e6. Report an issue: GitHub.