Зоны В Dart: Большой Брат Наблюдает За Тобой

Привет! Меня зовут Дима, я фронтенд-разработчик в Wrike. Клиентскую часть проекта мы пишем на Dart, но с асинхронными операциями нам приходится работать не меньше, чем с другими технологиями.

Зоны — один из удобных инструментов, которые Dart предоставляет для этого.

Я недавно начал изучать эту тему и сегодня планирую показать оставшиеся примеры использования зон и неочевидные особенности их использования.

Как и обещал, посмотрим на AngularDart. Если вы хотите разобраться в основных возможностях зон, прочтите мой первая статья .



Зоны в Dart: Большой Брат наблюдает за тобой



NgZone и оптимизация процесса обнаружения изменений

Представим себе рабочую ситуацию: на обзоре спринта вы рассказываете о новой фиче, уверенно обходите известные баги и показываете функционал с лучшей стороны.

Но после пары кликов самописный счетчик производительности фреймворка показывает более 9000 попыток перерисовки интерфейса.

И это занимает всего секунду! После окончания обзора возникает непреодолимое желание исправить ситуацию.

Скорее всего, первой идеей будет обмануть счетчик.

Для этого нужно понять, как это работает. Перейдем к коду.

Вероятно, мы увидим там следующие строки:

  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
   

class StandardPerformanceCounter { final NgZone _zone; StandardPerformanceCounter(this._zone) { _zone.onMicrotaskEmpty.listen(_countPerformance); } // .

}

После дальнейших исследований становится ясно, что это неспроста: Angular использует поток onMicrotaskEmpty в корне каждого приложения, чтобы автоматически запускать процесс обнаружения изменений для каждого своего события:

class ApplicationRef extends ChangeDetectionHost { ApplicationRef._( this._ngZone, // .

) { // .

_onMicroSub = _ngZone.onMicrotaskEmpty.listen((_) { _ngZone.runGuarded(tick); }); } // Start change detection void tick() { _changeDetectors.forEach((detector) { detector.detectChanges(); }); } // .

}

Похоже, нам нужно разобраться, что такое NgZone, как он работает и исправить приложение как положено.

Давайте заглянем под капот. NgZone — это не зона, а оболочка над двумя другими зонами — внешней (зона, в которой запустилось приложение Angular) и внутренней (зона, которую создал Angular и внутри которой автоматически выполняются все операции приложения).

Оба сохраняются на этапе создания NgZone:

class NgZone { NgZone._() { _outerZone = Zone.current; // Save reference to current zone _innerZone = _createInnerZone( Zone.current, handleUncaughtError: _onErrorWithoutLongStackTrace, ); } // Create Angular zone Zone _createInnerZone( Zone zone, // .

) { return zone.fork( specification: ZoneSpecification( scheduleMicrotask: _scheduleMicrotask, run: _run, runUnary: _runUnary, runBinary: _runBinary, handleUncaughtError: handleUncaughtError, createTimer: _createTimer, ), zoneValues: {_thisZoneKey: true, _anyZoneKey: true}, ); } // .

}

Внутренняя зона берет на себя большую работу и использует различные особенности зон.

Для начала кратко напомню, зачем нужно обнаружение изменений.



Зоны в Dart: Большой Брат наблюдает за тобой

Вот как может выглядеть простая древовидная структура компонентов: Для построения интерфейса Angular использует компоненты, расположенные в древовидной структуре.

В обычной ситуации после запуска приложения компоненты гидрируются данными, строят DOM-дерево, ждут и сохраняют изменения в своих данных.

При этом сам Angular не сразу узнает о существовании этих изменений: в удобные для него моменты он запускает процесс обхода дерева компонентов от корня через все потенциально затронутые узлы в поисках измененных данных — ChangeDetection. Если произошли изменения, платформа начинает обновлять соответствующее поддерево DOM. Источников этих изменений может быть много — пользовательские события, онлайн-уведомления, временные ограничения.

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

Отсюда вырастает первая задача — отслеживать все события.

За короткий промежуток времени может произойти множество событий.

