Skip to content

Commit

Permalink
Added cancellation support to TileProvider and surrounding mechanis…
Browse files Browse the repository at this point in the history
…ms (#1622)

* Added cancellation support to `TileProvider` and surrounding mechanisms
Cleanup `TileProvider` interface

* Removed duplicate import

* Improved documentation
Close `NetworkTileProvider.httpClient` in `dispose`

* Added example for cancellable `TileProvider`
  • Loading branch information
JaffaKetchup authored Aug 27, 2023
1 parent e5a7ec7 commit 40d213f
Show file tree
Hide file tree
Showing 13 changed files with 580 additions and 119 deletions.
3 changes: 3 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_map_example/pages/animated_map_controller.dart';
import 'package:flutter_map_example/pages/cancellable_tile_provider/cancellable_tile_provider.dart';
import 'package:flutter_map_example/pages/circle.dart';
import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart';
import 'package:flutter_map_example/pages/epsg3413_crs.dart';
Expand Down Expand Up @@ -47,6 +48,8 @@ class MyApp extends StatelessWidget {
),
home: const HomePage(),
routes: <String, WidgetBuilder>{
CancellableTileProviderPage.route: (context) =>
const CancellableTileProviderPage(),
PolylinePage.route: (context) => const PolylinePage(),
MapControllerPage.route: (context) => const MapControllerPage(),
AnimatedMapControllerPage.route: (context) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:flutter_map_example/pages/cancellable_tile_provider/ctp_impl.dart';
import 'package:flutter_map_example/widgets/drawer.dart';
import 'package:latlong2/latlong.dart';

class CancellableTileProviderPage extends StatelessWidget {
static const String route = '/cancellable_tile_provider_page';

const CancellableTileProviderPage({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cancellable Tile Provider')),
drawer: buildDrawer(context, CancellableTileProviderPage.route),
body: Column(
children: [
const Padding(
padding: EdgeInsets.all(12),
child: Text(
'This map uses a custom `TileProvider` that cancels HTTP requests for unnecessary tiles. This should help speed up tile loading and reduce unneccessary costly tile requests, mainly on the web!',
),
),
Expanded(
child: FlutterMap(
options: MapOptions(
initialCenter: const LatLng(51.5, -0.09),
initialZoom: 5,
cameraConstraint: CameraConstraint.contain(
bounds: LatLngBounds(
const LatLng(-90, -180),
const LatLng(90, 180),
),
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'dev.fleaflet.flutter_map.example',
tileProvider: CancellableNetworkTileProvider(),
),
],
),
),
],
),
);
}
}
115 changes: 115 additions & 0 deletions example/lib/pages/cancellable_tile_provider/ctp_impl.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:ui';

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart';
import 'package:http/retry.dart';

class CancellableNetworkTileProvider extends TileProvider {
CancellableNetworkTileProvider({
super.headers,
BaseClient? httpClient,
}) : httpClient = httpClient ?? RetryClient(Client());

final BaseClient httpClient;

@override
bool get supportsCancelLoading => true;

@override
ImageProvider getImageWithCancelLoadingSupport(
TileCoordinates coordinates,
TileLayer options,
Future<void> cancelLoading,
) =>
CancellableNetworkImageProvider(
url: getTileUrl(coordinates, options),
fallbackUrl: getTileFallbackUrl(coordinates, options),
headers: headers,
httpClient: httpClient,
cancelLoading: cancelLoading,
);
}

class CancellableNetworkImageProvider
extends ImageProvider<CancellableNetworkImageProvider> {
final String url;
final String? fallbackUrl;
final BaseClient httpClient;
final Map<String, String> headers;
final Future<void> cancelLoading;

const CancellableNetworkImageProvider({
required this.url,
required this.fallbackUrl,
required this.headers,
required this.httpClient,
required this.cancelLoading,
});

@override
ImageStreamCompleter loadImage(
CancellableNetworkImageProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>();

return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, chunkEvents, decode),
chunkEvents: chunkEvents.stream,
scale: 1,
debugLabel: url,
informationCollector: () => [
DiagnosticsProperty('URL', url),
DiagnosticsProperty('Fallback URL', fallbackUrl),
DiagnosticsProperty('Current provider', key),
],
);
}

@override
Future<CancellableNetworkImageProvider> obtainKey(
ImageConfiguration configuration,
) =>
SynchronousFuture<CancellableNetworkImageProvider>(this);

Future<Codec> _loadAsync(
CancellableNetworkImageProvider key,
StreamController<ImageChunkEvent> chunkEvents,
ImageDecoderCallback decode, {
bool useFallback = false,
}) async {
final cancelToken = CancelToken();
cancelLoading.then((_) => cancelToken.cancel());

final Uint8List bytes;
try {
final dio = Dio();
final response = await dio.get<Uint8List>(
useFallback ? fallbackUrl ?? '' : url,
cancelToken: cancelToken,
options: Options(
headers: headers,
responseType: ResponseType.bytes,
),
);
bytes = response.data!;
} on DioException catch (err) {
if (CancelToken.isCancel(err)) {
return decode(
await ImmutableBuffer.fromUint8List(TileProvider.transparentImage),
);
}
if (useFallback || fallbackUrl == null) rethrow;
return _loadAsync(key, chunkEvents, decode, useFallback: true);
} catch (_) {
if (useFallback || fallbackUrl == null) rethrow;
return _loadAsync(key, chunkEvents, decode, useFallback: true);
}

return decode(await ImmutableBuffer.fromUint8List(bytes));
}
}
7 changes: 7 additions & 0 deletions example/lib/widgets/drawer.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';

