Flutter加载图片流程之ImageCache源码示例解析

 更新时间:2023年04月20日 10:26:57   作者:Nicholas68  
这篇文章主要为大家介绍了Flutter加载图片流程之ImageCache源码解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

ImageCache

const int _kDefaultSize = 1000;
const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
/// Class for caching images.
///
/// Implements a least-recently-used cache of up to 1000 images, and up to 100
/// MB. The maximum size can be adjusted using [maximumSize] and
/// [maximumSizeBytes].
///
/// The cache also holds a list of 'live' references. An image is considered
/// live if its [ImageStreamCompleter]'s listener count has never dropped to
/// zero after adding at least one listener. The cache uses
/// [ImageStreamCompleter.addOnLastListenerRemovedCallback] to determine when
/// this has happened.
///
/// The [putIfAbsent] method is the main entry-point to the cache API. It
/// returns the previously cached [ImageStreamCompleter] for the given key, if
/// available; if not, it calls the given callback to obtain it first. In either
/// case, the key is moved to the 'most recently used' position.
///
/// A caller can determine whether an image is already in the cache by using
/// [containsKey], which will return true if the image is tracked by the cache
/// in a pending or completed state. More fine grained information is available
/// by using the [statusForKey] method.
///
/// Generally this class is not used directly. The [ImageProvider] class and its
/// subclasses automatically handle the caching of images.
///
/// A shared instance of this cache is retained by [PaintingBinding] and can be
/// obtained via the [imageCache] top-level property in the [painting] library.
///
/// {@tool snippet}
///
/// This sample shows how to supply your own caching logic and replace the
/// global [imageCache] variable.

ImageCache类是一个用于缓存图像的类。它实现了一个最近最少使用的缓存,最多缓存1000个图像,最大缓存100MB。缓存的最大大小可以使用maximumSize和maximumSizeBytes进行调整。

ImageCache还持有一个"活"图像引用的列表。当ImageStreamCompleter的监听计数在添加至少一个监听器后从未降至零时,图像被认为是"活"的。缓存使用ImageStreamCompleter.addOnLastListenerRemovedCallback方法来确定这种情况是否发生。

putIfAbsent方法是缓存API的主要入口点。如果给定键的先前缓存中有ImageStreamCompleter可用,则返回该实例;否则,它将首先调用给定的回调函数来获取该实例。在任何情况下,键都会被移到"最近使用"的位置。

调用者可以使用containsKey方法确定图像是否已经在缓存中。如果图像以待处理或已处理状态被缓存,containsKey将返回true。使用statusForKey方法可以获得更细粒度的信息。

通常情况下,这个类不会直接使用。ImageProvider类及其子类会自动处理图像缓存。

在PaintingBinding中保留了这个缓存的共享实例,并可以通过painting库中的imageCache顶级属性获取。

_pendingImages、_cache、_liveImages

final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};
final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};
/// ImageStreamCompleters with at least one listener. These images may or may
/// not fit into the _pendingImages or _cache objects.
///
/// Unlike _cache, the [_CachedImage] for this may have a null byte size.
final Map<Object, _LiveImage> _liveImages = <Object, _LiveImage>{};

这段代码定义了三个 Map 对象来实现图片的缓存机制。其中:

  • _pendingImages:用于缓存正在加载中的图片,键为图片的标识符,值为 _PendingImage 对象。
  • _cache:用于缓存已经加载完成的图片,键为图片的标识符,值为 _CachedImage 对象。
  • _liveImages:用于缓存当前正在使用中的图片,即其对应的 ImageStreamCompleter 对象有至少一个监听器。键为图片的标识符,值为 _LiveImage 对象。

需要注意的是,_liveImages 中的 _LiveImage 对象的 byteSize 可能为 null,而 _cache 中的 _CachedImage 对象的 byteSize 总是非空的。这是因为 _cache 中的图片已经加载完成并占用了内存,而 _liveImages 中的图片可能还处于加载中或者被释放了内存,因此其大小可能还未确定或者已经变为 null

maximumSize、currentSize