Если Angular попытается отслеживать изменения после каждого события, то производительность приложения резко пострадает. Причём реакции на события могут быть мгновенными для пользователя, но асинхронными для потока выполнения.

Давайте вспомним о цикле событий браузера:

Зоны в Dart: Большой Брат наблюдает за тобой

ЭЯ позаимствовал эту диаграмму у одного крутого Отчет Джейка Арчибальда о цикле событий Слева есть секция, в которой будет выполняться какая-то задача, а справа секция, в которой по очереди будут выполняться скрипты, запланированные с помощью requestAnimationFrame, затем будут происходить расчеты стиля, расчеты макета и рендеринг.

Мы можем выполнять скриптовые задачи только в желтых разделах.

Теперь попробуем залезть в головы авторов Angular и понять, когда лучше всего выполнять обнаружение изменений.

Это проще всего сделать после выполнения задачи, но можно запланировать микрозадачи, которые, в свою очередь, могут изменить данные.

Это приведет либо к несогласованности, либо к повторному запуску метода обнаружения изменений.

Не хорошо.

Было бы интересно отслеживать изменения внутри requestAnimationFrame, но тут мне в голову пришла целая куча «но»:

  • Обнаружение изменений в большом приложении может занять довольно много времени, что может привести к сбою многих кадров.

  • Скрипты, работающие в requestAnimationFrame, также могут планировать микрозадачи, которые будут выполняться сразу после выполнения скрипта и перед рендерингом, последствия чего мы уже обсуждали.

  • Интерфейс после обнаружения изменений может быть не совсем стабильным.

    Есть риск, что вместо ожидаемого конечного результата пользователь увидит незапланированную анимацию изменения интерфейса.

Остается еще один вариант — запустить defineChanges после выполнения скрипта и всех его микрозадач, если они есть.

Это задача номер два.

Оказывается, для «волшебства» Angular было бы неплохо:

  • Перехватите все возможные пользовательские события.

  • Запускайте обнаружение изменений после завершения выполнения сценариев в стеке и завершения выполнения всех запланированных на это время микрозадач.

Перехватываем все возможные пользовательские события.

Тот же InnerZone с этим прекрасно справляется.

Давайте посмотрим на это еще раз:

class NgZone { // .

// Create Angular zone Zone _createInnerZone( Zone zone, // .

) { return zone.fork( specification: ZoneSpecification( scheduleMicrotask: _scheduleMicrotask, run: _run, runUnary: _runUnary, runBinary: _runBinary, handleUncaughtError: handleUncaughtError, createTimer: _createTimer, ), zoneValues: {_thisZoneKey: true, _anyZoneKey: true}, ); } // .

}

В предыдущей статье мы уже обсуждали, что Future выполняет свой обратный вызов в той зоне, в которой он был создан.

Поскольку Angular при запуске пытается создать и выполнить все, что находится в своей внутренней зоне, после завершения Future задача выполняется с помощью обработчика _run. Вот как это выглядит:

