Optimizing industrial data displays in Flutter: a deep dive into ListView.Builder and custom RenderObjects

Jan 17, 2024

Werner Scholtz

1. Introduction

Imagine harnessing the power of Flutter to construct robust industrial software tailored for desktop computers and embedded devices. In the course of development, you're likely to encounter scenarios demanding the presentation of extensive and intricate data in a list format.

Picture a dynamic, scrollable list adorned with multiple columns, akin to the illustration below:

drawing_1

Now, the apparent solution might be to leverage Flutter's ListView widget. How challenging could it be to implement? Let's delve into this approach and explore its implications.

2. ListView.builder

The ListView.builder widget is a very powerful and elegant piece of technology , it allows you to continuously and smoothly scroll through a ridiculous amount of items (See more in this video)

Let's have a closer look at this magic by exploring the life-cycle of the widgets that it displays.

drawing_2

Creation:
- Lazily provides list elements, states, and render objects during the layout phase.
- Elements, states, and render objects are instantiated as needed while the list is being laid out.

Destruction:
- Occurs when a child is scrolled out of view in the element subtree.
- States and render objects associated with the scrolled-out child are destroyed.
- Upon scrolling back, a new child at the same position triggers the lazy recreation of elements, states, and render objects for seamless continuity.

This is an example of the ListView.builder that I will be using:

ListView.builder(
  itemCount: widget.items.length,
  itemExtent: 24.0,
  itemBuilder: (context, index) {
    final data = widget.items[index];
    return DataTile(
      data: data,
    );
  },
),

(full implementation)

Keep in mind that you can significantly enhance the performance and responsiveness of a ListView.builder by providing it with either an itemExtent or a prototypeItem.

Let's take a closer look at the build function of the DataTile in the snippet above:

@override
Widget build(BuildContext context) {
  // Generate the text widgets for each field.
  final columns = data.columns.indexed.map(
    (e) {
      if (e.$1 == 0) {
        // The first column is the id, so give it a fixed width. 
        return SizedBox(
          width: 52,
          child: Text(e.$2),
        );
      }
      return Text(e.$2);
    },
  ).toList();

  return Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: columns,
  );
}

(full implementation)

Just to give you an idea of what the DataTile looks like:

drawing_3

3. The Problem

Now, let's take it for a test spin. Initially, everything seems smooth, but as we start scrolling, it reveals a somewhat jittery performance.

data tile

This content can not be viewed with respect to your privacy. Allow statistics and marketing in your or view on YouTube.

Let's be real, presenting this to our users would be a disgrace.

Let's connect it to the profiler and assess the results. Keep in mind that utilising the performance profiler will negatively impact the overall performance of the application.

For the profiling runs I just scrolled down the list (this is true for all of them).

The first profiling run shows that the layout time for a single frame is taking up the most time.

image_1

Let's take a closer look at why the layout times are so long, the profiler offers a few options under "enhance tracing" for this run I have enabled "Track Widget Builds" , "Track Layouts" and "Track Paints".

Here you can see a single frame with all of the enhanced tracing options enabled. I have highlighted a single DataTile widget for us to look at in more depth.

image_2-1
image_2-2

Looking at the layout of a single DataTile we can see that the RenderFlex (Row Widget) takes a considerable amount of time because of all the RenderParagraph's (Text Widget) it has to layout.

image_3-1
image_3-2

Just looking at the Performance profiler does not give a complete picture, but it does highlight the fact that the RenderParagraph's are the main reason for the long layout times.

For interest sake a look at this from another angle using the CPU profiler. For this test I scrolled from the top to the bottom of the list using the scroll bar.

The CPU is spending the most time percentage wise on the FfiTrampoline___dispose$Method which is calling the _NativeParagraph._dispose method, the next thing is FfiTrampoline__build$Method, so it seems that most of the CPU's time is spent on disposing of and building widgets.

image_4

4. Solution

There are 2 things that we can try to improve the situation:

  1. Reduce the amount of widgets per item in the list.
  2. Reduce the amount of overhead needed to create/layout the widgets.

I am going to attempt to do this by making use of a custom RenderObject. A custom render object will give me a control over the layout algorithm used to display the text. It will also allow me to reduce the amount of RenderObject's that have to be created for each item in the list. This approach entails sacrificing most of the convenient features of a RenderParagraph and RenderFlex, this is a trade-off made in pursuit of better performance.

Find out more about RenderObjects in this great video here.
If you don't understand the next section I recommend watching the video above for more detail on performLayout() and paint().

Here is an example of the RenderObject that I will be using to improve the ListView's performance:

class CustomDataTile extends LeafRenderObjectWidget {
  const CustomDataTile({
    super.key,
    required this.data,
  });
 
  final Data data;
 
  @override
  RenderObject createRenderObject(BuildContext context) {
    return CustomDataRenderObject(data: data);
  }
 
  @override
  void updateRenderObject(
    BuildContext context,
    CustomDataRenderObject renderObject,
  ) {
    renderObject.data = data;
  }
}

(full implementation)