import 'package:flutter_map_example/pages/animated_map_controller.dart';
import 'package:flutter_map_example/pages/cancellable_tile_provider/cancellable_tile_provider.dart';
import 'package:flutter_map_example/pages/circle.dart';
import 'package:flutter_map_example/pages/custom_crs/custom_crs.dart';
import 'package:flutter_map_example/pages/epsg3413_crs.dart';
Expand Down Expand Up @@ -153,6 +154,12 @@ Drawer buildDrawer(BuildContext context, String currentRoute) {
FallbackUrlNetworkPage.route,
currentRoute,
),
_buildMenuItem(
context,
const Text('Cancellable Tile Provider'),
CancellableTileProviderPage.route,
currentRoute,
),
const Divider(),
_buildMenuItem(
context,
Expand Down
2 changes: 2 additions & 0 deletions example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ dependencies:
url_launcher: ^6.1.10
shared_preferences: ^2.1.1
url_strategy: ^0.2.0
http: ^1.1.0
dio: ^5.3.2

dev_dependencies:
flutter_lints: ^2.0.1
Expand Down
36 changes: 36 additions & 0 deletions lib/src/layer/tile_layer/tile_error_evict_callback.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
part of 'tile_layer.dart';

@Deprecated(
'Prefer creating a custom `TileProvider` instead. '
'This option has been deprecated as it is out of scope for the `TileLayer`. '
'This option is deprecated since v6.',
)
typedef TemplateFunction = String Function(
String str,
Map<String, String> data,
);

enum EvictErrorTileStrategy {
/// Never evict images for tiles which failed to load.
none,

/// Evict images for tiles which failed to load when they are pruned.
dispose,

/// Evict images for tiles which failed to load and:
/// - do not belong to the current zoom level AND/OR
/// - are not visible, respecting the pruning buffer (the maximum of the
/// [keepBuffer] and [panBuffer].
notVisibleRespectMargin,

/// Evict images for tiles which failed to load and:
/// - do not belong to the current zoom level AND/OR
/// - are not visible
notVisible,
}

typedef ErrorTileCallBack = void Function(
TileImage tile,
Object error,
StackTrace? stackTrace,
);
12 changes: 12 additions & 0 deletions lib/src/layer/tile_layer/tile_image.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/widgets.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_display.dart';
Expand Down Expand Up @@ -35,6 +37,11 @@ class TileImage extends ChangeNotifier {
/// An optional image to show when a loading error occurs.
final ImageProvider? errorImage;

/// Completer that is completed when this object is disposed
///
/// Intended to allow [TileProvider]s to cancel unneccessary HTTP requests.
final Completer<void> cancelLoading;

ImageProvider imageProvider;

/// True if an error occurred during loading.
Expand All @@ -58,6 +65,7 @@ class TileImage extends ChangeNotifier {
required this.onLoadError,
required TileDisplay tileDisplay,
required this.errorImage,
required this.cancelLoading,
}) : _tileDisplay = tileDisplay,
_animationController = tileDisplay.when(
instantaneous: (_) => null,
Expand Down Expand Up @@ -126,6 +134,8 @@ class TileImage extends ChangeNotifier {

/// Initiate loading of the image.
void load() {
if (cancelLoading.isCompleted) return;

loadStarted = DateTime.now();

try {
Expand Down Expand Up @@ -230,6 +240,8 @@ class TileImage extends ChangeNotifier {
}
}

cancelLoading.complete();

_readyToDisplay = false;
_animationController?.stop(canceled: false);
_animationController?.value = 0.0;
Expand Down
14 changes: 10 additions & 4 deletions lib/src/layer/tile_layer/tile_image_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,16 @@ class TileImageManager {
final tilesToReload = List<TileImage>.from(_tiles.values);

for (final tile in tilesToReload) {
tile.imageProvider = layer.tileProvider.getImage(
tileBounds.atZoom(tile.coordinates.z).wrap(tile.coordinates),
layer,
);
tile.imageProvider = layer.tileProvider.supportsCancelLoading
? layer.tileProvider.getImageWithCancelLoadingSupport(
tileBounds.atZoom(tile.coordinates.z).wrap(tile.coordinates),
layer,
tile.cancelLoading.future,
)
: layer.tileProvider.getImage(
tileBounds.atZoom(tile.coordinates.z).wrap(tile.coordinates),
layer,
);
tile.load();
}
}
Expand Down
Loading

0 comments on commit 40d213f

Please sign in to comment.