Skip to content

Commit d7bf4aa

Browse files
committed
Fix Google Maps rendering issues in TLHC mode when using LATEST renderer
The Google Maps LATEST renderer always uses a TextureView. We can use a signal from this TextureView to perform view invalidation that fixes the rendering glitches (missing updates) in TLHC mode. NOTE: We have an internal bug 311013682 requesting an official way of achieving this functionality but if the bug is ever acted on it will take many months/years before we can rely on this functionality. In the meantime, chain the internal SurfaceTextureListener with our own and piggyback on the OnSurfaceTextureUpdated callback to invalidate the MapView. Fixes flutter/flutter#103686 Tested on an emulator and a physical device (Pixel 6 Pro).
1 parent e4aaba8 commit d7bf4aa

File tree

8 files changed

+99
-167
lines changed

8 files changed

+99
-167
lines changed

packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
## NEXT
1+
## 2.6.0
22

3+
* Fixes missing updates in TLHC mode.
4+
* Switched default display mode to TLHC mode.
35
* Updates minimum supported SDK version to Flutter 3.10/Dart 3.0.
46

57
## 2.5.3

packages/google_maps_flutter/google_maps_flutter_android/README.md

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,26 +30,26 @@ void main() {
3030
final GoogleMapsFlutterPlatform mapsImplementation =
3131
GoogleMapsFlutterPlatform.instance;
3232
if (mapsImplementation is GoogleMapsFlutterAndroid) {
33+
// Force Hybrid Composition mode.
3334
mapsImplementation.useAndroidViewSurface = true;
3435
}
3536
// ···
3637
}
3738
```
3839

39-
### Hybrid Composition
40+
### Texture Layer Hybrid Composition
4041

41-
This is the current default mode, and corresponds to
42-
`useAndroidViewSurface = true`. It ensures that the map display will work as
43-
expected, at the cost of some performance.
42+
This is the the current default mode and corresponds to `useAndroidViewSurface = false`.
43+
This mode is more performant than Hybrid Composition and we recommend that you use this mode.
4444

45-
### Texture Layer Hybrid Composition
45+
### Hybrid Composition
4646

47-
This is a new display mode used by most plugins starting with Flutter 3.0, and
48-
corresponds to `useAndroidViewSurface = false`. This is more performant than
49-
Hybrid Composition, but currently [misses certain map updates][4].
47+
This mode is available for backwards compatability and corresponds to `useAndroidViewSurface = true`.
48+
We do not recommend its use as it is less performant than Texture Layer Hybrid Composition and
49+
certain flutter rendering effects are not supported.
5050

51-
This mode will likely become the default in future versions if/when the
52-
missed updates issue can be resolved.
51+
If you require this mode for correctness, please file a bug so we can investigate and fix
52+
the issue in the TLHC mode.
5353

5454
## Map renderer
5555

@@ -70,8 +70,13 @@ AndroidMapRenderer mapRenderer = AndroidMapRenderer.platformDefault;
7070
}
7171
```
7272

73-
Available values are `AndroidMapRenderer.latest`, `AndroidMapRenderer.legacy`, `AndroidMapRenderer.platformDefault`.
74-
Note that getting the requested renderer as a response is not guaranteed.
73+
`AndroidMapRenderer.platformDefault` corresponds to `AndroidMapRenderer.latest`.
74+
75+
You are not guaranteed to get the requested renderer. For example, on emulators without
76+
Google Play the latest renderer will not be available and the legacy renderer will always be used.
77+
78+
WARNING: `AndroidMapRenderer.legacy` is known to crash apps and is no longer supported by the Google Maps team
79+
and therefore cannot be supported by the Flutter team.
7580