Here is the CustomDataRenderBox's performLayout() that I will be using:

final double idWidth = 52;

/// The width of the field text painters.
double get fieldTextPainterWidth =>
      (constraints.maxWidth - idWidth) / numberOfItems;

@override
void performLayout() {
  // Layout the text painters.
  for (var i = 0; i < _textPainters.length; i++) {
    if (i == 0) {
      // Layout the id text painter, with a fixed width.
      _textPainters[i].layout(maxWidth: idWidth);
    } else {
      // Layout the field text painters.
      _textPainters[i].layout(maxWidth: fieldTextPainterWidth);
    }
  }

  // Use the full width of the constraints.
  final width = constraints.maxWidth;

  // Use the height of the first text painter.
  final height = _textPainters.first.height;

  // Set the size of this render object.
  size = constraints.constrain(
    Size(width, height),
  );
}

(full implementation)

The performLayout() function lays out the ID textPainter with a fixed width. The Field textPainters are all laid out with the calculated width.

Here is the paint() function of the CustomDataRenderBox's:

@override
void paint(PaintingContext context, Offset offset) {
  // Loop through the text painters and paint them.
  for (var i = 0; i < _textPainters.length; i++) {
    final textPainter = _textPainters[i];
    if (i == 0) {
      // Paint the id text painter at the offset.
      textPainter.paint(context.canvas, offset);
    } else {
      // Calculate the x position for the field text painter.
      final textPainterX = idWidth + (fieldTextPainterWidth * i);

      // Calculate the offset for the field text painter.
      final textPainterOffset = Offset(textPainterX, 0) + offset;

      // Paint the field text painter.
      textPainter.paint(context.canvas, textPainterOffset);
    }
  }
}

(full implementation)

The paint function paints the ID textpainter at the initial Offset. Then it calculates the offset for each of the Field textPainter's and paints them.

To give you an idea of what the CustomDataTile looks like:

drawing_4

Here I swapped out the DataTile with the CustomDataTile in the ListView.builder:

ListView.builder(,
  itemExtent: 24.0,
  itemCount: widget.items.length,
  itemBuilder: (context, index) {
    final data = widget.items[index];
    return CustomDataTile(
      data: data,
    );
  },
),

(full implementation)

It appears to be less jittery than the DataTile.

custom data tile

This content can not be viewed with respect to your privacy. Allow statistics and marketing in your or view on YouTube.

Let's fire up the trusty profiler once more without any enhancements and take a closer look at the results.

The layout time of the slowest frame is much faster than before (yay).

image_5

Let's dig a bit deeper to find out why it is faster.

Here is a single frame with all the enhance tracing options enabled again, I've highlighted a CustomDataTile in the frame for us to take a closer look at.

image_6-1
image_6-2

By examining the CustomDataTile it is clear that it takes less time to layout than the DataTile. This is because:

  1. It has much less overhead as it is only one Widget compared to the DataTile with multiple Widgets.
  2. It has slightly less layout calculations to perform than the DataTile.
image_7-1
image_7-2

Now let's see how this has affected the CPU times, for this test I did roughly the same as I did for the DataTile just scrolling to the bottom using the scroll bar.

Interestingly it seems that percentage wise the CPU is spending more time on theFfiTrampoline___dispose$Method and FfiTrampoline__build$Method which is a bit deceiving but is in fact what should happen: why is this ?

The CPU is spending less time doing something else and more time creating and destroying Widgets. Keep in mind that in this case the rate of widget creation and destruction is directly correlated to the scrolling performance. In the previous example the CPU could not keep up with with the creation/destruction of Widget, but now it can do so.

image_8

Let's compare the time it takes to layout the DataTile and CustomDataTile. The layout time of the CustomDataTile is much faster than the DataTile's layout time.

DataTile:

image_9

CustomDataTile:

image_10

4. Testing

Acknowledging the need for a more repeatable assessment, I've devised an integration test that removes inconsistencies introduced by manual scrolling. This test records the performance metrics of scrolling through a ListView and exports a summary to a .csv file.
There are 2 integration tests one for the DataTile and one for the CustomDataTile.

To run the tests yourself just clone this repository and check the readme for further details.

Here is a quick summary of the results I got:

Table showing the average results obtained from multiple integration tests:

Bild1

(full test results)

5. Conclusion

My implementation of the RenderObject has shown a significant improvement over the stock RenderObjects provided by flutter, however it has some glaring issues when it comes to Flexibility as it cannot adapt as well to changes screen/window sizes compared to the stock RenderObejcts. It also comes with the drawback that it takes more time and effort to implement.

Leave a Comment

Your Email address will not be published

KDAB is committed to ensuring that your privacy is protected.

  • Only the above data is collected about you when you fill out this form.
  • The data will be stored securely.
  • The data will only be used to contact you about possible business together.
  • If we do not engage in business within 3 years, your personal data will be erased from our systems.
  • If you wish for us to erase it earlier, email us at info@kdab.com.

For more information about our Privacy Policy, please read our privacy policy