-
Notifications
You must be signed in to change notification settings - Fork 3.8k
[google_maps_flutter] Fix for memory leak impacting all platforms due to subscriptions not getting cleaned up #4281
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
Changes from all commits
7dc9009
565def2
d3a4f84
e38f413
dec6f73
4e082cb
0aab33e
27256d1
971a143
4065b90
509fe6c
b778349
a21690e
61c293e
6a399e0
2a1a5e9
ccb562c
df4c835
fd940d6
19bc07b
5c51d1b
bc650c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,35 @@ class GoogleMapController { | |
| /// The mapId for this controller | ||
| final int mapId; | ||
|
|
||
| /// State map of the stream subscriptions for this controller, used internally. | ||
| /// | ||
| /// The map stores a [StreamSubscription] as key and a [bool] as value. | ||
| /// Each [StreamSubscription] represents a subscription to an event from the native side. | ||
| /// The corresponding [bool] indicates whether the subscription has been canceled `true` or not `false`. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's unclear to me why the boolean exists, since once it's been canceled there is no reason to track it any more; just being in a set is already a boolean state. Is this only for the unit test? If so, the unit test should be testing that the subscription is actually canceled, via fake subscription that keep track of |
||
| /// | ||
| /// This map allows for efficient management of subscriptions, facilitating operations | ||
| /// like mass cancellation during controller disposal. | ||
| /// | ||
| /// This field is private to prevent external mutation. Access is provided through | ||
| /// the [streamSubscriptionsState] getter, which returns an unmodifiable view of this map. | ||
| final Map<StreamSubscription<dynamic>, bool> _streamSubscriptionsState = | ||
| <StreamSubscription<dynamic>, bool>{}; | ||
|
|
||
| /// Returns an unmodifiable view of the map that holds the active StreamSubscriptions | ||
| /// and their corresponding states. | ||
| /// | ||
| /// This getter provides a way to inspect the current state of active StreamSubscriptions | ||
| /// in the [GoogleMapController] class without allowing any outside mutations. | ||
| /// | ||
| /// The map contains [StreamSubscription] instances as keys, and [bool] as values. | ||
| /// The value indicates if the subscription has been canceled `true` or not `false`. | ||
| /// | ||
| /// This is useful in situations where the need arises to monitor or debug the | ||
| /// lifecycle of various StreamSubscriptions handled within this class. | ||
| Map<StreamSubscription<dynamic>, bool> get streamSubscriptionsState => | ||
| Map<StreamSubscription<dynamic>, bool>.unmodifiable( | ||
| _streamSubscriptionsState); | ||
|
|
||
| /// Initialize control of a [GoogleMap] with [id]. | ||
| /// | ||
| /// Mainly for internal use when instantiating a [GoogleMapController] passed | ||
|
|
@@ -34,51 +63,112 @@ class GoogleMapController { | |
| ); | ||
| } | ||
|
|
||
| /// The state object of the [GoogleMap] widget. | ||
| /// | ||
| /// This instance holds important properties and methods that are essential for | ||
| /// the behavior and state of the [GoogleMap] widget. | ||
| final _GoogleMapState _googleMapState; | ||
|
|
||
| /// Subscribes to the various event streams from the [GoogleMapsFlutterPlatform] for a specific map. | ||
| /// | ||
| /// This method takes the `mapId` as an argument and uses it to subscribe to the various streams available from the [GoogleMapsFlutterPlatform]. | ||
| /// It only subscribes to the streams if the corresponding callback from the widget is not null. This includes streams for camera movements, | ||
| /// marker taps, marker drag events, info window taps, polyline taps, polygon taps, circle taps, map taps, and map long presses. | ||
| /// | ||
| /// Each subscription is added to [_streamSubscriptionsState] for lifecycle management. | ||
| /// | ||
| /// - [mapId] is the identifier of the map for which to initiate the streams. | ||
| void _connectStreams(int mapId) { | ||
| if (_googleMapState.widget.onCameraMoveStarted != null) { | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onCameraMoveStarted(mapId: mapId) | ||
| .listen((_) => _googleMapState.widget.onCameraMoveStarted!()); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onCameraMoveStarted(mapId: mapId) | ||
| .listen((_) => _googleMapState.widget.onCameraMoveStarted!()), | ||
| ); | ||
| } | ||
| if (_googleMapState.widget.onCameraMove != null) { | ||
| GoogleMapsFlutterPlatform.instance.onCameraMove(mapId: mapId).listen( | ||
| (CameraMoveEvent e) => _googleMapState.widget.onCameraMove!(e.value)); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance.onCameraMove(mapId: mapId).listen( | ||
| (CameraMoveEvent e) => | ||
| _googleMapState.widget.onCameraMove!(e.value), | ||
| ), | ||
| ); | ||
| } | ||
| if (_googleMapState.widget.onCameraIdle != null) { | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onCameraIdle(mapId: mapId) | ||
| .listen((_) => _googleMapState.widget.onCameraIdle!()); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onCameraIdle(mapId: mapId) | ||
| .listen((_) => _googleMapState.widget.onCameraIdle!()), | ||
| ); | ||
| } | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onMarkerTap(mapId: mapId) | ||
| .listen((MarkerTapEvent e) => _googleMapState.onMarkerTap(e.value)); | ||
| GoogleMapsFlutterPlatform.instance.onMarkerDragStart(mapId: mapId).listen( | ||
| (MarkerDragStartEvent e) => | ||
| _googleMapState.onMarkerDragStart(e.value, e.position)); | ||
| GoogleMapsFlutterPlatform.instance.onMarkerDrag(mapId: mapId).listen( | ||
| (MarkerDragEvent e) => | ||
| _googleMapState.onMarkerDrag(e.value, e.position)); | ||
| GoogleMapsFlutterPlatform.instance.onMarkerDragEnd(mapId: mapId).listen( | ||
| (MarkerDragEndEvent e) => | ||
| _googleMapState.onMarkerDragEnd(e.value, e.position)); | ||
| GoogleMapsFlutterPlatform.instance.onInfoWindowTap(mapId: mapId).listen( | ||
| (InfoWindowTapEvent e) => _googleMapState.onInfoWindowTap(e.value)); | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onPolylineTap(mapId: mapId) | ||
| .listen((PolylineTapEvent e) => _googleMapState.onPolylineTap(e.value)); | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onPolygonTap(mapId: mapId) | ||
| .listen((PolygonTapEvent e) => _googleMapState.onPolygonTap(e.value)); | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onCircleTap(mapId: mapId) | ||
| .listen((CircleTapEvent e) => _googleMapState.onCircleTap(e.value)); | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onTap(mapId: mapId) | ||
| .listen((MapTapEvent e) => _googleMapState.onTap(e.position)); | ||
| GoogleMapsFlutterPlatform.instance.onLongPress(mapId: mapId).listen( | ||
| (MapLongPressEvent e) => _googleMapState.onLongPress(e.position)); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onMarkerTap(mapId: mapId) | ||
| .listen((MarkerTapEvent e) => _googleMapState.onMarkerTap(e.value)), | ||
| ); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance.onMarkerDragStart(mapId: mapId).listen( | ||
| (MarkerDragStartEvent e) => | ||
| _googleMapState.onMarkerDragStart(e.value, e.position), | ||
| ), | ||
| ); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance.onMarkerDrag(mapId: mapId).listen( | ||
| (MarkerDragEvent e) => | ||
| _googleMapState.onMarkerDrag(e.value, e.position), | ||
| ), | ||
| ); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance.onMarkerDragEnd(mapId: mapId).listen( | ||
| (MarkerDragEndEvent e) => | ||
| _googleMapState.onMarkerDragEnd(e.value, e.position), | ||
| ), | ||
| ); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance.onInfoWindowTap(mapId: mapId).listen( | ||
| (InfoWindowTapEvent e) => _googleMapState.onInfoWindowTap(e.value), | ||
| ), | ||
| ); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance.onPolylineTap(mapId: mapId).listen( | ||
| (PolylineTapEvent e) => _googleMapState.onPolylineTap(e.value), | ||
| ), | ||
| ); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance.onPolygonTap(mapId: mapId).listen( | ||
| (PolygonTapEvent e) => _googleMapState.onPolygonTap(e.value), | ||
| ), | ||
| ); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onCircleTap(mapId: mapId) | ||
| .listen((CircleTapEvent e) => _googleMapState.onCircleTap(e.value)), | ||
| ); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance | ||
| .onTap(mapId: mapId) | ||
| .listen((MapTapEvent e) => _googleMapState.onTap(e.position)), | ||
| ); | ||
| _addSubscription( | ||
| GoogleMapsFlutterPlatform.instance.onLongPress(mapId: mapId).listen( | ||
| (MapLongPressEvent e) => _googleMapState.onLongPress(e.position), | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| /// Adds a new stream subscription to the [_streamSubscriptionsState] map. | ||
| /// | ||
| /// The method takes a [StreamSubscription] and sets its status to `false` | ||
| /// in the [_streamSubscriptionsState] map. The `false` status indicates that the subscription | ||
| /// is currently active and has not been canceled yet. | ||
| /// | ||
| /// The [_streamSubscriptionsState] map manages all active stream subscriptions | ||
| /// associated with this instance, which facilitates their lifecycle management | ||
| /// and ensures that they are properly canceled and disposed when necessary. | ||
| /// | ||
| /// [subscription]: A [StreamSubscription] that will be managed by the [_streamSubscriptionsState] map. | ||
| void _addSubscription(StreamSubscription<dynamic> subscription) { | ||
| _streamSubscriptionsState[subscription] = false; | ||
| } | ||
|
|
||
| /// Updates configuration options of the map user interface. | ||
|
|
@@ -270,6 +360,13 @@ class GoogleMapController { | |
|
|
||
| /// Disposes of the platform resources | ||
| void dispose() { | ||
| for (final StreamSubscription<dynamic> subscription | ||
| in _streamSubscriptionsState.keys) { | ||
| subscription.cancel().then((void value) { | ||
| _streamSubscriptionsState[subscription] = true; | ||
| }); | ||
| } | ||
| _streamSubscriptionsState.clear(); | ||
| GoogleMapsFlutterPlatform.instance.dispose(mapId: mapId); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -398,6 +398,9 @@ class _GoogleMapState extends State<GoogleMap> { | |
| } | ||
|
|
||
| Future<void> onPlatformViewCreated(int id) async { | ||
| if (!mounted) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How is this related to the PR?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See flutter/flutter#115283 - I can add a test for this. |
||
| return; | ||
| } | ||
| final GoogleMapController controller = await GoogleMapController.init( | ||
| id, | ||
| widget.initialCameraPosition, | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This file should be called |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| // Copyright 2013 The Flutter Authors. All rights reserved. | ||
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'package:flutter/services.dart'; | ||
| import 'package:flutter/widgets.dart'; | ||
| import 'package:flutter_test/flutter_test.dart'; | ||
| import 'package:google_maps_flutter/google_maps_flutter.dart'; | ||
| import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; | ||
|
|
||
| import 'fake_google_maps_flutter_platform.dart'; | ||
|
|
||
| void main() { | ||
| TestWidgetsFlutterBinding.ensureInitialized(); | ||
|
|
||
| group('GoogleMap widget', () { | ||
| testWidgets('Subscriptions are canceled on dispose', | ||
| (WidgetTester tester) async { | ||
| GoogleMapsFlutterPlatform.instance = FakeGoogleMapsFlutterPlatform(); | ||
|
|
||
| final FakePlatformViewsController fakePlatformViewsController = | ||
| FakePlatformViewsController(); | ||
|
|
||
| tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( | ||
| SystemChannels.platform_views, | ||
| fakePlatformViewsController.fakePlatformViewsMethodHandler, | ||
| ); | ||
|
|
||
| final ValueNotifier<GoogleMapController?> controllerNotifier = | ||
| ValueNotifier<GoogleMapController?>(null); | ||
|
|
||
| final GoogleMap googleMap = GoogleMap( | ||
| onMapCreated: (GoogleMapController controller) { | ||
| controllerNotifier.value = controller; | ||
| }, | ||
| initialCameraPosition: const CameraPosition( | ||
| target: LatLng(0, 0), | ||
| ), | ||
| ); | ||
|
|
||
| await tester.pumpWidget(Directionality( | ||
| textDirection: TextDirection.ltr, | ||
| child: googleMap, | ||
| )); | ||
|
|
||
| await tester.pump(); | ||
|
|
||
| final GoogleMapController? controller = controllerNotifier.value; | ||
|
|
||
| if (controller != null) { | ||
| expect(controller.streamSubscriptionsState, isNotEmpty); | ||
|
|
||
| controller.streamSubscriptionsState.forEach((_, bool isCanceled) { | ||
| expect(isCanceled, isFalse); | ||
| }); | ||
|
|
||
| await tester.pumpWidget(const Directionality( | ||
| textDirection: TextDirection.ltr, | ||
| child: SizedBox(), | ||
| )); | ||
|
|
||
| await tester.binding.runAsync(() async { | ||
| await tester.pump(); | ||
| }); | ||
|
|
||
| controller.streamSubscriptionsState.forEach((_, bool isCanceled) { | ||
| expect(isCanceled, isTrue); | ||
| }); | ||
| } else { | ||
| fail('GoogleMapController not created'); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| class FakePlatformViewsController { | ||
| Future<dynamic> fakePlatformViewsMethodHandler(MethodCall call) { | ||
| switch (call.method) { | ||
| case 'create': | ||
| return Future<int>.value(1); | ||
| default: | ||
| return Future<dynamic>.value(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Internal implementation details should not be public properties; this would make it impossible for us to ever change without a major version bump.