Unact / yandex_mapkit

Flutter implementation of YandexMapkit
MIT License
135 stars 148 forks source link

Асинхронная отрисовка Placemark #223

Closed Ae-Mc closed 2 years ago

Ae-Mc commented 2 years ago

Я хочу создать карту, на которой в качестве иконок мест будут фотографии пользователей. Когда мест станет много, для начальной инициализации карты придётся тратить очень много времени на подгрузку фотографий.

Сейчас места объединены с помощью ClusterizedPlacemarkCollection. Для решения проблемы я вижу два выхода:

DCrow commented 2 years ago

Здравствуйте!

Вы можете указать сначала для всех меток Placemark, иконку загрузки, и после загрузки нужных вам фотографий(либо после загрузки отдельной), обновить иконку для нужной вам метки.

placemark_page.dart, вместо действия

setState(() {
  mapObjects[mapObjects.indexOf(placemark)] = placemark.copyWith(
    icon: PlacemarkIcon.single(PlacemarkIconStyle(image: BitmapDescriptor.fromAssetImage('lib/assets/arrow.png')))
  );
});
Ae-Mc commented 2 years ago

При таком варианте у меня при каждом подгруженном изображении моргают точки, и, иногда, они дублируются (в одном месте становится несколько одинаковых)

Может быть, взглянув на код, вы сможете подсказать верное решение?

class MapPage extends StatefulWidget {
  const MapPage({Key? key}) : super(key: key);

  @override
  State<MapPage> createState() => _MapPageState();
}

class _MapPageState extends State<MapPage> {
  bool bottomSheetShown = false;
  List<Category> filters = [];
  late YandexMapController mapController;
  final Map<String, Uint8List> imageCache = {};
  late final Uint8List defaultIcon;
  bool defaultIconLoaded = false;
  bool imagesFetched = false;
  List<Request> filteredRequests = [];
  List<Placemark> placemarks = [];
  int rebuildsCount = 0;

