diff --git a/packages/google_maps_flutter/google_maps_flutter/AUTHORS b/packages/google_maps_flutter/google_maps_flutter/AUTHORS index 4fc3ace39f0..2f113a9ea83 100644 --- a/packages/google_maps_flutter/google_maps_flutter/AUTHORS +++ b/packages/google_maps_flutter/google_maps_flutter/AUTHORS @@ -66,3 +66,4 @@ Alex Li Rahul Raj <64.rahulraj@gmail.com> Taha Tesser Joonas Kerttula +gentlemanxzh diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 5789f8aba38..edc28239419 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 2.12.2 +* Fixes memory leak by disposing stream subscriptions in `GoogleMapController`. * Updates README to indicate that Andoid SDK <21 is no longer supported. ## 2.12.1 diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart index 46fc95a70d2..50b0641f68a 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart @@ -18,6 +18,14 @@ class GoogleMapController { /// The mapId for this controller final int mapId; + /// List of active stream subscriptions for map events. + /// + /// This list keeps track of all event subscriptions created for the map, + /// including camera movements, marker interactions, and other map events. + /// These subscriptions should be disposed when the controller is disposed. + final List> _streamSubscriptions = + >[]; + /// Initialize control of a [GoogleMap] with [id]. /// /// Mainly for internal use when instantiating a [GoogleMapController] passed @@ -38,53 +46,85 @@ class GoogleMapController { void _connectStreams(int mapId) { if (_googleMapState.widget.onCameraMoveStarted != null) { - GoogleMapsFlutterPlatform.instance - .onCameraMoveStarted(mapId: mapId) - .listen((_) => _googleMapState.widget.onCameraMoveStarted!()); + _streamSubscriptions.add( + 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)); + _streamSubscriptions.add( + 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!()); + _streamSubscriptions.add( + 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.onGroundOverlayTap(mapId: mapId).listen( - (GroundOverlayTapEvent e) => - _googleMapState.onGroundOverlayTap(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)); - GoogleMapsFlutterPlatform.instance - .onClusterTap(mapId: mapId) - .listen((ClusterTapEvent e) => _googleMapState.onClusterTap(e.value)); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance + .onMarkerTap(mapId: mapId) + .listen((MarkerTapEvent e) => _googleMapState.onMarkerTap(e.value)), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance.onMarkerDragStart(mapId: mapId).listen( + (MarkerDragStartEvent e) => + _googleMapState.onMarkerDragStart(e.value, e.position), + ), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance.onMarkerDrag(mapId: mapId).listen( + (MarkerDragEvent e) => + _googleMapState.onMarkerDrag(e.value, e.position), + ), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance.onMarkerDragEnd(mapId: mapId).listen( + (MarkerDragEndEvent e) => + _googleMapState.onMarkerDragEnd(e.value, e.position), + ), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance.onInfoWindowTap(mapId: mapId).listen( + (InfoWindowTapEvent e) => _googleMapState.onInfoWindowTap(e.value), + ), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance.onPolylineTap(mapId: mapId).listen( + (PolylineTapEvent e) => _googleMapState.onPolylineTap(e.value), + ), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance.onPolygonTap(mapId: mapId).listen( + (PolygonTapEvent e) => _googleMapState.onPolygonTap(e.value), + ), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance + .onCircleTap(mapId: mapId) + .listen((CircleTapEvent e) => _googleMapState.onCircleTap(e.value)), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance + .onTap(mapId: mapId) + .listen((MapTapEvent e) => _googleMapState.onTap(e.position)), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance.onLongPress(mapId: mapId).listen( + (MapLongPressEvent e) => _googleMapState.onLongPress(e.position), + ), + ); + _streamSubscriptions.add( + GoogleMapsFlutterPlatform.instance.onClusterTap(mapId: mapId).listen( + (ClusterTapEvent e) => _googleMapState.onClusterTap(e.value), + ), + ); } /// Updates configuration options of the map user interface. @@ -321,6 +361,11 @@ class GoogleMapController { /// Disposes of the platform resources void dispose() { + for (final StreamSubscription streamSubscription + in _streamSubscriptions) { + streamSubscription.cancel(); + } + _streamSubscriptions.clear(); GoogleMapsFlutterPlatform.instance.dispose(mapId: mapId); } } diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index 9a23a1d5253..974bbaa2101 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.12.1 +version: 2.12.2 environment: sdk: ^3.6.0 diff --git a/packages/google_maps_flutter/google_maps_flutter/test/controller_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/controller_test.dart new file mode 100644 index 00000000000..a279848a84b --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/test/controller_test.dart @@ -0,0 +1,63 @@ +// 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 'dart:async'; + +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(); + + testWidgets('Subscriptions are canceled on dispose', + (WidgetTester tester) async { + final FakeGoogleMapsFlutterPlatform platform = + FakeGoogleMapsFlutterPlatform(); + + GoogleMapsFlutterPlatform.instance = platform; + + final Completer controllerCompleter = + Completer(); + + final GoogleMap googleMap = GoogleMap( + onMapCreated: (GoogleMapController controller) { + controllerCompleter.complete(controller); + }, + initialCameraPosition: const CameraPosition( + target: LatLng(0, 0), + ), + ); + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: googleMap, + )); + + await tester.pump(); + + final GoogleMapController? controller = await controllerCompleter.future; + + if (controller == null) { + fail('GoogleMapController not created'); + } + + expect(platform.mapEventStreamController.hasListener, true); + + // Remove the map from the widget tree. + await tester.pumpWidget(const Directionality( + textDirection: TextDirection.ltr, + child: SizedBox(), + )); + + await tester.binding.runAsync(() async { + await tester.pump(); + }); + + expect(platform.mapEventStreamController.hasListener, false); + }); +}