在没有 OOM 的情况下,在 Flutter 中加载手机图库的速度与原生 IOS 一样快

Posted

技术标签:

【中文标题】在没有 OOM 的情况下,在 Flutter 中加载手机图库的速度与原生 IOS 一样快【英文标题】:Load phone's gallery as fast as native IOS in Flutter without OOM 【发布时间】:2021-12-04 23:56:03 【问题描述】:

我正在尝试将手机的图库(带有分页)加载到 GridView.builder 小部件中。

这是我使用photo_manager 包创建的issue。 我得到了一些帮助,这让我想到了一个可能的解决方案(请参阅我对这个问题的最后评论)。

我希望能够在不闪烁或出现白页的情况下加载资产。 在 ios 原生上它非常快速和流畅,我想在 Flutter 中实现同样的效果。

您将在上面的 github 链接中找到我制作的所有代码。我已经设法在内存中使用 Map 对象,但我需要改进算法以使其不在 OOM 中。

想要的解决方案(一个或另一个):

一种简单的方法,将手机的图库加载到与原生 IOS 一样快的 GridView,无论在工作时使用哪个包。 对我目前较差的算法的改进,例如,将 15 个资产保持在当前资产之上,15 个资产在内存中和滚动期间,不断更新这些值以围绕列表中的当前位置移动范围。

如果这还不够清楚,请告诉我,作为提醒,请查看我对 issue 的最后一条重要评论。

【问题讨论】:

在因为“需要更多关注”而要求关闭之前,请在评论中说明不清楚的地方,我会更新问题。用例很简单,我只需要手机的图库加载速度和IOS原生图库一样快。 如果你只需要创建一个图库作为图像选择器,你有没有考虑过使用image_picker插件? 我需要自定义图库。这意味着我必须构建一个自定义 UI 页面来加载项目 + 其他 UI 规范。 image_picker 运行良好,但它会打开一个新的 Intent / 新页面,该页面不可自定义。如果我错了,请随时写一个答案。 【参考方案1】:

你可以使用这样的逻辑:

final Map<String, Uint8List?> _cachedMap = ;
void precacheAssets(int index) async 
    // Handle cache before index
    for (int i = max(0, index - 50); i < 50; i++) 
      getItemAtIndex(i);
    
    // Handle cache after index
    for (int i = min(assetsList.length, index + 50); i < 50 + min(assetsList.length, index + 50); i++) 
      getItemAtIndex(i);
    
    _cachedMap.removeWhere((key, value) 
      int currIndex = assetsList.indexWhere((element) => element.id == key);
      return currIndex < index - 50 && currIndex > index + 50;
    );
  
  /// Get the asset from memory or fetch it if it doesn’t exist yet.
  /// Called in the builder method to display assets, not to precache them.
  Future<Uint8List?> getItemAtIndex(int index) async 
    AssetEntity entity = assetsList[index];
    if (_cachedMap.containsKey(entity.id)) 
      return _cachedMap[entity.id];
    
    else 
      Uint8List? thumb = await entity.thumbDataWithOption(
          ThumbOption.ios(
              width: width,
              height: height,
              deliveryMode: DeliveryMode.highQualityFormat,
              quality: 90));
      _cachedMap[entity.id] = thumb;
      return thumb;
    
  

您可以在 GridView.builder 中的特定索引处调用 precacheAssets 方法,例如 if (index % 25 == 0),它将告诉每 25 个项目,将 50 个下一个项目放入缓存中,这样它将向现有项目添加 25 个更多项目缓存。

另外,将Future.builder 中的getItemAtIndex 称为future 参数,如果资产在内存中,您将立即获得该资产,否则照常加载。

随意更改值并对其进行测试,我的 iPhone 中的这些值已经改进了它,但如果你滚动得非常快,你仍然会看到和以前一样。

在这种情况下,您可以添加FadeTransition,这将导致 UI 不难看。

【讨论】:

感谢您的回答。由于它的加载速度仍然没有本地 IOS 完美,因此我还没有将其设置为可接受的答案,以防有人找到更好的方法,但我正在调整你的示例,我有一些非常令人满意的东西!跨度> 【参考方案2】:

解决方案非常简单:用拇指而不是大图像填充您的 GridView

// Load [AssetEntity]s:
images = await album.getAssetListRange(start: 0, end: 50);