/// Maximum number of entries to store in the cache.
///
/// Once this many entries have been cached, the least-recently-used entry is
/// evicted when adding a new entry.
int get maximumSize => _maximumSize;
int _maximumSize = _kDefaultSize;
/// Changes the maximum cache size.
///
/// If the new size is smaller than the current number of elements, the
/// extraneous elements are evicted immediately. Setting this to zero and then
/// returning it to its original value will therefore immediately clear the
/// cache.
set maximumSize(int value) {
  assert(value != null);
  assert(value >= 0);
  if (value == maximumSize) {
    return;
  }
  TimelineTask? timelineTask;
  if (!kReleaseMode) {
    timelineTask = TimelineTask()..start(
      'ImageCache.setMaximumSize',
      arguments: <String, dynamic>{'value': value},
    );
  }
  _maximumSize = value;
  if (maximumSize == 0) {
    clear();
  } else {
    _checkCacheSize(timelineTask);
  }
  if (!kReleaseMode) {
    timelineTask!.finish();
  }
}
/// The current number of cached entries.
int get currentSize => _cache.length;
/// Maximum size of entries to store in the cache in bytes.
///
/// Once more than this amount of bytes have been cached, the
/// least-recently-used entry is evicted until there are fewer than the
/// maximum bytes.
int get maximumSizeBytes => _maximumSizeBytes;
int _maximumSizeBytes = _kDefaultSizeBytes;
/// Changes the maximum cache bytes.
///
/// If the new size is smaller than the current size in bytes, the
/// extraneous elements are evicted immediately. Setting this to zero and then
/// returning it to its original value will therefore immediately clear the
/// cache.
set maximumSizeBytes(int value) {
  assert(value != null);
  assert(value >= 0);
  if (value == _maximumSizeBytes) {
    return;
  }
  TimelineTask? timelineTask;
  if (!kReleaseMode) {
    timelineTask = TimelineTask()..start(
      'ImageCache.setMaximumSizeBytes',
      arguments: <String, dynamic>{'value': value},
    );
  }
  _maximumSizeBytes = value;
  if (_maximumSizeBytes == 0) {
    clear();
  } else {
    _checkCacheSize(timelineTask);
  }
  if (!kReleaseMode) {
    timelineTask!.finish();
  }
}
/// The current size of cached entries in bytes.
int get currentSizeBytes => _currentSizeBytes;
int _currentSizeBytes = 0;

maximumSizemaximumSizeBytes用于控制缓存的大小。maximumSize是缓存中最多可以存储的元素数,超出该限制时,添加新的元素会导致最近最少使用的元素被清除。maximumSizeBytes是缓存中最多可以存储的字节数,超出该限制时,添加新元素会导致最近最少使用的元素被清除,直到缓存中元素的字节总数小于最大值。currentSizecurrentSizeBytes分别表示当前缓存中元素的数量和字节总数。在缓存大小发生变化时,会自动清除多出的元素。

clear

/// Evicts all pending and keepAlive entries from the cache.
///
/// This is useful if, for instance, the root asset bundle has been updated
/// and therefore new images must be obtained.
///
/// Images which have not finished loading yet will not be removed from the
/// cache, and when they complete they will be inserted as normal.
///
/// This method does not clear live references to images, since clearing those
/// would not reduce memory pressure. Such images still have listeners in the
/// application code, and will still remain resident in memory.
///
/// To clear live references, use [clearLiveImages].
void clear() {
  if (!kReleaseMode) {
    Timeline.instantSync(
      'ImageCache.clear',
      arguments: <String, dynamic>{
        'pendingImages': _pendingImages.length,
        'keepAliveImages': _cache.length,
        'liveImages': _liveImages.length,
        'currentSizeInBytes': _currentSizeBytes,
      },
    );
  }
  for (final _CachedImage image in _cache.values) {
    image.dispose();
  }
  _cache.clear();
  for (final _PendingImage pendingImage in _pendingImages.values) {
    pendingImage.removeListener();
  }
  _pendingImages.clear();
  _currentSizeBytes = 0;
}

clear()方法用于清除ImageCache中所有的待定和保持活动状态的图像缓存。这对于需要获取新图像的情况非常有用,例如根资产包已更新。尚未完成加载的图像不会从缓存中删除,当它们完成时,它们将像往常一样被插入。

此方法不清除图像的活动引用,因为这样做不会减少内存压力。这些图像仍然在应用程序代码中具有侦听器,并且仍将保留在内存中。如果要清除活动引用,请使用clearLiveImages()方法。

evict

