ImageProvider 的缓存键(Cache Key):自定义比较逻辑与内存回收钩子

ImageProvider 的缓存键(Cache Key):自定义比较逻辑与内存回收钩子

大家好,今天我们来深入探讨 Flutter 中 ImageProvider 的缓存机制,特别是缓存键(Cache Key)的自定义比较逻辑以及内存回收钩子。理解这些概念对于优化图片加载性能、减少内存占用以及避免不必要的图片重新加载至关重要。

ImageProvider 与 ImageCache 简介

在 Flutter 中,ImageProvider 是一个抽象类,负责从各种来源(网络、本地文件、资源文件等)提供图片数据。Flutter 通过 ImageCache 来缓存已经加载的图片,以便在后续需要相同图片时快速获取,而无需重新加载。

ImageCache 本质上是一个键值对存储,其中键是 ImageProvider 的缓存键(Cache Key),值是 ImageStreamCompleter,它负责管理图片加载过程并提供 ImageInfo 对象。

当我们使用 Image.networkImage.asset 等 Widget 时,它们实际上是在幕后使用相应的 ImageProvider 来加载图片,并利用 ImageCache 进行缓存。

缓存键(Cache Key)的重要性

缓存键是 ImageCache 中用于查找已缓存图片的关键。默认情况下,Flutter 使用 ImageProvideroperator== 方法来比较缓存键。这意味着,如果两个 ImageProvider 对象被认为相等(即 provider1 == provider2 返回 true),它们将被视为指向同一张图片,并从缓存中返回相同的 ImageInfo

然而,默认的相等性比较可能并不总是满足我们的需求。例如,我们可能需要根据图片的特定属性(如尺寸、质量、变换参数等)来区分缓存键,即使两个 ImageProvider 对象本身是不同的。

自定义缓存键的比较逻辑

为了实现自定义的缓存键比较逻辑,我们需要自定义 ImageProvider,并重写 obtainKey 方法。obtainKey 方法负责生成一个用于缓存的键。这个键会被传递给 ImageCacheputIfAbsent 方法。

下面是一个自定义 ImageProvider 的例子,它根据图片的 URL 和期望的最大尺寸来生成缓存键:

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';

class CustomNetworkImageProvider extends ImageProvider<CustomNetworkImageProvider> {
  final String url;
  final double? maxWidth;
  final double? maxHeight;
  final Map<String, String>? headers;

  const CustomNetworkImageProvider(
    this.url, {
    this.maxWidth,
    this.maxHeight,
    this.headers,
  });

  @override
  ImageStreamCompleter load(CustomNetworkImageProvider key, DecoderCallback decode) {
    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key, decode),
      scale: key.scale,
      informationCollector: () sync* {
        yield DiagnosticsProperty<ImageProvider>(
          'Image provider: $this n Image key: $key',
          this,
          style: DiagnosticsTreeStyle.errorProperty,
        );
      },
    );
  }

  Future<Codec> _loadAsync(CustomNetworkImageProvider key, DecoderCallback decode) async {
    final Uri resolved = Uri.base.resolve(key.url);
    final HttpClient client = HttpClient();
    final HttpClientRequest request = await client.getUrl(resolved);
    if (headers != null) {
      headers!.forEach((String name, String value) {
        request.headers.add(name, value);
      });
    }
    final HttpClientResponse response = await request.close();
    if (response.statusCode != HttpStatus.ok) {
      // Depending on the error code, a more appropriate exception may be thrown.
      throw Exception('HTTP request failed, statusCode: ${response.statusCode}, URI: $resolved');
    }

    final Uint8List bytes = await consolidateHttpClientResponseBytes(response);
    if (bytes.isEmpty) {
      throw Exception('NetworkImage is an empty file: $resolved');
    }

    return decode(bytes);
  }

  @override
  Future<CustomNetworkImageProvider> obtainKey(ImageConfiguration configuration) {
    return SynchronousFuture<CustomNetworkImageProvider>(this);
  }

  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is CustomNetworkImageProvider &&
           other.url == url &&
           other.maxWidth == maxWidth &&
           other.maxHeight == maxHeight;
  }

  @override
  int get hashCode => Object.hash(url, maxWidth, maxHeight);

  @override
  String toString() => '$runtimeType(url: "$url", maxWidth: $maxWidth, maxHeight: $maxHeight)';
}

在这个例子中,CustomNetworkImageProvider 包含了 urlmaxWidthmaxHeight 三个属性。obtainKey 方法返回 this,这意味着 CustomNetworkImageProvider 对象本身就是缓存键。我们重写了 operator==hashCode 方法,以便根据这三个属性来比较 CustomNetworkImageProvider 对象。

