Or how assumptions can ruin the performance of your app.
We have a fairly large Angular app using PixiJS for a lot of the rendering. Due to animations and constant updates to the canvas area, we’ve had to optimize as much as possible to keep the app running reasonably well on tablets.
The standard optimizations have all been applied.
Almost all components are set to ChangeDetectionStrategy.onPush
, async and broadcasts use runOutsideAngular
, logging is suppressed in production, etc.
We’ve leveraged PixiJS texture cache and don’t make any .update()
calls on any display objects, and the render()
call on requestAnimationFrame
is run outside angular.
One oversight is size of certain call stacks when major data changing events occur, but other than that things seem fairly contained.
And yet we still had users noticing performance issues.
Lots of ideas for this. Use web workers for transport, distribute chunks of functionality across separate call stacks to reduce frame drop, make absolutely all components use ChangeDetectionStrategy.onPush
, detach components and reattach for updates…
A number of investigations into Angular and PixiJS performance issues were made.
Then, while investigating the memory and timing costs of angular binding, RxJS subjects, and Angular bound subjects, I created a quick Angular CLI app. After setting everything up I checked a performance recording and noticed a distinct shortage of change detection runs.
I should mention here that in order to keep our app’s frame rate decent we use requestAnimationFrame
on a loop to render the PixiJS stage. What we didn’t realize was that each RAF call was triggering change detection.
But why?
Understanding How Your Tools Work is Important
Enter Zone.js. I challenge any Angular developer to explain how Zone.js works and how Angular leverages Zones.
Here’s the short version: Zone.js changes the prototypes of all async calls in the browser api (setTimeout
, etc.) so that a) context can be shared across call stacks and b) that hooks can be emitted at the end of micro, macro, and event tasks.
Cool. And Angular?
Angular creates (forks) a new zone called NgZone
which is a child of the root zone. This way when those end-of-task hooks are emitted, angular can run change detection on the off chance that a call has resulted in something, somewhere, changing. Which is great when you don’t have a ton of components. Not so much when you have lots.
And I’m not knocking Angular’s change detection. given the size of our app it was actually doing an impressive job running.
This issue is with the memory allocations required to run it. We ended up getting 12Mb garbage collections every 900ms.
Ok, and PixiJS?
Any event listener you register to a PixiJS object doesn’t hit the browser api. It hits PixiJS’s custom handling of events via their InteractionManager
. And it’s the InteractionManager
that registers a pointermove
event to the document… which has been patched by Zone …which has been forked by Angular.
Ok. So our third party library is triggering change detection in our framework on mouse move — another problem — but that still doesn’t explain what’s triggering CD on every animation frame.
side note update: we resolved this using `Zone_symbolBLACK_LISTED_EVENTS`
Seriously Now, Understanding How Your Tools Work is Important
PixiJS has a nice way of handling the scenario of a DisplayObject
animating past your mouse. Under normal conditions you wouldn’t get a mouseover
event because the mouse didn’t move, ergo no event.
PixiJS is clever though. It’s using that pointermove
event to cache the last pointer event. Then on the next tick of pixi’s event loop it will check a moving DisplayObject
’s position against the cached pointer event’s location, and fire a mouseover
event if the display object is under the mouse.
Ok, next tick of Pixi’s event loop though?
Turns out in order to animate all their DisplayObjects
they have a shared ticker running … wait for it … as a callback in requestAnimationFrame
.
So now we not only have our render loop happening 60 times a second, we also have Pixi’s ticker triggering change detection 60 times a second. Not awesome.
First prize is to get PixiJS to run in the root zone instead of Angular’s NgZone
, but in the meantime these 3 lines of code stop all the unnecessary change detection runs:
const ticker = Pixi.shared.ticker;
ticker.autoStart = false;
ticker.stop();
We now have 1/3 of the garbage collections as before.
Top comments (2)
Nice writeup. Not sure if you ended with a call for help, but I remember looking through the Angular Skyhook source code and it uses its own zone rather than the
NgZone
angular uses.The maintainer has done a nice job commenting everything, including the custom zone setup and explaining how the interactions between the two different zones work.
May be helpful to look through: packages/core/src/connector.servic...
Thanks, I'll take a look.