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.
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`
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
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.