图片缓存区太大的案例 1、案例描述
在我们的Flutter打包的app上,出现测试人员对首页的无限列表疯狂频繁刷,冷启动刷的时候还好,越刷越后面越卡。
2、案例分析过程
经测试验证在其他机器上无法重现。后通过观察应用的磁盘占比,发现出现崩溃的该设备该应用的磁盘占比巨大,达好几G(只要占用有网络点播视频缓存、打赏动画视频、图片)。进一步观察发现在图片不大的情况下,其中图片的占比竟然也能接近上G,说明图片数量巨大 。由此猜测是图片数量太多导致。随后通过高磁盘占用环境模拟 ,确实也出现了同样的情况。由此我们开始设想磁盘对应用性能的影响 的几种原因,并进行对应修复。经过代码排查图片库内部的同步、阻塞问题,发现了一个潜在的同步危险。
1 if (cacheFlie.existsSync()) {
3、为什么原生iOS上没发现类似问题/微信为什么没出现类似问题?
可能原因:①之前原图相同的图片显示在不同地方,也只是原图和缩略图两套而已,而不像图片万象这样,可能会存在好多套。②原生的SDWebImage在监测到内存不足的时候会自动进行清除。
图片缓存(无、太小、太大)的问题 小结:
图片缓冲区默认太小时(一定有问题)
图片缓冲区设大后(可能有问题)
出现的平台
Flutter上默认的缓存上限1000个图片或者100MB内。 原生iOS的SDWebImage一般不设大小,只根据maxCacheAge
Flutter上extended_image判断图片是否已缓存时候有性能问题,而导致卡顿。 原因滑动列表会进行很多图片的获取,从而由于任务过多,对性能产生影响,例如增加CPU使用率,导致电池消耗加快,进而可能引发崩溃。
后果
加载过的图片很容易再次出现。
可能:存的图片数量太大。影响图片存取,而崩溃。
出现的案例
①列表划过之后再回来; ②列表页面离开后再回来。
测试人员频繁刷,导致刷满图片缓冲区后,图片数量太大,影响图片存取,从而崩溃。
解决方案
图片存储进行分区。对 url 进行 md5 取值,对 md5 取前两个字母为新文件夹。
1、图片缓冲区默认太小时 在Flutter中加载图片(一般是网络图片),我们常常会遇到下面几个问题:Flutter 图片缓存问题分析
同页面内,加载过的图片,再次出现的时候,会重新加载,特别是列表的图片;
根源:Flutter 内置的缓存机制 PaintingBinding.instance.imageCache 的 maximumSize 和 maximumSizeBytes 属性默认的缓存上限1000个图片或者100MB内。
1 2 const int _kDefaultSize = 1000 ;const int _kDefaultSizeBytes = 100 << 20 ;
即图片没有加载到100MB,加载到1000个图片,也会开始根据LRU的规则清理释放缓存。在某些情况下,比如电商,一整个页面80%的元素都用图片占满,小到图标,大到广告banner,个数很容易就到1000了,但是经过压缩剪裁,图片普遍都控制在几十kb甚至10kb以下,即使1000张也远远达不到内存的上限,并且100MB的上限对于某些机型来讲也相对较小了。
解决:设置大一点
1 2 PaintingBinding.instance.imageCache.maximumSize = 10000 ; PaintingBinding.instance.imageCache.maximumSizeBytes = 800 << 20 ;
附:除了 Flutter 内置的缓存机制外,还有第三方库如 cached_network_image 提供了更丰富的图片缓存功能,包括硬盘缓存等 。如果需要更复杂的图片缓存策略,可以考虑使用这类第三方库来扩展 Flutter 的图片加载和缓存能力。
附2:extended_image 提供了一套完整的图片加载和缓存解决方案,其缓存的控制也还是通过修改 ImageCache 的配置来控制内存缓存的大小和数量。
附2:SDWebImage 默认情况下会缓存图片一周(maxCacheAge 的默认值),并且没有对缓存空间大小设置限制(maxCacheSize 默认值未设定),这意味着理论上应用中的图片缓存可以占满整个设备存储空间。
1 [SDImageCache sharedImageCache].maxCacheSize = 50 * 1024 * 1024 ; *
列表快速滑动时,加载完成再往回滑动,之前的图片还是需要重新加载;
根源:等同于按需加载。
解释:加载图片时,为了避免过快滑动 ,使得同时加载的图片过多导致卡顿甚至崩溃。所以若是内存中已有缓存,则直接返回缓存,若是没有则判断是否在快速滑动,若是正在快速滑动,则下一帧 再加入队列处理,若是图片已经被移出屏幕(即没有在tree上),可能会被跳过。
解决:快速滑动如此,可不处理。
有时返回上一页面时,上一页面已经加载完成的图片,会重新加载,假如没有占位图会特别明显的闪动。
同现象1一样。
2、图片缓冲区设大后 图片分区:
SDWebImage 所有的图片都放在 _diskCachePath 目录下,_diskCachePath是只有一层吗?还是里面还会再分文件目录?
SDWebImage 的 _diskCachePath 目录是 SDWebImage 用来存储磁盘缓存的根目录。在这个根目录下,SDWebImage 会进一步组织文件结构,以避免所有图片都放在单一目录下造成文件系统性能问题。具体来说,**图片文件会根据图片 URL 的 MD5 值进行散列,然后根据这个散列值来创建子目录和文件名,从而在磁盘上形成一个树状的目录结构 **。
这种目录结构的设计有助于分散文件系统操作,减少单个目录下的文件数量,从而提高文件检索和写入的性能。此外,SDWebImage 还提供了设置选项,允许开发者自定义图片的存储路径,以及通过 addReadOnlyCachePath: 方法添加额外的只读缓存路径 。
总的来说,_diskCachePath 不是单一层级,而是一个包含多个子目录的复杂结构,每个子目录下存储着根据 MD5 散列值组织好的图片文件,以此来优化磁盘缓存的性能和效率 。
一、磁盘对应用性能的影响 1、原因猜测 可能原因:应用使用过程时候,存在对磁盘中的某个文件的操作,从而导致应用性能问题。操作的可能性如下:
序号
阶段
如果
后果
1
访问
文件量太大
访问效率问题
2
读写
文件大小太大
读写问题
2、排查方案
图片来源于:高磁盘占用的排查与优化.graffle
二、高磁盘占用环境模拟
步骤
阶段
操作
目的
1
资源准备1
提供图片的万象接口,以通过万象能获取到各种图片地址
避免得去找非常多的图片数据源
2
资源准备2
提供下载万象图片的接口(指定宽范围),以通过万象图片能下载到很多图片
避免得去找非常多的图片数据源
3
资源下载
下载指定宽范围的万象图片,得到各种图片 每次只添加 100 张,避免模拟功能在同步状态下执行出错;
通过下载占据磁盘空间
4
数据验证1
打印磁盘(沙盒)目录,验证下载iOS-查看沙盒文件(真机+模拟器)
验证确实磁盘空间被增加
5
数据验证2
计算磁盘大小
验证确实磁盘空间被增加
相应的代码实现,见 附1:高磁盘占用环境模拟的相关代码
三、高磁盘占用环境优化
序号
解决
方案
1
避免单文件夹太大
存储分区处理
2
单文件夹已经太大
存储空间清理
1、避免单文件夹太大—-存储分区处理 1、背景:图片文件下文件数量太多,导致搜索效率下降
1 2 3 4 5 6 7 8 9 10 final Directory _cacheImagesDirectory = Directory( join((await getTemporaryDirectory()).path, cacheImageFolderName)); if (_cacheImagesDirectory.existsSync()) { final File cacheFlie = File(join(_cacheImagesDirectory.path, md5Key)); if (cacheFlie.existsSync()) { } }
2、优化方案:对图片存储,进行分区存储。获取方式同理。
3、优化步骤如下
对 url 进行 md5 取值
对 md5 取首字母为新文件夹
将该url所对应的图片存储在图片存储目录下的子目录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 static String keyToMd5(String key) => md5.convert(utf8.encode(key)).toString();static Future<String > getLastDirPath(String imageUrl, {bool? useSubDir}) async { Directory cacheDir = await getTemporaryDirectory(); String imageCacheHomeDirPath = join(cacheDir.path, cacheImageFolderName); if (useSubDir != true ) { return imageCacheHomeDirPath; } String subDirHome = keyToMd5(imageUrl); String imageCacheSubDirName = subDirHome.substring(0 , 1 ); String lastDirPath = "$imageCacheHomeDirPath /$imageCacheSubDirName " ; return lastDirPath; }
4、优化验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 static Future<int > calculateFindDuration(String imageUrl, {bool? useSubDir}) async { String md5Key = keyToMd5(imageUrl); final String lastDirPath = await getLastDirPath(imageUrl, useSubDir: useSubDir); final Directory cacheImagesDirectory = Directory(lastDirPath); int foundStartTime = DateTime .now().millisecondsSinceEpoch; if (cacheImagesDirectory.existsSync()) { final File cacheFlie = File(join(cacheImagesDirectory.path, md5Key)); if (cacheFlie.existsSync()) { int foundEndTime = DateTime .now().millisecondsSinceEpoch; int foundDuration = foundEndTime - foundStartTime; return foundDuration; } } return -1 ; }
2、单文件夹已经太大—-存储空间清理 文件的属性有创建时间、修改时间
原理:变化过期时间,遍历文件属性,删除修改时间大于该过期时间的文件
1、设置过期时间的初始值,遍历文件属性,删除修改时间大于该过期时间的文件;
2、检查清理后的大小,如果大小还不在规定大小内,则继续清理;
3、继续清理:设置每次清理的过期时间增加值,对过期时间的初始值增加,并重复以上步骤;
四、优化方案的实施 目的:避免功能异常,出现集体性问题。
灰度方案:请参照 灰度系统
五、延伸 问题:一旦修改分区规则(如原本使用url进行md5后的首位值,后面改成md5后的两位值),旧文件就得重新下载。
解决:使用一致性哈希算法。16 张图解带你掌握一致性哈希算法
1、对存储节点进行hash,创建节点和hash值的哈希映射,如 {100001: “a”, 300001: “a”, 400001: “a”}
2、对数据url进行hash寻址
首先,对 key 进行哈希计算,确定此 key 在环上的位置;
然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。(对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?)
结果:在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响 。
假设节点数量从 3 减少到了 2,比如将节点 A 移除:你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。
隐患:一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题 。如下:
说明:一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值 。
附:hash算法
使用一致性哈希(Consistent Hashing)可以帮助您将大批图片文件分散存储到不同的文件中,其中每个文件对应一个字母或数字。一致性哈希算法能够提供高效的数据分布和负载均衡,同时允许动态添加或删除文件节点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 import 'dart:convert' ;import 'package:crypto/crypto.dart' ;class ConsistentHashing { List <String > nodes; Map <int , String > ring; ConsistentHashing(List <String > nodes) { this .nodes = nodes; this .ring = {}; for (String node in nodes) { addNode(node); } } void addNode(String node) { for (int n = 0 ; n < 3 ; n++) { String virtualNode = node + n.toString(); int hashValue = _hash(virtualNode); ring[hashValue] = node; } } void removeNode(String node) { for (int n = 0 ; n < 3 ; n++) { String virtualNode = node + n.toString(); int hashValue = _hash(virtualNode); ring.remove(hashValue); } } String getNode(String key) { if (ring.isEmpty) { return null ; } int hashValue = _hash(key); List <int > sortedKeys = ring.keys.toList()..sort(); for (int ringKey in sortedKeys) { if (hashValue <= ringKey) { return ring[ringKey]; } } return ring[sortedKeys[0 ]]; } int _hash(String key) { Digest hash = md5.convert(utf8.encode(key)); int hashValue = int .parse(hash.toString(), radix: 16 ); return hashValue; } } void main() { List <String > nodes = List .generate(26 , (index) => String .fromCharCode(97 + index)) ..addAll(List .generate(10 , (index) => index.toString())); ConsistentHashing ring = ConsistentHashing(nodes); List <String > imageFiles = ['image1.jpg' , 'image2.jpg' , 'image3.jpg' ]; for (String imageFile in imageFiles) { String node = ring.getNode(imageFile); print ('Storing $imageFile to node $node ' ); } }
附1:高磁盘占用环境模拟的相关代码 1、ts_toomany_image_data_vientiane.dart
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class TSTooManyImageDataVientiane { static String newImageUrl(String imageUrl, {required int width}) { String thumbnail = '?imageMogr2/thumbnail/' ; thumbnail += '${width} x/' ; thumbnail += 'format/webp/auto-orient/quality/100' ; String newImageUrl = "$imageUrl $thumbnail " ; return newImageUrl; } }
2、ts_toomany_image_download_util.dart
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import 'dart:io' ;import 'package:extended_image/extended_image.dart' ;import 'ts_toomany_image_data_vientiane.dart' ;class TSTooManyImageDownloadUtil { static String vientianeImageUrl = "https://images.xihuanwu.com/mcms/uploads/1647604960983901.jpg" ; static void simulate_download_too_many(int imageWidthStart, int imageWidthEnd) { int imageCount = imageWidthEnd - imageWidthStart; for (var i = 0 ; i < imageCount; i++) { int iImageWidth = imageWidthStart + i; String iImageUrl = TSTooManyImageDataVientiane.newImageUrl(vientianeImageUrl, width: iImageWidth); getNetworkImageData(iImageUrl); sleep(const Duration (milliseconds: 10 )); } } }
3、ts_toomany_image_optimize_check_util.dart
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import 'dart:convert' ;import 'dart:io' ;import 'package:crypto/crypto.dart' ;import 'package:flutter/foundation.dart' ;import 'package:path/path.dart' ;import 'package:path_provider/path_provider.dart' ;import 'package:extended_image_library/extended_image_library.dart' ;import 'ts_toomany_image_data_vientiane.dart' ;import 'ts_toomany_image_download_util.dart' ;class TSTooManyImageOptimizeCheckUtil { static checkImageFindDuration() async { String foundImageUrl = TSTooManyImageDataVientiane.newImageUrl(TSTooManyImageDownloadUtil.vientianeImageUrl, width: 6600 ); getNetworkImageData(foundImageUrl); int foundDuration = await TSTooManyImageOptimizeCheckUtil.calculateFindDuration(foundImageUrl); debugPrint("$foundImageUrl 的查找时间为:$foundDuration " ); } }
End