现在,如果两个 CustomNetworkImageProvider 对象具有相同的 urlmaxWidthmaxHeight 值,它们将被视为指向同一张图片,并从缓存中返回相同的 ImageInfo。即使它们的 headers 不同,也会命中缓存。

需要注意的是,obtainKey 方法必须返回一个 Future 对象。在上面的例子中,我们使用了 SynchronousFuture,因为它立即返回结果。但是在某些情况下,您可能需要在 obtainKey 方法中执行一些异步操作,例如从本地存储读取缓存键。

内存回收钩子(Memory Callback)

ImageCache 实现了 LRU (Least Recently Used) 算法,当缓存达到最大容量时,会自动移除最近最少使用的图片。但是,在某些情况下,我们可能需要手动干预缓存的回收过程。例如,我们可能需要在图片被移除缓存时执行一些清理操作,例如释放相关的资源。

ImageCache 提供了一个 onEvict 回调,允许我们在图片被移除缓存时执行自定义的逻辑。

以下是一个使用 onEvict 回调的例子:

import 'dart:ui' as ui;
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Image Cache Example',
      home: ImageCacheExample(),
    );
  }
}

class ImageCacheExample extends StatefulWidget {
  @override
  _ImageCacheExampleState createState() => _ImageCacheExampleState();
}

class _ImageCacheExampleState extends State<ImageCacheExample> {
  int _evictedCount = 0;

  @override
  void initState() {
    super.initState();
    PaintingBinding.instance.imageCache.maximumSize = 5; // 设置缓存最大数量

    PaintingBinding.instance.imageCache.onEvict = (Object key) {
      setState(() {
        _evictedCount++;
      });
      print('Image evicted from cache: $key');
    };
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Image Cache Example'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text('Images Evicted: $_evictedCount'),
            SizedBox(height: 20),
            Image.network('https://via.placeholder.com/150', width: 150, height: 150),
            Image.network('https://via.placeholder.com/151', width: 150, height: 150),
            Image.network('https://via.placeholder.com/152', width: 150, height: 150),
            Image.network('https://via.placeholder.com/153', width: 150, height: 150),
            Image.network('https://via.placeholder.com/154', width: 150, height: 150),
            Image.network('https://via.placeholder.com/155', width: 150, height: 150),
          ],
        ),
      ),
    );
  }
}

在这个例子中,我们在 initState 方法中设置了 ImageCacheonEvict 回调。当图片被移除缓存时,_evictedCount 变量会递增,并且会打印一条日志。

注意:onEvict 回调在图片被真正移除缓存时才会触发。这意味着,即使缓存达到了最大容量,如果所有缓存的图片都在使用中,onEvict 回调也不会立即触发。只有当缓存中的图片不再被使用时,才会被移除并触发 onEvict 回调。

实际应用场景

自定义缓存键和内存回收钩子在以下场景中非常有用:

  • 图片尺寸优化: 根据设备屏幕尺寸或用户设置,动态调整图片尺寸。可以使用不同的 maxWidthmaxHeight 值来生成不同的缓存键,从而避免加载过大的图片。
  • 图片质量控制: 根据网络状况或用户设置,动态调整图片质量。可以使用不同的质量参数来生成不同的缓存键,从而在网络状况较差时加载低质量的图片。
  • 图片变换: 对图片进行旋转、缩放、裁剪等变换。可以使用不同的变换参数来生成不同的缓存键,从而避免重复变换相同的图片。
  • 内存优化: 在图片被移除缓存时,释放相关的资源,例如解码后的图片数据。可以使用 onEvict 回调来执行清理操作,从而减少内存占用。
  • A/B测试: 针对不同的用户群体展示不同版本的图片。可以通过在 ImageProvider 中加入用户ID或者A/B测试的标识作为缓存键的一部分,保证每个用户看到的都是正确的图片。

缓存键和内存回收钩子使用的注意事项

  1. 缓存键的唯一性: 确保缓存键能够唯一标识一张图片。如果缓存键不唯一,可能会导致缓存污染,即错误的图片被显示在错误的位置。
  2. 缓存键的性能: 缓存键的生成和比较应该尽可能高效。复杂的缓存键可能会影响图片加载性能。
  3. 内存回收钩子的性能: 内存回收钩子应该尽可能快地执行。长时间运行的内存回收钩子可能会导致 UI 卡顿。
  4. 避免内存泄漏: 在内存回收钩子中,确保释放所有相关的资源。如果忘记释放资源,可能会导致内存泄漏。
  5. 正确处理异步操作: 如果在 obtainKey 或者 onEvict 中有异步操作,需要确保正确处理 Future 的完成和错误。

总结

