why flutter image did not release the memory when widget closed

1.8k views Asked by At

I am facing a problem recently that my flutter(2.2.0) app always crashed after running a while, then I am debbuing and found that when the article detail page contains many image, close and reopen for many times, the memory are always increase, why the image did not dispose when close the article detail page? I am tried this way to clear image cache before exit article detail page(this should not use any image cache, not a good practice):

if (PaintingBinding.instance != null && PaintingBinding.instance!.imageCache != null) {
      PaintingBinding.instance!.imageCache!.clear();
      PaintingBinding.instance!.imageCache!.clearLiveImages();
    }

but now the log shows this info:

I/rth.dolphin.de( 9117): Explicit concurrent copying GC freed 4578(176KB) AllocSpace objects, 1(20KB) LOS objects, 49% free, 2412KB/4824KB, paused 574us total 8.073ms
I/rth.dolphin.de( 9117): Explicit concurrent copying GC freed 4578(176KB) AllocSpace objects, 1(20KB) LOS objects, 49% free, 2412KB/4824KB, paused 734us total 7.350ms
I/rth.dolphin.de( 9117): Explicit concurrent copying GC freed 4578(176KB) AllocSpace objects, 1(20KB) LOS objects, 49% free, 2412KB/4824KB, paused 612us total 8.267ms
I/rth.dolphin.de( 9117): Explicit concurrent copying GC freed 4578(176KB) AllocSpace objects, 1(20KB) LOS objects, 49% free, 2412KB/4824KB, paused 746us total 7.442m

it is the right way to handle this problem, sees it has a side effect using this way to clear image cache. what should I do to fix it? By the way, this is the image change trend I captured:

enter image description here

I tried use this code snip to monitor the image cache:

var imageCache= PaintingBinding.instance!.imageCache;

  print("dd:"+imageCache!.currentSizeBytes.toString());
  print("size:" + imageCache.currentSize.toString());

found the image cache did not changed when close and reopen the article detail page but the memory still rising. This is the key code of showing the image:

if (item.content != "")
              Html(
                  data: item.content,
                  style: {
                    "body": Style(
                      fontSize: FontSize(19.0),
                    ),
                  },
                  customImageRenders: defaultImageRenders,
                  onLinkTap: (String? url, RenderContext context, Map<String, String> attributes, dom.Element? element) {
                    CommonUtils.launchUrl(url);
                  }),

the image is in the html content and I am using flutter html to show it. This is my full code:

import 'package:cruise/src/common/article_action.dart';
import 'package:cruise/src/common/helpers.dart';
import 'package:cruise/src/common/net/rest/http_result.dart';
import 'package:cruise/src/common/repo.dart';
import 'package:cruise/src/common/utils/common_utils.dart';
import 'package:cruise/src/models/Channel.dart';
import 'package:cruise/src/models/Item.dart';
import 'package:cruise/src/models/api/fav_status.dart';
import 'package:cruise/src/models/api/upvote_status.dart';
import 'package:cruise/src/page/channel/channelpg_component/page.dart';
import 'package:cruise/src/page/home/components/articledetail_component/action.dart';
import 'package:fish_redux/fish_redux.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_html/style.dart';
import 'package:flutter_icons/flutter_icons.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:html/dom.dart' as dom;

import 'state.dart';