  @override
  void initState() {
    BlocProvider.of<RequestsBloc>(context).add(const RequestsEvent.fetch());
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    final colorTheme = AppTheme.of(context).colorTheme;

    return BlocBuilder<RequestsBloc, RequestsState>(
      builder: (context, state) {
        return state.when(
          failure: (failure) {
            WidgetsBinding.instance?.addPostFrameCallback(
              (timeStamp) => showStandardFailure(
                context: context,
                failure: failure,
                onCustomFailure: (_) => null,
              ),
            );

            return Center(
              child: FloatingActionButton(
                backgroundColor: colorTheme.primary,
                child: Icon(
                  Icons.replay,
                  color: colorTheme.onPrimary,
                ),
                onPressed: () => BlocProvider.of<RequestsBloc>(context)
                    .add(const RequestsEvent.fetch()),
              ),
            );
          },
          loading: () => Center(
            child: CircularProgressIndicator.adaptive(
              valueColor: AlwaysStoppedAnimation(colorTheme.primary),
            ),
          ),
          loaded: (requests) {
            if (!imagesFetched) {
              imagesFetched = true;
              // ignore: avoid-ignoring-return-values
              fetchImages(requests);
            }

            return Stack(
              children: [
                FutureBuilder(
                  future: () async {
                    if (!defaultIconLoaded) {
                      final codec = await ui.instantiateImageCodec(
                        (await rootBundle
                                .load(Assets.images.userPlaceholder.path))
                            .buffer
                            .asUint8List(),
                        targetHeight: (MediaQuery.of(context).devicePixelRatio *
                                40 *
                                0.84)
                            .round(),
                        targetWidth: (MediaQuery.of(context).devicePixelRatio *
                                40 *
                                0.84)
                            .round(),
                      );
                      defaultIcon = await MapMarksBuilder(context)
                          .buildPlacemarkPersonFromImage(
                        (await codec.getNextFrame()).image,
                      );
                      defaultIconLoaded = true;
                      setState(() => {});
                    }

                    return true;
                  }(),
                  builder: (context, snapshot) {
                    if (snapshot.hasError) {
                      WidgetsBinding.instance?.addPostFrameCallback(
                        (_) => CustomToast(context).showTextFailureToast(
                          'Произошла ошибка при загрузке данных',
                        ),
                      );

                      GetIt.I<Logger>().e(
                        'Произошла ошибка при загрузке данных',
                        snapshot.error,
                        snapshot.stackTrace,
                      );

                      return Center(
                        child: FloatingActionButton(
                          onPressed: () =>
                              BlocProvider.of<RequestsBloc>(context)
                                  .add(const RequestsEvent.fetch()),
                          child: Icon(
                            Icons.replay,
                            color: colorTheme.onPrimary,
                          ),
                        ),
                      );
                    }

                    if (snapshot.hasData && defaultIconLoaded) {
                      WidgetsBinding.instance?.addPostFrameCallback(
                        (_) {
                          final newFilteredRequests =
                              getFilteredRequests(requests);
                          if (!const DeepCollectionEquality()
                              .equals(newFilteredRequests, filteredRequests)) {
                            filteredRequests = newFilteredRequests;
                            setState(() => {});
                          }
                        },
                      );
                      placemarks = filteredRequests
                          .map((element) => Placemark(
                                mapId: MapObjectId(element.hashCode.toString()),
                                point: Point(
                                  latitude: element.address.coordinateX,
                                  longitude: element.address.coordinateY,
                                ),
                                isVisible: false,
                                icon: PlacemarkIcon.single(
                                  PlacemarkIconStyle(
                                    image: BitmapDescriptor.fromBytes(
                                      imageCache.containsKey(element.photo)
                                          ? imageCache[element.photo]!
                                          : defaultIcon,
                                    ),
                                  ),
                                ),
                                onTap: (_, __) => AutoRouter.of(context)
                                    .push(RequestRoute(request: element)),
                                opacity: 1,
                              ))
                          .toList();

                      return YandexMap(
                        onMapCreated: (controller) =>
                            mapController = controller,
                        mapObjects: [
                          ClusterizedPlacemarkCollection(
                            mapId: MapObjectId('${++rebuildsCount}'),
                            onClusterAdded: (
                              ClusterizedPlacemarkCollection self,
                              Cluster cluster,
                            ) async =>
                                cluster.copyWith(
                              appearance: cluster.appearance.copyWith(
                                opacity: 1,
                                icon: PlacemarkIcon.single(PlacemarkIconStyle(
                                  image: BitmapDescriptor.fromBytes(
                                    await MapMarksBuilder(context)
                                        .buildPlacemarkCluster(cluster.size),
                                  ),
                                  scale: 1,
                                )),
                              ),
                            ),
                            placemarks: placemarks,
                            radius: 100,
                            minZoom: 19,
                          ),
                        ],
                      );
                    } else {
                      return Center(
                        child: CircularProgressIndicator.adaptive(
                          valueColor: AlwaysStoppedAnimation(
                            AppTheme.of(context).colorTheme.primary,
                          ),
                        ),
                      );
                    }
                  },
                ),
                Positioned(
                  right: 24,
                  top: 16,
                  height: 48,
                  width: 48,
                  child: Material(
                    clipBehavior: Clip.hardEdge,
                    color: bottomSheetShown
                        ? colorTheme.primary
                        : colorTheme.background,
                    elevation: 8,
                    shape: CircleBorder(
                      side: bottomSheetShown
                          ? BorderSide.none
                          : BorderSide(color: colorTheme.border),
                    ),
                    child: IconButton(
                      onPressed: () => _showBottomSheet(context),
                      icon: bottomSheetShown
                          ? Assets.icons.x.svg(color: colorTheme.onPrimary)
                          : Assets.icons.filters.svg(color: colorTheme.hint),
                    ),
                  ),
                ),
              ],
            );
          },
        );
      },
    );
  }