class NgZone { // .

R _run<R>(Zone self, ZoneDelegate parent, Zone zone, R fn()) { return parent.run(zone, () { try { _nesting++; // Count nested zone calls if (_isStable) { _isStable = false; // Set view may change // … } return fn(); } finally { _nesting--; _checkStable(); // Check we can try to start change detection } }); } // .

}

Используя семейство методов run*, мы перехватываем все пользовательские события, поскольку после запуска приложения изменения в нем, скорее всего, будут происходить от асинхронных взаимодействий.

Перед выполнением обратного вызова NgZone запоминает, что в данный момент в приложении могут происходить изменения, и учитывает вложенность обратных вызовов.

После выполнения обратного вызова зона вызывает метод _checkStable непосредственно внутри основного потока, не планируя его на следующую итерацию цикла обработки событий.

Мы запускаем обнаружение изменений после завершения выполнения скриптов в стеке и завершения выполнения всех запланированных на тот момент микрозадач.

Второй важный элемент внутренней зоны — ScheduleMicrotask:

class NgZone { // .

void _scheduleMicrotask(Zone _, ZoneDelegate parent, Zone zone, void fn()) { _pendingMicrotasks++; // Count scheduled microtasks parent.scheduleMicrotask(zone, () { try { fn(); } finally { _pendingMicrotasks--; if (_pendingMicrotasks == 0) { _checkStable(); // Check we can try to start change detection } } }); } // .

}

Эта функция отслеживает, когда все микрозадачи завершают свою работу.

Работа аналогична прогону — считаем, сколько микрозадач было запланировано и сколько уже выполнено.

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

Зона вызывает _checkStable как часть последней микрозадачи, не планируя еще одну.

Наконец, давайте посмотрим на метод, который все это завершает:

class NgZone { // .

void _checkStable() { // Check task and microtasks are done if (_nesting == 0 && !_hasPendingMicrotasks && !_isStable) { try { // .

_onMicrotaskEmpty.add(null); // Notify change detection } finally { if (!_hasPendingMicrotasks) { try { runOutsideAngular(() { _onTurnDone.add(null); }); } finally { _isStable = true; // Set view is done with changes } } } } } // .

}

Вот когда мы дошли до этого момента! Этот метод проверяет, есть ли еще вложения или невыполненные микрозадачи.

Если все выполнено, он отправляет событие через _onMicrotaskEmpty. Это тот самый поток, который синхронно запускает обнаружение изменений! Дополнительно в конце проверяется, были ли созданы новые микрозадачи на момент обнаружения изменений.

Если все хорошо, NgZone считает представление стабильным и сообщает, что проход завершился.

Подведем итоги: Angular пытается сделать все в NgZone. Каждый Future при завершении, каждый поток при каждом событии и каждый таймер по истечении времени запускают run* или ScheduleMicrotask и, следовательно, обнаруживают изменения.

Важно помнить, что это еще не все.

Например, addEventListener на объекте Element также обязательно сообщит текущей зоне о запланированной работе, несмотря на то, что это не поток, не таймер и не будущее.

Другой похожий пример — вызов _zone.run() сам по себе также вызовет обнаружение изменений, поскольку мы напрямую используем NgZone. Этот процесс был оптимизирован.

МетодDetectChanges запустится только один раз — в самом конце задачи, вызвавшей его, или внутри самой последней микрозадачи, запланированной в прошлом скрипте.

Обнаружение изменений произойдет не в следующей итерации цикла событий, а в текущей.

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

Это позволяет нам существенно сэкономить на данной операции.

Однако независимо от того, насколько быстро срабатывает неактивный метод обнаружения изменений, такие события, как прокрутка и mouseMove, могут запускать его очень часто.

Я тестировал: 1000 таких вызовов в секунду могут съесть 200мс пользовательского времени.

Зависит от многих условий, но есть над чем подумать.

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



Потоковая передача и запуск OutsideAngular

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

Например, onMouseMove для объекта Element. Быстро заглянуть под капот потока не представляется возможным, так как в Dart существует множество реализаций потока.

Но в статье Зоны есть простое и эффективное правило:

Преобразования и другие обратные вызовы выполняются в зоне, в которой поток был запущен для прослушивания.

Зона зависит от подписки.

Где оно создано, там оно и исполняется.

Поэтому рекомендуется подписаться и фильтровать быстрый поток за пределами зоны Angular:

// Part of AngularDart component class final NgZone _zone; final ChangeDetectorRef _detector; final Element _element; void onSomeLifecycleHook() { _zone.runOutsideAngular(() { _element.onMouseMove.where(filterEvent).

listen((event) { doWork(event); _zone.run(_detector.markForCheck); }); }); }

Что здесь не очевидно, так это: зачем выносить поток за пределы угловой зоны, если он и так фильтруется? Без этого было бы лаконично:

// Part of AngularDart component class final Element _element; void onSomeLifecycleHook() { _element.onMouseMove.where(filterEvent).

listen(doWork); }

Проблема в том, что мы делаем здесь более одной подписки.

Методwhere при вызове возвращает поток.

И это не тот же поток, это новый _WhereStream:

// Part of AngularDart component class final Element _element; void onSomeLifecycleHook() { _element.onMouseMove // _ElementEventStreamImpl .

where(filterEvent) // _WhereStream .

listen(doWork); }

Когда мы подписываемся на _WhereStream, он сразу же подписывается на родительский поток и так далее до самого источника.

И все эти подписки будут созданы в текущей зоне, а это значит, что обнаружение изменений будет срабатывать столько раз, сколько сработает самый быстрый поток в цепочке.

Даже если мы создали всю цепочку в другой зоне.



Управление зоной для пакета: redux_epics

Мы часто используем пакет redux_epics в наших представлениях.

Под капотом он очень активно использует стриминг и заставляет нас тоже его использовать.

Бывает, что действия, которые мы отправляем, могут не влиять на наше состояние.

Кроме того, у нас обнаружение изменений начнется в любом случае после того, как действие завершилось и что-то изменило, поэтому кикать его повторно не нужно.

Поэтому, чтобы избежать ложных срабатываний, нужно запускать эпики за пределами угловой зоны.

Как это сделать? Поскольку все действия потока выполняются в той зоне, внутри которой мы на него подписались, метод Listen стоит поискать в коде redux_epics:

class EpicMiddleware<State> extends MiddlewareClass<State> { bool _isSubscribed = false; // .

@override void call(Store<State> store, dynamic action, NextDispatcher next) { // Init on first call if (!_isSubscribed) { _epics.stream .

switchMap((epic) => epic(_actions.stream, EpicStore(store))) .

listen(store.dispatch); // Forward all stream actions to dispatch _isSubscribed = true; // Set middleware is initialized } next(action); // .

} }

Мы найдем его в методе вызова.

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

Отсюда простой вывод – первое действие нужно совершить за пределами угловой зоны.

Например, в корневом компоненте после создания магазина:

// Part of AngularDart component class final NgZone _zone; final AppDispatcher _element; void onInit() { _zone.runOutsideAngular(() { // .

_dispatcher.dispatch(const InitApp()); }); }