Widget buildView(ArticleDetailState state, Dispatch dispatch, ViewService viewService) {
  Item item = state.article;
  BuildContext context = viewService.context;
  Offset? _initialSwipeOffset;
  Offset? _finalSwipeOffset;

  void _onHorizontalDragStart(DragStartDetails details) {
    _initialSwipeOffset = details.globalPosition;
  }

  void _onHorizontalDragUpdate(DragUpdateDetails details) {
    _finalSwipeOffset = details.globalPosition;
  }

  void _onHorizontalDragEnd(DragEndDetails details) {
    if (_initialSwipeOffset != null) {
      final offsetDifference = _initialSwipeOffset!.dx - _finalSwipeOffset!.dx;
      if (offsetDifference < 0) {
        if (PaintingBinding.instance != null && PaintingBinding.instance!.imageCache != null) {
          // https://mp.weixin.qq.com/s/yUm4UFggYLgDbj4_JCjEdg
          // https://musicfe.dev/flutter/
          PaintingBinding.instance!.imageCache!.clear();
          PaintingBinding.instance!.imageCache!.clearLiveImages();
        }
        Navigator.pop(context);
      }
    }
  }

  void touchUpvote(String action, UpvoteStatus upvoteStatus) async {
    HttpResult result = (await ArticleAction.upvote(articleId: item.id.toString(), action: action))!;

    if (result.result == Result.error) {
      Fluttertoast.showToast(
          msg: "点赞失败",
          toastLength: Toast.LENGTH_SHORT,
          gravity: ToastGravity.CENTER,
          timeInSecForIosWeb: 1,
          backgroundColor: Colors.red,
          textColor: Colors.white,
          fontSize: 16.0);
    } else {
      if (upvoteStatus.statusCode == "upvote") {
        dispatch(ArticleDetailActionCreator.onVote(UpvoteStatus.UPVOTE));
      }
      if (upvoteStatus.statusCode == "unupvote" && item.upvoteCount > 0) {
        dispatch(ArticleDetailActionCreator.onVote(UpvoteStatus.UNUPVOTE));
      }
      Fluttertoast.showToast(
          msg: upvoteStatus.statusCode == "upvote" ? "点赞成功" : "取消点赞成功",
          toastLength: Toast.LENGTH_SHORT,
          gravity: ToastGravity.CENTER,
          timeInSecForIosWeb: 1,
          backgroundColor: Colors.red,
          textColor: Colors.white,
          fontSize: 16.0);
    }
  }

  void touchFav(String action, FavStatus favStatus) async {
    HttpResult result = (await ArticleAction.fav(articleId: item.id.toString(), action: action))!;

    if (result.result == Result.error) {
      Fluttertoast.showToast(
          msg: favStatus.statusCode == "fav" ? "添加收藏失败" : "取消收藏失败",
          toastLength: Toast.LENGTH_SHORT,
          gravity: ToastGravity.CENTER,
          timeInSecForIosWeb: 1,
          backgroundColor: Colors.red,
          textColor: Colors.white,
          fontSize: 16.0);
    } else {
      if (favStatus.statusCode == "fav") {
        dispatch(ArticleDetailActionCreator.onFav(FavStatus.FAV));
      }
      if (favStatus.statusCode == "unfav" && item.favCount > 0) {
        dispatch(ArticleDetailActionCreator.onFav(FavStatus.UNFAV));
      }
      Fluttertoast.showToast(
          msg: favStatus.statusCode == "fav" ? "添加收藏成功" : "取消收藏成功",
          toastLength: Toast.LENGTH_SHORT,
          gravity: ToastGravity.CENTER,
          timeInSecForIosWeb: 1,
          backgroundColor: Colors.red,
          textColor: Colors.white,
          fontSize: 16.0);
    }
  }

  void navToChannelDetail() async {
    Channel channel = (await Repo.fetchChannelItem(int.parse(item.subSourceId)))!;
    var data = {'name': "originalstories", "channel": channel};
    Widget page = ChannelpgPage().buildPage(data);
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => page),
    );
  }

  /// 是否是编辑选择频道链接显示不同的颜色
  TextStyle getDomainStyle(Item article) {
    if (article.editorPick == 1) {
      return new TextStyle(color: Color(0xFFFFA826), fontSize: 15);
    } else {
      return new TextStyle(color: Color(0xFF0A0A0A), fontSize: 15);
    }
  }

  ImageSourceMatcher base64UriMatcher() =>
      (attributes, element) => attributes["src"] != null && attributes["src"]!.startsWith("data:image") && attributes["src"]!.contains("base64,");

  Widget loadingWidget() {
    return Center(
      child: Container(
          height: 400.0,
          width: 120.0,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              SizedBox(
                child: CircularProgressIndicator(),
                height: 50.0,
                width: 50.0,
              )
            ],
          )),
    );
  }

  final Map<ImageSourceMatcher, ImageRender> defaultImageRenders = {
    base64UriMatcher(): base64ImageRender(),
    assetUriMatcher(): assetImageRender(),
    networkSourceMatcher(extension: "svg"): svgNetworkImageRender(),
    networkSourceMatcher(): networkImageRender(height: 400, loadingWidget: loadingWidget),
  };

  SingleChildScrollView buildListView(Item item, BuildContext context) {
    return SingleChildScrollView(
        key: PageStorageKey("detail" + item.id),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            InkWell(
              onTap: () => CommonUtils.launchUrl(item.link),
              child: Padding(
                padding: const EdgeInsets.only(bottom: 8.0),
                child: Container(
                  child: Text(
                    item.title,
                    style: Theme.of(context).textTheme.headline5!.copyWith(
                          fontWeight: FontWeight.w600,
                        ),
                  ),
                ),
              ),
            ),
            if (item.domain != "")
              Padding(
                padding: const EdgeInsets.only(bottom: 8.0),
                child: InkWell(
                    onTap: () async {
                      navToChannelDetail();
                    },
                    child: Text(
                      item.domain,
                      style: getDomainStyle(item),
                    )),
              ),
            InkWell(
              onTap: () {},
              child: RichText(
                text: TextSpan(
                  children: <TextSpan>[
                    TextSpan(
                      text: item.author,
                      style: Theme.of(context).textTheme.caption,
                    ),
                    TextSpan(
                      text: " ${String.fromCharCode(8226)} ",
                      style: Theme.of(context).textTheme.caption,
                    ),
                    TextSpan(
                      text: item.ago,
                      style: Theme.of(context).textTheme.caption,
                    ),
                  ],
                ),
              ),
            ),
            if (item.content != "")
              Html(
                  data: item.content,
                  style: {
                    "body": Style(
                      fontSize: FontSize(19.0),
                    ),
                  },
                  customImageRenders: defaultImageRenders,
                  onLinkTap: (String? url, RenderContext context, Map<String, String> attributes, dom.Element? element) {
                    CommonUtils.launchUrl(url);
                  }),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Row(
                  children: [
                    Padding(
                      padding: const EdgeInsets.only(right: 16.0),
                      child: Row(
                        children: [
                          if (item.isFav == 1)
                            IconButton(
                              icon: Icon(Icons.bookmark, color: Theme.of(context).primaryColor),
                              onPressed: () => touchFav("unfav", FavStatus.UNFAV),
                            ),
                          if (item.isFav != 1)
                            IconButton(
                              icon: Icon(Icons.bookmark),
                              onPressed: () => touchFav("fav", FavStatus.FAV),
                            ),
                          Padding(
                            padding: const EdgeInsets.only(left: 0.0),
                            child: Text(
                              "${item.favCount}",
                              textAlign: TextAlign.center,
                              style: Theme.of(context).textTheme.caption!.copyWith(
                                    color: Theme.of(context).primaryColor,
                                  ),
                            ),
                          ),
                        ],
                      ),
                    ),
                    Padding(
                      padding: const EdgeInsets.only(bottom: 0.0),
                      child: Row(
                        children: [
                          if (item.isUpvote == 1)
                            IconButton(
                              icon: Icon(Icons.thumb_up, color: Theme.of(context).primaryColor),
                              onPressed: () => touchUpvote("unupvote", UpvoteStatus.UNUPVOTE),
                            ),
                          if (item.isUpvote != 1)
                            IconButton(
                              icon: Icon(Icons.thumb_up),
                              onPressed: () => touchUpvote("upvote", UpvoteStatus.UPVOTE),
                            ),
                          Padding(
                            padding: const EdgeInsets.only(left: 8.0),
                            child: Text(
                              "${item.upvoteCount}",
                              textAlign: TextAlign.center,
                              style: Theme.of(context).textTheme.caption!.copyWith(
                                    color: Theme.of(context).primaryColor,
                                  ),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
                IconButton(
                  icon: Icon(
                    Feather.share_2,
                  ),
                  onPressed: () => handleShare(id: item.id, title: item.title, postUrl: item.link),
                ),
              ],
            ),
          ],
        ));
  }

  var imageCache= PaintingBinding.instance!.imageCache;

  print("dd:"+imageCache!.currentSizeBytes.toString());
  print("size:" + imageCache.currentSize.toString());

  return GestureDetector(
      onHorizontalDragStart: _onHorizontalDragStart,
      onHorizontalDragUpdate: _onHorizontalDragUpdate,
      onHorizontalDragEnd: _onHorizontalDragEnd,
      child: Container(
        constraints: BoxConstraints(
          minHeight: MediaQuery.of(context).size.height * 0.9,
        ),
        color: Theme.of(context).scaffoldBackgroundColor,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: buildListView(item, context),
        ),
      ));
}

Latest update: still trigger OOM when clear image cache, the picture seems not unload from memory, load more picture, the memory increase more(and the memory never be GC).

1

There are 1 answers

0
Mohammed Alfateh On

I was having the same issue and added your question as a bookmark waiting for an answer, recently I found a simple way to fix it.

the basic idea is to use ImageProvider for every image you want to display and put it in the memory using precacheImage function and then you can easily remove it from memory by calling evict function on the ImageProvider

// any class that extend ImageProvider
ImageProvider().evict();
// example 
MemoryImage(bytes).evict();

A full working code as an answer would be too long. But I have already released a package to pub.dev that can do the same and have other features disposable_cached_images.

You can review the source code to better understand the implementation.

edit:

After more tests, it turns out that calling evict on the image provider sometimes doesn't work, especially if it was called during resolve so I made changes, and found it's better to use ui.image directly without the provider image.

ui.image.dispose() works very well.