Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Flutter Memory Leak - Flutter Bloc

I am building a checklist application using Flutter and i seem to be getting a build up in memory across checkpoints and inspections. I've been going back and forth for 2 weeks now trying to restructure the page to no avail.

I am using Flutter Bloc https://felangel.github.io/bloc for state management on the checkpoint screen. I suspect that the Bloc is causing my memory leak.

The checkpoint screen is quite complex:

  1. A header showing where the user is within the checklist ie. checkpoint 4/50.
  2. A widget that displays answer options: Ok, Defective, N/A.
  3. A widget that allows the user to take up to 2 images for the checkpoint
  4. A TextFormField widget to enter findings for the checkpoint

When the user submits the checkpoint, the answer is stored and the next checkpoint is displayed for the user until they reach the end of the inspection and close it out.

Here is a screenshot of the screen, unfortunately the TextFormField cannot be seen here but it is just below the word "findings".

Screenshot of Checkpoint Screen

Things i have noticed:

When the checkpoint screen first loads and i take a snapshot in DevTools i can see 1 instance of each widget (AnswerOptions, CheckHeader, Comments, ImageGrid). However as soon as i start toggling the options ie. hopping between OK, DEFECTIVE, N/A the instances (AnswerOptions, CheckHeader, Comments, ImageGrid) start stacking up. When the user submits the checkpoint or even exits the inspection altogether those classes stay in the memory heap and are never released.

I have also noticed that the duplicated instances only start from the CheckpointForm downward through the widget tree. AssetInspection and InspectionView do not duplicate instances in the heap.

Example when the page first loads:

1 Instance of CheckHeader

I then toggle the OK, DEFECTIVE and N/A and take another snapshot:

Instances have accumulated after toggling options

Herewith the code:

AssetInspection

class AssetInspection extends StatefulWidget
{
  final Checklist checklist;
  final Asset asset;
  final Job job;
  final AssetPoolDatabase database;

  AssetInspection({
    Key key,
    @required this.checklist,
    @required this.asset,
    @required this.job,
    @required this.database,
  }) : super(key: key);

  @override
  AssetInspectionState createState() => new AssetInspectionState();
}

class AssetInspectionState extends State<AssetInspection>
{
  InspectionBloc _inspectionBloc;
  CheckpointBloc _checkpointBloc;

  @override
  void initState() {
    _checkpointBloc = CheckpointBloc(
      database: widget.database,
      answerRepo: AnswerRepo(database: widget.database),
    );

    _inspectionBloc = InspectionBloc(
      checklist: widget.checklist,
      job: widget.job,
      asset: widget.asset,
      inspectionRepo: InspectionRepo(database: widget.database),
      checkpointBloc: _checkpointBloc
    );
    super.initState();
  }

  @override
  void dispose() {
    _inspectionBloc.dispose();
    _checkpointBloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {

    return MultiBlocProvider(
      providers: [
        BlocProvider<InspectionBloc>(
          builder: (BuildContext context) => _inspectionBloc..dispatch(LoadInspection()),
        ),
        BlocProvider<CheckpointBloc>(
          builder: (BuildContext context) => _checkpointBloc,
        )
      ],
      child: InspectionView(),
    );
  }

}

InspectionView

class InspectionView extends StatelessWidget
{

  @override
  Widget build(BuildContext context) {
    final InspectionBloc _inspectionBloc = BlocProvider.of<InspectionBloc>(context);

    return BlocListener(
      bloc: _inspectionBloc,
      listener: (context, InspectionState state) {
        if(state is AnswerStored) {
          _inspectionBloc..dispatch(LoadInspection());
        }

        if(state is InspectionClosed) {
          Navigator.pushReplacement(
            context,
            CupertinoPageRoute(
              builder: (context) => JobManager(
                jobId: state.inspection.jobId,
              ),
            ),
          );
        }
      },
      child: BlocBuilder<InspectionBloc, InspectionState>(
        builder: (BuildContext context, InspectionState state) {
          if (state is InspectionInProgress) {
            return CheckpointView(
              currentCheck: state.currentCheck,
              totalChecks: state.totalChecks,
            );
          }

          if(state is InspectionNeedsSubmission) {
            return SubmitInspection(
              inspection: state.inspection,
              checklist: state.checklist,
            );
          }

          if(state is InspectionLoading) {
            return LoadingIndicator();
          }

          return LoadingIndicator();
        },
      ),
    );
  }
}

CheckpointView

class CheckpointView extends StatelessWidget {

  final int totalChecks;
  final int currentCheck;