通过自定义 ImageProvider 的缓存键比较逻辑,我们可以根据图片的特定属性来区分缓存键,从而实现更精细的缓存控制。通过使用 ImageCacheonEvict 回调,我们可以在图片被移除缓存时执行自定义的逻辑,例如释放相关的资源。理解这些概念对于优化图片加载性能、减少内存占用以及避免不必要的图片重新加载至关重要。

代码示例:带变换参数的缓存键

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'dart:ui' as ui;

class TransformedImageProvider extends ImageProvider<TransformedImageProvider> {
  final String url;
  final double rotationAngle;
  final double scale;

  const TransformedImageProvider(
    this.url, {
    this.rotationAngle = 0.0,
    this.scale = 1.0,
  });

  @override
  ImageStreamCompleter load(TransformedImageProvider key, DecoderCallback decode) {
    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key, decode),
      scale: key.scale,
      informationCollector: () sync* {
        yield DiagnosticsProperty<ImageProvider>(
          'Image provider: $this n Image key: $key',
          this,
          style: DiagnosticsTreeStyle.errorProperty,
        );
      },
    );
  }

  Future<Codec> _loadAsync(TransformedImageProvider key, DecoderCallback decode) async {
    final Uri resolved = Uri.base.resolve(key.url);
    final HttpClient client = HttpClient();
    final HttpClientRequest request = await client.getUrl(resolved);

    final HttpClientResponse response = await request.close();
    if (response.statusCode != HttpStatus.ok) {
      // Depending on the error code, a more appropriate exception may be thrown.
      throw Exception('HTTP request failed, statusCode: ${response.statusCode}, URI: $resolved');
    }

    final Uint8List bytes = await consolidateHttpClientResponseBytes(response);
    if (bytes.isEmpty) {
      throw Exception('NetworkImage is an empty file: $resolved');
    }

    // Apply the transformation here before decoding (for efficiency if possible)
    // This is a placeholder, actual transformation logic depends on the image library used.
    // You might use `image` package or similar.
    final transformedBytes = bytes; // Replace this with actual transformation logic

    return decode(transformedBytes);
  }

  @override
  Future<TransformedImageProvider> obtainKey(ImageConfiguration configuration) {
    return SynchronousFuture<TransformedImageProvider>(this);
  }

  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is TransformedImageProvider &&
           other.url == url &&
           other.rotationAngle == rotationAngle &&
           other.scale == scale;
  }

  @override
  int get hashCode => Object.hash(url, rotationAngle, scale);

  @override
  String toString() => '$runtimeType(url: "$url", rotationAngle: $rotationAngle, scale: $scale)';
}

// Usage in a Widget
class MyImageWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Image(
      image: TransformedImageProvider('https://example.com/image.jpg', rotationAngle: 0.5, scale: 1.2),
    );
  }
}

表格:ImageProvider 缓存配置选项

配置项 类型 描述
maximumSize int ImageCache 允许缓存的最大图片数量。超过这个数量后,最近最少使用的图片会被移除。
maximumSizeBytes int ImageCache 允许缓存的最大内存大小(以字节为单位)。超过这个大小后,最近最少使用的图片会被移除。
onEvict Function 一个回调函数,在图片被从 ImageCache 中移除时调用。可以用于执行清理操作,例如释放相关的资源。 回调函数接收一个 Object 类型的参数,表示被移除图片的缓存键。

理解 ImageCache 的内部实现

深入了解 ImageCache 的内部实现有助于我们更好地理解其工作原理,并做出更明智的缓存策略决策。ImageCache 内部使用一个 LinkedHashMap 来存储缓存的图片。LinkedHashMap 是一种保持插入顺序的 Map,这使得 ImageCache 可以方便地实现 LRU 算法。

当调用 ImageCache.putIfAbsent 方法时,ImageCache 会首先检查缓存中是否已经存在具有相同缓存键的图片。如果存在,则直接返回缓存的 ImageStreamCompleter。如果不存在,则将新的 ImageStreamCompleter 放入缓存中。

当缓存达到最大容量时,ImageCache 会从 LinkedHashMap 的头部移除最近最少使用的图片,并调用 onEvict 回调(如果已设置)。

总结:缓存键的自定义和回收机制

本文详细讲解了 Flutter 中 ImageProvider 的缓存键自定义比较逻辑以及内存回收钩子的使用。通过自定义缓存键,我们可以精确控制图片的缓存行为,避免不必要的重复加载。而通过内存回收钩子,我们可以在图片从缓存中移除时执行清理操作,优化内存占用。

建议:根据实际场景选择合适的缓存策略

在实际开发中,我们应该根据具体的应用场景选择合适的缓存策略。对于经常使用的图片,可以增加缓存大小,以提高加载速度。对于不常用的图片,可以减少缓存大小,以减少内存占用。同时,我们应该充分利用自定义缓存键和内存回收钩子,实现更精细的缓存控制。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注