Drawing a thousand nodes with minimal state changes

In the desktop application I am working on, there is a need to display over a thousand small graphical elements on the screen. The user manipulates these nodes using the mouse and keyboard. Currently the nodes are represented in a long list of widgets that is a child within a widget. If the state of only one node in the list changes, I want to ensure that only the minimum of rebuilding takes place. Is there a way to minimise rebuilding when the grahical elements are in a list?

1 Like

How do you know if a widget needs rebuilding? Is it strictly spatial, or are there data relationships between nodes?

It is a wysiwyg editor, so anything selected or changed or moved would need to be updated. Elements can be connected, and they can be marked when necessary, so there is a change of the elements’s state. Other unconnected elements would need to be redrawn if they are in the way of other elements moving, but I don’t know if this happens through rebuilding at the Flutter level or redrawing at the Impeller level. I’ve read the Flutter tutorials on the three trees but not sure how to make it work with minimal rebuilding.

1 Like

Afaik, you can avoid re-build in this scenario only by using const widgets. But I’m sure that’s not practical in your case.

That said: build() is generally not expensive. And the state is not created anew as long as it’s the same widget at the same index of the list.

If you want and you really see performance problems from these rebuilds, you could try having several stacks (instead of one sttack with 1000 widgets, you have 2 with 500). But that definitely needs to be perf tested. It’s possible the effect would be negligible or you would trade the rebuild times for something worse (compositing?).

1 Like

The ultimate solution, if you really have performance problems, is going lower than the widget layer. Create your own render object and such. You could have a look at how data table is implemented. It’s lower level than widgets, but still miles above something like Skia, all Dart, and well documented.

3 Likes

Thanks, this is an interesting suggestion. I am accustomed to working at a level below Flutter for graphics, i.e. at an imperative instead of declarative level, so I will give this a try.

1 Like

Without known more about your particular setup and how you manage data, I
can’t give you a more tailored advice.

Based on the information here, I assume the following:

  • You are currently using a Stack to render your nodes
  • Nodes data is saved in a list
  • The list is passed to a wrapper of some sorts that creates a Stack with widgets
    inflated with nodes

So something along the lines:

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

@immutable
class NodeData {
  final String id;
  final Offset offset;

  const NodeData(this.id, this.offset);
}

class Display extends StatelessWidget {
  final ValueListenable<List<NodeData>> nodes;

  const Display({super.key, required this.nodes});

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(valueListenable: nodes, builder: _buildStack);
  }

  Widget _buildStack(BuildContext context, List<NodeData> value, Widget? _) {
    return Stack(
      children: [
        for (var item in value) NodeDisplay(data: item),
      ],
    );
  }
}

class NodeDisplay extends StatelessWidget {
  final NodeData data;

  const NodeDisplay({
    super.key,
    required this.data,
  });

  @override
  Widget build(BuildContext context) {
    return Positioned(
      top: data.offset.dx,
      bottom: data.offset.dy,
      child: const SizedBox(),
    );
  }
}

If this is the case, you could make the Display into a stateful widget
and cache previously built nodes.

There are two ways to avoid a widget rebuilding:

  1. Use a const constructor
  2. Reuse the same widget instance

Both of these are actually the same thing. If a given widget is identical to
the previous one, flutter will stop rebuilding at it. So if you have a list of
node widgets, and only one changes, you can only rebuild that one, and reuse the
rest from the previous build.

However, with Stacks and other multi-child widgets you have to use keys if the
order of nodes changes, this way flutter can actually find the correct widget to
reuse.

So in this case you could do something like this:

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

@immutable
class NodeData {
  final String id;
  final Offset offset;

  const NodeData(this.id, this.offset);
}

class Display extends StatelessWidget {
  final ValueListenable<List<NodeData>> nodes;

  const Display({super.key, required this.nodes});

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(valueListenable: nodes, builder: _buildStack);
  }

  Widget _buildStack(BuildContext context, List<NodeData> value, Widget? _) {
    return _Stack(value);
  }
}

class _Stack extends StatefulWidget {
  final List<NodeData> nodes;

  const _Stack(this.nodes);

  @override
  State<_Stack> createState() => _StackState();
}

class _StackState extends State<_Stack> {
  // Using HashMap because order isn't important
  //
  // This assumes NodeData is immutable and either:
  // - has proper equality implemented
  // - or you never update a node that wasn't changed
  var _children = HashMap<NodeData, Widget>();

  @override
  Widget build(BuildContext context) {
    return Stack(children: _buildChildren());
  }