  CheckpointView({
    Key key,
    @required this.totalChecks,
    @required this.currentCheck,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<CheckpointBloc, CheckpointState>(
      builder: (context, CheckpointState state) {
        if(state is CheckpointLoaded) {
          return CheckpointForm(
            totalChecks: totalChecks,
            currentCheck: currentCheck,
          );
        }

        if(state is ManagingImage) {
          return ImageOptions();
        }

        return Container(color: Colors.white,);
      },
    );
  }

}

CheckpointForm

class CheckpointForm extends StatelessWidget
{
  final int totalChecks;
  final int currentCheck;

  CheckpointForm({
    this.totalChecks,
    this.currentCheck,
    Key key
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {

    final InspectionBloc _inspectionBloc = BlocProvider.of<InspectionBloc>(context);
    final CheckpointBloc _checkpointBloc = BlocProvider.of<CheckpointBloc>(context);

    final CheckpointLoaded currentState = _checkpointBloc.currentState as CheckpointLoaded;

    return Scaffold(
      appBar: AppBar(
        title: Text(_inspectionBloc.checklist.name),
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: () {
            Navigator.pushReplacement(
              context,
              CupertinoPageRoute(
                builder: (context) => JobManager(
                  jobId: _inspectionBloc.job.id,
                ),
              ),
            );
          },
        ),
      ),
      body: GestureDetector(
        onTap: () {
          FocusScope.of(context).requestFocus(new FocusNode());
        },
        child: SingleChildScrollView(
          padding: const EdgeInsets.only(left: 15, right: 15, top: 20, bottom: 20),
          child: Column(
            children: <Widget>[
              CheckHeader(
                totalChecks: totalChecks,
                currentCheck: currentCheck,
              ),
              AnswerOptions(),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: <Widget>[
                  const Text('Evidence',
                    style: const TextStyle(
                      fontSize: 20, fontWeight: FontWeight.w600)
                    ),
                  Padding(
                    padding: const EdgeInsets.only(left: 10),
                    child: Text(
                      _getImageValidationText(currentState),
                      style: const TextStyle(
                        color: Colors.deepOrange,
                        fontWeight: FontWeight.w500
                      ),
                    ),
                  )
                ],
              ),
              const Divider(),
              ImageGrid(),
              CheckpointComments(),
            ],
          ),
        ),
      ),
    );
  }

  String _getImageValidationText(CheckpointLoaded state) {
    if ((state.checkpoint.imageRule == 'when-defective' &&
            state.answer.answer == '0' &&
            state.answer.images.length == 0) ||
        (state.checkpoint.imageRule == 'always-required' &&
            state.answer.images.length == 0)) {
      return 'Please take up to 2 images';
    }
    return '';
  }
}

CheckHeader

class CheckHeader extends StatelessWidget
{
  final int totalChecks;
  final int currentCheck;

