Hello! My name is Dima, I am a frontend developer at Wrike. We write the client part of the project on Dart, however we have to work with asynchronous operations no less than on other technologies. Zones is one of the handy tools that Dart provides for this. But in the Dart community, you rarely find useful information about it, so I decided to sort it out and tell more about this powerful tool.


ITKarma picture
Disclaimer: All the code used in the article only pretends to be copy-paste. In fact, I greatly simplified it and got rid of the details that you should not pay attention to in the context of this article. In preparing the material, Dart version 2.7.2 and AngularDart version 5.0.0 were used.

I almost never heard of zones before Dart. This entity is often not used directly and has rather specific abilities. At the same time, as import of dart: async hints at us, they make sense precisely at asynchronous operations.


In Wrike (and in any other AngularDart project), mention of zones is usually found in such fragments:


//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); }); }); } 

Experienced Dart developers say that this is the lost knowledge of their ancestors. Do it and it will be beautiful.


However, if you get under the hood of some libraries, it turns out that the zones are often used to:


  • Simplify APIs in utilities that use asynchronous operations.
  • Correct some application performance drawdowns.
  • Catch unhandled exceptions and fix bugs (which are sometimes generated by the zones themselves, but this is an irrelevant detail).

There are few sources of zone information in the Dart community, except for issues on github. This API documentation , article from the official language website , which clearly explains the case with catching asynchronous errors, and a few reports , for example, from the DartUP conference. Therefore, the code becomes the most complete reference.


Here I propose to consider examples from the libraries that we use in the project:


  • package: intl;
  • package: quiver;
  • package: angular.

Let's start with a simple one.


Intl and temporarily switch locales


The intl package is part of our localization engine. The principle of operation is quite simple: when you start the application, we look at which locale is selected by default, load this locale and each time you call the message or plural methods, we return the desired text for the transmitted key.


Keys are designated like this:


class AppIntl { static String loginLabel() => Intl.message( 'Login', name: 'AppIntl_loginLabel', ); } 

Sometimes we need to temporarily change the locale. For example, a user enters a time period, and we need to parse it from the current language. And if you couldn’t do it from the current, then from some other. To do this, the withLocale method is provided, which is used something like this:


//User has 'en' as default locale, but he works from Russia final fallbackLocale='ru'; Future<Duration> parseText(String userText) async =>//Try to parse user text await _parseText(userText) ??//Try to parse with 'ru' locale if default parsing failed await Intl.withLocale(fallbackLocale, () => _parseText(userText));//This is actual parser Future<Duration> _parseText(String userText) {//... } 

Here we try to parse user input first in the default locale and then in the fallback locale.


It looks like withLocale changes the current language to the specified one, then executes the passed callback, then returns everything as it was. But!


The parseText method returns Future, because the desired locale may not be loaded yet, which means that the result needs to wait. While we wait, the user touched something in the interface, and he began to redraw. Since the current locale at this moment is Russian, the interface has also changed the language to Russian. And when the operation is over - back to English. It’s no good.


It would be nice for Future to inform itself that the asynchronous operation has ended, and it transfers control and the result further. Then the algorithm could switch the locale at the right time.


1.What is under the hood of Future


Good news - he does it! We call some kind of asynchronous function and get the Future instance on the output:


