Welcome to my session: Lazy Flutter Performance.
In this talk, I’m going to argue that when you look at the performance of your app
and the smoothness of your animations on the screen,
just as important to what’s slowing things down
is what's just off the screen.
I want to look at lazily loading this content so that it’s ready to go when it’s needed
and how the Flutter APIs guide you towards doing this
in a performant way, by default, out of the box.
So to explain a little bit with a real example of a complex app,
this here is the Stadia App, and it’s built in Flutter.
And here we have a big list of games with some rich images
and animations when the animation happens
and we need that list to scroll at 60 frames per second.
The hard part isn’t what you see in the screenshot;
that's really just the tip of the iceberg.
The hard part are all of assets that are lurking
just under the surface ready to slow everything down.
These other items in the list need to be ready
to go on the screen at any time the user scrolls,
and it needs to do it at 60 frames per second.
This is a pretty hard problem, but Flutter makes it easy to solve.
It guides you towards writing a fast app but it also gives you the tools that you need
to make your own custom implementations that are equally as fast.
So, in this talk, we’re going to take a look at a few examples of all of the above.
I am Justin; I’m a software engineer on the Flutter team at Google,
and I spend most of my time working on GitHub,
contributing to the framework and helping move things along,
but one thing that I think about a lot in the scope of my work
is how do we make widgets and Flutter's APIs
both easy to use for the usual cases
but also very customizable and powerful when needed?
I think this is a really hard duality to get right.
We spend a lot of time working on this at Flutter,
and I think it applies a lot to today’s topic with Lazy Performance.
Throughout the course of the talk, we’re going to look at 3 different examples.
The first is similar to the screenshot we saw of the Stadia App.
We’ve got a one-dimensional ListView that scrolls.
Next, we’re going to look at a two-dimensional grid
and how the same principles of performance and lazy loading
can apply to different widgets like this.
And the third example that I want to look at is a fun one:
doing procedural generation to generate content randomly right when it’s ready,
without having to keep any of that stuff in memory.
The first thing I want to do is try to reproduce the problems.
So, I’ve got this Marty McFly animated SVG asset here that I got from Rive.
It’s animated, it’s a little bit heavy,
so it’s going to help us slow things down and try this out.
And we’re going to put a bunch of these on the screen in a list.
First of all, we’ve got a red X here because this is just the naive implementation
of how you might do this in Flutter.
With its SingleChildScrollView,
if we just give it a list of children, it’s going to render all of these.
Flutter’s going to keep track of every widget created
whether or not it’s on the screen.
So let's take a look at the code and try running this.
So, here's our list of Martys. The Marty animation is pretty smooth.
The scrolling also pretty smooth and we just have a short list there of 10.
So let’s bump that up to 100 and see what happens.
I'll restart it.
Jump back to the app.
All right, so I’m clicking to open that demo.
There we go, so we’re opening here.
We can already see that the Marty animation is much slower
and the scrolling is also very stuttery,
and we can see that there are a lot more items
in the list this time with the gradient here.
So we are up to a hundred.
So, if I bumped this up even higher, to 1,000 and try that again, here we go,
it’s restarted, and I’m clicking into that, and then just as a spoiler here,
what’s going to happen is this is going to wait for about 60 seconds.
It will either crash,
or it will eventually open the list of Martys and it will be completely unusable.
So I’m going to go ahead and kill that here and move on.
So, here, I have already taken a look at the profiling,
the memory impact for all of the demos that we did.
Ten is roughly taking 15 megabytes, a hundred in the range of 80,
and a thousand, crash; we couldn’t even grab that information at all.
So this is about what we’d expect between 10 and 100.
We have almost an order of magnitude increase in the amount of memory being used
because we have an order of magnitude increase
in the number of assets we’re looking at,
minus a little bit for the rest of the application.
So now let’s look at how we can do this in a performant way by using a simple widget
that handles a lot of performance optimizations for us out of the box.
So we’re going to look at ListView.builder.
And here we have a performant way of handling this in the code.
Instead of a list of children, we've now got an item builder.
And this is a function, which is going to return a single item in the list.
This allows our ListView internally to call this only when it needs to render that item
that it needs to display just in time.
So let’s look at this in the code and try running it and see how performance changes.
So, here in the code, now we've got ListView.builder
with an itemBuilder instead of a child.
So, here we're returning a single child, which is the Marty at the current index.
And back up to our itemCount.
Again, we’re going to start here at 100, and you see it open quite quickly,
much faster than our slow naive demo with 100 Martys,
and the animation looks smooth,
and the scrolling also feels pretty good.
You can see we’ve got a lot of Martys here. We’ve got a hundred, just like we said.
So let’s bump this up now to 1000.
And in the previous demo with SingleChildScrollView,
we could not even run this,
but now that I’ve restarted, let’s see how it does with ListView.builder.
And there we go. It opened just about as quick as the 100.
The animation still looks smooth, and the scrolling also pretty good.
If I get going really quickly, there we go, I can get a little bit of pop in there.
So we have some white screens as the asset is loaded
because I'm loading all of these assets
on their own separately for the sake of the demo.
You can see how slowly the gradient changes because we do have 1000 Martys there.
Let’s take a look at what was happening under the hood in both of these widgets.
So, starting with the naive approach and the SingleChildScrollView,
this illustration shows all of the Martys that were being kept track of by Flutter
off-screen in black and white, even though they weren’t visible to the user,
and only the colorful Martys in the center were visible,
we still had paid the price for all those Martys,
even when the list was enormous, maybe even a thousand Martys long.
So, with ListView.builder, what we did is chop off all of the extra Martys
and we only rendered a few off-screen Martys that were likely to be scrolled
onto the screen in the very near future.
And this allowed us to get rid of the huge list of up to 1000 Martys
and just handle the next few constant number of upcoming Martys.
And so, the performance we saw there, I went ahead and tried this out in DevTools
and grabbed the memory snapshot for each.
You can see that each run, whether it was 10, 100 or 1000,
was taking just about the same amount of memory.
So that’s also what we would expect because the number of Martys
that were actually being handled by Flutter didn’t change with the number in the list,
because Flutter was holding them just off screen and getting rid of all the extras
and shuffling them around as the user scrolled.
All right. We saw our one-dimensional lists, like the Stadia app,
but let’s take a look at a slightly more complex example using InteractiveViewer.
So, here we’ve got a two-dimensional grid of Martys.
And what InteractiveViewer does is, it allows us to grab the screen
and pan around and view different areas of the screen not visible at the beginning.
This is going to have a similar problem that we saw before,
and the code is also going to look similar.
So, in this naive approach here,
when we just pass a child to InteractiveViewer,
Flutter is going to build that entire child subtree,
in this case a big list of Martys, rather, a big grid of Martys.
And what’s going to happen here is that all of these black-and-white Martys
are going to be handled by Flutter, just like in the one-dimensional case.
We’re handling way more than we actually need to show the user at any one time.
So, similarly to how we improve performance by using the ListView.builder widget,
we can use an InteractiveViewer.builder
and have a similar pattern here to improve the performance.
With our builder function, instead of having Flutter handle everything for us,
InteractiveViewer is a little bit more of a generic widget.
It doesn’t assume anything about the content that you’re rendering.
It doesn’t know that it has a grid, so it’s up to us to write our own builder
in a way that’s going to take advantage of any performance optimizations we can make.
Here, we’ve got our builder with a Quad representing the viewport.
That’s just four points in space that represent what the user can see at any time.
We’re just going to do a simple optimization
and check if any given cell is currently visible or not.
If it’s visible, we’ll display the Marty. If it’s not, we’ll just render an empty cell.
All right, so here, we’ve got our naive 2D example.
We have our InteractiveViewer,
and we’re just using a child once again, just like in SingleChildScrollView.
So all of this is going to be rendered, this entire widget tree.
It’s a grid containing a bunch of Martys,
and we're basing this on column count and row count.
So up here, we’ve got a 10x10 grid totaling 100 Martys.
So it’s about as long as the 100 naive SingleChildScrollView example.
And we can see the performance of the Marty animation is also similar,
pretty stuttery, as that example, and the scrolling as well.
Here, we’ve got InteractiveViewer.builder with our faster example.
We're using a builder parameter rather than a child.
So this is called with a viewport, and we’re able to use isCellVisible here
to decide whether or not we want to display the Marty.
If Marty is visible on the screen, we’re going to build him;
if not, we're building an empty cell.
And once again, up here at the top,
you’ve got a row count and column count of 10.
So we're doing 100 Martys.
And let's go ahead and take a look at the running app.
So, down at the bottom, the fourth,
we're looking at the InteractiveViewer builder example.
I’ve clicked that, and here it is. It opened.
A little bit of a delay but still much quicker
than our 100 Marty all at the same time example.
This one, as you can see, there's some pop in as I scroll.
So this is loading more Martys as I scroll and disposing the old ones as I go.
And the performance of the animation is still quite good
besides the pop-in that happens here as I load the new Martys as we pan around.
It’s not doing quite as complicated as the ListView.builder
with the shuffling of the cells in and out as the user pans,
but it is giving us this great performance here
when we look at the animations and pan within the area visible.
So it is a large step forward.
Okay, so, I've gone ahead and grabbed the memory snapshot
for these two examples as well.
And, as you can see, the naive approach uses about three times the amount of memory
as the InteractiveViewer.builder approach, which again is about as we'd expect
as we are rendering a lot more Martys on the screen
but we're also having a big savings in the InteractieViewer.builder
by getting rid of the rest of the 100 in the total grid.
So we’ve got roughly a proportional level of memory use
compared to the amount of Martys we’re saving by not rendering off screen.
To take a closer look at what’s happening here,
in our naive approach, we have all these black-and-white Martys
which are representing what Flutter is rendering internally
and not displaying to the user.
In our InteractiveViewer.builder approach, what we do is,
we just say if that Marty is off screen, we’re not going to render it,
but we are still rendering the empty grid cell.
So it’s not quite as optimized as what we saw in ListView.builder
because we’re not shuffling around and generating these cells as they’re needed,
we're just not rendering the contents of them.
This kind of approach, it is a lot more custom,
but it gives us the opportunity to do some fancier things as well.
So, how big can we go with this approach?
If we do something that is generated only when the user needs it,
how far can we push this kind of performance?
So this brings us to our procedural generation example.
I want to build a map for Marty, and I want to make this map as big as we can.
Ideally, infinitely big.
I want to generate this based on some seed only when the part of the map
that we're looking at is needed.
As a warning,
we’re going to have some programmer art here, such as the grass that you see.
Fortunately, we’ve got our great Marty asset from Rive,
but there’s gonna be a few assets I had to draw myself.
I wanna look at the performance optimizations that we've been able to make here.
We’re going to break up this map into a bunch of nested tiles.
So, the green field that’s visible itself will be broken up into tiles
where each tile has a few pieces of grass in it,
and then we’re going to group those into bigger tiles,
as we see here in black and white,
that we can manage outside the context of what's visible on the screen.
The first optimization we can make here with this approach
is just to not load any assets that are not on the screen.
Just like we did before, we’re going to check if the asset
is on the screen or not, and if it’s not, we are going to leave it out.
The other thing we can do, similar to ListView.builder,
we're going to get rid of any tiles that are far enough away from the viewport
that they're not likely to be immediately panned into the screen.
We’re just going to render a 3x3 grid surrounding the viewport,
and as the user pans around this much larger map,
we’re going to adjust what tiles are visible.
So we can dispose the downstream tiles that the user is scrolling away from,
and we can load the next tiles coming up and keep the 3x3 grid
always surrounding the viewport wherever it goes.
Here we are in the middle of some grass and some water,
and we have a randomly placed Marty here,
all of this being procedurally generated based on some seeds.
I can scroll around and just keep going as far as I want.
I’ve got some water here,
but then eventually, we get to another continent of land here.
We’ve got another randomly placed Marty.
This kind of just keeps going on and on.
We can generate that.
There’s another Marty; he’s popping up with his hoverboard.
We can just keep going in any direction and generating content,
and the swapping of the nine tiles is happening just outside of the screen
as we do this to keep things performant no matter how far we go.
But I want to talk about an even more complex optimization that we can make here.
So, InteractiveViewer does panning like this, but it also allows us to zoom.
And zooming in works nice but zooming out is going to expand so far
that our nine-cell grid will eventually be too small to cover the entire screen.
What we can do here is to make another optimization
and start grouping these cells into groups of 100
and simplifying them into something simpler
so that we take our parent level and we make it
shrunk down into basically another child level, and we re-simplify everything.
As I zoom out here on the continent, I can replace the entire continent
with just a big green cell with some clouds of more programmer art.
And as I continue to zoom out, I can see we've got a whole planet here,
a bunch of ocean and continents.
And as I continue to zoom out, I can simplify that yet again
and just keep doing those levels of simplification as we go
and really make this zoom as far out as I want to
without running to the problem of having too few cells on screen.
Here, I can see, if I keep going, we’re actually part of a solar system here.
That was our planet, but I can choose another planet
and just zoom back in on any of these things,
choose a continent and zoom down on that,
and it will generate our low-level content once again
with a nice animation performance, nice scrolling performance.
All right, let's take a look back at all of the things that we saw today.
So, first, we looked at an example of a ListView
that you might see pretty commonly in a lot of apps
and how Flutter allowed us to get some really interesting performance optimizations in
with just a few lines of code.
We also saw how we can use the similar builder concept in a two-dimensional grid
and how we can apply those similar concepts
to get performance gains on a totally unrelated widget.
And then we took that a step further and built something totally custom
with the procedurally generated map and saw how even a lot of the optimizations
that are made in a one-dimensional ListView can be applied to something custom like that.
Like I said in the beginning, at Flutter, we think a lot about making things
that are easy to use yet also powerful.
And chances are, whatever experience you want to build,
you can probably do it with Flutter,
and hopefully, Flutter will guide you towards doing so in a performant way out of the box.
All right, I’ve got a list of the documentation here
on the right for each of the widgets we took a look at today.
And I've got the code for the demos that we ran on GitHub
with a live example running right now, and I encourage you to check it out.
And if you haven’t already, be sure to take a look at
all of the other talks from I/O this year.
Thank you, guys.