brianegan / flutter_redux

A library that connects Widgets to a Redux Store
MIT License
1.65k stars 219 forks source link

Performance problem: ViewModel is unnecessarily calculated #197

Closed marcglasberg closed 3 years ago

marcglasberg commented 3 years ago

The _StoreStreamListener listens to a stream of dispatched states:

  void _createStream() {
    _stream = widget.store.onChange
        .where(_ignoreChange)
        .map(_mapConverter)
        .where(_whereDistinct)
        .transform(
            StreamTransformer.fromHandlers(handleData: _handleChange, handleError: _handleError));
  }

However, it doesn't actually uses these states to calculate the vm in _mapConverter. Granted, the _ignoreChange method uses the stream states, but it should not: https://github.com/brianegan/flutter_redux/issues/196

In other words, the states in the stream are being ignored by _mapConverter which instead uses the current store state:

  ViewModel _mapConverter(S state) {
    return widget.converter(widget.store);
  }

If find this very weird, but I can't think of a problem (once _ignoreChange is fixed, that is). The store state is guaranteed to be the same or more recent than the stream state. So it seems to me that by using the store state instead of the stream state we're managing to update the vm a few milliseconds early, with no other bad effects.

The stream in fact is only being used to signal to the widget that state changes have occurred, recently. This gives the widget a chance to recalculate its vm from the current store state, and rebuild if necessary. However, the stream changes are not in sync with the store state. This means the stream may be changing while the store state is not, and all those vm calculations are just a waste.

The following code demonstrates the problem:

import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';

enum Actions { Increment }

int counterReducer(int state, dynamic action) {
  if (action == Actions.Increment) return state + 1;
  return state;
}

void main() {
  final store = Store<int>(counterReducer, initialState: 0);
  runApp(FlutterReduxApp(store: store));
}

class FlutterReduxApp extends StatelessWidget {
  final Store<int> store;
  final String title;

  FlutterReduxApp({Key key, this.store, this.title}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return StoreProvider<int>(
        store: store,
        child: MaterialApp(
            title: 'Example',
            home: Scaffold(
                appBar: AppBar(title: const Text('Example')),
                body: Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Text(
                        'Each time you click it increments 5 times. '
                        'It should ignore odd results, but it does not:',
                      ),
                      StoreConnector<int, String>(
                        converter: (store) {
                          print('Called the converter = ${store.state}');
                          return store.state.toString();
                        },
                        ignoreChange: (count) {
                          var isOdd = (count % 2 == 1);
                          print('ignoreChange? $isOdd (count = ${count})');
                          return isOdd;
                        },
                        builder: (context, count) {
                          return Text(count, style: Theme.of(context).textTheme.display1);
                        },
                      )
                    ],
                  ),
                ),
                floatingActionButton: StoreConnector<int, VoidCallback>(converter: (store) {
                  // Increments 5 times.
                  return () {
                    store.dispatch(Actions.Increment);
                    store.dispatch(Actions.Increment);
                    store.dispatch(Actions.Increment);
                    store.dispatch(Actions.Increment);
                    store.dispatch(Actions.Increment);
                  };
                }, builder: (context, callback) {
                  return FloatingActionButton(
                    onPressed: callback,
                    child: Icon(Icons.add),
                  );
                }))));
  }
}

If you run this, you will see this in the console:

I/flutter ( 8595): Called the converter = 10
I/flutter ( 8595): Called the converter = 10
I/flutter ( 8595): Called the converter = 10
I/flutter ( 8595): Called the converter = 10
I/flutter ( 8595): Called the converter = 10

The converter is being called 5 times by the stream, once for each store.dispatch with different stream states. However, the store state is the same, and is this state the one used by the converter.

I propose the _createStream method should be changed to:

  void _createStream() {
    _stream = widget.store.onChange
        .where(_stateChanged)
        .where(_ignoreChange)
        .map(_mapConverter)
        .where(_whereDistinct)
        .transform(
            StreamTransformer.fromHandlers(handleData: _handleChange, handleError: _handleError));
  }

Where the _stateChanged method is given by:

S _lastConvertedState;

...
  // No need to recalculate the vm if the widget.store.state is identical.
  bool _stateChanged(S state) {
    var ifStateChanged = !identical(_lastConvertedState, widget.store.state);
    _lastConvertedState = widget.store.state;
    return ifStateChanged;
  }
marcglasberg commented 3 years ago

Continuing: The above fix is a low hanging fruit, and has the potential to greatly reduce the number of unnecessary view-model creations. I have already applied it to async_redux, and I believe it should be applied to flutter_redux too. See: PR https://github.com/brianegan/flutter_redux/pull/198

However, this is still not perfect. In the build method we find this:

  @override
  Widget build(BuildContext context) {
    return widget.rebuildOnChange
        ? StreamBuilder<ViewModel>(
            stream: _stream,
            builder: (context, snapshot) {
              if (_latestError != null) throw _latestError;

              return widget.builder(
                context,
                _latestValue,
              );
            },
          )
        : _latestError != null
            ? throw _latestError
            : widget.builder(context, _latestValue);
  }

The StreamBuilder may be called 60fps, and it will use the most recent _latestValue (it would be better to call this _latestVm). It will NOT be called every time the model changes. In other words, it may be called at most 60 times per second (each 16 milliseconds aprox.), and if the stream has a new value it will use the most recent _latestValue.

If the _latestValue has been calculated 100 times in the last 16 ms, this means 99 useless vm calculations.

It would be much better if we could check if a new value is available, and then, only if it is, we calculate the _latestValue, compare it with the previous one (if the distinct parameter is true).

This change would guarantee that we only calculate the vm if there is at least a chance we'll use it.

brianegan commented 3 years ago

Thanks again, @marcglasberg. I'll close this one with the fix merged!