/// Evicts a single entry from the cache, returning true if successful.
///
/// Pending images waiting for completion are removed as well, returning true
/// if successful. When a pending image is removed the listener on it is
/// removed as well to prevent it from adding itself to the cache if it
/// eventually completes.
///
/// If this method removes a pending image, it will also remove
/// the corresponding live tracking of the image, since it is no longer clear
/// if the image will ever complete or have any listeners, and failing to
/// remove the live reference could leave the cache in a state where all
/// subsequent calls to [putIfAbsent] will return an [ImageStreamCompleter]
/// that will never complete.
///
/// If this method removes a completed image, it will _not_ remove the live
/// reference to the image, which will only be cleared when the listener
/// count on the completer drops to zero. To clear live image references,
/// whether completed or not, use [clearLiveImages].
///
/// The `key` must be equal to an object used to cache an image in
/// [ImageCache.putIfAbsent].
///
/// If the key is not immediately available, as is common, consider using
/// [ImageProvider.evict] to call this method indirectly instead.
///
/// The `includeLive` argument determines whether images that still have
/// listeners in the tree should be evicted as well. This parameter should be
/// set to true in cases where the image may be corrupted and needs to be
/// completely discarded by the cache. It should be set to false when calls
/// to evict are trying to relieve memory pressure, since an image with a
/// listener will not actually be evicted from memory, and subsequent attempts
/// to load it will end up allocating more memory for the image again. The
/// argument must not be null.
///
/// See also:
///
///  * [ImageProvider], for providing images to the [Image] widget.
bool evict(Object key, { bool includeLive = true }) {
  assert(includeLive != null);
  if (includeLive) {
    // Remove from live images - the cache will not be able to mark
    // it as complete, and it might be getting evicted because it
    // will never complete, e.g. it was loaded in a FakeAsync zone.
    // In such a case, we need to make sure subsequent calls to
    // putIfAbsent don't return this image that may never complete.
    final _LiveImage? image = _liveImages.remove(key);
    image?.dispose();
  }
  final _PendingImage? pendingImage = _pendingImages.remove(key);
  if (pendingImage != null) {
    if (!kReleaseMode) {
      Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
        'type': 'pending',
      });
    }
    pendingImage.removeListener();
    return true;
  }
  final _CachedImage? image = _cache.remove(key);
  if (image != null) {
    if (!kReleaseMode) {
      Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
        'type': 'keepAlive',
        'sizeInBytes': image.sizeBytes,
      });
    }
    _currentSizeBytes -= image.sizeBytes!;
    image.dispose();
    return true;
  }
  if (!kReleaseMode) {
    Timeline.instantSync('ImageCache.evict', arguments: <String, dynamic>{
      'type': 'miss',
    });
  }
  return false;
}

从缓存中删除一个条目,如果成功则返回true。

同时删除等待完成的待处理图像,如果成功则返回true。当删除待处理的图像时,也将移除其上的监听器,以防止其在最终完成后添加到缓存中。

如果此方法删除了待处理的图像,则它还将删除图像的相应实时跟踪,因为此时无法确定图像是否会完成或具有任何侦听器,并且未删除实时引用可能会使缓存处于状态,其中所有后续对 [putIfAbsent] 的调用都将返回一个永远不会完成的 [ImageStreamCompleter]。

如果此方法删除了已完成的图像,则不会删除图像的实时引用,只有在监听器计数为零时才会清除实时图像引用。要清除已完成或未完成的实时图像引用,请使用 [clearLiveImages]。

key 必须等于用于在 [ImageCache.putIfAbsent] 中缓存图像的对象。

如果 key 不是立即可用的对象(这很常见),请考虑使用 [ImageProvider.evict] 间接调用此方法。

includeLive 参数确定是否也应将仍具有树中侦听器的图像清除。在图像可能损坏并需要完全丢弃缓存的情况下,应将此参数设置为true。在尝试缓解内存压力的情况下,应将其设置为false,因为具有侦听器的图像实际上不会从内存中清除,后续尝试加载它将再次为图像分配更多内存。该参数不能为空。

_touch

/// Updates the least recently used image cache with this image, if it is
/// less than the [maximumSizeBytes] of this cache.
///
/// Resizes the cache as appropriate to maintain the constraints of
/// [maximumSize] and [maximumSizeBytes].
void _touch(Object key, _CachedImage image, TimelineTask? timelineTask) {
  assert(timelineTask != null);
  if (image.sizeBytes != null && image.sizeBytes! <= maximumSizeBytes && maximumSize > 0) {
    _currentSizeBytes += image.sizeBytes!;
    _cache[key] = image;
    _checkCacheSize(timelineTask);
  } else {
    image.dispose();
  }
}

