Flutter (44): Scrollable component child cache

Time: Column:Mobile & Frontend views:224

General Solution for Caching Specific Items in Scrollable Widgets

In this section, we’ll introduce a general solution for caching specific items within scrollable widgets.

First, let’s recall that when we discussed ListView, there was an addAutomaticKeepAlives property that we didn’t cover. If addAutomaticKeepAlives is set to true, ListView will automatically wrap each list item with an AutomaticKeepAlive parent widget. While PageView and PageView.builder don’t expose this parameter directly, they ultimately generate a SliverChildDelegate responsible for loading list items on demand. In SliverChildDelegate, each time a list item is built, it gets wrapped with an AutomaticKeepAlive parent widget. Let’s first introduce the AutomaticKeepAlive widget.


44.1 AutomaticKeepAlive

The main function of the AutomaticKeepAlive widget is to mark the keepAlive property of the root RenderObject of list items as true or false based on necessity. For simplicity, we can think of the root RenderObject as the root widget of a list item, representing the entire list item component. We will refer to the Viewport area of the list widget, plus the cacheExtent (pre-render area), as the loading area:

  • When keepAlive is set to false, if a list item scrolls out of the loading area, the list component will be destroyed.

  • When keepAlive is set to true, after a list item scrolls out of the loading area, the Viewport caches the list component. When the list item re-enters the loading area, the Viewport will first check if it is cached, and if it is, the component is reused; otherwise, it is rebuilt.

So, when does AutomaticKeepAlive mark the keepAlive property of a list item as true or false? The answer is: it’s up to the developer! Flutter implements a client-server mechanism, where AutomaticKeepAlive acts as the server, and its child components can act as clients. If a child component wants to change its cache status, it sends a notification (called a KeepAliveNotification) to AutomaticKeepAlive, which then adjusts the keepAlive state and performs any necessary resource cleanup (such as releasing the cache when keepAlive changes from true to false).

Building on the PageView example from the previous section, the idea for implementing page caching is simple: make the Page a client of AutomaticKeepAlive. To make this easier for developers, Flutter provides the AutomaticKeepAliveClientMixin. All we need to do is mix this into the PageState and add a few necessary operations:

class _PageState extends State<Page> with AutomaticKeepAliveClientMixin {

  @override
  Widget build(BuildContext context) {
    super.build(context); // This must be called
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }

  @override
  bool get wantKeepAlive => true; // Indicates whether to cache
}

The code is simple. We just need to provide a wantKeepAlive value, which determines whether AutomaticKeepAlive should cache the current list item. Additionally, we must call super.build(context) in the build method. This is implemented in AutomaticKeepAliveClientMixin and sends a message to AutomaticKeepAlive based on the current wantKeepAlive value. AutomaticKeepAlive will then perform its operations, as shown in Figure .

General Solution for Caching Specific Items in Scrollable Widgets

Now, if we rerun the example, each page will only be built once, meaning the caching has succeeded.

Note that if we use PageView.custom to build pages and don’t wrap list items with an AutomaticKeepAlive parent widget, the above solution won’t work. In this case, when the client sends a message, it won’t find the server, resulting in a "404 error." 😊


44.2 KeepAliveWrapper

Although we can use AutomaticKeepAliveClientMixin to quickly implement page caching, doing so by mixing it in is not always elegant. It requires modifying the Page code, which can be intrusive and inflexible. For example, if a Page component needs to be used both inside and outside of a list, and we want to cache it in the list, we would have to maintain two separate implementations. To address this, I’ve created a KeepAliveWrapper component. If a list item needs to be cached, simply wrap it with KeepAliveWrapper.

@override
Widget build(BuildContext context) {
  var children = <Widget>[];
  for (int i = 0; i < 6; ++i) {
    // Simply wrap the item with KeepAliveWrapper
    children.add(KeepAliveWrapper(child: Page(text: '$i')));
  }
  return PageView(children: children);
}

Here’s the implementation of KeepAliveWrapper:

class KeepAliveWrapper extends StatefulWidget {
  const KeepAliveWrapper({
    Key? key,
    this.keepAlive = true,
    required this.child,
  }) : super(key: key);
  
  final bool keepAlive;
  final Widget child;

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

class _KeepAliveWrapperState extends State<KeepAliveWrapper>
    with AutomaticKeepAliveClientMixin {
    
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return widget.child;
  }

  @override
  void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
    if (oldWidget.keepAlive != widget.keepAlive) {
      // Update the keepAlive state
      updateKeepAlive();
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  bool get wantKeepAlive => widget.keepAlive;
}

Now, let’s test this with ListView:

class KeepAliveTest extends StatelessWidget {
  const KeepAliveTest({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(itemBuilder: (_, index) {
      return KeepAliveWrapper(
        // If set to true, all list items will be cached and won’t be destroyed.
        // If set to false, items will be destroyed after leaving the preloaded area.
        // Be cautious when using this, as caching all items will increase memory consumption.
        keepAlive: true,
        child: ListItem(index: index),
      );
    });
  }
}

class ListItem extends StatefulWidget {
  const ListItem({Key? key, required this.index}) : super(key: key);
  
  final int index;

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

class _ListItemState extends State<ListItem> {
  @override
  Widget build(BuildContext context) {
    return ListTile(title: Text('${widget.index}'));
  }

  @override
  void dispose() {
    print('dispose ${widget.index}');
    super.dispose();
  }
}

Since all the list items are cached, you won’t see any logs when scrolling the list, as shown in Figure .

General Solution for Caching Specific Items in Scrollable Widgets

As expected, no logs appear in the console. If we set keepAlive to false, once the list item moves out of the pre-rendered area, it will be destroyed, and logs will appear in the console, as shown in Figure .

General Solution for Caching Specific Items in Scrollable Widgets

As we can see, the KeepAliveWrapper works as expected. I have added this KeepAliveWrapper to the flukit widget library, so if you need it, you can find it there.