  void _showBottomSheet(BuildContext context) {
    setState(() => bottomSheetShown = true);
    showModalBottomSheet<List<Category>>(
      context: context,
      constraints: const BoxConstraints(maxHeight: 800),
      backgroundColor: AppTheme.of(context).colorTheme.background,
      builder: (context) => FiltrationBottomSheet(initialFilters: filters),
      isScrollControlled: true,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(35)),
      ),
      useRootNavigator: true,
    ).then((categories) => setState(() {
          bottomSheetShown = false;
          filters = categories ?? filters;
        }));
  }

  List<Request> getFilteredRequests(List<Request> requests) {
    if (filters.isEmpty) {
      return requests;
    }

    return requests
        .map((e) => e.copyWith())
        .where((element) =>
            element.categories.any((element) => filters.contains(element)))
        .toList();
  }

  Future<bool> fetchImages(List<Request> requests) async {
    final MapMarksBuilder mapMarksBuilder = MapMarksBuilder(context);
    for (final request in requests) {
      if (!imageCache.containsKey(request.photo)) {
        try {
          imageCache[request.photo] =
              await mapMarksBuilder.buildPlacemarkPerson(
            await getImageFromUrl(request.photo),
          );
          MapObjectId mapObjectId = MapObjectId(request.hashCode.toString());
          final placemark =
              placemarks.where((element) => element.mapId == mapObjectId);
          if (placemark.isNotEmpty) {
            int index = placemarks.indexOf(placemark.first);
            setState(() => placemarks[index] = placemarks[index].copyWith(
                  icon: PlacemarkIcon.single(PlacemarkIconStyle(
                    image: BitmapDescriptor.fromBytes(
                      imageCache[request.photo]!,
                    ),
                  )),
                ));

            GetIt.I<Logger>().d('Loaded photo for request $request');
          }
          // ignore: empty_catches
        } on DioError {}
      }
    }

    return true;
  }

  Future<ui.ImageDescriptor> getImageFromUrl(String url) async {
    final response = await Dio().get<Uint8List>(
      url,
      options: Options(responseType: ResponseType.bytes),
    );

    return ui.ImageDescriptor.encoded(
      await ui.ImmutableBuffer.fromUint8List(
        response.data!,
      ),
    );
  }
}
DCrow commented 2 years ago

Мерцание при обновлении кластера это бага нативных кластеров, тут надо ждать когда яндекс исправит в нативной библиотеке.

Дублирование, происходит у меток кластера или у меток точек? Какая версия библиотеки?

Ae-Mc commented 2 years ago

Версия библиотеки 2.0.4. У меток точек (когда кластер раскрывается на точки, в одной географической координате появляется 2 одинаковых Placemark'и). П. С. переписал, теперь вроде бы точки не дублируются, но и в коде, приложенном выше, я не вижу, откуда могли бы появлятся дубликаты

DCrow commented 2 years ago

Возможно тут был race condition между FutureBuilder(установлением placemarks) и изменением placemarks в fetchImages.

Ae-Mc commented 2 years ago

Но ведь у меня нет добавлений элементов в placemarks. Внутри fetchImages я перезаписываю конкретные элементы, внутри FutureBuilder я устанавливаю полностью новое значение переменной. Где мог возникнуть race condition?

Заранее спасибо!

Ae-Mc commented 2 years ago

Здравствуйте!

Вы можете указать сначала для всех меток Placemark, иконку загрузки, и после загрузки нужных вам фотографий(либо после загрузки отдельной), обновить иконку для нужной вам метки.

placemark_page.dart, вместо действия

setState(() {
  mapObjects[mapObjects.indexOf(placemark)] = placemark.copyWith(
    icon: PlacemarkIcon.single(PlacemarkIconStyle(image: BitmapDescriptor.fromAssetImage('lib/assets/arrow.png')))
  );
});

Несмотря на такую возможность, как мне кажется, было бы удобно, если бы данный механизм был встроен в класс Placemark. Также это позволит подгружать только те фотографии, которые пользователь сейчас должен видеть, а не все подряд.

DCrow commented 2 years ago

Но ведь у меня нет добавлений элементов в placemarks. Внутри fetchImages я перезаписываю конкретные элементы, внутри FutureBuilder я устанавливаю полностью новое значение переменной. Где мог возникнуть race condition?

Ваш метод getFilteredRequests не отсортирован, и может возвращать элементы в разном порядке. Из-за чего, в последствии, int index = placemarks.indexOf(placemark.first); может давать разные значения, что приведет к ошибке.

Несмотря на такую возможность, как мне кажется, было бы удобно, если бы данный механизм был встроен в класс Placemark. Также это позволит подгружать только те фотографии, которые пользователь сейчас должен видеть, а не все подряд.

Не очень понимаю вас. В mapObjects всегда можно добавить, только те точки, который пользователь должен сейчас видеть, а по мере движения камеры добавлять/убирать другие точки.

Ae-Mc commented 2 years ago

Спасибо за ответ по поводу ошибки в коде!

Для этого придётся постоянно отслеживать изменения положения камеры. При этом, если точки объединены в кластер, то вообще невозможно понять, какие точки сейчас отображаются, а какие — нет.

Также, как я уже говорил, если изменять mapObjects, то точки мигают, что плохо (да, я понял, что это из-за бага в API Yandex'а).