如果图片的大小小于此缓存的 [maximumSizeBytes],则使用此图像更新最近最少使用的图像缓存。

调整缓存大小以满足 [maximumSize] 和 [maximumSizeBytes] 的约束。

_checkCacheSize

// Remove images from the cache until both the length and bytes are below
// maximum, or the cache is empty.
void _checkCacheSize(TimelineTask? timelineTask) {
  final Map<String, dynamic> finishArgs = <String, dynamic>{};
  TimelineTask? checkCacheTask;
  if (!kReleaseMode) {
    checkCacheTask = TimelineTask(parent: timelineTask)..start('checkCacheSize');
    finishArgs['evictedKeys'] = <String>[];
    finishArgs['currentSize'] = currentSize;
    finishArgs['currentSizeBytes'] = currentSizeBytes;
  }
  while (_currentSizeBytes > _maximumSizeBytes || _cache.length > _maximumSize) {
    final Object key = _cache.keys.first;
    final _CachedImage image = _cache[key]!;
    _currentSizeBytes -= image.sizeBytes!;
    image.dispose();
    _cache.remove(key);
    if (!kReleaseMode) {
      (finishArgs['evictedKeys'] as List<String>).add(key.toString());
    }
  }
  if (!kReleaseMode) {
    finishArgs['endSize'] = currentSize;
    finishArgs['endSizeBytes'] = currentSizeBytes;
    checkCacheTask!.finish(arguments: finishArgs);
  }
  assert(_currentSizeBytes >= 0);
  assert(_cache.length <= maximumSize);
  assert(_currentSizeBytes <= maximumSizeBytes);
}

这段代码实现了检查缓存大小的逻辑,用于在缓存大小超过最大限制时从缓存中移除图像以释放内存。

该方法首先创建一个空的字典对象 finishArgs 用于保存一些统计数据,然后在非生产环境下创建一个时间线任务 checkCacheTask,用于记录缓存检查的时间。如果检查任务存在,则将 evictedKeyscurrentSize 和 currentSizeBytes 添加到 finishArgs 中。

然后,使用 while 循环,当缓存大小超过最大限制时,从 _cache 字典中删除第一个元素,并释放相关图像的内存。如果 checkCacheTask 存在,则将已删除的元素的键添加到 evictedKeys 列表中。

当循环结束时,将 endSize 和 endSizeBytes 添加到 finishArgs 中,表示缓存检查后的当前大小和字节数。最后,如果 checkCacheTask 存在,则完成任务并将 finishArgs 作为参数传递。

最后,这个方法断言 _currentSizeBytes 必须大于等于零, _cache 的长度必须小于等于 maximumSize, _currentSizeBytes 必须小于等于 maximumSizeBytes

_trackLiveImage

void _trackLiveImage(Object key, ImageStreamCompleter completer, int? sizeBytes) {
  // Avoid adding unnecessary callbacks to the completer.
  _liveImages.putIfAbsent(key, () {
    // Even if no callers to ImageProvider.resolve have listened to the stream,
    // the cache is listening to the stream and will remove itself once the
    // image completes to move it from pending to keepAlive.
    // Even if the cache size is 0, we still add this tracker, which will add
    // a keep alive handle to the stream.
    return _LiveImage(
      completer,
      () {
        _liveImages.remove(key);
      },
    );
  }).sizeBytes ??= sizeBytes;
}

putIfAbsent