7681
[1]: https://pub.dev/packages/google_maps_flutter
7782
[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin

packages/google_maps_flutter/google_maps_flutter_android/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java

Lines changed: 70 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
import android.content.pm.PackageManager;
1111
import android.graphics.Bitmap;
1212
import android.graphics.Point;
13+
import android.graphics.SurfaceTexture;
1314
import android.os.Bundle;
1415
import android.util.Log;
15-
import android.view.Choreographer;
16+
import android.view.TextureView;
17+
import android.view.TextureView.SurfaceTextureListener;
1618
import android.view.View;
19+
import android.view.ViewGroup;
1720
import androidx.annotation.NonNull;
1821
import androidx.annotation.Nullable;
1922
import androidx.annotation.VisibleForTesting;
@@ -135,61 +138,13 @@ private CameraPosition getCameraPosition() {
135138
return trackCameraPosition ? googleMap.getCameraPosition() : null;
136139
}
137140

138-
private boolean loadedCallbackPending = false;
139-
140-
/**
141-
* Invalidates the map view after the map has finished rendering.
142-
*
143-
* <p>gmscore GL renderer uses a {@link android.view.TextureView}. Android platform views that are
144-
* displayed as a texture after Flutter v3.0.0. require that the view hierarchy is notified after
145-
* all drawing operations have been flushed.
146-
*
147-
* <p>Since the GL renderer doesn't use standard Android views, and instead uses GL directly, we
148-
* notify the view hierarchy by invalidating the view.
149-
*
150-
* <p>Unfortunately, when {@link GoogleMap.OnMapLoadedCallback} is fired, the texture may not have
151-
* been updated yet.
152-
*
153-
* <p>To workaround this limitation, wait two frames. This ensures that at least the frame budget
154-
* (16.66ms at 60hz) have passed since the drawing operation was issued.
155-
*/
156-
private void invalidateMapIfNeeded() {
157-
if (googleMap == null || loadedCallbackPending) {
158-
return;
159-
}
160-
loadedCallbackPending = true;
161-
googleMap.setOnMapLoadedCallback(
162-
() -> {
163-
loadedCallbackPending = false;
164-
postFrameCallback(
165-
() -> {
166-
postFrameCallback(
167-
() -> {
168-
if (mapView != null) {
169-
mapView.invalidate();
170-
}
171-
});
172-
});
173-
});
174-
}
175-
176-
private static void postFrameCallback(Runnable f) {
177-
Choreographer.getInstance()
178-
.postFrameCallback(
179-
new Choreographer.FrameCallback() {
180-
@Override
181-
public void doFrame(long frameTimeNanos) {
182-
f.run();
183-
}
184-
});
185-
}
186-
187141
@Override
188142
public void onMapReady(GoogleMap googleMap) {
189143
this.googleMap = googleMap;
190144
this.googleMap.setIndoorEnabled(this.indoorEnabled);
191145
this.googleMap.setTrafficEnabled(this.trafficEnabled);
192146
this.googleMap.setBuildingsEnabled(this.buildingsEnabled);
147+
installInvalidator();
193148
googleMap.setOnInfoWindowClickListener(this);
194149
if (mapReadyResult != null) {
195150
mapReadyResult.success(null);
@@ -216,6 +171,71 @@ public void onMapReady(GoogleMap googleMap) {
216171
}
217172
}
218173

174+
// Returns the first TextureView found in the view hierarchy.
175+
private static TextureView findTextureView(ViewGroup group) {
176+
final int n = group.getChildCount();
177+
for (int i = 0; i < n; i++) {
178+
View view = group.getChildAt(i);
179+
if (view instanceof TextureView) {
180+
return (TextureView) view;
181+
}
182+
if (view instanceof ViewGroup) {
183+
TextureView r = findTextureView((ViewGroup) view);
184+
if (r != null) {
185+
return r;
186+
}
187+
}
188+
}
189+
return null;
190+
}
191+
192+
private void installInvalidator() {
193+
if (mapView == null) {
194+
// This should only happen in tests.
195+
return;
196+
}
197+
TextureView textureView = findTextureView(mapView);
198+
if (textureView == null) {
199+
Log.i(TAG, "No TextureView found. Likely using the LEGACY renderer.");
200+
return;
201+
}
202+
Log.i(TAG, "Installing custom TextureView driven invalidator.");
203+
SurfaceTextureListener internalListener = textureView.getSurfaceTextureListener();
204+
// Override the Maps internal SurfaceTextureListener with our own. Our listener
205+
// mostly just invokes the internal listener callbacks but in onSurfaceTextureUpdated
206+
// the mapView is invalidated which ensures that all map updates are presented to the
207+
// screen.
208+
final MapView mapView = this.mapView;
209+
textureView.setSurfaceTextureListener(
210+
new TextureView.SurfaceTextureListener() {
211+
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
212+
if (internalListener != null) {
213+
internalListener.onSurfaceTextureAvailable(surface, width, height);
214+
}
215+
}
216+
217+
public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
218+
if (internalListener != null) {
219+
return internalListener.onSurfaceTextureDestroyed(surface);
220+
}
221+
return true;
222+
}
223+
224+
public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
225+
if (internalListener != null) {
226+
internalListener.onSurfaceTextureSizeChanged(surface, width, height);
227+
}
228+
}
229+
230+
public void onSurfaceTextureUpdated(SurfaceTexture surface) {
231+
if (internalListener != null) {
232+
internalListener.onSurfaceTextureUpdated(surface);
233+
}
234+
mapView.invalidate();
235+
}
236+
});
237+
}
238+
219239
@Override
220240
public void onMethodCall(MethodCall call, MethodChannel.Result result) {
221241
switch (call.method) {
@@ -309,7 +329,6 @@ public void onSnapshotReady(Bitmap bitmap) {
309329
}
310330
case "markers#update":
311331
{
312-
invalidateMapIfNeeded();
313332
List<Object> markersToAdd = call.argument("markersToAdd");
314333
markersController.addMarkers(markersToAdd);
315334
List<Object> markersToChange = call.argument("markersToChange");
@@ -339,7 +358,6 @@ public void onSnapshotReady(Bitmap bitmap) {
339358
}
340359
case "polygons#update":
341360
{
342-
invalidateMapIfNeeded();
343361
List<Object> polygonsToAdd = call.argument("polygonsToAdd");
344362
polygonsController.addPolygons(polygonsToAdd);
345363
List<Object> polygonsToChange = call.argument("polygonsToChange");
@@ -351,7 +369,6 @@ public void onSnapshotReady(Bitmap bitmap) {
351369
}
352370
case "polylines#update":
353371
{
354-
invalidateMapIfNeeded();
355372
List<Object> polylinesToAdd = call.argument("polylinesToAdd");
356373
polylinesController.addPolylines(polylinesToAdd);
357374
List<Object> polylinesToChange = call.argument("polylinesToChange");
@@ -363,7 +380,6 @@ public void onSnapshotReady(Bitmap bitmap) {
363380
}
364381
case "circles#update":
365382
{
366-
invalidateMapIfNeeded();
367383
List<Object> circlesToAdd = call.argument("circlesToAdd");
368384
circlesController.addCircles(circlesToAdd);
369385
List<Object> circlesToChange = call.argument("circlesToChange");
@@ -443,7 +459,6 @@ public void onSnapshotReady(Bitmap bitmap) {
443459
}
444460
case "map#setStyle":
445461
{
446-
invalidateMapIfNeeded();
447462
boolean mapStyleSet;
448463
if (call.arguments instanceof String) {
449464
String mapStyle = (String) call.arguments;
@@ -466,7 +481,6 @@ public void onSnapshotReady(Bitmap bitmap) {
466481
}
467482
case "tileOverlays#update":
468483
{
469-
invalidateMapIfNeeded();
470484
List<Map<String, ?>> tileOverlaysToAdd = call.argument("tileOverlaysToAdd");
471485
tileOverlaysController.addTileOverlays(tileOverlaysToAdd);
472486
List<Map<String, ?>> tileOverlaysToChange = call.argument("tileOverlaysToChange");
@@ -478,7 +492,6 @@ public void onSnapshotReady(Bitmap bitmap) {
478492
}
479493
case "tileOverlays#clearTileCache":
480494
{
481-
invalidateMapIfNeeded();
482495
String tileOverlayId = call.argument("tileOverlayId");
483496
tileOverlaysController.clearTileCache(tileOverlayId);
484497
result.success(null);

packages/google_maps_flutter/google_maps_flutter_android/android/src/test/java/io/flutter/plugins/googlemaps/GoogleMapControllerTest.java

Lines changed: 0 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,24 @@
77
import static org.junit.Assert.assertNotNull;
88
import static org.junit.Assert.assertNull;
99
import static org.junit.Assert.assertTrue;
10-
import static org.mockito.Mockito.mock;
11-
import static org.mockito.Mockito.never;
1210
import static org.mockito.Mockito.times;
1311
import static org.mockito.Mockito.verify;
1412

1513
import android.content.Context;
1614
import android.os.Build;
17-
import android.os.Looper;
1815
import androidx.activity.ComponentActivity;
1916
import androidx.test.core.app.ApplicationProvider;
2017
import com.google.android.gms.maps.GoogleMap;
21-
import com.google.android.gms.maps.MapView;
2218
import io.flutter.plugin.common.BinaryMessenger;
23-
import io.flutter.plugin.common.MethodCall;
24-
import io.flutter.plugin.common.MethodChannel;
25-
import java.util.HashMap;
2619
import org.junit.After;
2720
import org.junit.Assert;
2821
import org.junit.Before;
2922
import org.junit.Test;
3023
import org.junit.runner.RunWith;
31-
import org.mockito.ArgumentCaptor;
3224
import org.mockito.Mock;
3325
import org.mockito.MockitoAnnotations;
3426
import org.robolectric.Robolectric;
3527
import org.robolectric.RobolectricTestRunner;
36-
import org.robolectric.Shadows;
3728
import org.robolectric.annotation.Config;
3829

3930
@RunWith(RobolectricTestRunner.class)
@@ -86,87 +77,6 @@ public void OnDestroyReleaseTheMap() throws InterruptedException {
8677
assertNull(googleMapController.getView());
8778
}
8879

89-
@Test
90-
public void InvalidateMapAfterMethodCalls() throws InterruptedException {
91-
String[] methodsThatTriggerInvalidation = {
92-
"markers#update",
93-
"polygons#update",
94-
"polylines#update",
95-
"circles#update",
96-
"map#setStyle",
97-
"tileOverlays#update",
98-
"tileOverlays#clearTileCache"
99-
};
100-
101-
for (String methodName : methodsThatTriggerInvalidation) {
102-
googleMapController =
103-
new GoogleMapController(0, context, mockMessenger, activity::getLifecycle, null);
104-
googleMapController.init();
105-
106-
mockGoogleMap = mock(GoogleMap.class);
107-
googleMapController.onMapReady(mockGoogleMap);
108-
109-
MethodChannel.Result result = mock(MethodChannel.Result.class);
110-
System.out.println(methodName);
111-
googleMapController.onMethodCall(
112-
new MethodCall(methodName, new HashMap<String, Object>()), result);
113-
114-
ArgumentCaptor<GoogleMap.OnMapLoadedCallback> argument =
115-
ArgumentCaptor.forClass(GoogleMap.OnMapLoadedCallback.class);
116-
verify(mockGoogleMap).setOnMapLoadedCallback(argument.capture());
117-
118-
MapView mapView = mock(MapView.class);
119-
googleMapController.setView(mapView);
120-
121-
verify(mapView, never()).invalidate();
122-
argument.getValue().onMapLoaded();
123-
Shadows.shadowOf(Looper.getMainLooper()).idle();
124-
verify(mapView).invalidate();
125-
}
126-
}
127-
128-
@Test
129-
public void InvalidateMapOnceAfterMethodCall() throws InterruptedException {
130-
googleMapController.onMapReady(mockGoogleMap);
131-
132-
MethodChannel.Result result = mock(MethodChannel.Result.class);
133-
googleMapController.onMethodCall(
134-
new MethodCall("markers#update", new HashMap<String, Object>()), result);
135-
googleMapController.onMethodCall(
136-
new MethodCall("polygons#update", new HashMap<String, Object>()), result);
137-
138-
ArgumentCaptor<GoogleMap.OnMapLoadedCallback> argument =
139-
ArgumentCaptor.forClass(GoogleMap.OnMapLoadedCallback.class);
140-
verify(mockGoogleMap).setOnMapLoadedCallback(argument.capture());
141-
142-
MapView mapView = mock(MapView.class);
143-
googleMapController.setView(mapView);
144-
145-
verify(mapView, never()).invalidate();
146-
argument.getValue().onMapLoaded();
147-
Shadows.shadowOf(Looper.getMainLooper()).idle();
148-
verify(mapView).invalidate();
149-
}
150-
151-
@Test
152-
public void MethodCalledAfterControllerIsDestroyed() throws InterruptedException {
153-
googleMapController.onMapReady(mockGoogleMap);
154-
MethodChannel.Result result = mock(MethodChannel.Result.class);
155-
googleMapController.onMethodCall(
156-
new MethodCall("markers#update", new HashMap<String, Object>()), result);
157-
158-
ArgumentCaptor<GoogleMap.OnMapLoadedCallback> argument =
159-
ArgumentCaptor.forClass(GoogleMap.OnMapLoadedCallback.class);
160-
verify(mockGoogleMap).setOnMapLoadedCallback(argument.capture());
161-
162-
MapView mapView = mock(MapView.class);
163-
googleMapController.setView(mapView);
164-
googleMapController.onDestroy(activity);
165-
166-
argument.getValue().onMapLoaded();
167-
verify(mapView, never()).invalidate();
168-
}
169-
17080
@Test
17181
public void OnMapReadySetsPaddingIfInitialPaddingIsThere() {
17282
float padding = 10f;

packages/google_maps_flutter/google_maps_flutter_android/example/lib/readme_excerpts.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ void main() {
1414
final GoogleMapsFlutterPlatform mapsImplementation =
1515
GoogleMapsFlutterPlatform.instance;
1616
if (mapsImplementation is GoogleMapsFlutterAndroid) {
17+
// Force Hybrid Composition mode.
1718
mapsImplementation.useAndroidViewSurface = true;
1819
}
1920
// #enddocregion DisplayMode

0 commit comments

Comments
 (0)