А если отправлять нечего, то подойдет null:

// Part of AngularDart component class final NgZone _zone; final AppDispatcher _element; void onInit() { _zone.runOutsideAngular(() { // .

_dispatcher.dispatch(null); }); }

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



Обнаружение множественных изменений для собственных событий

А вот очень забавный трюк.

Допустим, у нас есть родительский компонент, у него есть дочерний компонент, а у дочернего элемента есть элемент кнопки:

<!-- parent-component --> <child-component (click)="handleClick()"> </child-component> <!-- child-component --> <button type="button" (click)="handleClick()"> Click </button>

В каждом из этих компонентов мы слушаем собственное событие клика.

Он превратится в родителя, плавая.

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

Дело в том, что в шаблоне слушатели событий компилируются не как подписка на поток, а как нечто близкое к нативному addEventListener:

_el_0.addEventListener('click', eventHandler(_handleClick_0));

Это произойдет в обоих компонентах.

Это значит, что мы переносим сюда интересную особенность addEventListener: когда пользователь нажимает на кнопку, браузер создаст одну задачу, которая за одну итерацию событийного цикла сгенерирует столько выполнений скрипта, сколько подписок будет затронуто всплывающее окно события.

И после каждого скрипта все сгенерированные им микрозадачи будут сразу выполняться, а вместе с ними и обнаруживать Изменения.

Поэтому в Angular выгоднее было бы не рассчитывать на появление события, а сделать это в дочернем компоненте Output:

<!-- parent-component --> <child-component (buttonPress)="handleButtonPress()"> </child-component> <!-- child-component --> <button type="button" (click)="handleClick()"> Click </button>

Эта опция вызовет обнаружение изменений один раз, поскольку Output — это поток, а даже асинхронный поток использует микрозадачи, которые, как мы уже знаем, NgZone хорошо отслеживает. Это странное поведение пузырьков событий хорошо описано.

в статье о микрозадачах тот самый Джейк Арчибальд.

Как пройти в библиотеку

Зоны — мощный инструмент, решающий конкретные задачи и зачастую упрощающий интерфейс.

Но в то же время ни один из приведенных выше примеров не является тем, что мы написали в нашем проекте; все примеры взяты из сторонних библиотек.

Явное лучше неявного.

