Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to prevent unnecessary renders in Flutter?

Tags:

flutter

Below is a minimal app that demonstrates my concern. If you run it you'll see that the build method of every visible item runs even when the data for only one item has changed.

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

void main() {
  runApp(MyApp());
}

class MyList extends StatelessWidget {
  final List<int> items;
  MyList(this.items);
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        itemCount: items.length,
        itemBuilder: (context, index) {
          print("item $index built");
          return ListTile(
            title: Text('${items[index]}'),
          );
        });
  }
}

class MyApp extends StatefulWidget {
  MyApp({Key? key}) : super(key: key);

  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
   List<int> items = List<int>.generate(10000, (i) => i);

  void _incrementItem2() {
    setState(() {
      this.items[1]++;
    });
  }

  @override
  Widget build(BuildContext context) {
    final title = 'Long List';

    return MaterialApp(
      title: title,
      home: Scaffold(
        appBar: AppBar(
          title: Text(title),
        ),
        body: Column(
          children: <Widget>[
            MyButton(_incrementItem2),
            Expanded(child: MyList(this.items))
          ],
        ),
      ),
    );
  }
}

class MyButton extends StatelessWidget {
  final Function incrementItem2;
  void handlePress() {
    incrementItem2();
  }

  MyButton(this.incrementItem2);
  @override
  Widget build(BuildContext context) {
    return TextButton(
      style: ButtonStyle(
        foregroundColor: MaterialStateProperty.all<Color>(Colors.blue),
      ),
      onPressed: handlePress,
      child: Text('increment item 2'),
    );
  }
}

In React / React Native, I would use shouldComponentUpdate or UseMemo to prevent unnecessary renders. I'd like to do the same in Flutter.

Here is a link to a closed issue on the Flutter github on the same topic: (I'll be commenting there with a link to this question) https://github.com/flutter/flutter/issues/19421

What are the best practices for avoiding useless "rerenders" or calls to the build method in Flutter, or are these extra builds not a serious concern given structural differences between Flutter and React?

This is more or less similar this question asked on stack overflow a few years ago: How to Prevent rerender of a widget based on custom logic in flutter?

An upvoted answer given is to use ListView.builder. While that can prevent some if not most useless renders, it doesn't prevent all useless renders, as shown in my example.

Thanks in advance for your knowledge and insight.

like image 919
Benjamin Lee Avatar asked May 02 '21 16:05

Benjamin Lee


2 Answers

One of the key features of Flutter is that every Widget is its own unit and can decide to rebuild when needed.

The Bloc library gives really good control over what gets rebuilt. In the case of the text changing in your ListTile's you would have a MyListTile class:

class MyListTile extends StatelessWidget {
  final int index;
  MyListTile(this.index):super(key:ValueKey(index));

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ItemBloc, ItemState>(
      buildWhen: (previous, current) {
        return previous.items[index] != current.items[index];
      },
      builder: (context, state) {
        return ListTile(title: Text('${state.items[index]}'));
      },
    );
  }
}

BlocBuilder's buildWhen is like the shouldComponentUpdate that you are used to in that it gives you a lot of control.

like image 164
Christian Avatar answered Sep 30 '22 23:09

Christian


I'd argue, optimize when you see performance issues / jankiness, but it's prob. not worthwhile to do so prior to do that. Flutter team did a short video about this.

Keep in mind that while you may see rebuilds happening on your widgets which you can detect using print statements, you're not seeing Flutter optimizing renders which happens separately.

Flutter has a widget tree, an element tree and a renderObject tree.

Image taken from Architectural Overview.

Flutter build flow

