Minimal reproduction for a fatal abort in Hybrid Composition++ (HCPP / Android platform views v2):
[FATAL:flutter/shell/platform/android/platform_view_android_jni_impl.cc(2370)]
Check failed: fml::jni::CheckException(env).
The abort message does not carry the Java exception. It is:
java.lang.NullPointerException: Attempt to invoke interface method
'boolean android.view.AttachedSurfaceControl.applyTransactionOnDraw(android.view.SurfaceControl$Transaction)'
on a null object reference
at io.flutter.plugin.platform.PlatformViewsController2.onEndFrame(PlatformViewsController2.java:676)
at io.flutter.embedding.engine.FlutterJNI.endFrame2(FlutterJNI.java:1379)
at android.os.MessageQueue.nativePollOnce(Native Method)
...
AndroidExternalViewEmbedder2::SubmitFlutterView posts onEndFrame2() from the raster thread to
the platform thread on every frame, including frames with no platform layers
(external_view_embedder_2.cc:73-91 and :168-203). By the time that task runs on the main looper,
the FlutterView may have been detached from the controller, or detached from its window — and
View.getRootSurfaceControl() is documented to return null when the view is not attached to a
window. PlatformViewsController2.onEndFrame() guards neither:
// PlatformViewsController2.java:669
public void onEndFrame() {
SurfaceControl.Transaction tx = new SurfaceControl.Transaction();
for (int i = 0; i < activeTransactions.size(); i++) {
tx = tx.merge(activeTransactions.get(i));
}
activeTransactions.clear();
flutterView.invalidate();
flutterView.getRootSurfaceControl().applyTransactionOnDraw(tx); // <-- NPE
}Throwing here is fatal rather than merely wrong: the JNI caller ends with
FML_CHECK(fml::jni::CheckException(env)), which converts any pending Java exception into
abort(). There is no Dart-side recovery and nothing an app can do about it.
createOverlaySurface() (:716) dereferences getRootSurfaceControl() the same way, and
FlutterJNI.endFrame2() throws RuntimeException when platformViewsController2 is null — two
more paths to the same abort, not exercised by this repro.
- opts into HCPP via
io.flutter.embedding.android.EnableHcpp(android/app/src/main/AndroidManifest.xml) - forces the Vulkan backend via
io.flutter.embedding.android.ImpellerBackend— HCPP silently no-ops on the Impeller OpenGLES backend, so without this the repro renders nothing on an emulator - registers a trivial Kotlin
PlatformView(aTextView) — no third-party plugins - creates it with
PlatformViewsService.initHybridAndroidView, which always produces an HCPP view - caches the
FlutterEngineinApplication.onCreateso the engine outlives the activity
The cached engine is what makes this deterministic: it keeps FlutterJNI attached across an
activity destroy, so detachFromView() runs while frames are still in flight. The same window
exists during an ordinary activity teardown — detachFromFlutterEngine() runs before the engine is
destroyed — which is presumably why this shows up in the field as a rare crash rather than an
obvious one.
- Android 14+ (API 34) — HCPP is gated on
SurfaceControlAPIs - a Vulkan-capable device or emulator
Verified on an Android 16 (API 36) arm64 emulator, Flutter 3.44.4 / engine
a10d8ac38de835021c8d2f920dbf50a920ccc030. Originally reported from the field on a Xiaomi
HyperOS 3 (Android 16) device.
flutter run # confirm the screen shows "HCPP supported: true" and a blue native view
./repro.sh # launch/back loopOr by hand: launch the app, wait for the blue native view, press back, launch again, press back.
Expected: no crash. Actual: the process aborts on every teardown where the platform view was on screen. Because the abort kills the process, the next launch is a cold start with no platform view yet, so a naive loop shows a crash on every second iteration — every warm teardown crashes.
onEndFrame-fix.patch (and the full patched file, PlatformViewsController2.patched.java):
final AttachedSurfaceControl rootSurfaceControl =
flutterView == null ? null : flutterView.getRootSurfaceControl();
if (rootSurfaceControl == null) {
tx.close();
return;
}
flutterView.invalidate();
rootSurfaceControl.applyTransactionOnDraw(tx);tx.close() matters — the merged transaction owns native resources, so returning early without it
leaks.
The patched class was compiled against the prebuilt flutter_embedding_debug jar and swapped in, so
this is measured on-device, not just by inspection:
| build | teardowns | aborts |
|---|---|---|
| stock engine | 10 | 5 (every warm teardown) |
| patched engine | 12 | 0 |
Platform view rendering and Flutter/platform view compositing are unaffected after re-attach.
A cached engine started headless (entrypoint executed in Application.onCreate, before any activity
attaches) throws on the v2 channel:
MissingPluginException(No implementation found for method isSurfaceControlEnabled on channel flutter/platform_views_2)
MissingPluginException(No implementation found for method create on channel flutter/platform_views_2)
The flutter/platform_views_2 channel handler is only wired up once the engine attaches to an
activity, so platform view creation silently fails on that first attach. This app works around it by
rebuilding the platform view on AppLifecycleState.resumed.