  CheckHeader({
    Key key,
    @required this.totalChecks,
    @required this.currentCheck,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {

    final CheckpointBloc _checkpointBloc = BlocProvider.of<CheckpointBloc>(context);

    return BlocBuilder(
      bloc: _checkpointBloc,
      builder: (context, CheckpointState state) {
        if(state is CheckpointLoaded) {
          return Container(
            padding: const EdgeInsets.only(top: 20, bottom: 20),
            margin: const EdgeInsets.only(bottom: 30),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text('Check: $currentCheck/$totalChecks'),
                Text(
                  state.checkpoint.name,
                  style: const TextStyle(
                    fontSize: 25,
                    fontWeight: FontWeight.w900
                  ),
                ),
                const Divider(),
                Text(
                  state.checkpoint.task,
                  style: const TextStyle(
                    fontSize: 18
                  ),
                )
              ],
            ),
          );
        }

        return Container(color: Colors.white,);
      },
    );
  }
}

AnswerOptions

class AnswerOptions extends StatelessWidget
{
  AnswerOptions({
    Key key
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {

    final CheckpointBloc _checkpointBloc = BlocProvider.of<CheckpointBloc>(context);

    CheckpointLoaded state = _checkpointBloc.currentState as CheckpointLoaded;

    return Column(
      children: <Widget>[
        _option(
          label: 'Pass Check',
          value: '1',
          activeValue: state.answer.answer,
          activeColor: AssetPoolTheme.green,
          activeTextColor: Colors.white,
          passiveTextColor: Colors.blueGrey,
          passiveColor: AssetPoolTheme.grey,
          icon: Icons.check_circle_outline,
          state: state,
          checkpointBloc: _checkpointBloc
        ),
        _option(
          icon: Icons.highlight_off,
          label: 'Fail Check',
          value: '0',
          activeValue: state.answer.answer,
          activeColor: AssetPoolTheme.red,
          activeTextColor: Colors.white,
          passiveTextColor: Colors.blueGrey,
          passiveColor: AssetPoolTheme.grey,
          state: state,
          checkpointBloc: _checkpointBloc
        ),
        _option(
          icon: Icons.not_interested,
          label: 'Not Applicable',
          value: '-1',
          activeValue: state.answer.answer,
          activeTextColor: Colors.white,
          passiveTextColor: Colors.blueGrey,
          passiveColor: AssetPoolTheme.grey,
          activeColor: AssetPoolTheme.orange,
          state: state,
          checkpointBloc: _checkpointBloc
        ),
      ],
    );
  }

  _option({ 
    icon, 
    label, 
    value, 
    activeValue, 
    activeTextColor, 
    passiveTextColor, 
    passiveColor, 
    activeColor, 
    state, 
    checkpointBloc
    }) {
    return Container(
      margin: const EdgeInsets.only(bottom: 10),
      child: FlatButton(
        color: activeValue == value ? activeColor : passiveColor,
        textColor: Colors.white,
        disabledColor: Colors.grey,
        disabledTextColor: Colors.black,
        padding: const EdgeInsets.all(20),
        splashColor: activeColor,
        onPressed: () {
          checkpointBloc.dispatch(
            UpdateAnswer(answer: state.answer.copyWith(answer: value))
          );
        },
        child: Row(
          children: <Widget>[
            Padding(
              child: Icon(
                icon,
                color: activeValue == value ? activeTextColor : passiveTextColor,
              ),
              padding: const EdgeInsets.only(right: 15),
            ),
            Text(
              label,
              style: TextStyle(color: activeValue == value ? activeTextColor : passiveTextColor, fontSize: 20),
            )
          ],
        ),
      ),
    );
  }
}

ImageGrid

class ImageGrid extends StatelessWidget
{

  ImageGrid({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {

    return BlocBuilder<CheckpointBloc, CheckpointState>(
      builder: (BuildContext context, CheckpointState state) {
        if(state is CheckpointLoaded) {
          return GridView.count(
            addAutomaticKeepAlives: false,
            shrinkWrap: true,
            physics: const ScrollPhysics(),
            crossAxisCount: 2,
            childAspectRatio: 1.0,
            mainAxisSpacing: 4.0,
            crossAxisSpacing: 4.0,
            children: _imagesRow(state.answer.images),
          );
        }
        return Container();
      },
    );
  }

  List<Widget> _imagesRow(stateImages) {

    final List<Widget> previewImages = [];

    stateImages.forEach((imagePath) {
      final preview =  new ImagePreview(
        key: Key(imagePath),
        imagePath: '$imagePath',
        imageName: imagePath
      );
      previewImages.add(preview,);
    });

    final takePicture = TakePicture();

    if (stateImages.length < 2) previewImages.add(takePicture,);

    return previewImages;
  }
}

Inspection Bloc

class InspectionBloc extends Bloc<InspectionEvents, InspectionState>
{
  final Checklist checklist;
  final Job job;
  final Asset asset;
  final InspectionRepo inspectionRepo;
  final CheckpointBloc checkpointBloc;

  InspectionBloc({
    @required this.checklist,
    @required this.job,
    @required this.asset,
    @required this.inspectionRepo,
    @required this.checkpointBloc,
  });

  @override
  InspectionState get initialState => InspectionUnintialized();

  @override
  void dispose() {
    checkpointBloc.dispose();
    super.dispose();
  }

  @override
  Stream<InspectionState> mapEventToState(InspectionEvents event) async* {

    if(event is LoadInspection) {

      yield InspectionLoading();
      await Future.delayed(Duration(seconds: 1));

      final Inspection inspection = await initializeInspection();
      if(inspection == null) {
        yield InspectionNotLoaded();
      } else if(inspection.syncedAt != null) {
        yield InspectionSynced(inspection: inspection);
      } else if(inspection.completedAt != null) {
        yield InspectionSynced(inspection: inspection);
      } else if(inspection.completedAt == null && inspection.syncedAt == null) {
        yield* _mapCurrentCheckpoint(inspection);
      }
    } else if(event is CheckpointWasSubmitted) {
      final bool isValid = _validateCheckpoint(event.answer, event.checkpoint);
      if(isValid == false) {
        Toaster().error('Invalid, please complete the checkpoint before submitting');
      } else {
        Inspection inspection = await inspectionRepo.getInspection(job.id, asset.localId, checklist.id);
        await _storeAnswer(event.answer, event.checkpoint, inspection);
        await inspectionRepo.jobIsInProgress(job.id);
        yield AnswerStored(
          checklist: checklist,
          asset: asset,
          job: job
        );
      }
    } else if(event is CloseInspection) {
      inspectionRepo.closeInspection(event.closingComments, event.location, event.inspection.sourceUuid);
      yield InspectionClosed(inspection: event.inspection);
    }

  }

  Stream<InspectionState> _mapCurrentCheckpoint(Inspection inspection) async* {

    final List<Check> checks = await inspectionRepo.getChecksForChecklist(checklist.id);

    if(await inspectionRepo.hasAnswers(inspection.sourceUuid) == false) {
      final Check checkpoint = await inspectionRepo.firstCheckOnChecklist(inspection.checklistId);
      yield InspectionInProgress(
        totalChecks: checks.length,
        currentCheck: 1,
        inspection: inspection,
        checkpoint: checkpoint
      );

      checkpointBloc.dispatch(LoadForInspection(checkpoint: checkpoint));

    } else {
      final Answer lastAnswer = await inspectionRepo.getLatestAnswer(inspection.sourceUuid);
      final int latestAnswerIndex = checks.indexWhere((check) => check.id == lastAnswer.checkId);
      final int updatedIndex = latestAnswerIndex + 1;
      if(updatedIndex < checks.length) {
        final Check checkpoint = checks.elementAt(updatedIndex);
        yield InspectionInProgress(
          totalChecks: checks.length,
          currentCheck: updatedIndex + 1,
          checkpoint: checkpoint,
          inspection: inspection,
        );

        checkpointBloc.dispatch(LoadForInspection(checkpoint: checkpoint));
      }

      if(updatedIndex == checks.length) {
        yield InspectionNeedsSubmission(
          inspection: inspection,
          checklist: checklist
        );
      }

    }
  }

  Future<Inspection> initializeInspection() async {
    return await inspectionRepo.getInspection(job.id, asset.localId, checklist.id) 
        ?? await inspectionRepo.createInspection(job.id, asset.localId, checklist.id);
  }

  bool _validateCheckpoint(AnswerModel answer, Check checkpoint) {
    if(answer.answer == null) return false;
    if(checkpoint.imageRule == 'always-required' && answer.images.length == 0) return false;
    if(checkpoint.commentRule == 'always-required' && answer.comments.length == 0) return false;
    if(checkpoint.imageRule == 'when-defective' && answer.answer == '0' && answer.images.length == 0) {
      return false;
    }
    if(checkpoint.commentRule == 'when-defective' && answer.answer == '0' && answer.comments.length == 0) return false;
    return true;
  }

  Future _storeAnswer(AnswerModel answerModel, Check checkpoint, Inspection inspection) async {
    inspectionRepo.storeAnswer(
      answerModel,
      checkpoint,
      inspection
    );
  }

}

Checkpoint Bloc

class CheckpointBloc extends Bloc<CheckpointEvent, CheckpointState>
{
  final AssetPoolDatabase database;
  final AnswerRepo answerRepo;

  CheckpointBloc({
    @required this.database,
    @required this.answerRepo,
  });

  @override
  CheckpointState get initialState => CheckpointNotLoaded();

  @override
  Stream<CheckpointState> mapEventToState(event) async* {
    if(event is LoadForInspection) {
      yield CheckpointLoaded(
        checkpoint: event.checkpoint,
        answer: new AnswerModel(
          checkId: event.checkpoint.id,
          images: [],
        )
      );
    } else if(event is UpdateAnswer) {
      final state = currentState as CheckpointLoaded;
      yield CheckpointLoaded(
        checkpoint: state.checkpoint,
        answer: event.answer
      );
    } else if(event is AddImage) {
      final state = currentState as CheckpointLoaded;
      List<String> images = state.answer.images;
      images.add(event.imagePath);
      yield CheckpointLoaded(
        checkpoint: state.checkpoint,
        answer: state.answer.copyWith(images: images)
      );
    } else if(event is RemoveImage) {
      print('HERE');
      print(event.imageName);
      List<String> images = event.answer.images.where((imageName) => imageName != event.imageName).toList();
      yield CheckpointLoaded(
        checkpoint: event.checkpoint,
        answer: event.answer.copyWith(images: images)
      );
    } else if(event is ManageImage) {
      yield ManagingImage(
        image: event.image,
        checkpoint: event.checkpoint,
        answer: event.answer,
        imageName: event.imageName
      );
    } else if(event is CloseImageManager) {
      yield CheckpointLoaded(
        checkpoint: event.checkpoint,
        answer: event.answer
      );
    }
  }
}
like image 711
Warren Hansen Avatar asked Dec 31 '22 14:12

Warren Hansen


1 Answers

I managed to locate the memory leak. The cause was in fact Bloc. I was opening the camera in a modal by pushing with the navigator. The problem was that i was not pushing to this modal from a Bloc listener but rather from within the widget.

With Flutter Bloc it is recommended to perform Navigation from within a Bloc Listener.

I ended up removing the Navigation altogether and simply showed the camera widget in response to a change in state.

The change was dramatic in terms of memory usage, and the garbage collector has started behaving in a much more predictable way.

like image 146
Warren Hansen Avatar answered Feb 04 '23 17:02

Warren Hansen