Код приложения должен быть легким, ясным и понятным.

Зона — это инструмент, похожий на волшебство, который приемлем в хорошо протестированных общественных библиотеках или в самодельных и хорошо протестированных утилитах.

Но нам нужно быть осторожными при внедрении таких инструментов в код, с которым мы работаем каждый день.

Я хотел бы закончить небольшим предупреждением.

Зоны — не очень хорошо документированный функционал, и иногда с их использованием возникают ошибки, которые просто невозможно исправить.

Например, проблема , который мы начали по стопам одного из них.

Я расскажу вам об этом вкратце.

При создании Future сохраняет текущую зону, это дает нам некоторый контроль.

Но оказалось, что в Dart SDK есть как минимум два заранее созданных и скомпилированных Futures с сохраненной в них корневой зоной:

abstract class Future<T> { final Future<Null> _nullFuture = Future<Null>.

zoneValue(null, Zone.root); final Future<bool> _falseFuture = Future<bool>.

zoneValue(false, Zone.root); // .

}

Еще раз напомню, что любой Future должен выполнять запланированные коллбэки в микрозадаче.

Если мы попытаемся прикрепить задачу к Future через метод then, то она как минимум выполнится:

  • зона.

    scheduleMicrotask;

  • Zone.registerUnaryCallback;
  • Zone.runUnary.
Мы увидели, что обратный вызов гарантированно будет зарегистрирован и выполнен в той зоне, в которой он был передан методу then. А вот с ScheduleMicrotask все интереснее.

У Future есть оптимизация — если к одному Future привязано несколько коллбеков, то он попытается выполнить их все в одной микрозадаче:

// Callbacks doFirstWork and doSecondWork will be called in same microtask void doWork(Future future) { future.then(doFirstWork).

then(doSecondWork); }

Для обоих обратных вызовов из этого примера потребуется только один вызов ScheduleMicrotask. Прохладный.

Но бывает, что колбеки были размещены в разных зонах:

void doWork(Future future) { runZoned(() { // First zone future.then(doFirstWork); }, zoneValues: {#isFirst: true}); runZoned(() { // Second zone future.then(doSecondWork); }, zoneValues: {#isFirst: false}); }

В этом случае они все равно будут выполняться в одной микрозадаче.

Вопрос для ответа — для какой зоны следует запланировать эту микрозадачу? Первый? Второй? Ребята из Дарта решили, что это всегда будет планироваться по той зоне, которая записана в оригинальном Future:

// Zone that is saved in [future] argument will schedule microtask void doWork(Future future) { runZoned(() { // First zone future.then(doFirstWork); }, zoneValues: {#isFirst: true}); runZoned(() { // Second zone future.then(doSecondWork); }, zoneValues: {#isFirst: false}); }

Это означает, что если мы запланируем выполнение обратного вызова для ранее созданного и скомпилированного _nullFuture, то ScheduleMicrotask будет вызываться не из текущей зоны, а из корневой зоны:

final future = Future._nullFuture; final currentZone = Zone.current; future.then(doWork); // currentZone.registerUnaryCallback(.

); // _rootZone.scheduleMicrotask(.

); // currentZone.runUnary(.

);

Текущая зона никогда не узнает, что микрозадача запланирована.

Такое поведение может легко сломать ранее обсуждалось FakeAsync: Он не сможет синхронно сделать что-то, о чем понятия не имеет. Вы можете подумать, что _nullFuture никогда не выйдет, но:

final controller = StreamController<void>(sync: true); final subscription = controller.stream.listen(null); subscription.cancel(); // Returns Future._nullFuture

Достать его не так уж и сложно, причем из совершенно неожиданного места.

Отсюда и ошибки с FakeAsync. Нам могла бы пригодиться помощь в обсуждении этого странного поведения, пожалуйста, зайдите.

в выпуске , вместе мы победим! Кроме того, есть дополнительная информация от участников о том, как другие зоны взаимодействуют с Future и Stream, не пропустите! У меня все.

Буду рад ответить на ваши вопросы! Теги: #программирование #dart #angular #async #dartlang #angulardart #zone

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.