The widget tree is our hand-coded Dart blueprint for what we want built. (Only blue items on left tree. White items are underlying: we don't write that directly.)

The element tree is Flutter's interpretation of our blueprint, with more detail on what actual components are used and what will be needed for layout and rendering.

The renderObject tree will produce a list of items with details on their dimensions, layer-depth, etc. that needs rasterization onto screen.

The blueprint may be erased and rewritten many times, but that doesn't mean all (or even many) of the underlying components are destroyed/re-created/re-rendered/re-positioned. Components that haven't changed will be re-used.

Say we're building a bathroom that contains a sink and bathtub. Owner decides he wants to swap locations of the sink and bathtub. The contractor doesn't take a sledgehammer and pulverize the ceramics and buys/transports and installs a brand new sink and tub. He just detaches each and swaps their location.

Here's a contrived example using AnimatedSwitcher:

        child: AnimatedSwitcher(
          duration: Duration(milliseconds: 500),
            child: Container(
                key: key,
                color: color,
                padding: EdgeInsets.all(30),
                child: Text('Billy'))),

AnimatedSwitcher is supposed to cross-fade its children when they change. (Say from blue to green in this case.) But if key isn't provided, AnimatedSwitcher won't be rebuilt and won't do a cross-fade animation.

When Flutter walks the element tree and arrives at AnimatedSwitcher, it checks to see if its child has obviously changed, i.e. different tree structure, different element Type etc., but it won't do a deep inspection (too costly I assume). It sees the same object Type (Container) in the same tree position (immediate child) and keeps AnimatedSwitcher without rebuilding it. The result is the Container (when it is rebuilt since its color has changed) flips from green to blue with no animation.

Adding a unique key to Container helps Flutter identify them as different when analyzing the AnimatedSwitcher sub-tree for optimization. Now Flutter will rebuild AnimatedSwitcher about 30 times during its 500ms duration to visually show the animation. (Flutter aims for 60fps on most devices.)

Here's the above AnimatedSwitcher used in a copy/paste example:

  • on first load, the box flips from green to blue without animation (key didn't change)
  • press the refresh button in AppBar to reset the page, but the key is updated for the blue Container, allowing Flutter to spot the difference and do its rebuilds for AnimatedSwitcher
import 'package:flutter/material.dart';

class AnimatedSwitcherPage extends StatefulWidget {
  @override
  _AnimatedSwitcherPageState createState() => _AnimatedSwitcherPageState();
}

class _AnimatedSwitcherPageState extends State<AnimatedSwitcherPage> {
  Color color = Colors.lightGreenAccent;
  ValueKey key;

  @override
  void initState() {
    super.initState();
    key = ValueKey(color.value);

    Future.delayed(Duration(milliseconds: 1500), () => _update(useKey: false));
    // this initial color change happens WITHOUT key changing
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Animation Switcher'),
        actions: [
          IconButton(icon: Icon(Icons.refresh), onPressed: _restartWithKey)
        ],
      ),
      body: Center(
        child: AnimatedSwitcher(
          duration: Duration(milliseconds: 500),
            child: Container(
                key: key,
                color: color,
                padding: EdgeInsets.all(30),
                child: Text('Billy'))),
      ),
    );
  }

  void _update({bool useKey}) {
    setState(() {
      color = Colors.lightBlueAccent;
      if (useKey) key = ValueKey(color.value);
    });
  }

  void _restartWithKey() {
    setState(() {
      color = Colors.lightGreenAccent; // reset color to green
    });
    Future.delayed(Duration(milliseconds: 1500), () => _update(useKey: true));
    // this refresh button color change happens WITH a key change
  }
}

Addendum for Comments

Question:
I understand that it won't do the animation unless the keys are different, but if it's not rebuilding, then why/how is the color switching from green to blue?

Reply:
AnimatedSwitcher is a StatefulWidget. It saves the previous child widget between builds. When a new build occurs, it compares old child vs. new child. (See Widget.canUpdate() method for the comparison.)

If the old and new child are distinct/different, it performs a transition animation, for X amount of frames/ticks.

If the old child and new child are not distinct/different (as per .canUpdate()), AnimatedSwitcher is basically an unneeded wrapper: it won't do anything special, but does do a rebuild to maintain its latest child (?). I have to admit this was poor choice of example.

like image 21
Baker Avatar answered Oct 01 '22 00:10

Baker