diff --git a/.gitignore b/.gitignore
index 531000e..45baf01 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,482 @@
-bin/
-obj/
-/packages/
-riderModule.iml
-/_ReSharper.Caches/
-.idea/
-*.DotSettings*
\ No newline at end of file
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
+
+# dotenv files
+.env
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
diff --git a/AsyncImageLoader.Avalonia.Demo/App.axaml.cs b/AsyncImageLoader.Avalonia.Demo/App.axaml.cs
index b4fffc2..cbcc67a 100644
--- a/AsyncImageLoader.Avalonia.Demo/App.axaml.cs
+++ b/AsyncImageLoader.Avalonia.Demo/App.axaml.cs
@@ -3,6 +3,7 @@
using Avalonia.Markup.Xaml;
using AsyncImageLoader.Avalonia.Demo.ViewModels;
using AsyncImageLoader.Avalonia.Demo.Views;
+using AsyncImageLoader.Loaders;
namespace AsyncImageLoader.Avalonia.Demo;
@@ -12,13 +13,16 @@ public override void Initialize() {
}
public override void OnFrameworkInitializationCompleted() {
+ ImageLoader.AsyncImageLoader = new SmartImageLoader();
+ ImageBrushLoader.AsyncImageLoader = ImageLoader.AsyncImageLoader;
+
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
desktop.MainWindow = new MainWindow {
DataContext = new MainWindowViewModel(),
};
desktop.MainWindow.AttachDevTools();
}
-
+
base.OnFrameworkInitializationCompleted();
}
}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia.Demo/AsyncImageLoader.Avalonia.Demo.csproj b/AsyncImageLoader.Avalonia.Demo/AsyncImageLoader.Avalonia.Demo.csproj
index 55ec10a..1b65a6d 100644
--- a/AsyncImageLoader.Avalonia.Demo/AsyncImageLoader.Avalonia.Demo.csproj
+++ b/AsyncImageLoader.Avalonia.Demo/AsyncImageLoader.Avalonia.Demo.csproj
@@ -16,13 +16,13 @@
-
-
-
+
+
+
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImagePage.axaml b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImagePage.axaml
index f933f2b..805e9b6 100644
--- a/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImagePage.axaml
+++ b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImagePage.axaml
@@ -28,6 +28,7 @@
@@ -40,7 +41,8 @@
Current images loaded from AvaloniaResource Source="../Assets/cat4.jpg".
-
+
@@ -60,7 +62,9 @@
+ Source="/Assets/cat5.jpg"
+ AutoCleanupEnabled="True"
+ />
@@ -87,6 +91,8 @@
+ Source="/Assets/cat5.jpg"
+ AutoCleanupEnabled="True"
+ />
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImagePage.axaml.cs b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImagePage.axaml.cs
index ebc1272..4458b5f 100644
--- a/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImagePage.axaml.cs
+++ b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImagePage.axaml.cs
@@ -1,8 +1,6 @@
using System;
-using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
-using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
diff --git a/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml
new file mode 100644
index 0000000..16c4f5a
--- /dev/null
+++ b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml.cs b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml.cs
new file mode 100644
index 0000000..86f90e9
--- /dev/null
+++ b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml.cs
@@ -0,0 +1,11 @@
+using AsyncImageLoader.Avalonia.Demo.ViewModels;
+using Avalonia.Controls;
+
+namespace AsyncImageLoader.Avalonia.Demo.Pages;
+
+public partial class AdvancedImageSafeMemoryPage : UserControl {
+ public AdvancedImageSafeMemoryPage() {
+ InitializeComponent();
+ DataContext = new AdvancedImageSafeMemoryViewModel();
+ }
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia.Demo/Pages/AttachedPropertiesPage.axaml.cs b/AsyncImageLoader.Avalonia.Demo/Pages/AttachedPropertiesPage.axaml.cs
index 7837100..6c9c368 100644
--- a/AsyncImageLoader.Avalonia.Demo/Pages/AttachedPropertiesPage.axaml.cs
+++ b/AsyncImageLoader.Avalonia.Demo/Pages/AttachedPropertiesPage.axaml.cs
@@ -1,5 +1,4 @@
-using Avalonia;
-using Avalonia.Controls;
+using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace AsyncImageLoader.Avalonia.Demo.Pages;
diff --git a/AsyncImageLoader.Avalonia.Demo/Program.cs b/AsyncImageLoader.Avalonia.Demo/Program.cs
index fa9a87a..8b2e5f4 100644
--- a/AsyncImageLoader.Avalonia.Demo/Program.cs
+++ b/AsyncImageLoader.Avalonia.Demo/Program.cs
@@ -1,6 +1,5 @@
using System;
using Avalonia;
-using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.ReactiveUI;
namespace AsyncImageLoader.Avalonia.Demo;
diff --git a/AsyncImageLoader.Avalonia.Demo/ViewModels/AdvancedImageSafeMemoryViewModel.cs b/AsyncImageLoader.Avalonia.Demo/ViewModels/AdvancedImageSafeMemoryViewModel.cs
new file mode 100644
index 0000000..22c4c74
--- /dev/null
+++ b/AsyncImageLoader.Avalonia.Demo/ViewModels/AdvancedImageSafeMemoryViewModel.cs
@@ -0,0 +1,18 @@
+using System.Collections.ObjectModel;
+
+namespace AsyncImageLoader.Avalonia.Demo.ViewModels;
+
+public class AdvancedImageSafeMemoryViewModel : ViewModelBase {
+ public ObservableCollection ImageUrls { get; } =
+ new()
+ {
+ "https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/raw/master/AsyncImageLoader.Avalonia.Demo/Assets/cat0.jpg",
+ "https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/raw/master/AsyncImageLoader.Avalonia.Demo/Assets/cat1.jpg",
+ "https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/raw/master/AsyncImageLoader.Avalonia.Demo/Assets/cat2.jpg",
+ "https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/raw/master/AsyncImageLoader.Avalonia.Demo/Assets/cat3.jpg",
+ "https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/raw/master/AsyncImageLoader.Avalonia.Demo/Assets/cat4.jpg",
+ "https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/raw/master/AsyncImageLoader.Avalonia.Demo/Assets/cat5.jpg",
+ "https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/raw/master/AsyncImageLoader.Avalonia.Demo/Assets/cat6.jpg",
+ "https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/raw/master/AsyncImageLoader.Avalonia.Demo/Assets/cat7.jpg",
+ };
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia.Demo/ViewModels/MainWindowViewModel.cs b/AsyncImageLoader.Avalonia.Demo/ViewModels/MainWindowViewModel.cs
index 4dd58b6..7c21e4b 100644
--- a/AsyncImageLoader.Avalonia.Demo/ViewModels/MainWindowViewModel.cs
+++ b/AsyncImageLoader.Avalonia.Demo/ViewModels/MainWindowViewModel.cs
@@ -1,8 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-
-namespace AsyncImageLoader.Avalonia.Demo.ViewModels;
+namespace AsyncImageLoader.Avalonia.Demo.ViewModels;
public class MainWindowViewModel : ViewModelBase {
public string Greeting => "Welcome to Avalonia!";
diff --git a/AsyncImageLoader.Avalonia.Demo/ViewModels/ViewModelBase.cs b/AsyncImageLoader.Avalonia.Demo/ViewModels/ViewModelBase.cs
index 80fd3f9..dba9292 100644
--- a/AsyncImageLoader.Avalonia.Demo/ViewModels/ViewModelBase.cs
+++ b/AsyncImageLoader.Avalonia.Demo/ViewModels/ViewModelBase.cs
@@ -1,7 +1,4 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using ReactiveUI;
+using ReactiveUI;
namespace AsyncImageLoader.Avalonia.Demo.ViewModels;
diff --git a/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml b/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml
index fbecc58..a0ce67e 100644
--- a/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml
+++ b/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml
@@ -32,5 +32,8 @@
+
+
+
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs b/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs
index 3202a46..8a5b639 100644
--- a/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs
+++ b/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs
@@ -1,16 +1,15 @@
+
+using AsyncImageLoader.DevTools.Extensions;
using Avalonia;
using Avalonia.Controls;
-using Avalonia.Markup.Xaml;
namespace AsyncImageLoader.Avalonia.Demo.Views;
public partial class MainWindow : Window {
public MainWindow() {
InitializeComponent();
+
this.AttachDevTools();
- }
-
- private void InitializeComponent() {
- AvaloniaXamlLoader.Load(this);
+ this.AttachDevToolsBitmapInspector();
}
}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/AdvancedImage.axaml.cs b/AsyncImageLoader.Avalonia/AdvancedImage.axaml.cs
index 6343581..4eabea4 100644
--- a/AsyncImageLoader.Avalonia/AdvancedImage.axaml.cs
+++ b/AsyncImageLoader.Avalonia/AdvancedImage.axaml.cs
@@ -1,6 +1,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
+using AsyncImageLoader.Memory.Services;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Logging;
@@ -8,6 +9,9 @@
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
namespace AsyncImageLoader;
@@ -18,13 +22,18 @@ public class AdvancedImage : ContentControl {
public static readonly StyledProperty LoaderProperty =
AvaloniaProperty.Register(nameof(Loader));
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty AutoCleanupEnabledProperty =
+ AvaloniaProperty.Register(nameof(AutoCleanupEnabled));
+
///
/// Defines the property.
///
public static readonly StyledProperty SourceProperty =
AvaloniaProperty.Register(nameof(Source));
-
-
+
///
/// Defines the property.
///
@@ -68,7 +77,7 @@ public class AdvancedImage : ContentControl {
///
public static readonly StyledProperty StretchDirectionProperty =
Image.StretchDirectionProperty.AddOwner();
-
+
private readonly Uri? _baseUri;
private RoundedRect _cornerRadiusClip;
@@ -77,11 +86,14 @@ public class AdvancedImage : ContentControl {
private bool _isCornerRadiusUsed;
private bool _isLoading;
-
+
private bool _shouldLoaderChangeTriggerUpdate;
-
+
private CancellationTokenSource? _updateCancellationToken;
private readonly ParametrizedLogger? _logger;
+
+ private BitmapLease? _lease;
+ private bool _isInsideVirtualizingPanel;
static AdvancedImage() {
AffectsRender(CurrentImageProperty, StretchProperty, StretchDirectionProperty,
@@ -96,6 +108,7 @@ static AdvancedImage() {
public AdvancedImage(Uri? baseUri) {
_baseUri = baseUri;
_logger = Logger.TryGet(LogEventLevel.Error, ImageLoader.AsyncImageLoaderLogArea);
+ _isInsideVirtualizingPanel = IsInsideVirtualizingPanel(this);
}
///
@@ -106,6 +119,18 @@ public AdvancedImage(IServiceProvider serviceProvider)
: this((serviceProvider.GetService(typeof(IUriContext)) as IUriContext)?.BaseUri) {
}
+ ///
+ /// Gets or sets a value indicating whether automatic cleanup of image resources is enabled.
+ /// When enabled, the control may automatically release cached or unused image resources
+ /// to help reduce memory usage. When disabled, resource cleanup must be handled manually
+ /// or by other mechanisms.
+ ///
+ public bool AutoCleanupEnabled
+ {
+ get => GetValue(AutoCleanupEnabledProperty);
+ set => SetValue(AutoCleanupEnabledProperty, value);
+ }
+
///
/// Gets or sets the URI for image that will be displayed.
///
@@ -119,16 +144,12 @@ public IAsyncImageLoader? Loader {
///
public string? Source {
get => GetValue(SourceProperty);
- set => SetValue(SourceProperty, value);
+ set
+ {
+ SetValue(SourceProperty, value);
+ }
}
- ///
- /// Gets or sets the Bitmap for Fallback image that will be displayed if the Source image isn't loaded.
- ///
- public Bitmap? FallbackImage {
- get => GetValue(FallbackImageProperty);
- set => SetValue(FallbackImageProperty, value);
- }
///
/// Gets or sets the value controlling whether the image should be reloaded after changing the loader.
///
@@ -136,6 +157,14 @@ public bool ShouldLoaderChangeTriggerUpdate {
get => _shouldLoaderChangeTriggerUpdate;
set => SetAndRaise(ShouldLoaderChangeTriggerUpdateProperty, ref _shouldLoaderChangeTriggerUpdate, value);
}
+
+ ///
+ /// Gets or sets the Bitmap for Fallback image that will be displayed if the Source image isn't loaded.
+ ///
+ public Bitmap? FallbackImage {
+ get => GetValue(FallbackImageProperty);
+ set => SetValue(FallbackImageProperty, value);
+ }
///
/// Gets a value indicating is image currently is loading state.
@@ -171,17 +200,17 @@ public StretchDirection StretchDirection {
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) {
if (change.Property == SourceProperty)
- UpdateImage(change.GetNewValue(), Loader);
+ _ = UpdateImage(change.GetNewValue(), Loader);
else if (change.Property == LoaderProperty && ShouldLoaderChangeTriggerUpdate)
- UpdateImage(change.GetNewValue(), Loader);
+ _ = UpdateImage(change.GetNewValue(), Loader);
else if (change.Property == CurrentImageProperty)
ClearSourceIfUserProvideImage();
else if (change.Property == CornerRadiusProperty)
UpdateCornerRadius(change.GetNewValue());
- else if (change.Property == BoundsProperty && CornerRadius != default)
- UpdateCornerRadius(CornerRadius);
+ else if (change.Property == BoundsProperty && CornerRadius != default) UpdateCornerRadius(CornerRadius);
else if (change.Property == FallbackImageProperty && Source == null)
- UpdateImage(null, null);
+ _ = UpdateImage(null, null);
+
base.OnPropertyChanged(change);
}
@@ -192,77 +221,125 @@ private void ClearSourceIfUserProvideImage() {
}
}
- private async void UpdateImage(string? source, IAsyncImageLoader? loader) {
- var cancellationTokenSource = new CancellationTokenSource();
+ private async Task UpdateImage(string? source, IAsyncImageLoader? loader) {
+ var cts = ReplaceCts(ref _updateCancellationToken);
+
+ if (source is null && FallbackImage != null)
+ CurrentImage = FallbackImage;
- var oldCancellationToken = Interlocked.Exchange(ref _updateCancellationToken, cancellationTokenSource);
+ if (source is null && CurrentImage is not ImageWrapper)
+ return;
- try {
- oldCancellationToken?.Cancel();
+ IsLoading = true;
+
+ if (CurrentImage is ImageWrapper wrapper)
+ wrapper.Dispose();
+
+ CurrentImage = null;
+
+ var storage = TopLevel.GetTopLevel(this)?.StorageProvider;
+
+ BitmapLease? lease;
+
+ try
+ {
+ lease = await LoadImageInternalAsync(source, loader, storage, cts.Token);
}
- catch (ObjectDisposedException) {
+ finally
+ {
+ cts.Cancel();
+ cts.Dispose();
}
+
+ CurrentImage = lease is null ? null : new ImageWrapper(lease);
+ IsLoading = false;
+ }
- if (source is null && FallbackImage != null) {
- CurrentImage = FallbackImage;
- }
- if (source is null && CurrentImage is not ImageWrapper) {
- // User provided image himself
- return;
- }
+
+ private async Task LoadImageInternalAsync(
+ string? source,
+ IAsyncImageLoader? loader,
+ IStorageProvider? storage,
+ CancellationToken token)
+ {
+ token.ThrowIfCancellationRequested();
- IsLoading = true;
- CurrentImage = null;
+ loader ??= ImageLoader.AsyncImageLoader;
- var bitmap = await Task.Run(async () => {
- try {
- if (string.IsNullOrWhiteSpace(source))
- return null;
-
- // A small delay allows to cancel early if the image goes out of screen too fast (eg. scrolling)
- // The Bitmap constructor is expensive and cannot be cancelled
- await Task.Delay(10, cancellationTokenSource.Token);
-
- // Hack to support relative URI
- // TODO: Refactor IAsyncImageLoader to support BaseUri
- try {
- var uri = new Uri(source, UriKind.RelativeOrAbsolute);
- if (AssetLoader.Exists(uri, _baseUri))
- return new Bitmap(AssetLoader.Open(uri, _baseUri));
- }
- catch (Exception) {
- // ignored
- }
-
- loader ??= ImageLoader.AsyncImageLoader;
-
- if (loader is IAdvancedAsyncImageLoader advancedLoader) {
- return await advancedLoader.ProvideImageAsync(source, TopLevel.GetTopLevel(this)?.StorageProvider);
- }
-
- return await loader.ProvideImageAsync(source);
- }
- catch (TaskCanceledException) {
- return null;
- }
- catch (Exception e) {
- _logger?.Log(this, "AdvancedImage image resolution failed: {0}", e);
+ if (source == null)
+ return null;
+
+ BitmapLease? lease = null;
+
+ try {
+ var entry = await Load(source, loader, token);
+ if (entry is null)
return null;
- }
- finally {
- cancellationTokenSource.Dispose();
- }
- }, CancellationToken.None);
- if (cancellationTokenSource.IsCancellationRequested)
- return;
+ lease = new BitmapLease(entry);
- CurrentImage = bitmap is null ? null : new ImageWrapper(bitmap);
- IsLoading = false;
+ token.ThrowIfCancellationRequested();
+
+ return lease;
+ }
+ catch (TaskCanceledException)
+ {
+ lease?.Dispose();
+ throw;
+ }
+ catch
+ {
+ lease?.Dispose();
+ throw;
+ }
}
+
+ private async Task Load(string source, IAsyncImageLoader loader, CancellationToken token) {
+ Loader ??= ImageLoader.AsyncImageLoader;
+
+ token.ThrowIfCancellationRequested();
+
+ var uri = new Uri(source, UriKind.RelativeOrAbsolute);
+
+ if (AssetLoader.Exists(uri, _baseUri))
+ {
+ if(Loader is ICoordinatedImageLoader && BitmapStore.Instance.TryGet(source, out var entry))
+ return entry;
+
+ token.ThrowIfCancellationRequested();
+
+ using var stream = AssetLoader.Open(uri, _baseUri);
+
+ entry = new BitmapEntry(source, Bitmap.DecodeToWidth(stream, (int)Width));
+
+ BitmapStore.Instance.TryAdd(entry);
+
+ return entry;
+ }
+
+ token.ThrowIfCancellationRequested();
+ if (Loader is ICoordinatedImageLoader coordinated) {
+ var entry = await coordinated.CoordinatorProvideImageAsync(source);
+
+ if(entry == null)
+ return null;
+
+ return entry;
+ }
+
+ var bitmap = await loader
+ .ProvideImageAsync(source)
+ .ConfigureAwait(false);
+
+ if (bitmap == null)
+ return null;
+
+ return new BitmapEntry(source, bitmap);
+ }
+
private void UpdateCornerRadius(CornerRadius radius) {
_isCornerRadiusUsed = radius != default;
_cornerRadiusClip = new RoundedRect(new Rect(0, 0, Bounds.Width, Bounds.Height), radius);
@@ -308,26 +385,153 @@ protected override Size MeasureOverride(Size availableSize) {
: base.MeasureOverride(availableSize);
}
- ///
protected override Size ArrangeOverride(Size finalSize) {
return CurrentImage != null
? Stretch.CalculateSize(finalSize, CurrentImage.Size)
: base.ArrangeOverride(finalSize);
}
- public sealed class ImageWrapper : IImage {
- public IImage ImageImplementation { get; }
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) {
+ if(!_isInsideVirtualizingPanel)
+ AcquireImage();
+ base.OnAttachedToVisualTree(e);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) {
+ if(!_isInsideVirtualizingPanel)
+ ReleaseImage();
+ base.OnDetachedFromVisualTree(e);
+ }
+
+ protected override void OnDataContextChanged(EventArgs e) {
+ if(_isInsideVirtualizingPanel)
+ ReleaseImage();
+
+ base.OnDataContextChanged(e);
+ }
+
+ private void AcquireImage()
+ {
+ if(!AutoCleanupEnabled)
+ return;
+
+ if (Loader == null)
+ Loader = ImageLoader.AsyncImageLoader;
+
+ var loader = Loader;
+
+ if (loader == null || loader is not ICoordinatedImageLoader)
+ return;
+
+ if (Loader is ICoordinatedImageLoader)
+ if (CurrentImage is null)
+ _ = Dispatcher.UIThread.InvokeAsync(async () => await UpdateImage(Source, Loader));
+ }
+
+ private void ReleaseImage()
+ {
+ if(!AutoCleanupEnabled)
+ return;
+
+ var loader = Loader;
+
+ if (loader == null || loader is not ICoordinatedImageLoader)
+ return;
+
+ if (CurrentImage is ImageWrapper wrapper)
+ {
+ wrapper.Dispose();
+ CurrentImage = null;
+ }
+ }
+
+ private static bool IsInsideVirtualizingPanel(AdvancedImage visual)
+ {
+ var parent = visual.GetVisualParent();
+
+ while (parent != null)
+ {
+ if (parent is VirtualizingPanel)
+ return true;
+
+ parent = parent.GetVisualParent();
+ }
+
+ return false;
+ }
+
+ private static CancellationTokenSource ReplaceCts(ref CancellationTokenSource? field)
+ {
+ var newCts = new CancellationTokenSource();
+ var old = Interlocked.Exchange(ref field, newCts);
+
+ if (old != null)
+ {
+ try { old.Cancel(); }
+ catch { }
+ old.Dispose();
+ }
+
+ return newCts;
+ }
+
+ public sealed class ImageWrapper : IImage, IDisposable
+ {
+ private BitmapLease? _lease;
+ private int _disposed;
+
+ private readonly Size _size;
+
+ public bool IsDisposed => Volatile.Read(ref _disposed) == 1;
+
+ public ImageWrapper(BitmapLease lease)
+ {
+ _lease = lease;
- internal ImageWrapper(IImage imageImplementation) {
- ImageImplementation = imageImplementation;
+ var bmp = lease.Bitmap
+ ?? throw new ObjectDisposedException(nameof(BitmapLease));
+
+ _size = new Size(bmp.Size.Width, bmp.Size.Height);
+ }
+
+ ~ImageWrapper()
+ {
+ Dispose(false);
}
- ///
- public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) {
- ImageImplementation.Draw(context, sourceRect, destRect);
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
}
- ///
- public Size Size => ImageImplementation.Size;
+ private void Dispose(bool disposing)
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) == 1)
+ return;
+
+ if (disposing)
+ {
+ _lease?.Dispose();
+ }
+
+ _lease = null;
+ }
+
+ public Size Size => _size;
+
+ public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
+ {
+ if (IsDisposed)
+ return;
+
+ var lease = _lease;
+ var bmp = lease?.Bitmap;
+
+ if (bmp == null)
+ return;
+
+ ((IImage)bmp).Draw(context, sourceRect, destRect);
+ }
}
}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/DevTools/DevToolsBitmapInspector.cs b/AsyncImageLoader.Avalonia/DevTools/DevToolsBitmapInspector.cs
new file mode 100644
index 0000000..b3749d2
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/DevTools/DevToolsBitmapInspector.cs
@@ -0,0 +1,78 @@
+using System;
+using AsyncImageLoader.Views;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace AsyncImageLoader.DevTools;
+
+internal static class DevToolsBitmapInspector
+{
+ private static BitmapInspectorWindow? _window;
+
+ public static IDisposable Attach(TopLevel root, KeyGesture gesture)
+ {
+ void PreviewKeyDown(object? sender, KeyEventArgs e) {
+ if (gesture.Matches(e))
+ {
+ Open(root);
+ }
+ }
+
+ return (root ?? throw new ArgumentNullException(nameof(root))).AddDisposableHandler(
+ InputElement.KeyDownEvent,
+ PreviewKeyDown,
+ RoutingStrategies.Tunnel);
+ }
+
+ public static void Open(TopLevel root)
+ {
+ if (_window == null)
+ {
+ _window = new BitmapInspectorWindow();
+
+ _window.Closed += (_, _) =>
+ {
+ _window = null;
+ };
+
+ _window.Opened += (_, _) =>
+ {
+ PositionWindow(root);
+ };
+ }
+
+ if (!_window.IsVisible)
+ {
+ if (root is Window owner)
+ _window.Show(owner);
+ else
+ _window.Show();
+ }
+
+ if (_window.WindowState == WindowState.Minimized)
+ _window.WindowState = WindowState.Normal;
+
+ _window.Activate();
+ }
+
+ private static void PositionWindow(TopLevel root)
+ {
+ if (_window == null)
+ return;
+
+ var screen = _window.Screens.ScreenFromVisual(root);
+
+ if (screen == null)
+ return;
+
+ var area = screen.WorkingArea;
+ var bounds = _window.Bounds;
+
+ _window.Position = new PixelPoint(
+ area.Right - (int)bounds.Width,
+ area.Bottom - (int)bounds.Height);
+ }
+
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/DevTools/Extensions/InspectorExtensions.cs b/AsyncImageLoader.Avalonia/DevTools/Extensions/InspectorExtensions.cs
new file mode 100644
index 0000000..873c245
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/DevTools/Extensions/InspectorExtensions.cs
@@ -0,0 +1,12 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace AsyncImageLoader.DevTools.Extensions;
+
+public static class DevToolsBitmapInspector {
+ public static void AttachDevToolsBitmapInspector(this TopLevel root)
+ => DevTools.DevToolsBitmapInspector.Attach(root, new KeyGesture(Key.I, KeyModifiers.Control));
+
+ public static void AttachDevToolsBitmapInspector(this TopLevel root, KeyGesture gesture)
+ => DevTools.DevToolsBitmapInspector.Attach(root, gesture);
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/DevTools/LoggingHandler.cs b/AsyncImageLoader.Avalonia/DevTools/LoggingHandler.cs
new file mode 100644
index 0000000..3bce9c4
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/DevTools/LoggingHandler.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using AsyncImageLoader.Memory.Services;
+
+namespace AsyncImageLoader.DevTools;
+
+public class LoggingHandler : DelegatingHandler
+{
+ public LoggingHandler() : base(new HttpClientHandler())
+ {
+
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if(!HttpClientMonitor.Instance.IsRunning)
+ return await base.SendAsync(request, cancellationToken);
+
+ var log = new HttpRequestLog
+ {
+ Url = request.RequestUri?.ToString() ?? "",
+ Method = request.Method,
+ Timestamp = DateTime.UtcNow
+ };
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ try
+ {
+ var response = await base.SendAsync(request, cancellationToken);
+ sw.Stop();
+ log.StatusCode = (int)response.StatusCode;
+ log.Duration = sw.Elapsed;
+
+ HttpClientMonitor.Instance.Log(log);
+ return response;
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ log.Exception = ex;
+ log.Duration = sw.Elapsed;
+
+ HttpClientMonitor.Instance.Log(log);
+ throw;
+ }
+ }
+}
+
diff --git a/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs b/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs
index bd53781..5c66012 100644
--- a/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs
+++ b/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs
@@ -1,6 +1,8 @@
using System;
using System.Threading.Tasks;
+using AsyncImageLoader.Memory.Services;
using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
namespace AsyncImageLoader;
@@ -11,4 +13,11 @@ public interface IAsyncImageLoader : IDisposable {
/// Target url
/// Bitmap
public Task ProvideImageAsync(string url);
+}
+
+public interface ICoordinatedImageLoader
+{
+ public Task CoordinatorProvideImageAsync(string url);
+
+ public Task CoordinatorProvideImageAsync(string url, IStorageProvider? storageProvider = null);
}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/ImageBrushLoader.cs b/AsyncImageLoader.Avalonia/ImageBrushLoader.cs
index 2ce55b9..ca5a671 100644
--- a/AsyncImageLoader.Avalonia/ImageBrushLoader.cs
+++ b/AsyncImageLoader.Avalonia/ImageBrushLoader.cs
@@ -1,5 +1,8 @@
using System;
+using System.Collections.Concurrent;
+using System.Threading;
using AsyncImageLoader.Loaders;
+using AsyncImageLoader.Memory.Services;
using Avalonia;
using Avalonia.Logging;
using Avalonia.Media;
@@ -11,30 +14,67 @@ public static class ImageBrushLoader {
private static readonly ParametrizedLogger? Logger;
public static IAsyncImageLoader AsyncImageLoader { get; set; } = new RamCachedWebImageLoader();
+ private static readonly ConcurrentDictionary PendingOperations = new();
+
static ImageBrushLoader() {
SourceProperty.Changed.AddClassHandler(OnSourceChanged);
Logger = Avalonia.Logging.Logger.TryGet(LogEventLevel.Error, ImageLoader.AsyncImageLoaderLogArea);
}
- private static async void OnSourceChanged(ImageBrush imageBrush, AvaloniaPropertyChangedEventArgs args) {
+ private static async void OnSourceChanged(ImageBrush imageBrush, AvaloniaPropertyChangedEventArgs args)
+ {
var (oldValue, newValue) = args.GetOldAndNewValue();
if (oldValue == newValue)
return;
+
+ var cts = PendingOperations.AddOrUpdate(imageBrush, _ => new CancellationTokenSource(),
+ (_, oldCts) =>
+ {
+ oldCts.Cancel();
+ oldCts.Dispose();
+ return new CancellationTokenSource();
+ });
SetIsLoading(imageBrush, true);
Bitmap? bitmap = null;
- try {
- if (!string.IsNullOrWhiteSpace(newValue)) {
- bitmap = await AsyncImageLoader.ProvideImageAsync(newValue!);
+
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(newValue))
+ {
+ if (BitmapStore.Instance.TryGet(newValue, out var entry))
+ bitmap = entry.Bitmap;
+ else
+ {
+ bitmap = await AsyncImageLoader.ProvideImageAsync(newValue);
+ }
+
+ if(AsyncImageLoader is ICoordinatedImageLoader)
+ if (bitmap != null)
+ BitmapStore.Instance.TryAdd(new BitmapEntry(newValue, bitmap), true);
}
}
- catch (Exception e) {
+ catch (OperationCanceledException) { }
+ catch (Exception e)
+ {
Logger?.Log("ImageBrushLoader", "ImageBrushLoader image resolution failed: {0}", e);
}
- if (GetSource(imageBrush) != newValue) return;
- imageBrush.Source = bitmap;
+ if (!cts.Token.IsCancellationRequested && GetSource(imageBrush) == newValue)
+ {
+ if (imageBrush.Source is Bitmap oldBmp)
+ oldBmp.Dispose();
+
+ imageBrush.Source = bitmap;
+ }
+ else
+ {
+ bitmap?.Dispose();
+ }
+
+ if (PendingOperations.TryRemove(imageBrush, out var removedCts))
+ removedCts.Dispose();
SetIsLoading(imageBrush, false);
}
diff --git a/AsyncImageLoader.Avalonia/ImageLoader.cs b/AsyncImageLoader.Avalonia/ImageLoader.cs
index 3f68c19..ee9dfdc 100644
--- a/AsyncImageLoader.Avalonia/ImageLoader.cs
+++ b/AsyncImageLoader.Avalonia/ImageLoader.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using System.Threading.Tasks;
using System.Threading;
using AsyncImageLoader.Loaders;
@@ -7,6 +6,8 @@
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using System.Collections.Concurrent;
+using AsyncImageLoader.Memory;
+using AsyncImageLoader.Memory.Services;
using Avalonia.Logging;
namespace AsyncImageLoader;
@@ -25,61 +26,80 @@ static ImageLoader() {
SourceProperty.Changed.AddClassHandler(OnSourceChanged);
Logger = Avalonia.Logging.Logger.TryGet(LogEventLevel.Error, AsyncImageLoaderLogArea);
}
-
+
public static IAsyncImageLoader AsyncImageLoader { get; set; } = new RamCachedWebImageLoader();
+
+ public static BitmapCacheCoordinator BitmapCacheEvictionManager { get; set; } =
+ new (new VisibilityTimeoutPolicy(TimeSpan.FromSeconds(20)));
private static readonly ConcurrentDictionary PendingOperations = new();
- private static async void OnSourceChanged(Image sender, AvaloniaPropertyChangedEventArgs args) {
+ private static async void OnSourceChanged(Image sender, AvaloniaPropertyChangedEventArgs args)
+ {
var url = args.GetNewValue();
- // Cancel/Add new pending operation
var cts = PendingOperations.AddOrUpdate(sender, new CancellationTokenSource(),
(x, y) => {
y.Cancel();
+ y.Dispose();
return new CancellationTokenSource();
});
- if (string.IsNullOrWhiteSpace(url)) {
- ((ICollection>)PendingOperations).Remove(
- new KeyValuePair(sender, cts));
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ if (PendingOperations.TryRemove(sender, out var removedCts))
+ removedCts.Dispose();
+
+ if (sender.Source is Bitmap oldBmp)
+ oldBmp.Dispose();
+
sender.Source = null;
return;
}
SetIsLoading(sender, true);
- var bitmap = await Task.Run(async () => {
- try {
- // A small delay allows to cancel early if the image goes out of screen too fast (eg. scrolling)
- // The Bitmap constructor is expensive and cannot be cancelled
- await Task.Delay(10, cts.Token);
+ Bitmap? bitmap = null;
- if (AsyncImageLoader is IAdvancedAsyncImageLoader advancedLoader) {
- return await advancedLoader.ProvideImageAsync(url, TopLevel.GetTopLevel(sender)?.StorageProvider);
- }
-
- return await AsyncImageLoader.ProvideImageAsync(url);
- }
- catch (TaskCanceledException) {
- return null;
+ try {
+ if (AsyncImageLoader is ICoordinatedImageLoader coordinatedImageLoader) {
+ var entry = await coordinatedImageLoader.CoordinatorProvideImageAsync(url);
+
+ if(entry != null)
+ entry.Acquire();
+
+ bitmap = entry?.Bitmap;
}
- catch (Exception e) {
- Logger?.Log(LogEventLevel.Error, "ImageLoader image resolution failed: {0}", e);
+ else if (AsyncImageLoader is IAdvancedAsyncImageLoader advancedLoader)
+ bitmap = await advancedLoader.ProvideImageAsync(url, TopLevel.GetTopLevel(sender)?.StorageProvider);
+ else
+ bitmap = await AsyncImageLoader.ProvideImageAsync(url);
+ }
+ catch (TaskCanceledException) { }
+ catch (Exception e)
+ {
+ Logger?.Log(LogEventLevel.Error, "ImageLoader image resolution failed: {0}", e);
+ }
- return null;
- }
- });
+ if (!cts.Token.IsCancellationRequested && bitmap != null)
+ {
+ if (sender.Source is Bitmap oldBmp)
+ oldBmp.Dispose();
+
+ sender.Source = bitmap;
+ }
+ else
+ {
+ bitmap?.Dispose();
+ }
- if (bitmap != null && !cts.Token.IsCancellationRequested)
- sender.Source = bitmap!;
+ if (PendingOperations.TryRemove(sender, out var removedCtsFinal))
+ removedCtsFinal.Dispose();
- // "It is not guaranteed to be thread safe by ICollection, but ConcurrentDictionary's implementation is. Additionally, we recently exposed this API for .NET 5 as a public ConcurrentDictionary.TryRemove"
- ((ICollection>)PendingOperations).Remove(
- new KeyValuePair(sender, cts));
SetIsLoading(sender, false);
}
+
public static string? GetSource(Image element) {
return element.GetValue(SourceProperty);
}
diff --git a/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs
index 975f868..003d656 100644
--- a/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs
+++ b/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs
@@ -1,7 +1,10 @@
using System;
using System.IO;
using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
using System.Threading.Tasks;
+using AsyncImageLoader.DevTools;
using Avalonia.Logging;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
@@ -14,13 +17,13 @@ namespace AsyncImageLoader.Loaders;
/// Can be used as base class if you want to create custom caching mechanism
///
public class BaseWebImageLoader : IAsyncImageLoader, IAdvancedAsyncImageLoader {
- private readonly ParametrizedLogger? _logger;
+ protected readonly ParametrizedLogger? Logger;
private readonly bool _shouldDisposeHttpClient;
///
/// Initializes a new instance with new instance
///
- public BaseWebImageLoader() : this(new HttpClient(), true) { }
+ public BaseWebImageLoader() : this(new HttpClient(new LoggingHandler()), true) { }
///
/// Initializes a new instance with the provided , and specifies whether that
@@ -34,7 +37,7 @@ public BaseWebImageLoader() : this(new HttpClient(), true) { }
public BaseWebImageLoader(HttpClient httpClient, bool disposeHttpClient) {
HttpClient = httpClient;
_shouldDisposeHttpClient = disposeHttpClient;
- _logger = Logger.TryGet(LogEventLevel.Error, ImageLoader.AsyncImageLoaderLogArea);
+ Logger = Avalonia.Logging.Logger.TryGet(LogEventLevel.Error, ImageLoader.AsyncImageLoaderLogArea);
}
protected HttpClient HttpClient { get; }
@@ -50,7 +53,7 @@ public void Dispose() {
}
///
- public async Task ProvideImageAsync(string url, IStorageProvider? storageProvider = null) {
+ public virtual async Task ProvideImageAsync(string url, IStorageProvider? storageProvider = null) {
return await LoadAsync(url, storageProvider).ConfigureAwait(false);
}
@@ -62,6 +65,7 @@ public void Dispose() {
/// Bitmap
protected virtual async Task LoadAsync(string url, IStorageProvider? storageProvider) {
var fromLocal = await LoadFromLocalAsync(url, storageProvider).ConfigureAwait(false);
+
if (fromLocal != null) return fromLocal;
return await LoadAsync(url).ConfigureAwait(false);
}
@@ -85,10 +89,11 @@ await LoadFromLocalAsync(url, null).ConfigureAwait(false)
using var memoryStream = new MemoryStream(externalBytes);
var bitmap = new Bitmap(memoryStream);
await SaveToGlobalCache(url, externalBytes).ConfigureAwait(false);
+
return bitmap;
}
catch (Exception e) {
- _logger?.Log(this, "Failed to resolve image: {RequestUri}\nException: {Exception}", url, e);
+ Logger?.Log(this, "Failed to resolve image: {RequestUri}\nException: {Exception}", url, e);
return null;
}
@@ -100,10 +105,10 @@ await LoadFromLocalAsync(url, null).ConfigureAwait(false)
/// Url to load
/// Avalonia's storage provider
private async Task LoadFromLocalAsync(string url, IStorageProvider? storageProvider) {
- if (File.Exists(url))
- return new Bitmap(url);
-
+ if (File.Exists(url)) return new Bitmap(url);
+
if (storageProvider is null) return null;
+
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme is not ("file" or "content")) return null;
try {
@@ -113,7 +118,7 @@ await LoadFromLocalAsync(url, null).ConfigureAwait(false)
return new Bitmap(fileStream);
}
catch (Exception e) {
- _logger?.Log(this,
+ Logger?.Log(this,
"Failed to resolve local image via storage provider with uri: {RequestUri}\nException: {Exception}",
url, e);
return null;
@@ -142,7 +147,7 @@ await LoadFromLocalAsync(url, null).ConfigureAwait(false)
return Task.FromResult(new Bitmap(AssetLoader.Open(uri)))!;
}
catch (Exception e) {
- _logger?.Log(this,
+ Logger?.Log(this,
"Failed to resolve image from request with uri: {RequestUri}\nException: {Exception}", url, e);
return Task.FromResult(null);
}
@@ -159,7 +164,7 @@ await LoadFromLocalAsync(url, null).ConfigureAwait(false)
return await HttpClient.GetByteArrayAsync(url).ConfigureAwait(false);
}
catch (Exception e) {
- _logger?.Log(this,
+ Logger?.Log(this,
"Failed to resolve image from request with uri: {RequestUri}\nException: {Exception}", url, e);
return null;
}
@@ -172,6 +177,7 @@ await LoadFromLocalAsync(url, null).ConfigureAwait(false)
/// Bitmap
protected virtual Task LoadFromGlobalCache(string url) {
// Current implementation does not provide global caching
+
return Task.FromResult(null);
}
@@ -183,6 +189,7 @@ await LoadFromLocalAsync(url, null).ConfigureAwait(false)
/// Bitmap
protected virtual Task SaveToGlobalCache(string url, byte[] imageBytes) {
// Current implementation does not provide global caching
+
return Task.CompletedTask;
}
@@ -193,4 +200,14 @@ protected virtual Task SaveToGlobalCache(string url, byte[] imageBytes) {
protected virtual void Dispose(bool disposing) {
if (disposing && _shouldDisposeHttpClient) HttpClient.Dispose();
}
+
+ protected static string CreateMD5(string input) {
+ // Use input string to calculate MD5 hash
+ using var md5 = MD5.Create();
+ var inputBytes = Encoding.ASCII.GetBytes(input);
+ var hashBytes = md5.ComputeHash(inputBytes);
+
+ // Convert the byte array to hexadecimal string
+ return BitConverter.ToString(hashBytes).Replace("-", "");
+ }
}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs
index 2565829..342a871 100644
--- a/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs
+++ b/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs
@@ -1,8 +1,5 @@
-using System;
-using System.IO;
+using System.IO;
using System.Net.Http;
-using System.Security.Cryptography;
-using System.Text;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
@@ -33,28 +30,21 @@ public DiskCachedWebImageLoader(HttpClient httpClient, bool disposeHttpClient,
}
#if NETSTANDARD2_1
- protected override async Task SaveToGlobalCache(string url, byte[] imageBytes) {
- var path = Path.Combine(_cacheFolder, CreateMD5(url));
+ protected override async Task SaveToGlobalCache(string url, byte[] imageBytes)
+ {
+ var path = Path.Combine(_cacheFolder, CreateMD5(url));
+
+ Directory.CreateDirectory(_cacheFolder);
+ await File.WriteAllBytesAsync(path, imageBytes).ConfigureAwait(false);
- Directory.CreateDirectory(_cacheFolder);
- await File.WriteAllBytesAsync(path, imageBytes).ConfigureAwait(false);
- }
+ }
#else
- protected override Task SaveToGlobalCache(string url, byte[] imageBytes) {
+ protected override async Task SaveToGlobalCache(string url, byte[] imageBytes)
+ {
var path = Path.Combine(_cacheFolder, CreateMD5(url));
+
Directory.CreateDirectory(_cacheFolder);
File.WriteAllBytes(path, imageBytes);
- return Task.CompletedTask;
}
#endif
-
- protected static string CreateMD5(string input) {
- // Use input string to calculate MD5 hash
- using var md5 = MD5.Create();
- var inputBytes = Encoding.ASCII.GetBytes(input);
- var hashBytes = md5.ComputeHash(inputBytes);
-
- // Convert the byte array to hexadecimal string
- return BitConverter.ToString(hashBytes).Replace("-", "");
- }
}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs
index b169955..99a3afa 100644
--- a/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs
+++ b/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs
@@ -1,7 +1,9 @@
using System.Collections.Concurrent;
using System.Net.Http;
using System.Threading.Tasks;
+using AsyncImageLoader.Memory.Services;
using Avalonia.Media.Imaging;
+using IStorageProvider = Avalonia.Platform.Storage.IStorageProvider;
namespace AsyncImageLoader.Loaders;
@@ -10,10 +12,10 @@ namespace AsyncImageLoader.Loaders;
/// Can be used as base class if you want to create custom in memory caching
///
public class RamCachedWebImageLoader : BaseWebImageLoader {
- private readonly ConcurrentDictionary> _memoryCache = new();
+ private readonly ConcurrentDictionary> _memoryCache = new();
///
- public RamCachedWebImageLoader() { }
+ public RamCachedWebImageLoader() { }
///
public RamCachedWebImageLoader(HttpClient httpClient, bool disposeHttpClient) : base(httpClient,
@@ -22,10 +24,57 @@ public RamCachedWebImageLoader(HttpClient httpClient, bool disposeHttpClient) :
///
public override async Task ProvideImageAsync(string url) {
- var bitmap = await _memoryCache.GetOrAdd(url, LoadAsync).ConfigureAwait(false);
+ var entry = await _memoryCache.GetOrAdd(url, async (url) => {
+ var bitmap = await LoadAsync(url);
+
+ if(bitmap == null)
+ return null;
+
+ var lease = new BitmapEntry(url, bitmap);
+
+ BitmapStore.Instance.AddBitmapEntry(lease);
+
+ lease.Acquire();
+
+ return lease;
+ })
+ .ConfigureAwait(false);
+
+
// If load failed - remove from cache and return
// Next load attempt will try to load image again
- if (bitmap == null) _memoryCache.TryRemove(url, out _);
- return bitmap;
+ if (entry == null) {
+ _memoryCache.TryRemove(url, out _);
+ BitmapStore.Instance.RemoveBitmapEntry(url);
+ }
+
+ return entry.Bitmap;
+ }
+
+ public override async Task ProvideImageAsync(string url, IStorageProvider? storageProvider = null) {
+ var entry = await _memoryCache.GetOrAdd(url, async (url) => {
+ var bitmap = await LoadAsync(url);
+
+ if(bitmap == null)
+ return null;
+
+ var lease = new BitmapEntry(url, bitmap);
+
+ BitmapStore.Instance.AddBitmapEntry(lease);
+
+ lease.Acquire();
+
+ return lease;
+ })
+ .ConfigureAwait(false);
+
+ // If load failed - remove from cache and return
+ // Next load attempt will try to load image again
+ if (entry == null) {
+ _memoryCache.TryRemove(url, out _);
+ BitmapStore.Instance.RemoveBitmapEntry(url);
+ }
+
+ return entry.Bitmap;
}
}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs
new file mode 100644
index 0000000..f109f74
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs
@@ -0,0 +1,53 @@
+using System.IO;
+using System.Threading.Tasks;
+using AsyncImageLoader.Memory.Services;
+using Avalonia.Media.Imaging;
+
+namespace AsyncImageLoader.Loaders;
+
+public class SmartDiskImageLoader : SmartImageLoader, ICoordinatedImageLoader {
+
+ private readonly string _cacheFolder;
+
+ public SmartDiskImageLoader(string cacheFolder = "Cache/Images/") {
+ _cacheFolder = cacheFolder;
+ }
+
+ protected override Task LoadFromGlobalCache(string url) {
+ var path = Path.Combine(_cacheFolder, CreateMD5(url));
+
+ return File.Exists(path) ? Task.FromResult(new Bitmap(path)) : Task.FromResult(null);
+ }
+
+#if NETSTANDARD2_1
+ protected sealed override async Task SaveToGlobalCache(string url, byte[] imageBytes) {
+ using var memoryStream = new MemoryStream(imageBytes);
+
+ var bitmap = new Bitmap(memoryStream);
+ var entry = new BitmapEntry(url, bitmap);
+
+ BitmapStore.Instance.TryAdd(entry);
+
+ var path = Path.Combine(_cacheFolder, CreateMD5(url));
+
+ Directory.CreateDirectory(_cacheFolder);
+ await File.WriteAllBytesAsync(path, imageBytes).ConfigureAwait(false);
+ }
+#else
+ protected sealed override Task SaveToGlobalCache(string url, byte[] imageBytes) {
+
+ using var memoryStream = new MemoryStream(imageBytes);
+
+ var bitmap = new Bitmap(memoryStream);
+ var entry = new BitmapEntry(url, bitmap);
+
+ BitmapStore.Instance.TryAdd(entry);
+
+ var path = Path.Combine(_cacheFolder, CreateMD5(url));
+ Directory.CreateDirectory(_cacheFolder);
+ File.WriteAllBytes(path, imageBytes);
+
+ return Task.CompletedTask;
+ }
+#endif
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs
new file mode 100644
index 0000000..4d06bca
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Concurrent;
+using System.Net.Http;
+using System.Threading.Tasks;
+using AsyncImageLoader.Memory.Services;
+using Avalonia.Platform.Storage;
+
+namespace AsyncImageLoader.Loaders;
+
+public class SmartImageLoader : BaseWebImageLoader, ICoordinatedImageLoader
+{
+ private readonly ConcurrentDictionary> _loadingTasks = new();
+
+ protected override async Task LoadDataFromExternalAsync(string url)
+ {
+ var task = _loadingTasks.GetOrAdd(url, GetImageFromExternalAsync);
+
+ try
+ {
+ return await task.ConfigureAwait(false);
+ }
+ finally
+ {
+ _loadingTasks.TryRemove(url, out _);
+ }
+ }
+
+ private async Task GetImageFromExternalAsync(string url)
+ {
+ try
+ {
+ using var response = await HttpClient.SendAsync(
+ new HttpRequestMessage(HttpMethod.Get, url),
+ HttpCompletionOption.ResponseHeadersRead)
+ .ConfigureAwait(false);
+
+ response.EnsureSuccessStatusCode();
+
+ var bytes = await response.Content
+ .ReadAsByteArrayAsync()
+ .ConfigureAwait(false);
+
+ return bytes;
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (Exception e)
+ {
+ Logger.Value.Log(
+ "Failed to resolve image from request with uri: {0}\nException: {1}",
+ url, e);
+ return null;
+ }
+ }
+
+ public async Task CoordinatorProvideImageAsync(string url) {
+ if (BitmapStore.Instance.TryGet(url, out var entry))
+ return entry;
+
+ var bitmap = await LoadAsync(url)
+ .ConfigureAwait(false);
+
+ if(bitmap == null)
+ return null;
+
+ entry = new BitmapEntry(url, bitmap);
+
+ BitmapStore.Instance.TryAdd(entry);
+
+ return entry;
+ }
+
+ public async Task CoordinatorProvideImageAsync(string url, IStorageProvider? storageProvider = null) {
+ if (BitmapStore.Instance.TryGet(url, out var entry))
+ return entry;
+
+ var bitmap = await LoadAsync(url, storageProvider)
+ .ConfigureAwait(false);
+
+ if(bitmap == null)
+ return null;
+
+ entry = new BitmapEntry(url, bitmap);
+
+ BitmapStore.Instance.TryAdd(entry);
+
+ return entry;
+ }
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/Memory/Interfaces/IBitmapEvictionPolicy.cs b/AsyncImageLoader.Avalonia/Memory/Interfaces/IBitmapEvictionPolicy.cs
new file mode 100644
index 0000000..1ee3a1d
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Memory/Interfaces/IBitmapEvictionPolicy.cs
@@ -0,0 +1,8 @@
+using AsyncImageLoader.Memory.Services;
+
+namespace AsyncImageLoader.Memory.Interfaces;
+
+public interface IBitmapEvictionPolicy
+{
+ bool ShouldEvict(BitmapEntry value);
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/Memory/Services/BitmapCacheCoordinator.cs b/AsyncImageLoader.Avalonia/Memory/Services/BitmapCacheCoordinator.cs
new file mode 100644
index 0000000..d31b5a5
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Memory/Services/BitmapCacheCoordinator.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AsyncImageLoader.Memory.Interfaces;
+using Avalonia.Media.Imaging;
+
+namespace AsyncImageLoader.Memory.Services;
+
+public class BitmapCacheCoordinator : IDisposable
+{
+ private IBitmapEvictionPolicy _policy;
+ private readonly CancellationTokenSource _cts = new();
+
+ public BitmapCacheCoordinator(IBitmapEvictionPolicy policy) {
+ _policy = policy;
+ _ = CleanupLoop(_cts.Token);
+ }
+
+ public async Task GetOrAdd(string key, Func> factory) {
+ if (BitmapStore.Instance.TryGet(key, out var result))
+ return result;
+
+ var entry = new BitmapEntry(key, await factory());
+
+ BitmapStore.Instance.TryAdd(entry);
+
+ return entry;
+ }
+
+ private async Task CleanupLoop(CancellationToken token)
+ {
+ while (!token.IsCancellationRequested)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(5), token);
+
+ foreach (var entry in BitmapStore.Instance.EnumerateFromOldest())
+ {
+ if(entry.RefCount > 0)
+ break;
+
+ if (!_policy.ShouldEvict(entry))
+ continue;
+
+ BitmapStore.Instance.Remove(entry.Key);
+
+ entry.Dispose();
+ }
+ }
+ }
+
+ public void Dispose() {
+ _cts.Cancel();
+ }
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/Memory/Services/BitmapStore.cs b/AsyncImageLoader.Avalonia/Memory/Services/BitmapStore.cs
new file mode 100644
index 0000000..b720eda
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Memory/Services/BitmapStore.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Avalonia.Media.Imaging;
+
+namespace AsyncImageLoader.Memory.Services;
+
+using System.Collections.Concurrent;
+
+public sealed class BitmapStore
+{
+ private readonly ConcurrentDictionary _bitmaps = new();
+
+ internal ConcurrentBag BitmapEntries { get; } = new();
+
+ public static BitmapStore Instance { get; } = new BitmapStore();
+ private BitmapStore() { }
+
+ public bool TryGet(string key, out BitmapEntry entry)
+ {
+ if (_bitmaps.TryGetValue(key, out var node))
+ {
+ entry = node;
+ return true;
+ }
+
+ entry = null!;
+ return false;
+ }
+
+ public void TryAdd(BitmapEntry entry, bool acquire = false)
+ {
+ if(acquire)
+ entry.Acquire();
+
+ _bitmaps.TryAdd(entry.Key, entry);
+ }
+
+ public IEnumerable EnumerateFromOldest()
+ {
+ BitmapEntry[] snapshot;
+ snapshot = _bitmaps.Values.Concat(BitmapEntries).ToArray();
+
+ return snapshot.OrderBy(x => x.RefCount).ToArray();
+ }
+
+ public void Remove(string key) {
+ _bitmaps.TryRemove(key, out var node);
+ }
+
+ public void AddBitmapEntry(BitmapEntry bitmapEntry)
+ {
+ BitmapEntries.Add(bitmapEntry);
+ }
+
+ public void RemoveBitmapEntry(string url)
+ {
+ var toRemove = BitmapEntries.Where(x => x.Key == url).ToList();
+
+ foreach (var entry in toRemove)
+ BitmapEntries.TryTake(out _);
+ }
+}
+
+public sealed class BitmapEntry : IDisposable
+{
+ public string Key { get; }
+ public Bitmap Bitmap { get; }
+
+ private int _refCount;
+ public int RefCount => _refCount;
+
+ public DateTime LastReleased { get; private set; }
+
+ public BitmapEntry(string key, Bitmap bitmap)
+ {
+ Key = key;
+ Bitmap = bitmap;
+ }
+
+ public void Acquire() {
+ Interlocked.Increment(ref _refCount);
+ }
+
+ public void Release() {
+ if (Interlocked.Decrement(ref _refCount) == 0)
+ LastReleased = DateTime.UtcNow;
+ }
+
+ public void Dispose() {
+ Bitmap.Dispose();
+ }
+}
+
+public sealed class BitmapLease : IDisposable
+{
+ private readonly BitmapEntry _entry;
+ private int _disposed;
+
+ public Bitmap? Bitmap
+ {
+ get
+ {
+ if (Volatile.Read(ref _disposed) == 1)
+ return null;
+
+ return _entry.Bitmap;
+ }
+ }
+
+
+ public BitmapLease(BitmapEntry entry) {
+ _entry = entry;
+ _entry.Acquire();
+ }
+
+ public void Dispose() {
+ if (Interlocked.Exchange(ref _disposed, 1) == 1)
+ return;
+
+ _entry.Release();
+ }
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/Memory/Services/BitmapStoreMonitor.cs b/AsyncImageLoader.Avalonia/Memory/Services/BitmapStoreMonitor.cs
new file mode 100644
index 0000000..4d593f8
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Memory/Services/BitmapStoreMonitor.cs
@@ -0,0 +1,154 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Media.Imaging;
+
+namespace AsyncImageLoader.Memory.Services;
+
+public sealed class BitmapStoreMonitor : IDisposable
+{
+ private readonly BitmapStore _store;
+ private readonly TimeSpan _interval;
+ private CancellationTokenSource? _cts;
+ private Task? _loopTask;
+
+ public event Action? SnapshotUpdated;
+
+ public bool IsRunning => _cts != null;
+
+ public BitmapStoreMonitor(
+ BitmapStore? store = null,
+ TimeSpan? interval = null)
+ {
+ _store = store ?? BitmapStore.Instance;
+ _interval = interval ?? TimeSpan.FromSeconds(2);
+ }
+
+ public void Start()
+ {
+ if (_cts != null)
+ return;
+
+ _cts = new CancellationTokenSource();
+ _loopTask = Task.Run(() => LoopAsync(_cts.Token));
+ }
+
+ public async Task StopAsync()
+ {
+ if (_cts == null)
+ return;
+
+ _cts.Cancel();
+
+ try
+ {
+ if (_loopTask != null)
+ await _loopTask;
+ }
+ catch (OperationCanceledException)
+ {
+ }
+
+ _cts.Dispose();
+ _cts = null;
+ _loopTask = null;
+ }
+
+ private async Task LoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ PublishSnapshot();
+
+ try
+ {
+ await Task.Delay(_interval, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+ }
+
+ private void PublishSnapshot()
+ {
+ var entries = _store
+ .EnumerateFromOldest()
+ .Select(e => {
+ var bytes = Estimate(e.Bitmap);
+
+ return new BitmapEntryInfo(
+ e.Key,
+ e.RefCount,
+ e.LastReleased,
+ bytes);
+ })
+ .ToList();
+
+ var total = entries.Sum(x => x.EstimatedBytes);
+
+ SnapshotUpdated?.Invoke(
+ new BitmapStoreSnapshot(entries, total));
+ }
+
+ public static long Estimate(Bitmap bmp)
+ {
+ var size = bmp.PixelSize;
+ return (long)size.Width * size.Height * 4;
+ }
+
+ public void Dispose()
+ {
+ _ = StopAsync();
+ }
+}
+
+public sealed class BitmapStoreSnapshot
+{
+ public IReadOnlyList Items { get; }
+ public long TotalEstimatedBytes { get; }
+
+ public string TotalEstimatedSizeFormatted =>
+ $"{TotalEstimatedBytes / 1024.0 / 1024.0:0.00} MB";
+
+ public BitmapStoreSnapshot(
+ IReadOnlyList items,
+ long totalEstimatedBytes)
+ {
+ Items = items;
+ TotalEstimatedBytes = totalEstimatedBytes;
+ }
+}
+
+public sealed class BitmapEntryInfo
+{
+ public string Key { get; }
+ public int RefCount { get; }
+ public DateTime LastReleased { get; }
+
+ public long EstimatedBytes { get; }
+
+ public string EstimatedSizeFormatted =>
+ $"{EstimatedBytes / 1024.0 / 1024.0:0.00} MB";
+
+ public string LastReleasedFormatted =>
+ LastReleased == default
+ ? "-"
+ : LastReleased.ToLocalTime().ToString("HH:mm:ss");
+
+ public BitmapEntryInfo(
+ string key,
+ int refCount,
+ DateTime lastReleased,
+ long estimatedBytes)
+ {
+ Key = key;
+ RefCount = refCount;
+ LastReleased = lastReleased;
+ EstimatedBytes = estimatedBytes;
+ }
+}
+
diff --git a/AsyncImageLoader.Avalonia/Memory/Services/HttpClientMonitor.cs b/AsyncImageLoader.Avalonia/Memory/Services/HttpClientMonitor.cs
new file mode 100644
index 0000000..11ce63b
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Memory/Services/HttpClientMonitor.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AsyncImageLoader.Memory.Services;
+
+public sealed class HttpClientMonitor : IDisposable
+ {
+ public static HttpClientMonitor Instance { get; internal set; } = new();
+
+ private readonly LinkedList _logs = new();
+ private readonly TimeSpan _interval;
+ private CancellationTokenSource? _cts;
+ private Task? _loopTask;
+
+ private readonly int _maxLogs = 100;
+
+ public event Action? SnapshotUpdated;
+
+ public bool IsRunning => _cts != null;
+
+ public HttpClientMonitor(TimeSpan? interval = null)
+ {
+ _interval = interval ?? TimeSpan.FromSeconds(2);
+ }
+
+ public void Start()
+ {
+ if (_cts != null)
+ return;
+
+ _cts = new CancellationTokenSource();
+ _loopTask = Task.Run(() => LoopAsync(_cts.Token));
+ }
+
+ public async Task StopAsync()
+ {
+ if (_cts == null)
+ return;
+
+ _cts.Cancel();
+
+ try
+ {
+ if (_loopTask != null)
+ await _loopTask;
+ }
+ catch (OperationCanceledException) { }
+
+ _cts.Dispose();
+ _cts = null;
+ _loopTask = null;
+ }
+
+ private async Task LoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ PublishSnapshot();
+
+ try
+ {
+ await Task.Delay(_interval, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+ }
+
+ private void PublishSnapshot()
+ {
+ HttpRequestSnapshot snapshot;
+ lock (_logs)
+ {
+ snapshot = new HttpRequestSnapshot(_logs.ToList());
+ }
+
+ SnapshotUpdated?.Invoke(snapshot);
+ }
+
+ public void Log(HttpRequestLog log)
+ {
+ lock (_logs)
+ {
+
+ _logs.AddLast(log);
+ if (_logs.Count > _maxLogs)
+ _logs.RemoveFirst();
+ }
+ }
+
+ public void Dispose()
+ {
+ _ = StopAsync();
+ }
+ }
+
+ public sealed class HttpRequestSnapshot
+ {
+ public IReadOnlyList Requests { get; }
+
+ public HttpRequestSnapshot(IReadOnlyList requests)
+ {
+ Requests = requests;
+ }
+ }
+
+ public sealed class HttpRequestLog
+ {
+ public string Url { get; set; } = "";
+ public HttpMethod Method { get; set; } = HttpMethod.Get;
+ public int? StatusCode { get; set; }
+ public TimeSpan Duration { get; set; }
+ public Exception? Exception { get; set; }
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+
+ public override string ToString()
+ {
+ if (Exception != null)
+ return $"[{Timestamp:HH:mm:ss}] {Method} {Url} FAILED: {Exception.Message}";
+ return $"[{Timestamp:HH:mm:ss}] {Method} {Url} => {StatusCode} ({Duration.TotalMilliseconds:0}ms)";
+ }
+ }
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/Memory/VisibilityTimeoutPolicy.cs b/AsyncImageLoader.Avalonia/Memory/VisibilityTimeoutPolicy.cs
new file mode 100644
index 0000000..09e2b92
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Memory/VisibilityTimeoutPolicy.cs
@@ -0,0 +1,18 @@
+using System;
+using AsyncImageLoader.Memory.Interfaces;
+using AsyncImageLoader.Memory.Services;
+
+namespace AsyncImageLoader.Memory;
+
+public class VisibilityTimeoutPolicy : IBitmapEvictionPolicy
+{
+ private readonly TimeSpan _timeout;
+
+ public VisibilityTimeoutPolicy(TimeSpan timeout)
+ => _timeout = timeout;
+
+ public bool ShouldEvict(BitmapEntry entry) {
+ return entry.RefCount == 0 &&
+ entry.LastReleased + _timeout <= DateTime.UtcNow;
+ }
+}
\ No newline at end of file
diff --git a/AsyncImageLoader.Avalonia/ViewModels/BitmapInspectorDesignViewModel.cs b/AsyncImageLoader.Avalonia/ViewModels/BitmapInspectorDesignViewModel.cs
new file mode 100644
index 0000000..c73f108
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/ViewModels/BitmapInspectorDesignViewModel.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.ObjectModel;
+using AsyncImageLoader.Memory.Services;
+
+namespace AsyncImageLoader.ViewModels;
+
+public class BitmapInspectorDesignViewModel : IDisposable
+{
+ private readonly BitmapStoreMonitor _monitor = new();
+
+ public ObservableCollection Items { get; }
+ = new();
+
+ public ObservableCollection HttpLogs { get; }
+ = new();
+
+ public BitmapInspectorDesignViewModel ()
+ {
+ _monitor.SnapshotUpdated += OnSnapshot;
+ _monitor.Start();
+
+ HttpClientMonitor.Instance.Start();
+ HttpClientMonitor.Instance.SnapshotUpdated += OnHttpLog;
+ }
+
+ private void OnSnapshot(BitmapStoreSnapshot snapshot)
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ Items.Clear();
+
+ foreach (var item in snapshot.Items)
+ Items.Add(item);
+ });
+ }
+
+ private void OnHttpLog(HttpRequestSnapshot snapshot)
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ HttpLogs.Clear();
+
+ foreach (var item in snapshot.Requests)
+ HttpLogs.Add(item);
+ });
+ }
+
+ ~BitmapInspectorDesignViewModel() {
+ Dispose();
+ }
+
+
+ public void Dispose() {
+ _monitor.SnapshotUpdated -= OnSnapshot;
+ HttpClientMonitor.Instance.SnapshotUpdated -= OnHttpLog;
+
+ _monitor.Dispose();
+ HttpClientMonitor.Instance.Dispose();
+ }
+}
+
diff --git a/AsyncImageLoader.Avalonia/Views/BitmapInspectorWindow.axaml b/AsyncImageLoader.Avalonia/Views/BitmapInspectorWindow.axaml
new file mode 100644
index 0000000..73420e7
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Views/BitmapInspectorWindow.axaml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AsyncImageLoader.Avalonia/Views/BitmapInspectorWindow.axaml.cs b/AsyncImageLoader.Avalonia/Views/BitmapInspectorWindow.axaml.cs
new file mode 100644
index 0000000..2dedc58
--- /dev/null
+++ b/AsyncImageLoader.Avalonia/Views/BitmapInspectorWindow.axaml.cs
@@ -0,0 +1,11 @@
+using AsyncImageLoader.ViewModels;
+using Avalonia.Controls;
+
+namespace AsyncImageLoader.Views;
+
+public partial class BitmapInspectorWindow : Window {
+ public BitmapInspectorWindow() {
+ InitializeComponent();
+ DataContext = new BitmapInspectorDesignViewModel();
+ }
+}
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e54b0be..5e52e65 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,4 +16,4 @@
-
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 04c1e66..faed0ae 100644
--- a/README.md
+++ b/README.md
@@ -1,62 +1,96 @@
-# AsyncImageLoader.Avalonia
+# **AsyncImageLoader.Avalonia**
-Provides way to asynchronous bitmap loading for Avalonia Image control.
-Features:
-- Supports urls and downloading from web
-- Asynchronous loading
-- Integrated inmemory cache
-- Integrated disk cache
-- Easy to implement your own way of images loading and caching
+Provides a way to **asynchronously load bitmaps** for Avalonia `Image` controls.
-## Getting started
+---
+
+## **Features**
+
+* Supports URLs and web downloading
+* Asynchronous loading
+* Integrated in-memory cache
+* Integrated disk cache
+* Easy to implement custom loading and caching
+
+---
+
+## **Getting started**
+
+1. Install the `AsyncImageLoader.Avalonia` NuGet package:
-1. Install `AsyncImageLoader.Avalonia` [nuget package](https://www.nuget.org/packages/AsyncImageLoader.Avalonia/)
```
dotnet add package AsyncImageLoader.Avalonia
```
+
2. Start using
-## Using
+---
+
+## **Using**
+
+> Note: The first time, you need to import the `AsyncImageLoader` namespace in your XAML file. Usually your IDE will suggest it automatically.
+
+Example root element:
-Note: The first time you will need to import the AsyncImageLoader namespace to your xaml file. Usually your IDE should [suggest it automatically](https://user-images.githubusercontent.com/29896317/140953397-00028365-5b93-4e6c-b470-094a555870c8.png). The root element in the file will be [like this](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml#L6):
```xaml
```
-Note: Assets and resources in Avalonia described [here](https://docs.avaloniaui.net/docs/getting-started/assets).
-### ImageLoader attached property
-The only thing you need to do in your xaml is to replace the `Source` property in `Image` with `ImageLoader.Source`.
-For example, your old code:
+Avalonia assets documentation: [docs](https://docs.avaloniaui.net/docs/getting-started/assets)
+
+---
+
+### **ImageLoader attached property**
+
+Replace the `Source` property of `Image` with `ImageLoader.Source`:
+
+Old code:
+
```xaml
-```
-Should turn into:
+```
+
+New code:
+
```xaml
```
-Also you can use `ImageLoader.IsLoading` readonly attached property that indicates whether the load is in progress or not.
-AsyncImageLoader **support** `resm:` and `avares:` links.
-And does **not** support relative referenced assets such as `Source="icon.png"` or `Source="/icon.png"`. Use [AdvancedImage control](#advancedimage-control).
+You can also use the readonly `ImageLoader.IsLoading` property to check if loading is in progress.
+
+**Supports `resm:` and `avares:` links**.
+**Does not support relative assets** such as `Source="icon.png"` or `Source="/icon.png"`. Use the [AdvancedImage control](#advancedimage-control).
+
+---
+
+### **AdvancedImage control**
+
+This control provides all the features of `ImageLoader.Source` and **supports relative assets**.
+
+Add the style to your `App.xaml`:
-### AdvancedImage control
-This control provides all capabilities of ImageLoader attached property and **support** relative referenced assets such as `Source="icon.png"` or `Source="/icon.png"`.
-Before you go, add following style to you `App.xaml` file and `Application.Styles` section:
```xaml
```
-And you can use `AdvancedImage` as any other control:
+
+Example usage:
+
```xaml
```
-This control allows specifying a custom IAsyncImageLoader for particular control.
-Also, this control has loading indicator support out of the box.
-### ImageBrush
-If you need a brush you can use Avalonia's `ImageBrush` with `ImageBrushLoader.Source` property (instead of default `Source`). It will look like that:
+* Allows specifying a custom `IAsyncImageLoader` per control
+* Built-in support for loading indicators
+
+---
+
+### **ImageBrush**
+
+If you need a brush, use `ImageBrushLoader.Source` instead of `Source`:
+
```xaml
@@ -66,13 +100,32 @@ If you need a brush you can use Avalonia's `ImageBrush` with `ImageBrushLoader.S
```
-## Loaders
-ImageLoader will use instance of [IImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs) for serving your requests.
-You can change the loader used by setting new one to the [ImageLoader.AsyncImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/ImageLoader.cs#L10) property. Do not forget to Dispose previous loader.
-There are several loaders available out of the box:
-- [BaseWebImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/Loaders/BaseCachedWebImageLoader.cs) - Provides non cached way to asynchronously load images without caching. Can be used as base class for custom loaders you dont want caching in any way.
-- [RamCachedWebImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs) - This is inheritor if BaseWebImageLoader with in memory images caching. Can be used as base class for custom loaders you want only inmemory caching.
-- [DiskCachedWebImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs) - This is inheritor if RamCachedWebImageLoader with in memory caching and disk caching for downloaded from the internet images. Can be used as base class for custom loaders if you want disk caching out of the box.
- If you are using DiskCachedWebImageLoader on a non-PC platforms (mobile/wasm/etc) make sure to specify correct path for storing files on this platform. Default most likely doesn't work there.
+---
+
+## **Loaders**
+
+`ImageLoader` uses an instance of [IImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs) to serve image requests.
+
+You can change the loader by assigning a new instance to [ImageLoader.AsyncImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/ImageLoader.cs#L10).
+**Remember to dispose the previous loader.**
+
+---
+
+### **Default loaders**
+
+* [BaseWebImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/Loaders/BaseCachedWebImageLoader.cs) – loads images asynchronously **without caching**. Can be used as a base class if you want no caching.
+* [RamCachedWebImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs) – inherits `BaseWebImageLoader` and adds **in-memory caching**.
+* [DiskCachedWebImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs) – inherits `RamCachedWebImageLoader` and adds **disk caching** for downloaded images.
+
+### **New Smart Loaders**
+
+* [SmartImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs) – inherits `BaseWebImageLoader` and implements **smart caching**:
+
+ * Prevents simultaneous downloads of the same URL by reusing active tasks
+ * Tracks usage references for each bitmap
+ * Automatically evicts images unused for **20 seconds**
+ * Reuses already loaded images without downloading again
+
+* [SmartDiskImageLoader](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia/blob/master/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs) – inherits `SmartImageLoader` and adds **disk caching**
-`RamCachedWebImageLoader` are used by default.
+> Note: **automatic memory cleanup cannot be disabled anymore**. This is now the default behavior and only works for loaders inheriting from `SmartImageLoader`. Older loaders (`RamCachedWebImageLoader`, `DiskCachedWebImageLoader`) do not have this mechanism.
\ No newline at end of file