Skip to content
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

Added cancellation support to TileProvider and surrounding mechanisms #1622

Merged
merged 4 commits into from
Aug 27, 2023

Conversation

JaffaKetchup
Copy link
Member

@JaffaKetchup JaffaKetchup commented Aug 26, 2023

Closes #1430. Note that this doesn't provide an implementation of a TileProvider that actually cancels HTTP requests, as this is currently unavailable without Dio (see #1430's thread for more info). It just implements the infrastructure necessary to allow an implementation.

EDIT: flutter_map_cancellable_tile_provider has been released as part of the flutter_map organization, which includes a TileProvider that can cancel unnecessary HTTP requests.

Without 'flutter_map_cancellable_tile_provider'

To test the cancellation support, install Dio, and try these code blocks on top of this branch. Then, run on the web, and open the browser's DevTools Network tab. Observe how some requests are marked as "(cancelled)". Note that this is more apparent with slower tile servers, so using the default OpenStreetMap tile server likely won't cause too many cancellations.

network_tile_provider.dart
import 'package:flutter/rendering.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_image_provider.dart';
import 'package:http/http.dart';
import 'package:http/retry.dart';

/// [TileProvider] to fetch tiles from the network
///
/// By default, a [RetryClient] is used to retry failed requests. 'dart:http'
/// or 'dart:io' might be needed to override this.
///
/// On the web, the 'User-Agent' header cannot be changed as specified in
/// [TileLayer.tileProvider]'s documentation, due to a Dart/browser limitation.
class NetworkTileProvider extends TileProvider {
  /// [TileProvider] to fetch tiles from the network
  ///
  /// By default, a [RetryClient] is used to retry failed requests. 'dart:http'
  /// or 'dart:io' might be needed to override this.
  ///
  /// On the web, the 'User-Agent' header cannot be changed as specified in
  /// [TileLayer.tileProvider]'s documentation, due to a Dart/browser limitation.
  NetworkTileProvider({
    super.headers,
    BaseClient? httpClient,
  }) : httpClient = httpClient ?? RetryClient(Client());

  /// The HTTP client used to make network requests for tiles
  final BaseClient httpClient;

  @override
  bool get supportsCancelLoading => true;

  @override
  ImageProvider getImageWithCancelLoadingSupport(
    TileCoordinates coordinates,
    TileLayer options,
    Future<void> cancelLoading,
  ) =>
      FlutterMapNetworkImageProvider(
        url: getTileUrl(coordinates, options),
        fallbackUrl: getTileFallbackUrl(coordinates, options),
        headers: headers,
        httpClient: httpClient,
        cancelLoading: cancelLoading,
      );
}
network_image_provider.dart
import 'dart:async';
import 'dart:ui';

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_provider/base_tile_provider.dart';
import 'package:http/http.dart';

/// Dedicated [ImageProvider] to fetch tiles from the network
@immutable
class FlutterMapNetworkImageProvider
    extends ImageProvider<FlutterMapNetworkImageProvider> {
  /// The URL to fetch the tile from (GET request)
  final String url;

  /// The URL to fetch the tile from (GET request), in the event the original
  /// [url] request fails
  final String? fallbackUrl;

  /// The HTTP client to use to make network requests
  final BaseClient httpClient;

  /// The headers to include with the tile fetch request
  final Map<String, String> headers;

  final Future<void> cancelLoading;

  /// Dedicated [ImageProvider] to fetch tiles from the network
  const FlutterMapNetworkImageProvider({
    required this.url,
    required this.fallbackUrl,
    required this.headers,
    required this.httpClient,
    required this.cancelLoading,
  });

  @override
  ImageStreamCompleter loadImage(
    FlutterMapNetworkImageProvider 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<FlutterMapNetworkImageProvider> obtainKey(
    ImageConfiguration configuration,
  ) =>
      SynchronousFuture<FlutterMapNetworkImageProvider>(this);

  Future<Codec> _loadAsync(
    FlutterMapNetworkImageProvider 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));
  }
}

Also involves a heavy (and breaking for a minority of implementations) cleanup of the TileProvider interface, to reduce the number of private methods, reduce the amount of code that must be overridden to achieve a custom behaviour, and reduce the scope of the TileLayer.

@josxha
Copy link
Contributor

josxha commented Aug 26, 2023

Hi @JaffaKetchup, thanks for implementing this awesome feature!
I created a demo app, throttled the internet speed in the chrome dev tools and did some testing on web.
image

The cancellation works flawlessly.
image

I haven't looked at the complete code yet but noticed two things:

josxha added a commit to josxha/flutter_map_plugins that referenced this pull request Aug 27, 2023
Close `NetworkTileProvider.httpClient` in `dispose`
Copy link
Collaborator

@TesteurManiak TesteurManiak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, maybe wwe should add your code snippets as examples in the test app to showcase the cancellation mechanism 🤔

@JaffaKetchup
Copy link
Member Author

@TesteurManiak That's a good idea, I'll do that. Someone else said they might make a package, but it's easy enough just to make an example for. Just to double check, you're happy with the breaking refactoring and non-breaking cancellation support of the TileProvider?

@TesteurManiak
Copy link
Collaborator

you're happy with the breaking refactoring and non-breaking cancellation support of the TileProvider?

Yeah, deprecation messages are clear and the changes are not too intrusive 👍

@JaffaKetchup JaffaKetchup merged commit 40d213f into master Aug 27, 2023
7 checks passed
@JaffaKetchup JaffaKetchup deleted the tile-provider-improvements branch August 27, 2023 15:29
@JaffaKetchup JaffaKetchup moved this from In progress to Done in v6 Release Planning Aug 27, 2023
josxha added a commit to josxha/flutter_map_plugins that referenced this pull request Oct 9, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
No open projects
Development

Successfully merging this pull request may close these issues.

[FEATURE] Cancel unnecessary tile requests
3 participants