class Future { Future() : _zone=Zone.current;//Save current zone on creation final Zone _zone;//... } 

When creating an instance, Future remembers the current zone. Now we need to plan the processing of the result. Of course, using the then method:


class Future {//... Future<R> then<R>( FutureOr<R> callback(T value),//... ) {//Notify zone about callback for async operation callback=Zone.current.registerUnaryCallback(callback); final result=Future();//Schedule to complete [result] when async operation ends _addListener(_FutureListener.then( result, callback,//... )); return result; } } class _FutureListener {//... FutureOr<T> handleValue(S value) =>//Call scheduled work inside zone that was saved in [result] Future result._zone.runUnary(_callback, value); } 

Interesting! First, we tell the current zone which callback will be executed in the future. Then we create a new Future, plan for it to process the result of the asynchronous operation, and return it. At the same time, the new Future remembers the zone in which it was created - Zone.current. After receiving the result using the runUnary method, it performs a callback in the saved zone. The zone knows when the callback was added, and knows when it needs to be done. So, you can somehow influence the execution process!


2. What is a zone, where does the current zone come from, and what does “execute inside a zone” mean


It's an execution context. - Brian Ford, zone.js author.

A zone is an object that can be described with the word "context": it can carry contextual data and contextual behavior. All entities that know about zones, one way or another, use the data or behavior of the current zone. Using the Future example, we have already seen that if he needs to perform a callback, he delegates this work to the zone using the run * family of methods. And she already imposes the final features and side effects on the callback.


The current zone is the zone the link to which is currently stored in the _current field. Zone.current is a static getter for accessing _current. In code, it looks like this:


class Zone { static Zone _current=_rootZone;//This is where current zone lives static Zone get current => _current;//... } 

This field differs from the global variable in that it cannot be changed just like that. To do this, use the set of native run * methods for the zone instance: run, runUnary, runBinary. In the simplest case, these methods temporarily record their zone in the _current field:


class Zone {//... R run<R>(R action()) { Zone previous=_current;//Place [this] zone in [_current] for a while _current=this; try { return action();//Then do stuff we wanted } finally { _current=previous;//Then revert current zone to previous } } } 

Before executing the callback, a new zone is written in the _current field, and after execution, the old one is returned. Running inside a zone means replacing the zone in the Zone.current field with the function selected for the duration of the run.


That's it! You can even sin and say that if the current field had a setter, then these two code fragments would perform similar actions:


class _FutureListener {//... FutureOr<T> handleValue(T value) => result._zone.runUnary(_callback, value); } class _FutureListener {//... FutureOr<T> handleValue(T value) { final previousZone=Zone.current; Zone.current=result._zone; final updatedValue=_callback(value); Zone.current=previousZone; return updatedValue; } } 

But an important difference still exists. The run * methods ensure that the current zone will not only change to the desired one, but will definitely return to the previous one when the callback finishes work. The change will certainly be temporary. In an imperative way, a developer can forget about switching or just find it unnecessary.


Now we understand that with the Intl service everything should be in order if it uses the zones under the hood. They will help to control the switching of the locale.


3. How Intl Service Uses Zones


Let's go back to the service and look at the withLocale method:


class Intl {//... static withLocale(String locale, Function() callback) =>//Create new zone with saved locale, then call callback inside it runZoned(callback, zoneValues: {#Intl.locale: locale}); } 

Immediately something new! Let's take it in order.


The runZoned function performs a callback inside the zone. But we do not provide a link to the zone, because runZoned creates a new zone and immediately executes a callback in it. And the run * methods callback in an already created zone.


The second thing we see is the zoneValues ​​map. A powerful tool that allows you to save data in the zone. In zoneValues, you can put any type of data using a unique key (the fragment uses Symbol).


Now let's see where the data is read:


class Intl {//... static String getCurrentLocale() {//Get locale from current zone var zoneLocale=Zone.current[#Intl.locale]; return zoneLocale == null ? _defaultLocale : zoneLocale; }//... } 

Voila! First, see if there is a locale identifier in the zone. Next, return either it or the default locale. If a locale is recorded in a zone, then it will be used.


Reading data from a zone occurs using the [] operator, which searches for a value in the zone itself and in its parents (more about them later). But the operator []=is not defined - that means the data assigned to the zone at creation cannot be changed. Because of this, the runZoned method is also used in the withLocale method:


class Intl {//... static withLocale(String locale, Function() callback) =>//Create new zone with saved locale, then call callback inside it runZoned(callback, zoneValues: {#Intl.locale: locale}); } 

A previously created zone would not fit here, so we are constantly creating a new zone with current data.


Finally, back to the very beginning:


//User has 'en' as default locale, but he works from Russia final fallbackLocale='ru'; Future<Duration> parseText(String userText) async =>//Try to parse user text await _parseText(userText) ??//Try to parse with 'ru' locale if default parsing failed await Intl.withLocale(fallbackLocale, () => _parseText(userText));//This is actual parser Future<Duration> _parseText(String userText) {//... } 

Now we know that the callback of the withLocale method will be executed in the zone, and the locale we need will be written in it. Moreover, each Future created in this zone will keep a link to the zone and will execute its callbacks in it. So the locale will change to the one specified each time before executing _parseText and come back right after executing _parseText. What you need!


We figured out how Future can interact with the zone. Future, Stream and Timer are “permeated” by such interactions with the zones along and across. Zones are aware of almost every step they take left or right, so they have such capabilities and power. From the following example, we can learn a little more about this.


FakeAsync and quick testing of asynchronous code


In our team, unit tests are written by all front-end developers. Sometimes we need to test code that works asynchronously. Out of the box, Dart has great testing tools. For example, the test package, which can work with asynchronous tests. When we need to wait, it is enough to return Future from the callback of the test function, and the tests will wait for the call to the expect function until Future is completed:


void main() { test('do some testing', () { return getAsyncResult().then((result) { expect(result, isTrue); }); }); } 

In all this splendor, we are not satisfied with one thing - to wait. Suddenly we test a stream that uses debounce per second, or the operation has a timer set to an hour. In such cases, it is worth “throwing” the mock from the outside, but this is not always possible.


We again approached the problem associated with asynchronous tasks. Only now we know that we can seek help from the zone. The authors of package: quiver have already done this and have written the FakeAsync utility.


It is used like this:


import 'package:quiver/testing/async.dart'; void main() { test('do some testing', () {//Make FakeAsync object and run async code with it FakeAsync().run((fakeAsync) { getAsyncResult().then((result) { expect(result, isTrue); });//Ask FakeAsync to flush all timers and microtasks fakeAsync.flushTimers(); }); }); } 

A FakeAsync object is created, with its help an asynchronous operation starts, all timers and microtasks are reset. And after that all the planned work is done right there.


Let's imagine ourselves Penn and Teller again and see how this magic works.


1. How Inside FakeAsync Works


We go to the run method and see this:


class FakeAsync {//... dynamic run(callback(FakeAsync self)) {//Make new zone if there wasn't any zone created before _zone ??= Zone.current.fork(specification: _zoneSpec); dynamic result;//Call the test callback inside custom zone _zone.runGuarded(() { result=callback(this); }); return result; } } 

Nothing complicated here - we create our zone, execute a callback in it and return the result.


And on the very first line we get acquainted with two new details - fork and specification.


When you start the application on Dart, we always already have one zone - root. It is so special that there is always access to it - through the Zone.root static getter. Each correct zone should be a fork of root, since all basic features are implemented in the root zone. Remember this run snippet that should change the current zone?


class Zone {//... R run<R>(R action()) { Zone previous=_current;//Place [this] zone in [_current] for a while _current=this; try { return action();//Then do stuff we wanted } finally { _current=previous;//Then revert current zone to previous } } } 

It was a hoax similar to the school rule "You cannot divide by zero." In fact, the code should look more like this snippet:


class _RootZone implements Zone {//Only root zone can change current zone//... R _run<R>(Zone self, ZoneDelegate parent, Zone zone, R action()) { Zone previous=Zone._current;//On this [zone] the.run() method was initially called Zone._current=zone; try { return action();//Then do stuff we wanted } finally { Zone._current=previous;//Then revert current zone to previous } } } 

This is how a real big guy was hiding under the mask of a regular zone!


All other zones are forks of the root zone. They are needed to add side effects to the main work.


2. How zoneSpecification affects zone behavior


ZoneSpecification is the second important public zone interface along with zoneValues:


abstract class ZoneSpecification {//All this handlers can be added during object creation//... HandleUncaughtErrorHandler get handleUncaughtError; RunHandler get run; RunUnaryHandler get runUnary; RunBinaryHandler get runBinary; RegisterCallbackHandler get registerCallback; RegisterUnaryCallbackHandler get registerUnaryCallback; RegisterBinaryCallbackHandler get registerBinaryCallback; ErrorCallbackHandler get errorCallback; ScheduleMicrotaskHandler get scheduleMicrotask; CreateTimerHandler get createTimer; CreatePeriodicTimerHandler get createPeriodicTimer; PrintHandler get print; ForkHandler get fork; } 

Before you are all possible ways to influence the behavior of the zone and, accordingly, the environment. Each of the specification getters is a handler function. For each handler, the first three arguments are the same, therefore, using one function as an example, several can be considered at once.


To do this, we will analyze a small abstract example - let's calculate how many times the code will ask you to execute something in the zone:


//This is the actual type of run handler typedef RunHandler=R Function<R>( Zone self,//Reference to the zone with this specification ZoneDelegate parent,//Object for delegating work to [self] parent zone Zone zone,//On this zone.run() method was initially called R Function() action,//The actual work we want to run in [zone] ); int _counter=0; final zone=Zone.current.fork( specification: ZoneSpecification(//This will be called within [zone.run(doWork);] run: <R>(self, parent, zone, action) {//RunHandler//Delegate an updated work to parent, so in addition//to the work being done, the counter will also increase parent.run(zone, () { _counter += 1; action(); }); }, ), ); void main() { zone.run(doWork); } 

Here we create a zone with our specification. All the callbacks called with the run method will pass through the run handler. The side effect of the handler is an increment of the global counter, so let's calculate it.


It’s important to understand a few nuances.


When we call any method on a zone, it calls the corresponding handler from its specification under the hood. The method helps minimize the number of arguments that we pass to the handler function. He substitutes the first three.


A forked zone is a tree node.When we call the zone method with any arguments, they must be "forwarded" from the node to the root through each parent. If the chain breaks, then the work we are counting on may not be completed, since nothing will reach the root zone. This makes sense to cut off unnecessary work, but not in this example.


The first argument is the zone that owns the handler.


The second argument is the delegate of the parent zone, which is used to "forward" further. If we do not call it, the root zone will not be able to switch the current zone to the one we need, and there is no other way to write to the _current field.


Third argument is the area in which the call originally occurred. We sometimes need to know this, because the zone that owns the handler may not be the last in the hierarchy. When this argument reaches the root, the value of the current zone in the example will change exactly on it.


All this doesn’t sound very easy, but usually enough to practice. Here is another small example in the form of a chart:


ITKarma picture

A fictitious hierarchy is shown here: you can see in steps what will happen if you work in zone B with the current zone D


Similarly, the first arguments work for all other handlers, which means the same patterns apply to them.


3. What side effects do FakeAsync need


Now back to FakeAsync. As we remember, the current zone is forked in the run method, and a new one is created using the specification. Let's look at her:


class FakeAsync {//... ZoneSpecification get _zoneSpec => ZoneSpecification(//... scheduleMicrotask: (_, __, ___, Function microtask) { _microtasks.add(microtask);//Just save callback }, createTimer: (_, __, ___, Duration duration, Function callback) {//Save timer that can immediately provide its callback to us var timer=_FakeTimer._(duration, callback, isPeriodic, this); _timers.add(timer); return timer; }, ); } 

The first to see is the scheduleMicrotask handler. It will be called when something asks you to schedule work in the microtask, so that it runs immediately after the current thread of execution. For example, Future is obliged to process the result in microtask, so each stand-alone Future will at least once ask the zone to do this. And not only him: asynchronous Stream also actively uses this.


At FakeAsync, with the planning of microtusks, everything is simple - they are stored synchronously under the hood for the future without any questions.


Next is the createTimer handler. You can call the createTimer method on the zone to, strangely enough, get a Timer object. You ask: "What about his own constructor?" And so:


abstract class Timer { factory Timer(Duration duration, void callback()) {//Create timer with current zone return Zone.current .createTimer(duration, Zone.current.bindCallbackGuarded(callback)); }//...//Create timer from environment external static Timer _createTimer(Duration duration, void callback()); } class _RootZone implements Zone {//... Timer createTimer(Duration duration, void f()) { return Timer._createTimer(duration, f); } } 

Dart timer is always just a wrapper over the environment timer, but the zone wants to be able to influence the process of its creation. For example, if you need to create a dummy timer that counts nothing. That's exactly what FakeAsync does: it creates _FakeTimer, whose only task is to save the callback passed to it.


class FakeAsync {//... ZoneSpecification get _zoneSpec => ZoneSpecification(//... scheduleMicrotask: (_, __, ___, Function microtask) { _microtasks.add(microtask);//Just save callback }, createTimer: (_, __, ___, Duration duration, Function callback) {//Save timer that can immediately provide its callback to us var timer=_FakeTimer._(duration, callback, isPeriodic, this); _timers.add(timer); return timer; }, ); } 

Unlike our run handler example, here the delegate to the parent is not used in any way. This is because we do not need to really plan anything. The chain has broken, there will be no timers and microtucks in the test environment. After all, the task of the FakeAsync zone is to collect all the callbacks for timers and micro-clocks, which are planned by any asynchronous structures or actions during code execution.


All this in order to then execute them synchronously in the right order! This will happen when the flushTimers method is called:


class FakeAsync {//... void flushTimers() {//Call timer callback for every saved timer while (_timers.isNotEmpty) { final timer=_timers.removeFirst(); timer._callback(timer);//Call every microtask after processing each timer _drainMicrotasks(); } } void _drainMicrotasks() { while (_microtasks.isNotEmpty) { final microtask=_microtasks.removeFirst(); microtask(); } } } 

The method iterates over the scheduled timers, calls their callbacks synchronously, then iterates over all the microtasks planned by them and does this until there are no tasks left. It turns out that all asynchronous tasks are performed synchronously!


We figured out how zones are created and talked about ZoneSpecification. But there are still many not mentioned handlers.


Here's what else they can do:


  • catch and handle errors (handleUncaughtError, errorCallback);
  • modify the callback at the creation stage (registerCallback *);
  • create a repeating timer (createPeriodicTimer);
  • influence standard logging (print);
  • influence the fork process.

Perhaps that's all for today. In this article, we looked at examples of using zones in two small libraries and, hopefully, understood their basic capabilities.In the next article, we will analyze interesting and not the most obvious details on the example of AngularDart - a framework that has grown more than others in our front-end life.


I will be happy to answer questions!

.

Source