1 В избранное 0 Ответвления 0

OSCHINA-MIRROR/CarGuo-GSYFlutterBook

Присоединиться к Gitlife
Откройте для себя и примите участие в публичных проектах с открытым исходным кодом с участием более 10 миллионов разработчиков. Приватные репозитории также полностью бесплатны :)
Присоединиться бесплатно
Клонировать/Скачать
Flutter-13.md 33 КБ
Копировать Редактировать Web IDE Исходные данные Просмотреть построчно История
Отправлено 10.03.2025 00:06 5767d61

Эта статья поможет вам глубже понять принципы передачи событий жестами в Flutter, распределение этих событий, конфликты и соперничество между ними, а также плавность прокрутки. Она поможет вам создать полный цикл знаний о жестах в Flutter.

Содержание статьи:

Полный实战 Flutter серия статей

Специальные серии статей Flutter

По умолчанию в Flutter все события происходят от класса io.flutter.view.FlutterView, являющегося подклассом SurfaceView. Процесс событий жестов фактически проходит через три этапа: JAVA => C++ => Dart. Этот процесс одинаков как для Android, так и для iOS. Оригинальный слой просто собирает все события и отправляет их дальше; например, на Android информация о жестах упаковывается в объект ByteBuffer и затем распаковывается в методе _dispatchPointerDataPacket уровня Dart, где он становится доступен в виде объекта PointerDataPacket.

Иллюстрация процесса событий жестов

Как именно Flutter распределяет и использует события жестов?

1. Процесс событий

Как мы видели на диаграмме выше, все события жестов начинаются с метода _dispatchPointerDataPacket на уровне Dart. Затем они проверяются через контекст выполнения Zone, после чего вызывается метод _handlePointerEvent в классе-обёртке GestureBinding. *(Если у вас есть вопросы относительно Zone или GestureBinding, вы можете обратиться к предыдущим статьям)*Как показано ниже, метод _handlePointerEvent класса GestureBinding состоит главным образом из двух шагов: hitTest для определения списка элементов управления, требующих обработки (HitTestResult), и dispatchEvent для распределения событий и создания конкуренции, чтобы выбрать победителя.

  void _обрабатывать_событие_поинтера(PointerEvent event) {
    assert(!locked);
    HitTestResult hitTestResult;
    if (event is PointerDownEvent || event is PointerSignalEvent) {
      hitTestResult = HitTestResult();
      /// Начался процесс проверки столкновений, который добавляет все контролы в список для дальнейшей обработки
      hitTest(hitTestResult, event.position);
      if (event is PointerDownEvent) {
        _hitTests[event.pointer] = hitTestResult;
      }
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      /// Механизм повторного использования, при отпускании или отмене события, нет необходимости выполнять hitTest, просто удаляем
      hitTestResult = _hitTests.remove(event.pointer);
    } else if (event.down) {
      /// Механизм повторного использования, когда палец находится в состоянии перемещения, нет необходимости выполнять hitTest
      hitTestResult = _hitTests[event.pointer];
    }
    if (hitTestResult != null ||
        event is PointerHoverEvent ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      /// Начинается распределение событий
      dispatchEvent(event, hitTestResult);
    }
  }

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

1.1. Метод hitTest

Метод hitTest предназначен для получения объекта HitTestResult, который содержит список List<HitTestEntry>. Этот список используется для распределения и конкуренции событий, а каждый HitTestEntry.target хранит RenderObject каждого контроля.Так как RenderObject по умолчанию реализует интерфейс HitTestTarget, то можно сказать, что HitTestTarget в большинстве случаев является RenderObject, а HitTestResult представляет собой список контроллов после выполнения теста столкновения.

На самом деле, метод hitTest является частью абстрактного класса HitTestable, и во Flutter все классы, реализующие HitTestable, это GestureBinding и RendererBinding, которые являются миксинами в классе WidgetsFlutterBinding. Из-за порядка миксинов, метод hitTest в RendererBinding вызывается раньше, чем в GestureBinding.

Тогда, какие действия выполняют эти два метода?

1.2. Метод RendererBinding.hitTest