  List<Widget> _buildChildren() {
    // create a new lookup map
    final next = HashMap<NodeData, Widget>();

    final children = <Widget>[];
    for (var node in widget.nodes) {
      final child =
          _children[node] ?? NodeDisplay(key: Key(node.id), data: node);
      next[node] = child;
      children.add(child);
    }

    // let the old map be GC'd
    _children = next;
    return children;
  }
}

class NodeDisplay extends StatelessWidget {
  final NodeData data;

  const NodeDisplay({
    super.key,
    required this.data,
  });

  @override
  Widget build(BuildContext context) {
    return Positioned(
      top: data.offset.dx,
      bottom: data.offset.dy,
      child: const SizedBox(),
    );
  }
}

How this works is that when flutter rebuilds the stack it does something
along the lines of:

  • Let me check each child of the Stack
  • Let me match these new children with the old ones based on their keys
  • Is a Widget pair identical? If so, great reuse the Element and don’t rebuild it
  • If not, it either creates a new Element or rebuilt’s it (depends on the .runtimeType of the Widget)

So if you have a list of 100 nodes, and only one changes, and your keys are
properly set up. Flutter will simply do:

  • Node 1 > check > identical > reuse
  • Node 2 > check > identical > reuse
  • Node 3 > check > different > (re)build Element > children of Node 3 > (re)build …
  • Node 4 > check > identical > reuse
  • …

Instead of:

  • Node 1 > check > different > (re)build Element > children of Node 1 > (re)build …
  • Node 2 > check > different > (re)build Element > children of Node 2 > (re)build …
  • Node 3 > check > different > (re)build Element > children of Node 3 > (re)build …

Now, if even this is too much rebuilding, you could also push more mutable state on your nodes
so they can even update independently of the parent. For instance, if a node is being moved,
it is the only thing that moves, and the stack doesn’t even rebuild.

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

abstract interface class NodesController {
  ValueListenable<List<NodeData>> get nodes;

  void reorder(String id, int zIndex);
}

abstract interface class NodeData {
  String get id;

  /// Offset or other state, if there is more
  ValueListenable<Offset> get offset;

  void moveTo(Offset offset);
}

class Display extends StatelessWidget {
  final NodesController controller;

  const Display({super.key, required this.controller});

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: controller.nodes,
      builder: _buildStack,
    );
  }

  Widget _buildStack(BuildContext context, List<NodeData> value, Widget? _) {
    // We still cache the widgets so reordering doesn't cause all to rebuild.
    return _Stack(value);
  }
}

class _Stack extends StatefulWidget {
  final List<NodeData> nodes;

  const _Stack(this.nodes);

  @override
  State<_Stack> createState() => _StackState();
}

class _StackState extends State<_Stack> {
  var _children = HashMap<NodeData, Widget>();

  @override
  Widget build(BuildContext context) {
    return Stack(children: _buildChildren());
  }

  List<Widget> _buildChildren() {
    final next = HashMap<NodeData, Widget>();

    final children = <Widget>[];
    for (var node in widget.nodes) {
      final child =
          _children[node] ?? NodeDisplay(key: Key(node.id), data: node);
      next[node] = child;
      children.add(child);
    }

    _children = next;
    return children;
  }
}

class NodeDisplay extends StatelessWidget {
  final NodeData data;

  const NodeDisplay({
    super.key,
    required this.data,
  });

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: data.offset,
      builder: _build,
      child: const SizedBox(),
    );
  }

  Widget _build(BuildContext context, Offset value, Widget? child) {
    return Positioned(
      top: value.dy,
      left: value.dx,
      child: child!,
    );
  }
}

With this approach, if you move a single node, only that node’s position will update.
If you change its color, again it will be the only thing that updates.

This way you can have independent controls on nodes, and only use a managing
controller for inter-node interactions, like reordering or adding/removing nodes.

I usually choose this third approach and move all logic, and imperitive state changes
into a “controller” class, that just reports the current state. This way data is
easily exportable, testable and the UI just becomes a pure function of state.

It also becomes trivial to scope widget updates if you actually separate state
changes based on reasonable “channels”.

Hope this helps!

3 Likes

Thank you! This is really really helpful, and I now have a better understanding of what is involved. Yes, if the order is changed, it implies that the display list has been edited (e.g. by dragging an item on the screen), so some widgets need to be rebuilt, but not the whole list. I will experiment with the code examples you have supplied. Thanks again.

1 Like