/// Returns the previously cached [ImageStream] for the given key, if available;
/// if not, calls the given callback to obtain it first. In either case, the
/// key is moved to the 'most recently used' position.
///
/// The arguments must not be null. The `loader` cannot return null.
///
/// In the event that the loader throws an exception, it will be caught only if
/// `onError` is also provided. When an exception is caught resolving an image,
/// no completers are cached and `null` is returned instead of a new
/// completer.
ImageStreamCompleter? putIfAbsent(Object key, ImageStreamCompleter Function() loader, { ImageErrorListener? onError }) {
  assert(key != null);
  assert(loader != null);
  TimelineTask? timelineTask;
  TimelineTask? listenerTask;
  if (!kReleaseMode) {
    timelineTask = TimelineTask()..start(
      'ImageCache.putIfAbsent',
      arguments: <String, dynamic>{
        'key': key.toString(),
      },
    );
  }
  ImageStreamCompleter? result = _pendingImages[key]?.completer;
  // Nothing needs to be done because the image hasn't loaded yet.
  if (result != null) {
    if (!kReleaseMode) {
      timelineTask!.finish(arguments: <String, dynamic>{'result': 'pending'});
    }
    return result;
  }
  // Remove the provider from the list so that we can move it to the
  // recently used position below.
  // Don't use _touch here, which would trigger a check on cache size that is
  // not needed since this is just moving an existing cache entry to the head.
  final _CachedImage? image = _cache.remove(key);
  if (image != null) {
    if (!kReleaseMode) {
      timelineTask!.finish(arguments: <String, dynamic>{'result': 'keepAlive'});
    }
    // The image might have been keptAlive but had no listeners (so not live).
    // Make sure the cache starts tracking it as live again.
    _trackLiveImage(
      key,
      image.completer,
      image.sizeBytes,
    );
    _cache[key] = image;
    return image.completer;
  }
  final _LiveImage? liveImage = _liveImages[key];
  if (liveImage != null) {
    _touch(
      key,
      _CachedImage(
        liveImage.completer,
        sizeBytes: liveImage.sizeBytes,
      ),
      timelineTask,
    );
    if (!kReleaseMode) {
      timelineTask!.finish(arguments: <String, dynamic>{'result': 'keepAlive'});
    }
    return liveImage.completer;
  }
  try {
    result = loader();
    _trackLiveImage(key, result, null);
  } catch (error, stackTrace) {
    if (!kReleaseMode) {
      timelineTask!.finish(arguments: <String, dynamic>{
        'result': 'error',
        'error': error.toString(),
        'stackTrace': stackTrace.toString(),
      });
    }
    if (onError != null) {
      onError(error, stackTrace);
      return null;
    } else {
      rethrow;
    }
  }
  if (!kReleaseMode) {
    listenerTask = TimelineTask(parent: timelineTask)..start('listener');
  }
  // A multi-frame provider may call the listener more than once. We need do make
  // sure that some cleanup works won't run multiple times, such as finishing the
  // tracing task or removing the listeners
  bool listenedOnce = false;
  // We shouldn't use the _pendingImages map if the cache is disabled, but we
  // will have to listen to the image at least once so we don't leak it in
  // the live image tracking.
  final bool trackPendingImage = maximumSize > 0 && maximumSizeBytes > 0;
  late _PendingImage pendingImage;
  void listener(ImageInfo? info, bool syncCall) {
    int? sizeBytes;
    if (info != null) {
      sizeBytes = info.sizeBytes;
      info.dispose();
    }
    final _CachedImage image = _CachedImage(
      result!,
      sizeBytes: sizeBytes,
    );
    _trackLiveImage(key, result, sizeBytes);
    // Only touch if the cache was enabled when resolve was initially called.
    if (trackPendingImage) {
      _touch(key, image, listenerTask);
    } else {
      image.dispose();
    }
    _pendingImages.remove(key);
    if (!listenedOnce) {
      pendingImage.removeListener();
    }
    if (!kReleaseMode && !listenedOnce) {
      listenerTask!.finish(arguments: <String, dynamic>{
        'syncCall': syncCall,
        'sizeInBytes': sizeBytes,
      });
      timelineTask!.finish(arguments: <String, dynamic>{
        'currentSizeBytes': currentSizeBytes,
        'currentSize': currentSize,
      });
    }
    listenedOnce = true;
  }
  final ImageStreamListener streamListener = ImageStreamListener(listener);
  pendingImage = _PendingImage(result, streamListener);
  if (trackPendingImage) {
    _pendingImages[key] = pendingImage;
  }
  // Listener is removed in [_PendingImage.removeListener].
  result.addListener(streamListener);
  return result;
}

这个是图片缓存的核心方法。

clearLiveImages

(调用此方法不会减轻内存压力,因为活动图像缓存仅跟踪同时由至少一个其他对象持有的图像实例。)

/// Clears any live references to images in this cache.
///
/// An image is considered live if its [ImageStreamCompleter] has never hit
/// zero listeners after adding at least one listener. The
/// [ImageStreamCompleter.addOnLastListenerRemovedCallback] is used to
/// determine when this has happened.
///
/// This is called after a hot reload to evict any stale references to image
/// data for assets that have changed. Calling this method does not relieve
/// memory pressure, since the live image caching only tracks image instances
/// that are also being held by at least one other object.
void clearLiveImages() {
  for (final _LiveImage image in _liveImages.values) {
    image.dispose();
  }
  _liveImages.clear();
}