// Then build [GridView]:
GridView.builder(
  gridDelegate:
      const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 4,
    crossAxisSpacing: 3,
    mainAxisSpacing: 3,
  ),
  itemCount: images.length,
  itemBuilder: (context, index) 
    final image = images[index];
    return Image(
      image: DeviceImage(
        image,
        size: const Size(200, 200),
      ),
      fit: BoxFit.cover,
    );
  ,
);

如您所见,我将AssetEntity 传递给DeviceImage 提供者。 DeviceImage 加载大小为 (200, 200) 的原始字节,Image 显示拇指。

这是DeviceImage的代码,我是基于local_image_provider构建的:

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui show Codec;
import 'dart:ui';

import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import 'package:photo_manager/photo_manager.dart';

/// Decodes the given [LocalImage] object as an [ImageProvider], associating it with the given
/// scale.
///
///
/// In general only the constructor of this object should be used directly,
/// after that use the resulting object wherever an [ImageProvider] is
/// needed in Flutter. In particular with an [Image] widget.
///
class DeviceImage extends ImageProvider<DeviceImage> 
  static const int _kMaxSize = 1200;
  static const String _kNoImageBase64 =
      "iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAIAAABLixI0AAAAA3NCSVQICAjb4U/gAAADCUlEQVQ4ja2VT0zTYBTAX9dttB3b6AzFAEIlgEaH7gRxHIyBYGIkGMNAQpaBCdEYjiacPHD0QCQSlcSL8aaZhIN/4xBFlkVj5mY0hgMskwDyt5SNtdBu9VDp1m6AGt/p/fm+33vv+16/IpIkwX8S/d7hZCy2NTMtzs/rS0ryDlegZvPfs1IpZsQbuzeYYlcz3Yi5wNx1lex0I/ocG5HsHoWlxcUr3eLs9G75dYXF1NB9rLJyH5YkCHOdLnHme9qF6nU2SoqxEr+Zxh04WPLkGZqfr8qhYa/cvpUG6VBLb1+5P1z2Yrx8Mlh496GuoFCOpFZ/Lvff0NabafA8n3j9XDFN167j7R28IHAcx3GcruYEOXAnvTjwbovjcvSYSCS8Xi83NXX2lVcObOkMo+fbthFEk9z19BGWFGR9vKEFPW5vbW0lCAKUewyFQtFoFDBstLmDjMdNsY0UimaDiriEAkoCEsNxNhoNhUJOpzPNYhhGVqiq6tra2rW1Nb/fD/F4JsiYStmDHxSTMZMsqs/cqxoTDMNcLpfBYAAAk8k0MjKihMxJsf79WCG7LJsSwGzVMU3VKlZxcbEMAgCaptOgVPL0m5dkYl0Bfaup+3qIBrWoWJFIhGVZq9UKAOFwWGnNOeFTQADIRsulz4BClqhYkiQNDw/b7XaGYSKRiOys/zhJ7bQGANa+ftrVvuzzBQIBDUs7q9vb28FgcGFhQa6uQBRLF38oUUtvH+lqB4DGxsa6urp9WABAEITH4+np6aEoqmhlSfFjZ87Zui4rZlNTE4Zhe7EIgnC73RRF4TjudrtLd6YJAIw1JzWLEfUAqs4LRVEZpHCPnqrfnIvKZl71kewmdmUZjUYFJIvN023zdO+NyM0SBEFzO5a3Y8aJMVkX7Y71i22a9TlYJEkCgCiKPp8vM1wf+lSxPC/rK1/AZ7FllyPvBeXsHQ5H5qD/udA07XA4ZF31rvI8r3lm2aFB/vEDWTc4G2w3BzKjCIJkjoX229akRZov8FW/r89QVobj+B415vh3/LP8AvvVK04ZJmjyAAAAAElFTkSuQmCC";
  static final Uint8List noImageBytes = base64Decode(_kNoImageBase64);

  /// Creates an object that decodes a [LocalImage] as an image.
  ///
  /// The arguments must not be null. [scale] returns a scaled down
  /// version of the image. For example to load a thumbnail you could
  /// use something like .1 as the scale. There's a convenience method
  /// on [LocalImage] that can calculate the scale for a given pixel
  /// size.
  /// [minPixels] can be used to specify a minimum independent of the
  /// requested scale. The idea is that scaling an image that you don't know
  /// the original size of can result in some results that are too small.
  /// If the goal is to display the image in a 50x50 thumbnail then you might
  /// want to set 50 as the minPixels, then regardless of the image size and
  /// scale you'll get at least 50 pixels in each dimension. This parameter
  /// was added as a result of a strange result in iOS where an image with
  /// a portrait aspect ratio was failing to load when scaled below 120 pixels.
  /// Setting 150 as the minimum in this case resolved the problem.
  const DeviceImage(this.assetEntity,
      this.scale = 1.0, this.minPixels = 0, this.quality = 70, this.size);

  /// The LocalImage to decode into an image.
  final AssetEntity assetEntity;

  /// The scale to place in the [ImageInfo] object of the image.
  final double scale;

  /// The minPixels to place in the [ImageInfo] object of the image.
  final int minPixels;

  /// Optional image quality (0-100), default is set to 70.
  final int quality;

  /// Optional image size. If null, then full size will be loaded.
  final Size? size;

  @override
  Future<DeviceImage> obtainKey(ImageConfiguration? configuration) 
    return SynchronousFuture<DeviceImage>(this);
  

  @override
  ImageStreamCompleter load(DeviceImage key, DecoderCallback decode) 
    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key, decode),
      scale: key.scale,
      informationCollector: () sync* 
        yield ErrorDescription('Id: $assetEntity.id');
      ,
    );
  

  @override
  void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream,
      DeviceImage key, ImageErrorListener handleError) 
    if (shouldCache()) 
      super.resolveStreamForKey(configuration, stream, key, handleError);
      return;
    
    final ImageStreamCompleter completer =
        load(key, PaintingBinding.instance!.instantiateImageCodec);
    stream.setCompleter(completer);
  

  int get height => max((assetEntity.height * scale).round(), minPixels);
  int get width => max((assetEntity.width * scale).round(), minPixels);

  @visibleForTesting
  bool shouldCache() 
    return size == null;
  

  Future<ui.Codec> _loadAsync(DeviceImage key, DecoderCallback decoder) async 
    assert(key == this);
    try 
      final int width;
      final int height;
      if (size == null) 
        width = _kMaxSize;
        height = _kMaxSize;
       else 
        width = size!.width.toInt();
        height = size!.height.toInt();
      
      final bytes = await assetEntity.thumbDataWithSize(
        width,
        height,
        quality: quality,
      );
      if (bytes == null || bytes.lengthInBytes == 0) 
        return decoder(noImageBytes);
      

      return await decoder(bytes);
     on PlatformException 
      return await decoder(noImageBytes);
    
  

  @override
  bool operator ==(dynamic other) 
    if (other.runtimeType != runtimeType) return false;
    final DeviceImage typedOther = other;
    return assetEntity.id == typedOther.assetEntity.id &&
        scale == typedOther.scale;
  

  @override
  int get hashCode => assetEntity.hashCode;

  @override
  String toString() => '$runtimeType($assetEntity, scale: $scale)';

