diff --git a/rfc/410.0000-deprecate-android-flutterrenderer-getbitmap.md b/rfc/410.0000-deprecate-android-flutterrenderer-getbitmap.md new file mode 100644 index 0000000..b9e5f58 --- /dev/null +++ b/rfc/410.0000-deprecate-android-flutterrenderer-getbitmap.md @@ -0,0 +1,219 @@ +--- +type: rfc +rfc: '410.0000' +title: Deprecate Android FlutterRenderer.getBitmap +description: Deprecates the Android embedding's FlutterRenderer.getBitmap() and FlutterJNI.getBitmap() screenshot APIs in favor of the framework's OffsetLayer.toImage(). +status: draft +created: 2026-10-30T00:00:00Z +updated: 2026-11-10T00:00:00Z +tags: + - 410-android + - 030-deprecation + - 120-rendering + - 440-plugins +authors: + - https://github.com/mboetger # Matt Boetger +--- + +# RFC 410.0000: Deprecate Android FlutterRenderer.getBitmap + +## Overview + +This RFC proposes deprecating the two Android embedding APIs that return the current Flutter frame as an `android.graphics.Bitmap`: + +- [`FlutterRenderer.getBitmap()`](https://github.com/flutter/flutter/blob/9d80afcdecf4f9641a9bed1e03d3a17ca44b4e1f/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java#L1342-L1344) +- [`FlutterJNI.getBitmap()`](https://github.com/flutter/flutter/blob/9d80afcdecf4f9641a9bed1e03d3a17ca44b4e1f/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java#L1701-L1708) + +Callers should use [`OffsetLayer.toImage()`](https://api.flutter.dev/flutter/rendering/OffsetLayer/toImage.html) on the `RenderView`'s layer from Dart instead. + +Why: + +- Nothing in `flutter/flutter` or `flutter/packages` calls `getBitmap()`, and it has had no test coverage since 2024. +- The engine implementation blocks the Android main thread for 200–470 ms per call. +- Reimplementing it in the embedder alone requires reading back and compositing separate swapchain surfaces and hardware buffers across rendering modes. See [Alternatives Considered](#alternatives-considered). +- Removing it deletes a JNI entry point and the embedder's dependency on `Shell::Screenshot`, which the embedder C-API does not expose. + +### Goals + +- Give callers a documented migration path to `OffsetLayer.toImage()`. +- Platform view support is unchanged. See [Platform view support](#platform-view-support). +- Deprecate both APIs with no behavior change. +- Then remove the Java APIs, the `nativeGetBitmap` JNI entry point, and `AndroidShellHolder::Screenshot`. + +### Non-Goals + +- Changing `Shell::Screenshot` or `Rasterizer::ScreenshotLastLayerTree`. +- Changing `flutter screenshot` (which uses `adb shell screencap -p`), `integration_test`'s `binding.takeScreenshot()`, or the VM service `_flutter.screenshot` RPC (`Shell::OnServiceProtocolScreenshot`). None of these call `FlutterRenderer.getBitmap()`. +- Adding a new native screenshot API. + +## Background + +### Current implementation + +```mermaid +sequenceDiagram + participant Caller as App or SDK (main thread) + participant Renderer as FlutterRenderer + participant JNI as FlutterJNI + participant Shell as AndroidShellHolder / Shell + participant Raster as Rasterizer (raster thread) + + Caller->>Renderer: getBitmap() + Renderer->>JNI: getBitmap() (main thread only) + JNI->>Shell: nativeGetBitmap(), then Screenshot() + Note over Caller,Shell: Main thread blocks until the screenshot is ready + Shell->>Raster: ScreenshotLastLayerTree() + Raster->>Raster: Re-render the last layer tree off screen (no view embedder) + Raster->>Raster: Read the pixels back from the GPU + Raster-->>Shell: RGBA pixels + Shell-->>JNI: Screenshot + JNI-->>Caller: new ARGB_8888 Bitmap +``` + +Source: + +- [`GetBitmap`](https://github.com/flutter/flutter/blob/9d80afcdecf4f9641a9bed1e03d3a17ca44b4e1f/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl.cc#L449-L480) +- [`AndroidShellHolder::Screenshot`](https://github.com/flutter/flutter/blob/9d80afcdecf4f9641a9bed1e03d3a17ca44b4e1f/engine/src/flutter/shell/platform/android/android_shell_holder.cc#L307-L314) +- [`Shell::Screenshot`](https://github.com/flutter/flutter/blob/9d80afcdecf4f9641a9bed1e03d3a17ca44b4e1f/engine/src/flutter/shell/common/shell.cc#L2395) +- [`Rasterizer::ScreenshotLastLayerTree`](https://github.com/flutter/flutter/blob/9d80afcdecf4f9641a9bed1e03d3a17ca44b4e1f/engine/src/flutter/shell/common/rasterizer.cc#L1089-L1152) + +### Platform view support + +| Platform view mode | How it is drawn | Current `getBitmap()` | `toImage()` (proposed) | Alternative `PixelCopy` ([#195526](https://github.com/flutter/flutter/pull/195526)) | +| :--- | :--- | :--- | :--- | :--- | +| None | Flutter draws the whole frame | Works | Works | Works when `EnableHcpp=false` or on OpenGL ES; returns `null` on API 29+ Vulkan when `EnableHcpp=true` | +| Texture Layer Hybrid Composition (TLHC) | The native view draws into an external texture that Flutter composites | Works | Works | Works when `EnableHcpp=false` or on OpenGL ES; returns `null` on API 29–33 Vulkan when `EnableHcpp=true` | +| Virtual Display (VD) | The native view renders to a virtual display that Flutter uses as an external texture | Works | Works | Works when `EnableHcpp=false` or on OpenGL ES; returns `null` on API 29–33 Vulkan when `EnableHcpp=true` | +| Hybrid Composition (HC) | The native view is a real Android `View`; Flutter draws in surfaces below and above it | Flutter UI captured; native view missing | Flutter UI captured; native view missing | Native view and Flutter overlay UI missing; returns `null` on API 29–33 Vulkan when `EnableHcpp=true` | +| Hybrid Composition++ (HCPP, API 34+ Vulkan) | The native view is composited by the system with `SurfaceControl` | Flutter UI captured; native view missing | Flutter UI captured; native view missing | Returns `null` | + +On API 34+ with Impeller Vulkan and `EnableHcpp=true`, legacy platform view modes (TLHC, VD, HC) are upgraded to HCPP. On API 29–33, HCPP platform views are disabled (`kMinAPILevelHCPP = 34`), so views remain in TLHC, VD, or HC, but Impeller Vulkan still uses its `SurfaceControl` swapchain (`AHBSwapchainVK`) from startup when `EnableHcpp=true`, causing `PixelCopy` on `FlutterSurfaceView`'s `Surface` to return `null` even when no platform views are present. + +> [!IMPORTANT] +> Platform view support is the same for the proposed method (`toImage()`) and the current method (`getBitmap()`) in every mode. Migrating from `getBitmap()` to `toImage()` does not change which platform views appear in the capture. + +### Known usage + +Public GitHub code search finds these callers. Metrics are as of 2026-10-30; private usage is unknown. + +| Project | Use | pub.dev likes / downloads (30 days) | GitHub stars / forks | Latest release | +| :--- | :--- | :--- | :--- | :--- | +| [Gleap Flutter SDK](https://github.com/GleapSDK/Flutter-SDK) (`gleap_sdk`) | Bug-report screenshots | 14 / 14,327 | 4 / 3 | 19.3.1 (2026-10-19) | +| [Appcues Flutter plugin](https://github.com/appcues/appcues-flutter-plugin) (`appcues_flutter`) | Screenshots for element targeting | 2 / 12,205 | 6 / 0 | 5.0.6 (2026-10-16) | +| [GrowingIO autotracker plugin](https://github.com/growingio/flutter-growingio-sdk-autotracker-plugin) | Analytics screenshots | Not on pub.dev | 3 / 0 | Last commit 2022-12-13 | +| [flutter_native_boost](https://github.com/bozaigao/flutter_native_boost) (`flutter_native_boost`) | Snapshot shown during native page transitions | 0 / 29 | 1 / 1 | 1.0.3 (2021-01-13) | + +Gleap and Appcues are actively maintained and account for nearly all downloads, so they are the main migration targets. + +## Proposal + +### Phase 1: Deprecate + +1. Annotate both methods with `@Deprecated(since = "Flutter X.Y", forRemoval = true)`, plus a Javadoc `@deprecated` tag that points to `OffsetLayer.toImage()` and the migration guide. Behavior does not change. +2. Publish a migration guide under [docs.flutter.dev breaking changes](https://docs.flutter.dev/release/breaking-changes). +3. Announce the deprecation on flutter-announce, and apply the `c: API break` label. +4. Notify the Gleap and Appcues maintainers. + +```java +/** + * @deprecated Use {@code OffsetLayer.toImage()} on the {@code RenderView}'s layer from Dart + * instead. It captures the same content without blocking the platform thread. + * See https://docs.flutter.dev/release/breaking-changes/. + * This feature was deprecated after v. + */ +@Deprecated(since = "Flutter X.Y", forRemoval = true) +@Nullable +public Bitmap getBitmap() { + return flutterJNI.getBitmap(); +} +``` + +### Phase 2: Remove + +After the deprecation has shipped in at least 1 stable release, remove: + +- `FlutterRenderer.getBitmap()` and `FlutterJNI.getBitmap()`; +- `nativeGetBitmap` and its JNI registration and `Bitmap` class lookups; +- `AndroidShellHolder::Screenshot`. + +## Proposed Migration Guide + +To capture the whole Flutter view without adding a `RepaintBoundary` widget to the tree, call `toImage()` on the `RenderView`'s root layer (`RenderView` is already a repaint boundary and its root `TransformLayer` already scales by `devicePixelRatio`, so pass `view.paintBounds`): + +```dart +Future captureFlutterUi() async { + await WidgetsBinding.instance.endOfFrame; + final RenderView view = RendererBinding.instance.renderViews.first; + // ignore: invalid_use_of_protected_member + final OffsetLayer layer = view.layer! as OffsetLayer; + return layer.toImage(view.paintBounds); +} +``` + +(`RenderObject.layer` is currently annotated `@protected`, which is why [`flutter_driver` suppresses `invalid_use_of_protected_member`](https://github.com/flutter/flutter/blob/9d80afcdecf4f9641a9bed1e03d3a17ca44b4e1f/packages/flutter_driver/lib/src/common/handler_factory.dart#L381-L391). We can also expose `RenderView.layer` or add a public `RenderView.toImage()` helper so callers do not need the ignore comment.) + +Native SDKs that need an `android.graphics.Bitmap` can ask Dart for the capture over a platform channel. The Dart side returns `image.toByteData(format: ui.ImageByteFormat.rawRgba)` (premultiplied RGBA) and the image size. The native side copies the bytes into a bitmap: + +```kotlin +val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) +bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(rgbaBytes)) +``` + +Measured on a Pixel 8 (API 34, profile build, full-screen 1080×2337 capture, median of 24): + +| | Current `getBitmap()` | Pigeon → `toImage()` → `Bitmap` | +| :--- | :--- | :--- | +| Time until the `Bitmap` is ready | 217 ms | 112 ms | +| Main thread | Blocked for 217 ms | Longest task 50 ms (Dart encodes the 10 MB reply) | +| Missed frames at 60 Hz | 13 | 5 | + +Returning PNG instead of `rawRgba` shrinks the message to 15 KB, but takes 269 ms because of the encode and decode. + +> [!WARNING] +> From native code, the capture arrives asynchronously in a callback; calls from native code into Dart (Pigeon or platform channels) are always asynchronous. Do not block the main thread waiting for the result. Dart runs on the main thread, so the capture can never complete and the app deadlocks. If an existing API must return a `Bitmap` synchronously, request the capture from a background thread and wait on that thread, or capture from Dart ahead of time. + +How `toImage()` differs from `getBitmap()`: + +- It is called from Dart, is asynchronous, and does not block the Android main thread. +- It requires the Dart UI isolate to be running. Unlike `getBitmap()`, which hops directly to the raster thread, `toImage()` does not complete while the Dart isolate is paused (for example, at a debugger breakpoint). Developer tools (`flutter screenshot` and the VM service `_flutter.screenshot` RPC) still work while the isolate is paused. + +## Alternatives Considered + +### Reimplement `getBitmap()` with `PixelCopy` + +[flutter/flutter#195526](https://github.com/flutter/flutter/pull/195526) removed the engine path and reimplemented `FlutterRenderer.getBitmap()` in Java. It calls `PixelCopy.request()` on the `Surface` that Flutter renders into. + +When that `Surface` holds the whole frame, the result is pixel-exact and about 7–10× faster than the engine path. But `PixelCopy` only sees buffers queued to that one `Surface`, which fails in two cases: + +- **`EnableHcpp=true` (the default on master and beta):** On API 29+ with Impeller Vulkan, Impeller presents through a child `SurfaceControl` swapchain (`AHBSwapchainVK`) from startup, even on API 29–33 where HCPP platform views are not active (`kMinAPILevelHCPP = 34`) and even when no platform views are on screen. Nothing reaches `FlutterSurfaceView`'s `Surface`, so `getBitmap()` returns `null`. +- **Hybrid Composition:** Flutter UI drawn over a platform view lives in separate overlay surfaces, so it is dropped. + +Android has no public API to read back a window's composited `SurfaceControl` layers in one call: + +- Through Android 16 (API 36), `PixelCopy` only accepts a `Surface`, `SurfaceView`, `Window`, or `View`. `PixelCopy` on a `Window` reads only the window's `ViewRootImpl` `ThreadedRenderer` surface and does not composite `SurfaceView` or `SurfaceControl` layers. (`integration_test`'s [`FlutterDeviceScreenshot`](https://github.com/flutter/flutter/blob/9d80afcdecf4f9641a9bed1e03d3a17ca44b4e1f/packages/integration_test/android/src/main/java/dev/flutter/plugins/integration_test/FlutterDeviceScreenshot.java#L101-L271) works around this by calling `flutterView.convertToImageView()`, scheduling a new Dart frame, and waiting two `Choreographer` frames on the main thread before calling `PixelCopy.request(window, ...)`, which cannot be done inside a synchronous main-thread `getBitmap()` call without deadlocking.) +- In AOSP, `AccessibilityInteractionController` composites a window's `SurfaceControl` subtree in-process by calling `android.window.ScreenCapture.captureLayers` on `ViewRootImpl.getSurfaceControl()` with `.setChildrenOnly(false).setUid(Process.myUid())`, but both APIs are `@hide` and blocked by Android's non-SDK interface restrictions (`blacklist`) for callers outside `/system/framework/framework.jar`. +- Android 17 (API 37) adds `AttachedSurfaceControl.createMirror()`, which lets an app mirror its window `SurfaceControl` hierarchy (filtering out layers from other UIDs) into a `VirtualDisplay` and `ImageReader`. However, that path is asynchronous, requires setting up a `VirtualDisplay` and waiting for a composition pass, and does not help on Android 14–16 (API 34–36), where HCPP is active, or Android 10–13 (API 29–33), where `EnableHcpp=true` still switches Impeller Vulkan to `AHBSwapchainVK`. + +### Reconstruct the frame piece by piece in the embedder + +Without changing `embedder.h`, the Android embedder could read back each layer it owns and composite them manually: use `PixelCopy` when Flutter renders to a `Surface` (OpenGL ES or `KHRSwapchainVK`), and retain a reference to the last presented `AHardwareBuffer` on the root `"ImpellerSurface"` and each `"overlayerSurface"` when `AHBSwapchainVK` is active (wrapping them via `AHardwareBuffer_toHardwareBuffer` and `Bitmap.wrapHardwareBuffer`). Combining those retained buffers would reproduce current `getBitmap()`'s Flutter-only output in HCPP and Hybrid Composition. + +Going further to include platform views would also require capturing `SurfaceView`s via `PixelCopy`, `TextureView`s via `TextureView.getBitmap()`, and standard Android `View`s via `View.draw(Canvas)`, while platform views that attach their own child `SurfaceControl`s still could not be read back on API <= 36. Retaining swapchain `AHardwareBuffer`s across frames and maintaining user-space composition across OpenGL ES, Vulkan `Surface`, HC overlays, and `AHBSwapchainVK` is too much complexity and memory overhead to preserve an untested, main-thread-blocking API with two active plugin callers. + +### Add `FlutterRenderer.getBitmapAsync()` over a system channel + +We also considered adding `FlutterRenderer.getBitmapAsync(Consumer)` backed by a built-in `flutter/screenshot` system channel: `FlutterRenderer` would invoke a `RendererBinding` method call handler in Dart that runs `OffsetLayer.toImage()` on the `RenderView`, reads the pixels with `toByteData(format: ImageByteFormat.rawRgba)`, and returns the byte array to Java to copy into an `ARGB_8888` `Bitmap`. + +We rejected this approach for three reasons: + +- Calling into Dart over a platform channel is asynchronous and requires the Android main thread `Looper` to remain unblocked (the Dart UI isolate runs on the main thread when threads are merged, and channel responses are delivered on the main `Looper`). It cannot satisfy existing synchronous `getBitmap()` call sites without deadlocking, so callers still have to migrate to an asynchronous API. +- Only two active external packages (`gleap_sdk` and `appcues_flutter`) call `getBitmap()`, and both are Flutter plugins with a Dart layer. Capturing in Dart directly avoids adding a permanent `flutter/screenshot` system channel and avoids an extra Java-to-Dart request when Dart code is already coordinating the action. +- Because it delegates to `OffsetLayer.toImage()`, it has the same limitations as calling `toImage()` directly: Hybrid Composition and HCPP platform views are omitted, each full-screen 1080×2400 capture allocates and copies a ~10 MB uncompressed RGBA buffer across the channel, and capture cannot complete while the Dart UI isolate is paused. + +### Add a screenshot API to the embedder C-API + +Exposing `Shell::Screenshot` in `embedder.h` would keep `FlutterRenderer.getBitmap()` working, but keeps a flawed API: + +- `FlutterRenderer.getBitmap()` is synchronous on the Android main thread. `Shell::Screenshot` blocks the main thread on a latch while the raster thread re-renders the layer tree off screen and reads the pixels back from the GPU, stalling the UI thread for 200–470 ms (13–28 frames at 60 Hz). +- None of the C-API embedders (macOS, Windows, Linux) expose a public `Shell::Screenshot` API, and on iOS `-[FlutterEngine screenshot:base64Encode:]` is internal (`FlutterEngine_Internal.h`). +- Only two active external packages (`gleap_sdk` and `appcues_flutter`) call `getBitmap()`. Both are Flutter plugins with a Dart layer and can capture the `RenderView` asynchronously with `toImage()` without modifying the app's widget tree or blocking the main thread.