清除此缓存中任何图像的现有引用。

如果一个图像的 [ImageStreamCompleter] 至少添加了一个侦听器并且从未达到零侦听器,则认为该图像是“现有的”。

[ImageStreamCompleter.addOnLastListenerRemovedCallback] 用于确定是否发生了这种情况。

在热重载之后调用此方法以清除对已更改资产的图像数据的任何过时引用。

调用此方法不会减轻内存压力,因为活动图像缓存仅跟踪同时由至少一个其他对象持有的图像实例。

答疑解惑

_pendingImages 正在加载中的缓存,这个有什么作用呢? 假设Widget1加载了图片A,Widget2也在这个时候加载了图片A,那这时候Widget就复用了这个加载中的缓存

_cache 已经加载成功的图片缓存

_liveImages 存活的图片缓存,看代码主要是在CacheImage之外再加一层缓存。收到内存警告时, 调用clear()方法清除缓存时, 并不是清除_liveImages, 因为官方解释: 因为这样做不会减少内存压力

参考链接

flutter图片组件源码解析

Flutter图片加载与缓存机制的深入探究

Flutter网络图片本地缓存的实现

以上就是Flutter加载图片流程之ImageCache源码解析的详细内容,更多关于Flutter图片加载ImageCache的资料请关注脚本之家其它相关文章!

相关文章

  • Android Studio签名打包的两种方式(图文教程)

    Android Studio签名打包的两种方式(图文教程)

    这篇文章主要介绍了Android Studio签名打包的两种方式(图文教程),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-10-10
  • Android实现app开机自启动功能

    Android实现app开机自启动功能

    这篇文章主要为大家详细介绍了Android实现app开机自启动功能,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-05-05
  • Android贝塞尔曲线实现手指轨迹

    Android贝塞尔曲线实现手指轨迹

    这篇文章主要为大家详细介绍了Android贝塞尔曲线实现手指轨迹效果,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2019-06-06
  • Android实现微信朋友圈发本地视频功能

    Android实现微信朋友圈发本地视频功能

    这篇文章主要介绍了Android实现微信朋友圈发本地视频功能的相关资料,非常不错,具有参考借鉴价值,需要的朋友可以参考下
    2016-11-11
  • Android 中隐藏虚拟按键的方法实例代码

    Android 中隐藏虚拟按键的方法实例代码

    本文通过实例代码给大家详细介绍了android隐藏虚拟按键的方法,非常不错,具有参考借鉴价值,需要的朋友参考下吧
    2016-12-12
  • Handler实现线程之间的通信下载文件动态更新进度条

    Handler实现线程之间的通信下载文件动态更新进度条

    每一个线程对应一个消息队列MessageQueue,实现线程之间的通信,可通过Handler对象将数据装进Message中,再将消息加入消息队列,而后线程会依次处理消息队列中的消息。这篇文章主要介绍了Handler实现线程之间的通信下载文件动态更新进度条,需要的朋友可以参考下
    2017-08-08
  • flutter BottomAppBar实现不规则底部导航栏

    flutter BottomAppBar实现不规则底部导航栏

    这篇文章主要为大家详细介绍了flutter BottomAppBar实现不规则底部导航栏,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2019-07-07
  • Android开发实现标题随scrollview滑动变色的方法详解

    Android开发实现标题随scrollview滑动变色的方法详解

    这篇文章主要介绍了Android开发实现标题随scrollview滑动变色的方法,涉及Android针对滑动事件的响应、界面布局、属性动态变换等相关操作技巧,需要的朋友可以参考下
    2017-11-11
  • Android CountDownTimer案例总结

    Android CountDownTimer案例总结

    这篇文章主要介绍了Android CountDownTimer案例总结,本篇文章通过简要的案例,讲解了该项技术的了解与使用,以下就是详细内容,需要的朋友可以参考下
    2021-08-08
  • Android 7.0中拍照和图片裁剪适配的问题详解

    Android 7.0中拍照和图片裁剪适配的问题详解

    这篇文章主要介绍了Android 7.0中拍照和图片裁剪适配的相关问题,文中通过示例代码介绍的很详细,对大家具有一定的参考价值,有需要的朋友们下面来一起学习学习吧。
    2017-02-02

最新评论