В методе RendererBinding.hitTest вызывается renderView.hitTest(result, position: position);, как показано ниже. Внутри renderView.hitTest вызывается child.hitTest, который пытается добавить подходящий child-контроль в HitTestResult, а затем добавляет себя.

// RendererBinding

bool hitTest(HitTestResult result, {Offset position}) {
  if (child != null) child.hitTest(result, position: position);
  result.add(HitTestEntry(this));
  return true;
}
```Анализируя исходный код метода `child.hitTest`, представленного ниже, видим, что внутри `RenderObject` метод `hitTest` проверяет, находится ли текущее положение внутри размера с помощью `_size.contains()`. После того как область отклика подтверждается, выполняются методы `hitTestChildren()` и `hitTestSelf()`, чтобы попытаться добавить нижестоящие child-элементы и самого себя. Такой **рекурсивный процесс позволяет получить список контролов, отвечающих за события, сверху вниз, где самый нижний child-контроль оказывается самым верхним**.```dart
// RenderObject

bool hitTest(HitTestResult result, {required Offset position}) {
  if (_size.contains(position)) {
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}
```#### 1.3 Гестюрбиндинг.хиттест

Метод `GestureBinding.hitTest` в конце концов добавляет сам объект `GestureBinding` в `HitTestResult`. Это делается потому что нам потребуется вернуться к `GestureBinding`, чтобы завершить последующие шаги нашего процесса.

#### 1.4 dispatchEvent

В методе `dispatchEvent` происходит распределение событий, а также обработка этих событий с помощью `target.handleEvent`, добавленной выше. Как показано ниже:

@override // от HitTestDispatcher void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) { ///Если нет результатов хита, то событие будет передано глобальной обработке через pointerRouter.route. if (hitTestResult == null) { try { pointerRouter.route(event); } catch (exception, stack) { return; } } ///Как мы знаем, в HitTestEntry цели представляют собой контролы сверху вниз, ///включая renderView и GestureBinding ///Каждый handleEvent выполняется в цикле for (HitTestEntry entry in hitTestResult.path) { try { entry.target.handleEvent(event, entry); } catch (exception, stack) { } } }


На самом деле, не все контролы, являющиеся подклассами `RenderObject`, обрабатывают события `handleEvent`. В большинстве случаев это происходит только у контролов, содержащих `RenderPointerListener` (RenderObject) / `Listener` (Widget). Из вышеприведённого кода видно, что выполнение `handleEvent` не может быть перехвачено или прервано.Тогда возникает вопрос: если несколько контроллов внутри одного региона реализуют `handleEvent`, кто должен получить и обработать это событие?

Более конкретно, проблема заключается в следующем: **например, на странице списка есть возможность прокрутки вверх/вниз и клика по элементам. Как Flutter распределяет эти жестовые события?** Это связано с конкуренцией за события.

> **Основная идея вот-вот появится, будьте готовы к высокой нагрузке!!!**

## 2 Конкуренция за события

При проектировании конкуренции за события Flutter использует интересную концепцию: **контроллы участвуют в конкурсе на "поляне", победителем становится тот, кто первым достигнет своей цели или останется до самого конца.** Чтобы анализировать эту "войну", нам нужно понять несколько ключевых концепций:

- **`GestureRecognizer`**: базовый класс для распознавания жестов, основные события жестов в `RenderPointerListener`, такие как события нажатия, свайпов и тапов, передаются соответствующим экземплярам класса `GestureRecognizer`. Обрабатываются и затем распространяются дальше. Примерами таких классов могут быть: `OneSequenceGestureRecognizer`, `MultiTapGestureRecognizer`, `VerticalDragGestureRecognizer`, `TapGestureRecognizer` и так далее.

- **`GestureArenaManager`**: управляет процессом "войны" жестами, условием победы в конкурсе является первый участник, выигравший конкурс, либо последний участник, который не был отвергнут.- **`GestureArenaEntry`** : Элемент, предоставляющий информацию о событиях конкурса жестов, внутри которого содержатся участники события конкурса.

- **`GestureArenaMember`** : Абстрактный объект участника конкурса, содержащий методы `acceptGesture` и `rejectGesture`, представляющие участников конкурса жестов. По умолчанию все реализаторы `GestureRecognizer` имплементируют его. Все участники конкурса можно рассматривать как соперничество между различными `GestureRecognizer`.

- **`_GestureArena`** : Арена конкурса внутри `GestureArenaManager`, которая хранит список участников `members`. Официальное объяснение арены конкурса гласит: если жест пытается победить при открытом доступе к арене (`isOpen = true`), он становится объектом с атрибутом "желание победить". Когда арена закрывается (`isOpen = false`), она ищет объект с атрибутом "желание победить", чтобы стать новым участником. Если в этот момент остается только один участник, то этот участник будет считаться победителем этого конкурса.

Теперь, когда мы знаем эти концепции, давайте рассмотрим процесс. Мы знаем, что `GestureBinding` проверяет наличие результата `HitTestResult` перед выполнением `dispatchEvent`. Обычно это так, поэтому прямой цикл выполняется через `entry.target.handleEvent`.

#### 2.1 PointerDownEvent

При выполнении цикла известно, что `entry.target.handleEvent` вызывает `handleEvent` у `RenderPointerListener`. Первое событие обычно является `PointerDownEvent`.> Процесс `PointerDownEvent` играет ключевую роль в процессе конкурса событий, поскольку он запускает метод `GestureRecognizer.addPointer`.

**Метод `GestureRecognizer.addPointer` позволяет связать событие `PointerDownEvent` со своим экземпляром и добавить его в маршрутизацию событий `PointerRouter` в `GestureBinding` и в управление конкурсом событий `GestureArenaManager`. Только после этого следующие события могут быть обработаны и принять участие в конкурсе.**

![](http://img.cdn.guoshuyu.cn/20190604_Flutter-13/image2)

> На самом деле событие **Down** в Flutter обычно используется для добавления проверок. При наличии конкурентов результат обычно не выводится сразу, а событие **Move** может проявляться по-разному в различных `GestureRecognizer`. После события **Up** обычно получается окончательный результат. Поэтому мы знаем, что **когда событие начинает распространяться в `GestureBinding`, при событии `PointerDownEvent` должны отозваться события `GestureRecognizer`, которые вызывают метод `addPointer`, чтобы добавиться в список участников. В последующих этапах, если нет особых случаев, обычно процесс продолжается до последнего участника списка конкурентов, то есть до самого `GestureBinding`.**

Как показано ниже, когда достигает `handleEvent` в `GestureBinding`, в потоках событий Down обычно `pointerRouter.route` не выполняет много логики, а затем следует закрытие арены с помощью `gestureArena.close`, пытаясь получить победителя.```markdown
  @override // из HitTestTarget
  void handleEvent(PointerEvent event, HitTestEntry entry) {
    /// Навигационное событие запускает метод `handleEvent` в `GestureRecognizer`.
    /// Обычно при событии `PointerDownEvent` в `route` мало что происходит.
    pointerRouter.route(event);

    /// `gestureArena` - это `GestureArenaManager`.
    if (event is PointerDownEvent) {

      /// Закрывает эту арену для события Down, пытаясь найти победителя.
      /// Если победитель не найден, он будет найден при событиях Move или Up.
      gestureArena.close(event.pointer);

    } else if (event is PointerUpEvent) {
      /// Уже событие Up, вынужденное получение результата.
      gestureArena.sweep(event.pointer);

    } else if (event is PointerSignalEvent) {
      pointerSignalResolver.resolve(event);
    }
  }
```Давайте рассмотрим метод `close` в `GestureArenaManager`. Как видно из следующего кода, если участник не был добавлен в `_arenas` с помощью `addPointer` во время события Down, он даже не сможет принять участие в конкурсе. После того как процесс достигнет `_tryToResolveArena`, **если `state.members.length == 1`, это значит, что у нас один участник, и поэтому нет необходимости в конкуренции; этот участник автоматически становится победителем и реагирует на все последующие события.** Если же участников несколько, они будут участвовать в дальнейшей конкуренции.

```markdown
  void close(int указатель) {
  	/// Получаем членов, добавленных с помощью метода addPointer выше
    final _GestureArena состояние = _arenas[указатель];
    if (состояние == null)
      return; // Это арена либо никогда не существовала, либо была разрешена ранее.
    состояние.isOpen = false;
    /// Начнем разрешение конфликтов
    _попробоватьРазрешитьАрену(указатель, состояние);
  }

  void _попробоватьРазрешитьАрену(int указатель, _GestureArena состояние) {
    if (состояние.members.length == 1) {
      scheduleMicrotask(() => _разрешитьПоУмолчанию(указатель, состояние));
    } else if (состояние.members.isEmpty) {
      _arenas.remove(указатель);
    } else if (состояние.eagerWinner != null) {
      _разрешитьВПользу(указатель, состояние, состояние.eagerWinner);
    }
  }

2.2 Начало соревнованияА что насчет конкуренции? Давайте рассмотрим пример с TapGestureRecognizer. Если в области компонента присутствуют два TapGestureRecognizer, то победителя не будет выбран при событии PointerDownEvent. В этом случае, если событие MOVE не прервет процесс, при событии UP выполнится метод gestureArena.sweep(event.pointer); для принудительного выбора одного из них.

Метод выбора также прост: это state.members.first, то есть самый внутренний потомок дерева компонентов согласно результатам предыдущего hitTest. Выигравший участник затем вызывает метод members.first.acceptGesture(pointer), который передается в метод TapGestureRecognizer.acceptGesture, где переменная _wonArenaForPrimaryPointer устанавливается как true, чтобы указать на победу, после чего выполняются методы _checkDown и _checkUp, которые генерируют события для этого компонента.

Здесь интересной особенностью является то, что метод _checkUp внутри метода acceptGesture в потоке DOWN не будет выполнен, так как нет значения _finalPosition. Переменная _finalPosition получает свое значение в методе handlePrimaryPointer, где проверяется флаг _wonArenaForPrimaryPointer, и только тогда метод _checkUp будет успешно выполнен.

handlePrimaryPointer вызывается в потоке UP через pointerRouter.route, который активирует метод handleEvent в TapGestureRecognizer.

**И вот вопрос: если методы _checkDown и _checkUp выполняются одновременно при событии UP, то что произойдет, если я буду долго нажимать? Не будет ли метод _checkDown недоступен для корректного выполнения?**Конечно, это не проблема. Внутри TapGestureRecognizer существует механизм didExceedDeadline. В потоке DOWN, при добавлении указателя в метод addPointer, создается таймер, время которого равно kPressTimeout = 100 миллисекунд. Если вы продолжите длительно нажимать, то этот таймер завершит свой отсчет и вызовет метод didExceedDeadline, который выполнит _checkDown и отправит событие onTabDown.

При выполнении метода _checkDown используется флаг _sentTapDown, который проверяет, был ли уже отправлен данный сигнал. Если сигнал уже отправлен, он повторно не отправляется; вместо этого процесс переходит обратно к конкурентному состоянию. После того, как палец поднимется, победитель будет определен, а флаг _sentTapDown будет сброшен.

Это позволяет анализировать несколько сценариев нажатия:

Обычное нажатие:- 1. В области находится только один TapGestureRecognizer: при событии DOWN победитель сразу определяется в момент закрытия соревнования close, вызывая метод acceptGesture, который выполняет _checkUp. При событии UP метод _checkUp снова выполняется в методе handlePrimaryPointer, завершая процесс.
    1. Внутри области находятся несколько TapGestureRecognizer: при событии DOWN в конкурсе "close" победителя не выбирается, а при событии UP после прохождения маршрута через метод handlePrimaryPointer устанавливается значение _finalPosition. Затем происходит этап "sweep", где выбирается первый участник как победитель, вызывается метод acceptGesture, выполняются методы _checkDown и _checkUp.##### После долгого нажатия и отпускания:
  1. Внутри области находится один TapGestureRecognizer: за исключением того, что событие Down вызывает _checkDown при превышении срока в методе didExceedDeadline, остальное почти не отличается от вышеописанного.
    1. Внутри области находятся несколько TapGestureRecognizer: при событии Down в конкурсе "close" победителя не выбирается, но запускается таймер didExceedDeadline, который сначала вызывает _checkDown, затем после этапа "sweep" выбирается первый участник как победитель, вызывается метод acceptGesture, и запускается метод _checkUp.

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

Ответ: да! Потому что таймеры запускают метод didExceedDeadline, поэтому _checkDown будет выполнен для обоих, и они оба получат событие onTapDown. Однако после последующего конкурса будет выполнен только один _checkUp, так что только один компонент ответит на событие onTap.

Проигрыш в конкурсе:

Участники, потерпевшие поражение в конкурсе, исключаются из него, и больше не могут принимать участие в последующих конкурсах, например, TapGestureRecognizer при получении события PointerMoveEvent сразу отклоняется (rejected) и вызывает метод rejectGesture, затем таймер закрывается, и вызывается событие onTapCancel, после чего сбрасываются метки.Подведём итог:

Событие Down добавляет участника GestureRecognizer в область конкурса через метод addPointer. До удаления участника он может участвовать в последующих конкурсах. Удалённые участники не смогут принять участие в последующих событиях. Если нет победителей в конкурсе, в процессе Up первым участником автоматически считается победителем.

2.3 Событие свайпа

Событие свайпа также требует добавления указателя через метод addPointer в процессе Down, а затем выполнения метода DragGestureRecognizer.handleEvent после события MOVE через метод PointerRouter.route.

image.png

В методе DragGestureRecognizer.handleEvent при событии PointerMoveEvent проверяется условие через метод _hasSufficientPendingDragDeltaToAccept, например:

bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;

Если условие выполняется, то сразу выполняется resolve(GestureDisposition.accepted);. Это возвращает процесс обратно в арену, где затем вызывается метод acceptGesture, после чего срабатывают события onStart и onUpdate.Перейдём к нашему списку с возможностью прокрутки вверх и вниз, который можно было бы нажать. В этом случае всё становится очевидным: если это клик, то событие MOVE не происходит, поэтому DragGestureRecognizer не принимается, и первый элемент (child) откликается на клик. Если же событие MOVE произошло, то DragGestureRecognizer будет принят методом acceptGesture, а событие клика (TapGestureRecognizer) будет удалено из конкуренции за события, следовательно, последующее событие UP не будет происходить.А как работает событие onUpdate?

Допустим, что мы рассматриваем ListView. Исходный код показывает, что onUpdate в конце вызывает метод _handleDragUpdate класса Scrollable, при этом вызывается метод Drag.update.

image.png

Исходный код также указывает, что реализация Drag в ListView фактически является ScrollDragController, который связан с ScrollPositionWithSingleContext внутри Scrollable. А что такое ScrollPositionWithSingleContext?

ScrollPositionWithSingleContext — это ключевой компонент прокрутки, являющийся подклассом ScrollPosition, который, в свою очередь, является подклассом ViewportOffset, который, в свою очередь, является ChangeNotifier. Таким образом, имеют место следующие отношения:

Наследование: ScrollPositionWithSingleContext : ScrollPosition : ViewportOffset : ChangeNotifier

Поэтому ViewportOffset является ключевым моментом прокрутки. Мы знаем, что после того, как область DragGestureRecognizer выиграла конкуренцию, вызывается метод Drag.update, что в конечном итоге приводит к вызову метода applyUserOffset класса ScrollPositionWithSingleContext, что приводит к изменению внутреннего значения позиции pixels и вызову метода notifyListeners родительского класса ChangeNotifier для уведомления о необходимости обновления.

Внутри ListView в классе RenderViewportBase этот ViewportOffset связывается через _offset.addListener(markNeedsLayout). Поэтому, когда происходит движение пальцем, вызывается метод Drag.update, что в конечном итоге приводит к вызову метода markNeedsLayout класса RenderViewportBase, что запускает обновление страницы.Что касается того, как метод markNeedsLayout обновляет интерфейс и список прокрутки, здесь подробное описание пока не требуется, но вот диаграмма для наглядности: image.png

наконец, тринадцатая статья завершена! (///▽///)

Рекомендованные ресурсы

Рекомендованные полные открытые проекты:

Увидимся ли мы снова?

Опубликовать ( 0 )

Вы можете оставить комментарий после Вход в систему

1
https://api.gitlife.ru/oschina-mirror/CarGuo-GSYFlutterBook.git
git@api.gitlife.ru:oschina-mirror/CarGuo-GSYFlutterBook.git
oschina-mirror
CarGuo-GSYFlutterBook
CarGuo-GSYFlutterBook
master