While working on Flutter applications we often want to use collections, like List and Set. When we write a function that takes a collection as an argument, we usually make the type of the parameter Iterable. Iterables are read-only, so we don't have to worry about accidentally modifying the collection.

I'm currently working on a project where a user can upload a file with a list of 20 to 36 IDs of trading cards. To verify these cards exist, the app ensures they're included in a list of 10.000+ valid IDs. In the business logic, we iterate over the list of given IDs using a for loop in which we check if the list of valid IDs contains each given ID. If all given IDs are present in the list of valid IDs, the user uploaded a correct file.

While I was testing this feature locally I noticed the UI froze and it took longer than 40 seconds to complete this operation. I thought I could unblock the UI and speed this process up by using the compute function to run the business logic in an Isolate. After making this change and retrying, an error occurred saying it's not allowed to pass a callback to the compute function.

After some debugging, I realised I didn't pass a list with 10.000+ valid IDs to the compute function. What I actually passed was a WhereIterable, which is a lazy Iterable containing the original Iterable and a predicate test. As long as the WhereIterable isn't iterated over, the supplied test function isn't invoked. Iterating over the Iterable doesn't cache the results, thus every time the body of the for loop is executed, the supplied test function is invoked. This explains the long execution time.

I created a new List by calling .toList() on the WhereIterable to make sure the test function is only executed once and the results are cached. After making this change, it took less than 2 seconds to run the business logic instead of over 40 seconds. It's fascinating to see that a small change can have such a big impact.

Experiment

Because developers like to see code and benchmarks, I set up an experiment in DartPad. My project might be a bit too complex to understand without context, so I copy/pasted the essential parts. An API call is consumed to get the 10.000+ valid card IDs. Instead of letting a user upload a file, I created a static list of given IDs.

There's a function called _getValidIds which takes the list of valid card IDs as input and returns a list of validated given IDs as output.

List<int> _getValidIds(Iterable<int> cardIds) {
final validIds = <int>[];

for (final givenId in _givenIds) {
if (cardIds.contains(givenId)) {
validIds.add(givenId);
}
}

return validIds;
}

In the first experiment, we run the business logic without any optimisations. We pass the WhereIterable to _getValidIds and the test function will be invoked every time the body of the for loop is executed.

final stopwatch = Stopwatch()..start();

_getValidIds(cardIdsIterable);

stopwatch.stop();
print('Experiment 1: ${stopwatch.elapsed} - no optimisations');

In the second experiment, we call .toList() before passing the valid card IDs to the business logic. The test function of the WhereIterable will only be invoked once.

stopwatch.start();

final cardIdsList2 = cardIdsIterable.toList();
_getValidIds(cardIdsList2);

stopwatch.stop();
print('Experiment 2: ${stopwatch.elapsed} - using .toList()');

In the third experiment, we call .toList() before passing the data to the business logic, just like in experiment 2. We also spin up an Isolate using the compute function to prevent the business logic from being executed on the Main Thread.

stopwatch.start();

final cardIdsList3 = cardIdsIterable.toList();
await compute(_getValidIds, cardIdsList3);

stopwatch.stop();
print('Experiment 3: ${stopwatch.elapsed} - using .toList() and compute');

Results

Amount of card IDs: 11816
Amount of given IDs: 31
------- Results -------
Experiment 1: 0:00:00.117300 - no optimisations
Experiment 2: 0:00:00.017200 - using .toList()
Experiment 3: 0:00:00.019000 - using .toList() and compute

These results differ a bit every time you run the DartPad, but the conclusions are always the same.

The first takeaway from this experiment is that the unoptimised code takes several times longer to execute than the optimised code. Invoking the test function of the WhereIterable and caching the results speeds up the process a lot, in exchange for some memory.

The second takeaway is that running the business logic in a separate Isolate takes a bit longer, presumably because it takes a while to spin up the Isolate. While it doesn't seem to be beneficial in this scenario, it's always worth a try when you run an expensive task.

Conclusion

Using the where method on an Iterable will create a new lazy Iterable, a WhereIterable, which contains the original Iterable as well as a predicate test. When you use a WhereIterable inside a for loop, each time the body of the loop is executed, the test function will be invoked without caching the results. This makes the execution time of your code a lot longer and can potentially even freeze your UI.

If you use a lazy Iterable inside a for loop, you might want to call .toList() first to cache the results in memory and speed up the execution time.