注意:代码不是生产就绪的。

【讨论】:

感谢您抽出宝贵时间回复。但是它没有按预期工作。你检查过我的 github 问题吗?我已经在使用缩略图甚至 64x64 进行测试,但它不起作用。我实际上也在 Github 上提供了一个解决方案的开始。有了你的,我看到的和我在 Github 上要求的一样:(我检查了你的代码,你使用的方法和我一样,不幸的是,项目需要太多时间才能加载到 GridView 中:/。我需要的是流畅的用户体验,在滚动非常快时,很少有milliseconds 出现空屏。 那你需要在页面打开后在后台预缓存缩略图,使用album.getAssetListRange 你是对的,这就是我已经开始做的事情,这个问题实际上是关于这个解决方案的实现,使用 5k + 资产并且最多只预先缓存 20-30 个(向上滚动+向下滚动)最大限度地减少内存负载并避免 OOM。

以上是关于在没有 OOM 的情况下,在 Flutter 中加载手机图库的速度与原生 IOS 一样快的主要内容,如果未能解决你的问题,请参考以下文章

在没有事件的情况下在 actionscript 中加载 xml 资产

无法在 webview_flutter 中加载相机

Flutter:在没有上下文的情况下从 InheritedWidgets 访问数据?

如何在没有主机的情况下启动我的 Flutter 应用程序?

京东APP中Flutter探索及优化

如何在没有 Firebase 和自定义后端的情况下使用 Flutter 设置推送通知