Зоны в Dart: большой брат следит за тобой
Привет! Меня зовут Дима, я frontend-разработчик в компании Wrike. Клиентскую часть проекта мы пишем на Dart, однако работать с асинхронными операциями нам приходится не меньше, чем на других технологиях. Зоны — один из удобных инструментов, который Dart для этого предоставляет. Недавно я начал разбирать эту тему, а сегодня планирую показать оставшиеся у меня примеры применения зон и неочевидные особенности их использования. Как обещал — посмотрим на AngularDart.
Если хотите разобраться с базовыми возможностями зон, прочитайте мою первую статью.
NgZone и оптимизация процесса change detection
Представим рабочую ситуацию: на ревью спринта вы рассказываете про новую фичу, уверенно обходите стороной известные баги и показываете функциональность с лучшей стороны. Но через пару кликов самописный счетчик производительности фреймворка показывает over 9000 попыток перерисовать интерфейс. И это только за секунду!
После окончания ревью возникает непреодолимое желание исправить ситуацию. Скорее всего, первой идеей будет — обмануть счетчик. Для этого надо разобраться, как он работает. Идем в код. Вероятно, мы увидим там эти строки:
class StandardPerformanceCounter {
final NgZone _zone;
StandardPerformanceCounter(this._zone) {
_zone.onMicrotaskEmpty.listen(_countPerformance);
}
// ...
}
После дальнейшего исследования становится понятно, что это неспроста: Angular использует именно стрим onMicrotaskEmpty в корне каждого приложения для того, чтобы на каждый его event автоматически запускать процесс change detection:
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},
);
}
// ...
}
Внутренняя зона принимает на себя много работы и использует разные особенности зон.
Для начала поверхностно напомню, зачем нужен change detection.
Так может выглядеть простая древовидная структура компонентов
Для построения интерфейса Angular использует компоненты, которые выстраиваются в древовидную структуру. В обычной ситуации после старта приложения компоненты гидрируются данными, выстраивают DOM дерево, ждут и сохраняют изменения в своих данных. При это сам Angular не узнает о существовании этих изменений тотчас: в удобные для него моменты он запускает процесс обхода дерева компонентов от корня по всем потенциально затронутым нодам в поисках измененных данных — change detection. Если изменения произошли, то фреймворк запускает обновление соответствующего DOM поддерева.
Источников у этих изменений может быть море — пользовательские события, онлайн нотификации, временные отсечки. Любой из них может повлиять на интерфейс, а значит надо проверить, не изменилось ли что-то в данных компонентов и не надо ли обновить интерфейс. Отсюда растет первая задача — отследить все события.
Событий может быть очень много за короткий промежуток времени. Если Angular попытается отследить изменения после каждого события, то приложение резко просядет по производительности. Более того, реакции на события могут быть моментальными для пользователя, но асинхронными для потока выполнения.
Вспомним про event loop браузера:
Эту схему я позаимствовал из крутого доклада Джейка Арчибальда об event loop
Слева находится секция, в которой будет выполняться какой-то таск, а справа — секция, в которой будут по очереди выполняться сначала запланированные с помощью requestAnimationFrame скрипты, потом произойдет калькуляция стилей, калькуляция лейаута и отрисовка. Скриптовые задачи мы можем выполнять только в желтых секциях.
А теперь попробуем залезть в голову к авторам Angular и понять, когда лучше всего выполнять detectChanges.
Проще всего это сделать после выполнения таски, но она может запланировать микротаски, которые, в свою очередь, могут изменить данные. Это приведет либо к неконсистентности, либо к еще одному пуску detectChanges. Не годится.
Интересно было бы отслеживать изменения в рамках requestAnimationFrame, но тут мне пришел в голову целый набор «но»:
- Change detection на большом приложении может быть довольно долгим, из-за этого может вылететь много фреймов.
- Скрипты, выполняемые в requestAnimationFrame, также могут запланировать микротаски, которые будут запускаться сразу же после выполнения скрипта и перед отрисовкой, последствия мы уже обсуждали.
- Интерфейс после change detection может быть не полностью стабилен. Есть риск, что пользователь увидит незапланированную анимацию изменения интерфейса вместо ожидаемого конечного результата.
Остается еще один вариант — запускать detectChanges после того, как будет выполнен скрипт и все его микротаски, если они есть. Это задача номер два.
Получается, что для «магии» Angular было бы неплохо:
- Ловить все возможные пользовательские события.
- Запускать change detection после того, как закончится выполнение скриптов в стеке и закончат выполнение все запланированные на тот момент микротаски.
Ловим все возможные пользовательские события. С этим прекрасно справляется та самая 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(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
}
});
}
// ...
}
C помощью семейства методов run* мы и ловим все пользовательские события, потому что после старта приложения изменения в нем, скорее всего, произойдут от асинхронных взаимодействий. Перед выполнением коллбека NgZone запоминает, что сейчас в приложении могут происходить изменения, и считает вложенность коллбеков. После выполнения коллбека зона вызывает метод _checkStable прямо в рамках основного потока, не планируя это на следующую итерацию event loop.
Запускаем change detection после того, как закончится выполнение скриптов в стеке и закончат выполнение все запланированные на тот момент микротаски. Второй важный элемент внутренней зоны — 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
}
}
});
}
// ...
}
Эта функция отслеживает, когда закончат свою работу все микротаски. Работа похожа на run — мы считаем, сколько было запланировано микротасок и сколько уже успело выполниться. Микротасок может быть запланировано сразу много, и они обязательно выполнятся до запуска следующего скрипта. Зона вызывает _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. Это и есть тот самый стрим, который синхронно запускает detectChanges! Дополнительно в конце проверяется, не создалось ли в момент работы change detection новых микротасок. Если все хорошо, NgZone считает вьюху стабильной и сообщает, что проход закончился.
Подытожим:
Angular старается выполнить все в NgZone. Каждый Future при комплите, каждый Stream при каждом событии и каждый Timer по истечении времени запустит run* или scheduleMicrotask, а значит и detectChanges.
Важно помнить, что это не все. Например, addEventListener на объекте Element также обязательно расскажет текущей зоне о запланированной работе, несмотря на то что это не стрим, не таймер и не фьючер. Еще один похожий пример — сам по себе вызов _zone.run () точно также запустит detectChanges, ведь мы напрямую используем NgZone.
Этот процесс оптимизирован. Метод detectChanges запустится только один раз — в самом конце того таска, который его триггернул, или в рамках самой последней микротаски, которая была запланирована в прошедшем скрипте. Change detection произойдет не в следующей итерации event loop, а в текущей.
Мы в проекте используем OnPush стратегию для change detection компонентов. Это позволяет нам сильно сэкономить на этой операции. Однако как бы ни был быстр холостой запуск detectChanges, события типа scroll и mouseMove могут запускать его очень часто. Я тестировал: 1000 таких вызовов в секунду могут съесть у пользователя 200 мс времени. Зависит от многих условий, но есть над чем задуматься.
И раз уж это погружение в недра Angular началось с желания оптимизировать, то закончим мы парой забавных и не очень очевидных выводов из полученных знаний.
Stream и runOutsideAngular
Основной кейс runOutsideAngular относится как раз к ситуации, когда мы слушаем очень быстрый стрим, который хотим еще и фильтровать. Например, onMouseMove у объекта Element. Быстро посмотреть под капот стрима не получится, поскольку реализаций стримов в Dart — уйма. Но в статье Zones написано простое и действенное правило:
Трансформации и другие коллбеки выполняются в той зоне, в которой стрим начали слушать.
Зона зависит от подписки. Где она создана, там и выполняется. Поэтому рекомендуется подписываться и фильтровать быстрый стрим вне зоны 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, он тут же подписывается на родительский стрим и так далее до самого источника. И все эти подписки будут созданы в текущей зоне, а значит detectChanges будет срабатывать столько раз, сколько срабатывает самый быстрый стрим в цепочке. Даже если мы создали всю цепочку в другой зоне.
Контроль зоны для package: redux_epics
Мы часто используем в наших вьюшках пакет redux_epics. Под капотом он очень активно использует стримы и принуждает и нас их использовать. Бывает, что экшены, которые мы диспатчим, могут не повлиять на наше состояние. К тому же наш change detection запустится в любом случае после того, как action отработает и что-то изменит, не стоит пинать его лишний раз. Поэтому, чтобы избежать ложных срабатываний, нужно выполнять эпики вне зоны ангуляра. Как это сделать?
Раз уж все действия стрима выполняются в зоне, внутри которой мы на него подписались, стоит поискать метод listen в коде redux_epics:
class EpicMiddleware extends MiddlewareClass {
bool _isSubscribed = false;
// ...
@override
void call(Store 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);
// ...
}
}
Мы его найдем в методе call. Значит подписка создается в момент вызова мидлвары (в данном случае — первого вызова), а это происходит при диспатче экшена.
Отсюда простой вывод — первый 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);
});
}
После этого стримы эпиков будут выполняться вне зоны ангуляра, что избавит от части паразитных запусков change detection.
Многократный change detection для нативных событий
Вот теперь совсем забавный трюк. Допустим, у нас есть компонент родитель, в нем есть компонент дочка, в дочке есть элемент button:
В каждом из этих компонентов мы слушаем нативное событие click. Оно прорастет в родителя благодаря всплыванию. Подвох в том, что change detection здесь запустится дважды. Дело в том, что в шаблоне event listeners компилируются не как подписка на стрим, а как близкий к нативному addEventListener:
_el_0.addEventListener('click', eventHandler(_handleClick_0));
Так произойдет в обоих компонентах. А значит мы переносим сюда и интересную особенность addEventListener: когда пользователь нажмет на кнопку, то браузер создаст один таск, который в рамках одной итерации event loop породит столько выполнений скриптов, сколько подписок будет затронуто всплыванием события. И после каждого скрипта будут сразу выполняться все порожденные им микротаски, а вместе с ними и detectChanges.
Поэтому в Angular выгоднее будет не рассчитывать на всплытие ивента, а сделать в дочернем компоненте Output:
Такой вариант запустит change detection единожды, поскольку Output — это стрим, а даже асинхронный стрим использует микротаски, которые, как мы уже знаем, NgZone хорошо отслеживает.
Это странное поведение всплывающих событий отлично описано в статье о микротасках все того же Джейка Арчибальда.
Как пройти в библиотеку
Зоны — это мощный инструмент, который решает специальные задачи и зачастую упрощает интерфейс. Но при этом ни один из показанных выше примеров не является чем-то написанным нами в нашем проекте, все примеры — из сторонних библиотек.
Явное лучше, чем неявное. Коду приложения лучше быть легким, четким и понятным. Зона — это инструмент, который выглядит как магия, что приемлемо в хорошо протестированных сообществом библиотеках или в собственноручно разработанных и хорошо протестированных утилитах. Но надо быть осторожными, внедряя такие инструменты в код, с которым мы ежедневно работаем.
Закончить хотелось бы на небольшом предостережении. Зоны — не очень хорошо задокументированная функциональность, и периодически с их использованием связаны баги, которые просто так не пофиксить. Вот, например, issue, который мы завели по следам одного из них. Вкратце о нем расскажу.
При создании Future сохраняет текущую зону, это дает нам некоторый контроль. Но оказалось, что в Dart SDK есть как минимум два заранее созданных и закомпличеных Future с сохраненной в них root зоной:
abstract class Future {
final Future _nullFuture = Future.zoneValue(null, Zone.root);
final Future _falseFuture = Future.zoneValue(false, Zone.root);
// ...
}
Еще раз напомню, что любой Future обязательно должен выполнять запланированные коллбеки в микротаске. Если мы попытаемся к Future пристыковать задачу через метод then, то он, как минимум, выполнит:
- zone.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});
}
В этом случае они все еще будут выполнены в одной микротаске. Вопрос на засыпку — какая зона должна запланировать этот микротаск? Первая? Вторая? Ребята из Dart выбрали, что планировать это всегда будет зона, которая записана в изначальный 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 будет вызван не из текущей зоны, а из root зоны:
final future = Future._nullFuture;
final currentZone = Zone.current;
future.then(doWork);
// currentZone.registerUnaryCallback(...);
// _rootZone.scheduleMicrotask(...);
// currentZone.runUnary(...);
Текущая зона так и не узнает, что была запланирована микротаска. Такое поведение легко может сломать рассмотренный ранее FakeAsync: он не сможет выполнить синхронно то, о чем понятия не имеет.
Можно подумать, что _nullFuture никогда наружу не вылезет, но:
final controller = StreamController(sync: true);
final subscription = controller.stream.listen(null);
subscription.cancel(); // Returns Future._nullFuture
Не так уж и сложно его достать, причем из совершенно неожиданного места. Отсюда и баги с FakeAsync.
Нам бы пригодилась помощь в дискуссии об этом странном поведении, заходите в issue, вместе победим! К тому же там есть дополнительная информация от контрибьюторов о том, как еще зоны взаимодействуют с Future и Stream, не упустите!
У меня все. Буду рад ответить на вопросы!