Created
January 18, 2025 19:28
-
-
Save DamianSuess/96dae336338810b4064906ef6e763674 to your computer and use it in GitHub Desktop.
ImageCache for .NET MAUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Microsoft.Maui.Storage; | |
using System.Security.Cryptography; | |
using System.Text; | |
// Based on: https://github.com/soyfrien/ImageCache | |
namespace MyProjectNamespace; | |
/// <summary> | |
/// Use this class to cache images from the Internet. | |
/// Its functions receive a URI and turn the resource into an ImageSource, byte array, Stream, or Func‹Stream›. | |
/// Where ever you would give a control a URL, a stream, or byte[], do so as normal, but have ImageCache sit in the middle. For example, in .NET MAUI: | |
/// | |
/// <code> | |
/// // MauiProgram.cs: | |
/// builder.Services.AddSingleton‹ImageCache›(); | |
/// </code> | |
/// | |
/// Then use Dependency Injection to gain access to the class of a View, ViewModel, page or control: | |
/// | |
/// <code> | |
/// Using Ppdac.ImageCache.Maui; | |
/// ImageCache _imageCache; | |
/// ... | |
/// Page(ViewModel viewModel, ImageCache imageCache) | |
/// { | |
/// _imageCache = imageCache; | |
/// foreach (Image image in viewModel.Images) | |
/// Image.Source = _imageCache.GetImageAsImageSource(image.Url); | |
/// | |
/// Stream imageStream = await _imageCache.GetImageAsStreamAsync(image.Url); | |
/// Bitmap bitmap = new(imageStream); | |
/// | |
/// byte[] imageBytes = await _imageCache.GetImageAsBytesAsync(image.Url); | |
/// ... | |
/// </code> | |
/// See the demo project on the project's GitHub page for more examples. | |
/// </summary> | |
public class ImageCache | |
{ | |
/// <summary> | |
/// Provides a quicker and more energy efficient lookup of the known (and thus cached) <see cref="Uri"/>s when compared to scanning the filesystem. | |
/// </summary> | |
/// <remarks> | |
/// This field isn't intended to be directly manipulated by a consumer, and was originally created to ease development. | |
/// It's of type <see cref="string"/> instead of <see cref="Uri"/> based on an unproven assumption that this will use slightly less memory. | |
/// For reasons like these it may be removed in a future version.</remarks> | |
protected static readonly List<string> _cachedUris = new(); | |
private static readonly HashAlgorithm HashMd5 = MD5.Create(); | |
/// <summary> | |
/// Gets the number of items in the cache. | |
/// </summary> | |
public static int Count => _cachedUris.Count; | |
/// <summary>Gets or sets the number of seconds after which to fetch a remote resource.</summary> | |
/// <remarks>Defaults to twelve seconds.</remarks> | |
/// TODO: Make a test for expired cache files. | |
public int CacheExpiryInSeconds { get; set; } = 12; | |
/// <summary>Gets or sets the path of the cache folder.</summary> | |
/// <remarks> | |
/// Defaults to the App's CacheDirectory on Windows, Android, iOS, and Mac Catalyst, or when running on .NET 7.0 or newer, | |
/// or to the user's AppData folder on Windows when running on .NET 5.0 or older. | |
/// | |
/// Release value for ImageCachePath when Microsoft.Maui.Storage is available: Path.Combine(FileSystem.Current.CacheDirectory, "ppdac"); | |
/// BenchmarkDotNet value: "C:\\Users\\Louis\\source\\repos\\ImageCache.Benchmarks\\bin\\Debug\\Images"; | |
/// </remarks> | |
#if NET7_0_OR_GREATER && (ANDROID || IOS || MACCATALYST || WINDOWS) | |
public string ImageCachePath { get; set; } = Path.Combine(FileSystem.Current.CacheDirectory, "ppdac.cache.imagecache"); | |
#else | |
public string ImageCachePath { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ppdac.cache.imagecache"); | |
#endif | |
/// <summary> | |
/// Clears the tracked Uris from memory, but does not delete the files from the filesystem, and will still hit the cache before the Internet. | |
/// </summary> | |
public static Task Clear() | |
{ | |
_cachedUris.Clear(); | |
return Task.CompletedTask; | |
} | |
/// <summary> | |
/// Retrieves the resource at the specified <see cref="Uri"/> as a <see cref="byte"/>[] array. | |
/// </summary> | |
/// <param name="uri">The URI is both the key and the location of the image on the Internet.</param> | |
/// <returns><see cref="byte"/>[] of the image if found, or an empty byte array.</returns> | |
public async Task<byte[]> GetAsBytesAsync(Uri uri) | |
{ | |
ArgumentNullException.ThrowIfNull(uri); | |
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}"; | |
if (Directory.Exists(ImageCachePath) is false) | |
Directory.CreateDirectory(ImageCachePath); | |
if (File.Exists(pathToCachedFile)) | |
return File.ReadAllBytes(pathToCachedFile); | |
using HttpClient httpClient = new(); | |
HttpResponseMessage responseMessage = await httpClient.GetAsync(uri).ConfigureAwait(false); | |
if (responseMessage.IsSuccessStatusCode is false) | |
return Array.Empty<byte>(); //// throw new Exception($"Failed to get image from {url.AbsoluteUri}."); | |
using Stream stream = await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false); | |
byte[] buffer = new byte[stream.Length]; | |
_ = await stream.ReadAsync(buffer.AsMemory(0, (int)stream.Length)) | |
.ConfigureAwait(false); | |
return buffer; | |
} | |
/// <summary> | |
/// A Func that returns a MemoryStream which can be used to set an ImageSource | |
/// on objects like the Microsoft.Maui.Controls.Image or the Microsoft.Maui.Graphics.IImage, | |
/// for example in conjunction with ImageSource.FromStream(Func‹stream› stream): | |
/// <code> | |
/// Microsoft.Maui.Controls.Image _img; | |
/// ImageCache _imageCache = new ImageCache(); | |
/// _img.Source = ImageSource.FromStream(_imageCache.AsFuncStream(url)) | |
/// </code> | |
/// </summary> | |
/// <example> | |
/// Microsoft.Maui.Controls.Image _img; | |
/// ImageCache _imageCache = new ImageCache(); | |
/// _img.Source = ImageSource.FromStream(_imageCache.AsFuncStream(url)); | |
/// </example> | |
/// <param name="uri">A <see cref="Uri"/> whose .AbsoluteUri property is the location of the image.</param> | |
/// <returns>A <see cref="MemoryStream"/> of the byte array of the image.</returns> | |
public async Task<Func<Stream>> GetAsFuncStreamAsync(Uri uri) | |
{ | |
ArgumentNullException.ThrowIfNull(uri); | |
if (!_cachedUris.Contains(uri.AbsoluteUri)) | |
await KeepAsync(uri); | |
// byte[] buffer = await GetFromStorageAsBytesAsync(uri); | |
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}"; | |
byte[] buffer = File.ReadAllBytes(pathToCachedFile); | |
return () => new MemoryStream(buffer); | |
} | |
/// <summary> | |
/// Looks for a key equal to the URL and returns a stream of byte[], which can be used to set an ImageSource | |
/// on objects like the Microsoft.Maui.Controls.Image or the Microsoft.Maui.Graphics.IImage. | |
/// | |
/// If not found, the image is cached and then as a MemoryStream of its buffer. | |
/// </summary> | |
/// <param name="uri">The URI is both the key and the location of the image on the Internet.</param> | |
/// <returns>A MemoryStream of the byte array of the image.</returns> | |
public async Task<Stream> GetAsStreamAsync(Uri uri) | |
{ | |
ArgumentNullException.ThrowIfNull(uri); | |
if (Directory.Exists(ImageCachePath) is false) | |
Directory.CreateDirectory(ImageCachePath); | |
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}"; | |
if (File.Exists(pathToCachedFile) is false) | |
await KeepAsync(uri); | |
byte[] buffer = await File.ReadAllBytesAsync(pathToCachedFile) | |
.ConfigureAwait(false); | |
return new MemoryStream(buffer); | |
} | |
/// <summary> | |
/// Looks for a key equal to the string and returns a the size image in buffer. | |
/// If not found it is added then the size is returned. If null, -1 is returned. | |
/// </summary> | |
/// <remarks>This is currently limited to <see cref="Int32.MaxValue"/> as it uses <see cref="Stream.ReadAsync"/> which returns a <see cref="ValueTask"/>‹int›.</remarks> | |
/// <param name="uri">The URL is both the key and the location of the image on the Internet.</param> | |
/// <returns>The size of the image if found in buffer, as long. Or -1, if null.</returns> | |
public async Task<int> GetByteCountAsync(Uri uri) | |
{ | |
if (uri is null) | |
return -1; | |
if (!_cachedUris.Contains(uri.AbsoluteUri)) | |
{ | |
_cachedUris.Add(uri.AbsoluteUri); | |
await KeepAsync(uri).ConfigureAwait(false); | |
} | |
// return (await GetImageAsBytesAsync(uri).ConfigureAwait(false))?.Length ?? -1; | |
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}"; | |
if (Directory.Exists(ImageCachePath) is false) | |
Directory.CreateDirectory(ImageCachePath); | |
if (File.Exists(pathToCachedFile)) | |
return File.ReadAllBytes(pathToCachedFile).Length; | |
using HttpClient httpClient = new(); | |
HttpResponseMessage responseMessage = await httpClient.GetAsync(uri).ConfigureAwait(false); | |
if (responseMessage.IsSuccessStatusCode is false) | |
return -404; //// throw new Exception($"Failed to get image from {url.AbsoluteUri}."); | |
using Stream stream = await responseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false); | |
return await stream.ReadAsync(new byte[stream.Length]) | |
.ConfigureAwait(false); | |
} | |
/// <summary> | |
/// See <see cref="GetByteCountAsync(Uri)"/>. | |
/// </summary> | |
/// <remarks>Using a <see cref="Uri"/> instead of a <see cref="string"/> URL is recommended.</remarks> | |
/// <param name="url">The URL is both the key and the location of the image on the Internet.</param> | |
/// <returns>The size of the image if found in buffer. Or -1, if null.</returns> | |
public async Task<int> GetByteCountAsync(string url) => await GetByteCountAsync(new Uri(url)); | |
/// <summary> | |
/// Saves the resource to the <see cref="FileSystem.CacheDirectory"/> and becomes aware of its <see cref="Uri"/>. | |
/// </summary> | |
/// <remarks>You can use this to pre-cache items.</remarks> | |
/// <param name="uri">The URI of the image about to be kept in storage.</param> | |
public async Task KeepAsync(Uri uri) | |
{ | |
ArgumentNullException.ThrowIfNull(uri); | |
if (Directory.Exists(ImageCachePath) is false) | |
Directory.CreateDirectory(ImageCachePath); | |
string pathToCachedFile = $"{ImageCachePath}\\{GetFilename(uri)}"; | |
if (File.Exists(pathToCachedFile)) | |
return; | |
byte[]? bytes = await GetAsBytesAsync(uri).ConfigureAwait(false); | |
await File.WriteAllBytesAsync($"{pathToCachedFile}", bytes!).ConfigureAwait(false); | |
if (_cachedUris.Contains(uri.AbsoluteUri) is false) | |
_cachedUris.Add(uri.AbsoluteUri); | |
} | |
/// <summary> | |
/// Removes the cache from the file system, but does not clear the tracked URIs in memory. | |
/// </summary> | |
/// <code>StatusLabel.Text = _imageStore.Purge().Result;</code> | |
/// <remarks>You can, for example, Purge the images from disk and use <see cref="Save"/> to write them back to the cache.</remarks> | |
/// <returns>A string from a TResult.</returns> | |
public Task<string> Purge() | |
{ | |
if (Directory.Exists(ImageCachePath) is false) | |
return Task.FromResult($"{ImageCachePath} wasn't there."); | |
if (Directory.GetFiles(ImageCachePath).ToList().Count > 0) | |
{ | |
foreach (string file in Directory.GetFiles(ImageCachePath).ToList()) | |
File.Delete(file); | |
} | |
Directory.Delete($"{ImageCachePath}"); | |
return Task.FromResult($"Image cache purged from {ImageCachePath}."); | |
} | |
/// <summary> | |
/// Restores the cache from the file system. | |
/// </summary> | |
/// <code>StatusLabel.Text = _imageStore.Restore().Result;</code> | |
/// <remarks>For example, when used after using Dependency Injection to access the ImageCache</remarks> | |
/// <returns>A string as TResult.</returns> | |
public Task<string> Restore() | |
{ | |
if (!Directory.Exists(ImageCachePath)) | |
Directory.CreateDirectory(ImageCachePath); | |
int count = Directory.GetFiles(ImageCachePath).ToList().Count; | |
return Task.FromResult($"{count} items in cache."); | |
} | |
/// <summary> | |
/// Gives an item count and size of the cache, in mebibytes. | |
/// </summary> | |
/// <returns>A Task‹string› giving the item count and amount of storage used.</returns> | |
public Task<string> Report() | |
{ | |
if (!Directory.Exists(ImageCachePath)) | |
Directory.CreateDirectory(ImageCachePath); | |
int count = Directory.GetFiles(ImageCachePath).ToList().Count; | |
long size = 0; | |
foreach (string file in Directory.GetFiles(ImageCachePath).ToList()) | |
size += new FileInfo(file).Length; | |
if (size / 1024 / 1024 / 1024 >= 1) | |
return Task.FromResult($"{count} items in cache ({size / 1024 / 1024 / 1024} GiB)."); | |
else if (size / 1024 / 1024 >= 1) | |
return Task.FromResult($"{count} items in cache ({size / 1024 / 1024} MiB)."); | |
else if ((size / 1024) >= 1) | |
return Task.FromResult($"{count} items in cache ({size / 1024} KiB)."); | |
else | |
return Task.FromResult($"{count} items in cache ({size} bytes)."); | |
} | |
/// <summary>Saves the cache to the filesystem.</summary> | |
/// <code>StatusLabel.Text = _imageStore.Save().Result;</code> | |
/// <remarks>Files are now saved automatically. <see cref="Restore"/> is also no longer neccessary.</remarks> | |
/// <returns>A result string from a TResult. </returns> | |
public async Task<string> SaveAsync() | |
{ | |
foreach (string url in _cachedUris) | |
await KeepAsync(new Uri(url)).ConfigureAwait(false); | |
return "Tracked URIs saved to cache."; | |
} | |
/// <summary> | |
/// Gets image from <see cref="Uri"/> and returns it as an <see cref="ImageSource"/>. | |
/// </summary> | |
/// <param name="uri"><see cref="Uri"/> of image to cache.</param> | |
/// <returns>The image is returned as an <see cref="ImageSource"/>.</returns> | |
public async Task<ImageSource?> GetAsImageSourceAsync(Uri uri) | |
{ | |
if (string.IsNullOrEmpty(uri.AbsoluteUri)) | |
return null!; | |
if (Directory.Exists(ImageCachePath) is false) | |
Directory.CreateDirectory(ImageCachePath); | |
string pathToCachedFile = Path.Combine(ImageCachePath, GetFilename(uri)); | |
if (File.Exists(pathToCachedFile) is false || _cachedUris.Contains(uri.AbsoluteUri) is false) | |
await KeepAsync(uri).ConfigureAwait(false); | |
byte[] buffer = await File.ReadAllBytesAsync(pathToCachedFile).ConfigureAwait(false); | |
return ImageSource.FromStream(() => | |
{ | |
return new MemoryStream(buffer); | |
}); | |
} | |
/// <summary>See <see cref="GetAsBytesAsync(Uri)"/>.</summary> | |
/// <param name="url">The string URL</param> | |
/// <returns>A byte array.</returns> | |
internal async Task<byte[]?> GetAsBytesAsync(string url) => await GetAsBytesAsync(new Uri(url)); | |
/// <summary> | |
/// Looks for a key equal to the URL and returns a stream of <see cref="byte"/>[], | |
/// which can be used to set an ImageSource. | |
/// </summary> | |
/// <param name="uri">A <see cref="Uri"/> whose .AbsoluteUri property is the location of the image.</param> | |
/// <returns>A <see cref="byte"/> array of the image from storage.</returns> | |
internal async Task<byte[]> GetFromStorageAsBytesAsync(Uri uri) | |
{ | |
ArgumentNullException.ThrowIfNull(uri); | |
string pathToCachedFile = Path.Combine(ImageCachePath, GetFilename(uri)); | |
if (Directory.Exists(ImageCachePath) is false) | |
{ | |
Directory.CreateDirectory(ImageCachePath); | |
await KeepAsync(uri); | |
} | |
return File.ReadAllBytes(pathToCachedFile); | |
} | |
/// <summary>Checks the date of the cached file and returns true if it's older than the specified time.</summary> | |
/// <param name="filename">The filename of the cached file.</param> | |
/// <returns>True if the file is older than the specified time.</returns> | |
internal bool CachedFileExpired(string filename) | |
{ | |
TimeSpan timeSinceLastWrite = DateTime.Now - File.GetLastWriteTime(filename); | |
if (timeSinceLastWrite.TotalSeconds > CacheExpiryInSeconds) | |
return true; | |
else | |
return false; | |
} | |
/// <summary> | |
/// Using the first sixteen buffer of the <see cref="Uri"/>, computes the deterministic filename as a <see cref="Guid"/> <see cref="string"/>. | |
/// </summary> | |
/// <param name="uri">The <see cref="Uri"/> whose filename to lookup.</param> | |
/// <returns>A <see cref="Guid"/> as a <see cref="string"/>.</returns> | |
/// <remarks> | |
/// TODO: Even after switching to 16-byte hashes, is this a good solution? Probably not. | |
/// Also, is a faster MD5 implementation really better than a slower collision-free hash? | |
/// Needs both tested. | |
/// </remarks> | |
protected internal string GetFilename(Uri uri) | |
{ | |
ArgumentNullException.ThrowIfNull(uri); | |
lock (HashMd5) | |
{ | |
byte[] uriHash = HashMd5.ComputeHash(Encoding.UTF8.GetBytes(uri.AbsoluteUri)); | |
Guid filename = new(uriHash); | |
var pth = Path.Combine(ImageCachePath, filename.ToString()); | |
if (CachedFileExpired(pth) is true) | |
File.Delete(pth); | |
return $"{filename}"; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment