From d2744c5693b495f509c0f3d7795773ac2bf03875 Mon Sep 17 00:00:00 2001 From: JaymeFernandes Date: Sat, 14 Feb 2026 18:59:45 -0300 Subject: [PATCH 1/2] Add memory stress test page with DevTools support --- .gitignore | 489 +++++++++++++++++- AsyncImageLoader.Avalonia.Demo/App.axaml.cs | 5 +- .../Pages/AdvancedImagePage.axaml.cs | 2 - .../Pages/AdvancedImageSafeMemoryPage.axaml | 45 ++ .../AdvancedImageSafeMemoryPage.axaml.cs | 11 + .../Pages/AttachedPropertiesPage.axaml.cs | 3 +- AsyncImageLoader.Avalonia.Demo/Program.cs | 1 - .../AdvancedImageSafeMemoryViewModel.cs | 18 + .../ViewModels/MainWindowViewModel.cs | 6 +- .../ViewModels/ViewModelBase.cs | 5 +- .../Views/MainWindow.axaml | 3 + .../Views/MainWindow.axaml.cs | 8 +- .../AdvancedImage.axaml.cs | 292 ++++++++--- .../DevTools/DevToolsBitmapInspector.cs | 78 +++ .../Extensions/InspectorExtensions.cs | 12 + .../IAsyncImageLoader.cs | 5 + AsyncImageLoader.Avalonia/ImageBrushLoader.cs | 42 +- AsyncImageLoader.Avalonia/ImageLoader.cs | 86 +-- .../Loaders/BaseWebImageLoader.cs | 37 +- .../Loaders/DiskCachedWebImageLoader.cs | 20 +- .../Loaders/RamCachedWebImageLoader.cs | 22 +- .../Loaders/SmartDiskImageLoader.cs | 53 ++ .../Loaders/SmartImageLoader.cs | 65 +++ AsyncImageLoader.Avalonia/LoggingHandler.cs | 50 ++ .../Interfaces/IBitmapEvictionPolicy.cs | 8 + .../Memory/Services/BitmapCacheCoordinator.cs | 55 ++ .../Memory/Services/BitmapStore.cs | 153 ++++++ .../Memory/Services/BitmapStoreMonitor.cs | 154 ++++++ .../Memory/Services/HttpClientMonitor.cs | 128 +++++ .../Memory/VisibilityTimeoutPolicy.cs | 21 + .../BitmapInspectorDesignViewModel.cs | 61 +++ .../Views/BitmapInspectorWindow.axaml | 109 ++++ .../Views/BitmapInspectorWindow.axaml.cs | 11 + Directory.Packages.props | 2 +- README.md | 131 +++-- 35 files changed, 1969 insertions(+), 222 deletions(-) create mode 100644 AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml create mode 100644 AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml.cs create mode 100644 AsyncImageLoader.Avalonia.Demo/ViewModels/AdvancedImageSafeMemoryViewModel.cs create mode 100644 AsyncImageLoader.Avalonia/DevTools/DevToolsBitmapInspector.cs create mode 100644 AsyncImageLoader.Avalonia/DevTools/Extensions/InspectorExtensions.cs create mode 100644 AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs create mode 100644 AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs create mode 100644 AsyncImageLoader.Avalonia/LoggingHandler.cs create mode 100644 AsyncImageLoader.Avalonia/Memory/Interfaces/IBitmapEvictionPolicy.cs create mode 100644 AsyncImageLoader.Avalonia/Memory/Services/BitmapCacheCoordinator.cs create mode 100644 AsyncImageLoader.Avalonia/Memory/Services/BitmapStore.cs create mode 100644 AsyncImageLoader.Avalonia/Memory/Services/BitmapStoreMonitor.cs create mode 100644 AsyncImageLoader.Avalonia/Memory/Services/HttpClientMonitor.cs create mode 100644 AsyncImageLoader.Avalonia/Memory/VisibilityTimeoutPolicy.cs create mode 100644 AsyncImageLoader.Avalonia/ViewModels/BitmapInspectorDesignViewModel.cs create mode 100644 AsyncImageLoader.Avalonia/Views/BitmapInspectorWindow.axaml create mode 100644 AsyncImageLoader.Avalonia/Views/BitmapInspectorWindow.axaml.cs 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..96a2efe 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,15 @@ public override void Initialize() { } public override void OnFrameworkInitializationCompleted() { + ImageLoader.AsyncImageLoader = new SmartImageLoader(); + 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/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..1e53544 --- /dev/null +++ b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..48ebc24 100644 --- a/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs +++ b/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs @@ -1,16 +1,14 @@ +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..41a3df3 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,10 +9,12 @@ using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Platform; +using Avalonia.Platform.Storage; +using Avalonia.Threading; namespace AsyncImageLoader; -public class AdvancedImage : ContentControl { +public partial class AdvancedImage : ContentControl { /// /// Defines the property. /// @@ -23,8 +26,7 @@ public class AdvancedImage : ContentControl { /// public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register(nameof(Source)); - - + /// /// Defines the property. /// @@ -68,7 +70,7 @@ public class AdvancedImage : ContentControl { /// public static readonly StyledProperty StretchDirectionProperty = Image.StretchDirectionProperty.AddOwner(); - + private readonly Uri? _baseUri; private RoundedRect _cornerRadiusClip; @@ -77,11 +79,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 string? _source; static AdvancedImage() { AffectsRender(CurrentImageProperty, StretchProperty, StretchDirectionProperty, @@ -119,16 +124,13 @@ public IAsyncImageLoader? Loader { /// public string? Source { get => GetValue(SourceProperty); - set => SetValue(SourceProperty, value); + set + { + SetValue(SourceProperty, value); + _source = 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 +138,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 +181,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 +202,123 @@ private void ClearSourceIfUserProvideImage() { } } - private async void UpdateImage(string? source, IAsyncImageLoader? loader) { - var cancellationTokenSource = new CancellationTokenSource(); + private async Task UpdateImage(string? source, IAsyncImageLoader? loader) { + _source = source; + + var cts = ReplaceCts(ref _updateCancellationToken); + + if (source is null && CurrentImage is not ImageWrapper) + return; + + IsLoading = true; + + var storage = TopLevel.GetTopLevel(this)?.StorageProvider; - var oldCancellationToken = Interlocked.Exchange(ref _updateCancellationToken, cancellationTokenSource); + BitmapLease? lease; - try { - oldCancellationToken?.Cancel(); + try + { + lease = await LoadImageInternalAsync(source, loader, storage, cts.Token); } - catch (ObjectDisposedException) { + catch (TaskCanceledException) + { + return; } - - if (source is null && FallbackImage != null) { - CurrentImage = FallbackImage; + catch (Exception e) + { + return; + } + finally + { + cts.Dispose(); } - if (source is null && CurrentImage is not ImageWrapper) { - // User provided image himself + if (cts.IsCancellationRequested) return; - } - IsLoading = true; - CurrentImage = null; - - 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); + if (CurrentImage is ImageWrapper wrapper) + wrapper.Dispose(); + + CurrentImage = lease is null ? null : new ImageWrapper(lease); + + IsLoading = false; + } + + + + private async Task LoadImageInternalAsync( + string? source, + IAsyncImageLoader? loader, + IStorageProvider? storage, + CancellationToken token) + { + async Task Load() + { + token.ThrowIfCancellationRequested(); + + var uri = new Uri(source, UriKind.RelativeOrAbsolute); + + if (AssetLoader.Exists(uri, _baseUri)) + { + using var stream = AssetLoader.Open(uri, _baseUri); + + token.ThrowIfCancellationRequested(); + return new Bitmap(stream); } - catch (TaskCanceledException) { - return null; + + if (loader is IAdvancedAsyncImageLoader advanced) + { + token.ThrowIfCancellationRequested(); + + return await advanced + .ProvideImageAsync(source, storage) + .ConfigureAwait(false); } - catch (Exception e) { - _logger?.Log(this, "AdvancedImage image resolution failed: {0}", e); - return null; + token.ThrowIfCancellationRequested(); + + return await loader + .ProvideImageAsync(source) + .ConfigureAwait(false); + } + + token.ThrowIfCancellationRequested(); + + loader ??= ImageLoader.AsyncImageLoader; + + if (source == null) + return null; + + BitmapLease? lease = null; + + try + { + if (loader is ICoordinatedImageLoader) + { + lease = await ImageLoader.BitmapCacheEvictionManager + .GetOrAdd(source, Load); } - finally { - cancellationTokenSource.Dispose(); + else + { + var entry = new BitmapEntry(source, await Load()); + lease = new BitmapLease(entry); } - }, CancellationToken.None); - - if (cancellationTokenSource.IsCancellationRequested) - return; - CurrentImage = bitmap is null ? null : new ImageWrapper(bitmap); - IsLoading = false; + token.ThrowIfCancellationRequested(); + return lease; + } + catch (TaskCanceledException) + { + lease?.Dispose(); + throw; + } + catch + { + lease?.Dispose(); + throw; + } } - + private void UpdateCornerRadius(CornerRadius radius) { _isCornerRadiusUsed = radius != default; _cornerRadiusClip = new RoundedRect(new Rect(0, 0, Bounds.Width, Bounds.Height), radius); @@ -308,26 +364,92 @@ 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 (CurrentImage is null) + _ = Dispatcher.UIThread.InvokeAsync(HandleAttachedAsync); + + + base.OnAttachedToVisualTree(e); + } + + private async Task HandleAttachedAsync() + => await UpdateImage(Source, Loader); + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { + ReleaseImage(); + base.OnDetachedFromVisualTree(e); + } + + protected override void OnDataContextChanged(EventArgs e) + { + ReleaseImage(); + base.OnDataContextChanged(e); + } + + private void ReleaseImage() + { + if (CurrentImage is ImageWrapper wrapper) + { + wrapper.Dispose(); + CurrentImage = null; + } + } + + 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 bool _disposed = false; + + private Size _size; + + public bool IsDisponse => _disposed; + + public ImageWrapper(BitmapLease lease) + { + _lease = lease; + + _size = new Size(lease.Bitmap.Size.Width, lease.Bitmap.Size.Height ); + + } - internal ImageWrapper(IImage imageImplementation) { - ImageImplementation = imageImplementation; + ~ImageWrapper() { + Dispose(); } - /// - public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) { - ImageImplementation.Draw(context, sourceRect, destRect); + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + _lease?.Dispose(); } - /// - public Size Size => ImageImplementation.Size; + public Size Size => _size; + + public void Draw(DrawingContext context, Rect s, Rect d) + => ((IImage)_lease?.Bitmap).Draw(context, s, d); } } \ 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/IAsyncImageLoader.cs b/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs index bd53781..46f5aee 100644 --- a/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs +++ b/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs @@ -11,4 +11,9 @@ public interface IAsyncImageLoader : IDisposable { /// Target url /// Bitmap public Task ProvideImageAsync(string url); +} + +public interface ICoordinatedImageLoader : IDisposable +{ + } \ No newline at end of file diff --git a/AsyncImageLoader.Avalonia/ImageBrushLoader.cs b/AsyncImageLoader.Avalonia/ImageBrushLoader.cs index 2ce55b9..020715c 100644 --- a/AsyncImageLoader.Avalonia/ImageBrushLoader.cs +++ b/AsyncImageLoader.Avalonia/ImageBrushLoader.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Concurrent; +using System.Threading; using AsyncImageLoader.Loaders; using Avalonia; using Avalonia.Logging; @@ -11,30 +13,58 @@ 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)) { + + try + { + if (!string.IsNullOrWhiteSpace(newValue)) + { bitmap = await AsyncImageLoader.ProvideImageAsync(newValue!); } } - 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..94383d4 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,74 @@ static ImageLoader() { SourceProperty.Changed.AddClassHandler(OnSourceChanged); Logger = Avalonia.Logging.Logger.TryGet(LogEventLevel.Error, AsyncImageLoaderLogArea); } - + public static IAsyncImageLoader AsyncImageLoader { get; set; } = new RamCachedWebImageLoader(); + + public static TimeSpan DefaultImageLifetime { get; set; } = TimeSpan.FromSeconds(10); + + public static BitmapCacheCoordinator BitmapCacheEvictionManager { get; set; } = + new (new VisibilityTimeoutPolicy()); 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) => { y.Cancel(); 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); - - if (AsyncImageLoader is IAdvancedAsyncImageLoader advancedLoader) { - return await advancedLoader.ProvideImageAsync(url, TopLevel.GetTopLevel(sender)?.StorageProvider); - } - - return await AsyncImageLoader.ProvideImageAsync(url); - } - catch (TaskCanceledException) { - return null; - } - catch (Exception e) { - Logger?.Log(LogEventLevel.Error, "ImageLoader image resolution failed: {0}", e); - - return null; - } - }); - - if (bitmap != null && !cts.Token.IsCancellationRequested) - sender.Source = bitmap!; - - // "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)); + Bitmap? bitmap = null; + + try + { + 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); + } + + if (!cts.Token.IsCancellationRequested && bitmap != null) + { + if (sender.Source is Bitmap oldBmp) + oldBmp.Dispose(); + + sender.Source = bitmap; + } + else + { + bitmap?.Dispose(); + } + + if (PendingOperations.TryRemove(sender, out var removedCtsFinal)) + removedCtsFinal.Dispose(); + 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..7a6e8a2 100644 --- a/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs +++ b/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs @@ -1,6 +1,8 @@ using System; using System.IO; using System.Net.Http; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using Avalonia.Logging; using Avalonia.Media.Imaging; @@ -14,13 +16,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 +36,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 +52,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 +64,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); } @@ -74,8 +77,8 @@ public void Dispose() { protected virtual async Task LoadAsync(string url) { var internalOrCachedBitmap = await LoadFromLocalAsync(url, null).ConfigureAwait(false) - ?? await LoadFromInternalAsync(url).ConfigureAwait(false) - ?? await LoadFromGlobalCache(url).ConfigureAwait(false); + ?? await LoadFromGlobalCache(url).ConfigureAwait(false) + ?? await LoadFromInternalAsync(url).ConfigureAwait(false); if (internalOrCachedBitmap != null) return internalOrCachedBitmap; try { @@ -85,10 +88,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; } @@ -104,6 +108,7 @@ await LoadFromLocalAsync(url, null).ConfigureAwait(false) 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..67a71dd 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; @@ -40,21 +37,12 @@ protected override async Task SaveToGlobalCache(string url, byte[] imageBytes) { 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) { + await base.SaveToGlobalCache(url, 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..ffc1c67 100644 --- a/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs +++ b/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs @@ -1,7 +1,9 @@ -using System.Collections.Concurrent; +using System; +using System.Collections.Concurrent; using System.Net.Http; using System.Threading.Tasks; using Avalonia.Media.Imaging; +using IStorageProvider = Avalonia.Platform.Storage.IStorageProvider; namespace AsyncImageLoader.Loaders; @@ -13,7 +15,7 @@ public class RamCachedWebImageLoader : BaseWebImageLoader { private readonly ConcurrentDictionary> _memoryCache = new(); /// - public RamCachedWebImageLoader() { } + public RamCachedWebImageLoader() { } /// public RamCachedWebImageLoader(HttpClient httpClient, bool disposeHttpClient) : base(httpClient, @@ -22,7 +24,21 @@ public RamCachedWebImageLoader(HttpClient httpClient, bool disposeHttpClient) : /// public override async Task ProvideImageAsync(string url) { - var bitmap = await _memoryCache.GetOrAdd(url, LoadAsync).ConfigureAwait(false); + var bitmap = await _memoryCache.GetOrAdd(url, LoadAsync) + .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; + } + + public override async Task ProvideImageAsync(string url, IStorageProvider? storageProvider = null) { + var bitmap = await _memoryCache.GetOrAdd(url, LoadAsync) + .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 _); diff --git a/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs new file mode 100644 index 0000000..404a86e --- /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.Add(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.Add(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..6f0e38a --- /dev/null +++ b/AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +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(); + + using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var ms = new MemoryStream(); + + var buffer = new byte[81920]; + int read; + while ((read = await responseStream.ReadAsync(buffer, 0, buffer.Length) + .ConfigureAwait(false)) > 0) + { + ms.Write(buffer, 0, read); + } + + return ms.ToArray(); + } + 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; + } + } + +} \ No newline at end of file diff --git a/AsyncImageLoader.Avalonia/LoggingHandler.cs b/AsyncImageLoader.Avalonia/LoggingHandler.cs new file mode 100644 index 0000000..1c270a2 --- /dev/null +++ b/AsyncImageLoader.Avalonia/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; + +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/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..154b4c8 --- /dev/null +++ b/AsyncImageLoader.Avalonia/Memory/Services/BitmapCacheCoordinator.cs @@ -0,0 +1,55 @@ +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 new BitmapLease(result); + + var bitmap = await factory(); + var entry = new BitmapEntry(key, bitmap); + + BitmapStore.Instance.Add(entry); + + return new BitmapLease(entry); + } + + private async Task CleanupLoop(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(1), 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..2ef41ae --- /dev/null +++ b/AsyncImageLoader.Avalonia/Memory/Services/BitmapStore.cs @@ -0,0 +1,153 @@ +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 BitmapStore { + private readonly Dictionary> _map = new(); + private readonly LinkedList _lru = new(); + private readonly object _lock = new(); + + public static BitmapStore Instance { get; } = new BitmapStore(); + + private BitmapStore() { } + + public bool TryGet(string key, out BitmapEntry entry) + { + lock (_lock) + { + if (_map.TryGetValue(key, out var node)) + { + MoveToFront(node); + entry = node.Value; + return true; + } + + entry = null!; + return false; + } + } + + public async Task GetOrAdd(string key, Func factory) { + lock (_lock) { + if (TryGet(key, out var entry)) + return entry.Bitmap; + + var bitmap = factory(); + + if (bitmap == null) + return bitmap; + + entry = new BitmapEntry(key, bitmap); + + Add(entry); + + return bitmap; + } + } + + public void Add(BitmapEntry entry) + { + lock (_lock) + { + if (_map.ContainsKey(entry.Key)) + return; + + var node = new LinkedListNode(entry); + _lru.AddFirst(node); + _map[entry.Key] = node; + } + } + + public IEnumerable EnumerateFromOldest() + { + lock (_lock) + { + return _lru.OrderBy(x => x.RefCount).ToList(); + } + } + + public void Remove(string key) + { + lock (_lock) + { + if (!_map.TryGetValue(key, out var node)) + return; + + _lru.Remove(node); + _map.Remove(key); + } + } + + private void MoveToFront(LinkedListNode node) + { + _lru.Remove(node); + _lru.AddFirst(node); + } +} + +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..ecbc0f0 --- /dev/null +++ b/AsyncImageLoader.Avalonia/Memory/VisibilityTimeoutPolicy.cs @@ -0,0 +1,21 @@ +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 VisibilityTimeoutPolicy() + => _timeout = ImageLoader.DefaultImageLifetime; + + 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..982e8f3 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,34 @@ 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 From ce4e6f46df5ce1e226003f414abd0113365609d3 Mon Sep 17 00:00:00 2001 From: JaymeFernandes Date: Thu, 19 Feb 2026 15:53:25 -0300 Subject: [PATCH 2/2] Added as an option: automatic cleanup --- AsyncImageLoader.Avalonia.Demo/App.axaml.cs | 1 + .../AsyncImageLoader.Avalonia.Demo.csproj | 6 +- .../Pages/AdvancedImagePage.axaml | 12 +- .../Pages/AdvancedImageSafeMemoryPage.axaml | 1 + .../Views/MainWindow.axaml.cs | 1 + .../AdvancedImage.axaml.cs | 264 ++++++++++++------ .../{ => DevTools}/LoggingHandler.cs | 2 +- .../IAsyncImageLoader.cs | 6 +- AsyncImageLoader.Avalonia/ImageBrushLoader.cs | 12 +- AsyncImageLoader.Avalonia/ImageLoader.cs | 20 +- .../Loaders/BaseWebImageLoader.cs | 10 +- .../Loaders/DiskCachedWebImageLoader.cs | 18 +- .../Loaders/RamCachedWebImageLoader.cs | 53 +++- .../Loaders/SmartDiskImageLoader.cs | 4 +- .../Loaders/SmartImageLoader.cs | 54 +++- .../Memory/Services/BitmapCacheCoordinator.cs | 13 +- .../Memory/Services/BitmapStore.cs | 99 +++---- .../Memory/VisibilityTimeoutPolicy.cs | 3 - README.md | 2 - 19 files changed, 359 insertions(+), 222 deletions(-) rename AsyncImageLoader.Avalonia/{ => DevTools}/LoggingHandler.cs (93%) diff --git a/AsyncImageLoader.Avalonia.Demo/App.axaml.cs b/AsyncImageLoader.Avalonia.Demo/App.axaml.cs index 96a2efe..cbcc67a 100644 --- a/AsyncImageLoader.Avalonia.Demo/App.axaml.cs +++ b/AsyncImageLoader.Avalonia.Demo/App.axaml.cs @@ -14,6 +14,7 @@ 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 { 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/AdvancedImageSafeMemoryPage.axaml b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml index 1e53544..16c4f5a 100644 --- a/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml +++ b/AsyncImageLoader.Avalonia.Demo/Pages/AdvancedImageSafeMemoryPage.axaml @@ -33,6 +33,7 @@ Source="{Binding}" Height="200" Margin="4" + AutoCleanupEnabled="True" HorizontalAlignment="Center" VerticalAlignment="Center"/> diff --git a/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs b/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs index 48ebc24..8a5b639 100644 --- a/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs +++ b/AsyncImageLoader.Avalonia.Demo/Views/MainWindow.axaml.cs @@ -1,3 +1,4 @@ + using AsyncImageLoader.DevTools.Extensions; using Avalonia; using Avalonia.Controls; diff --git a/AsyncImageLoader.Avalonia/AdvancedImage.axaml.cs b/AsyncImageLoader.Avalonia/AdvancedImage.axaml.cs index 41a3df3..4eabea4 100644 --- a/AsyncImageLoader.Avalonia/AdvancedImage.axaml.cs +++ b/AsyncImageLoader.Avalonia/AdvancedImage.axaml.cs @@ -11,16 +11,23 @@ using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Threading; +using Avalonia.VisualTree; namespace AsyncImageLoader; -public partial class AdvancedImage : ContentControl { +public class AdvancedImage : ContentControl { /// /// Defines the property. /// public static readonly StyledProperty LoaderProperty = AvaloniaProperty.Register(nameof(Loader)); + /// + /// Defines the property. + /// + public static readonly StyledProperty AutoCleanupEnabledProperty = + AvaloniaProperty.Register(nameof(AutoCleanupEnabled)); + /// /// Defines the property. /// @@ -86,7 +93,7 @@ public partial class AdvancedImage : ContentControl { private readonly ParametrizedLogger? _logger; private BitmapLease? _lease; - private string? _source; + private bool _isInsideVirtualizingPanel; static AdvancedImage() { AffectsRender(CurrentImageProperty, StretchProperty, StretchDirectionProperty, @@ -101,6 +108,7 @@ static AdvancedImage() { public AdvancedImage(Uri? baseUri) { _baseUri = baseUri; _logger = Logger.TryGet(LogEventLevel.Error, ImageLoader.AsyncImageLoaderLogArea); + _isInsideVirtualizingPanel = IsInsideVirtualizingPanel(this); } /// @@ -111,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. /// @@ -127,7 +147,6 @@ public string? Source { set { SetValue(SourceProperty, value); - _source = value; } } @@ -203,14 +222,20 @@ private void ClearSourceIfUserProvideImage() { } private async Task UpdateImage(string? source, IAsyncImageLoader? loader) { - _source = source; - var cts = ReplaceCts(ref _updateCancellationToken); + + if (source is null && FallbackImage != null) + CurrentImage = FallbackImage; if (source is null && CurrentImage is not ImageWrapper) return; IsLoading = true; + + if (CurrentImage is ImageWrapper wrapper) + wrapper.Dispose(); + + CurrentImage = null; var storage = TopLevel.GetTopLevel(this)?.StorageProvider; @@ -220,27 +245,13 @@ private async Task UpdateImage(string? source, IAsyncImageLoader? loader) { { lease = await LoadImageInternalAsync(source, loader, storage, cts.Token); } - catch (TaskCanceledException) - { - return; - } - catch (Exception e) - { - return; - } finally { + cts.Cancel(); cts.Dispose(); } - - if (cts.IsCancellationRequested) - return; - - if (CurrentImage is ImageWrapper wrapper) - wrapper.Dispose(); - + CurrentImage = lease is null ? null : new ImageWrapper(lease); - IsLoading = false; } @@ -252,36 +263,6 @@ private async Task UpdateImage(string? source, IAsyncImageLoader? loader) { IStorageProvider? storage, CancellationToken token) { - async Task Load() - { - token.ThrowIfCancellationRequested(); - - var uri = new Uri(source, UriKind.RelativeOrAbsolute); - - if (AssetLoader.Exists(uri, _baseUri)) - { - using var stream = AssetLoader.Open(uri, _baseUri); - - token.ThrowIfCancellationRequested(); - return new Bitmap(stream); - } - - if (loader is IAdvancedAsyncImageLoader advanced) - { - token.ThrowIfCancellationRequested(); - - return await advanced - .ProvideImageAsync(source, storage) - .ConfigureAwait(false); - } - - token.ThrowIfCancellationRequested(); - - return await loader - .ProvideImageAsync(source) - .ConfigureAwait(false); - } - token.ThrowIfCancellationRequested(); loader ??= ImageLoader.AsyncImageLoader; @@ -291,20 +272,16 @@ private async Task UpdateImage(string? source, IAsyncImageLoader? loader) { BitmapLease? lease = null; - try - { - if (loader is ICoordinatedImageLoader) - { - lease = await ImageLoader.BitmapCacheEvictionManager - .GetOrAdd(source, Load); - } - else - { - var entry = new BitmapEntry(source, await Load()); - lease = new BitmapLease(entry); - } + try { + var entry = await Load(source, loader, token); + + if (entry is null) + return null; + + lease = new BitmapLease(entry); token.ThrowIfCancellationRequested(); + return lease; } catch (TaskCanceledException) @@ -319,6 +296,50 @@ private async Task UpdateImage(string? source, IAsyncImageLoader? loader) { } } + 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); @@ -371,29 +392,52 @@ protected override Size ArrangeOverride(Size finalSize) { } protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { - if (CurrentImage is null) - _ = Dispatcher.UIThread.InvokeAsync(HandleAttachedAsync); - - + if(!_isInsideVirtualizingPanel) + AcquireImage(); base.OnAttachedToVisualTree(e); } - private async Task HandleAttachedAsync() - => await UpdateImage(Source, Loader); - protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { - ReleaseImage(); + if(!_isInsideVirtualizingPanel) + ReleaseImage(); base.OnDetachedFromVisualTree(e); } - - protected override void OnDataContextChanged(EventArgs e) - { - ReleaseImage(); + + 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(); @@ -401,6 +445,21 @@ private void ReleaseImage() } } + 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(); @@ -418,38 +477,61 @@ private static CancellationTokenSource ReplaceCts(ref CancellationTokenSource? f public sealed class ImageWrapper : IImage, IDisposable { - private BitmapLease _lease; - private bool _disposed = false; + private BitmapLease? _lease; + private int _disposed; - private Size _size; - - public bool IsDisponse => _disposed; + private readonly Size _size; + + public bool IsDisposed => Volatile.Read(ref _disposed) == 1; public ImageWrapper(BitmapLease lease) { _lease = lease; - - _size = new Size(lease.Bitmap.Size.Width, lease.Bitmap.Size.Height ); - + + var bmp = lease.Bitmap + ?? throw new ObjectDisposedException(nameof(BitmapLease)); + + _size = new Size(bmp.Size.Width, bmp.Size.Height); } - ~ImageWrapper() { - Dispose(); + ~ImageWrapper() + { + Dispose(false); } public void Dispose() { - if (_disposed) + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; - _disposed = true; - - _lease?.Dispose(); + if (disposing) + { + _lease?.Dispose(); + } + + _lease = null; } public Size Size => _size; - public void Draw(DrawingContext context, Rect s, Rect d) - => ((IImage)_lease?.Bitmap).Draw(context, s, d); + 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/LoggingHandler.cs b/AsyncImageLoader.Avalonia/DevTools/LoggingHandler.cs similarity index 93% rename from AsyncImageLoader.Avalonia/LoggingHandler.cs rename to AsyncImageLoader.Avalonia/DevTools/LoggingHandler.cs index 1c270a2..3bce9c4 100644 --- a/AsyncImageLoader.Avalonia/LoggingHandler.cs +++ b/AsyncImageLoader.Avalonia/DevTools/LoggingHandler.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using AsyncImageLoader.Memory.Services; -namespace AsyncImageLoader; +namespace AsyncImageLoader.DevTools; public class LoggingHandler : DelegatingHandler { diff --git a/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs b/AsyncImageLoader.Avalonia/IAsyncImageLoader.cs index 46f5aee..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; @@ -13,7 +15,9 @@ public interface IAsyncImageLoader : IDisposable { public Task ProvideImageAsync(string url); } -public interface ICoordinatedImageLoader : IDisposable +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 020715c..ca5a671 100644 --- a/AsyncImageLoader.Avalonia/ImageBrushLoader.cs +++ b/AsyncImageLoader.Avalonia/ImageBrushLoader.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Threading; using AsyncImageLoader.Loaders; +using AsyncImageLoader.Memory.Services; using Avalonia; using Avalonia.Logging; using Avalonia.Media; @@ -42,7 +43,16 @@ private static async void OnSourceChanged(ImageBrush imageBrush, AvaloniaPropert { if (!string.IsNullOrWhiteSpace(newValue)) { - bitmap = await AsyncImageLoader.ProvideImageAsync(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 (OperationCanceledException) { } diff --git a/AsyncImageLoader.Avalonia/ImageLoader.cs b/AsyncImageLoader.Avalonia/ImageLoader.cs index 94383d4..ee9dfdc 100644 --- a/AsyncImageLoader.Avalonia/ImageLoader.cs +++ b/AsyncImageLoader.Avalonia/ImageLoader.cs @@ -29,10 +29,8 @@ static ImageLoader() { public static IAsyncImageLoader AsyncImageLoader { get; set; } = new RamCachedWebImageLoader(); - public static TimeSpan DefaultImageLifetime { get; set; } = TimeSpan.FromSeconds(10); - public static BitmapCacheCoordinator BitmapCacheEvictionManager { get; set; } = - new (new VisibilityTimeoutPolicy()); + new (new VisibilityTimeoutPolicy(TimeSpan.FromSeconds(20))); private static readonly ConcurrentDictionary PendingOperations = new(); @@ -41,8 +39,9 @@ private static async void OnSourceChanged(Image sender, AvaloniaPropertyChangedE var url = args.GetNewValue(); var cts = PendingOperations.AddOrUpdate(sender, new CancellationTokenSource(), - (_, y) => { + (x, y) => { y.Cancel(); + y.Dispose(); return new CancellationTokenSource(); }); @@ -62,9 +61,16 @@ private static async void OnSourceChanged(Image sender, AvaloniaPropertyChangedE Bitmap? bitmap = null; - try - { - if (AsyncImageLoader is IAdvancedAsyncImageLoader advancedLoader) + try { + if (AsyncImageLoader is ICoordinatedImageLoader coordinatedImageLoader) { + var entry = await coordinatedImageLoader.CoordinatorProvideImageAsync(url); + + if(entry != null) + entry.Acquire(); + + bitmap = entry?.Bitmap; + } + else if (AsyncImageLoader is IAdvancedAsyncImageLoader advancedLoader) bitmap = await advancedLoader.ProvideImageAsync(url, TopLevel.GetTopLevel(sender)?.StorageProvider); else bitmap = await AsyncImageLoader.ProvideImageAsync(url); diff --git a/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs index 7a6e8a2..003d656 100644 --- a/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs +++ b/AsyncImageLoader.Avalonia/Loaders/BaseWebImageLoader.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; +using AsyncImageLoader.DevTools; using Avalonia.Logging; using Avalonia.Media.Imaging; using Avalonia.Platform; @@ -77,8 +78,8 @@ public void Dispose() { protected virtual async Task LoadAsync(string url) { var internalOrCachedBitmap = await LoadFromLocalAsync(url, null).ConfigureAwait(false) - ?? await LoadFromGlobalCache(url).ConfigureAwait(false) - ?? await LoadFromInternalAsync(url).ConfigureAwait(false); + ?? await LoadFromInternalAsync(url).ConfigureAwait(false) + ?? await LoadFromGlobalCache(url).ConfigureAwait(false); if (internalOrCachedBitmap != null) return internalOrCachedBitmap; try { @@ -104,9 +105,8 @@ 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; diff --git a/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs index 67a71dd..342a871 100644 --- a/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs +++ b/AsyncImageLoader.Avalonia/Loaders/DiskCachedWebImageLoader.cs @@ -30,17 +30,19 @@ 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 async Task SaveToGlobalCache(string url, byte[] imageBytes) { - await base.SaveToGlobalCache(url, imageBytes); - + protected override async Task SaveToGlobalCache(string url, byte[] imageBytes) + { var path = Path.Combine(_cacheFolder, CreateMD5(url)); + Directory.CreateDirectory(_cacheFolder); File.WriteAllBytes(path, imageBytes); } diff --git a/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs index ffc1c67..99a3afa 100644 --- a/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs +++ b/AsyncImageLoader.Avalonia/Loaders/RamCachedWebImageLoader.cs @@ -1,7 +1,7 @@ -using System; -using System.Collections.Concurrent; +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; @@ -12,7 +12,7 @@ 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() { } @@ -24,24 +24,57 @@ public RamCachedWebImageLoader(HttpClient httpClient, bool disposeHttpClient) : /// public override async Task ProvideImageAsync(string url) { - var bitmap = await _memoryCache.GetOrAdd(url, LoadAsync) + 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 bitmap = await _memoryCache.GetOrAdd(url, LoadAsync) + 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; } } \ No newline at end of file diff --git a/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs index 404a86e..f109f74 100644 --- a/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs +++ b/AsyncImageLoader.Avalonia/Loaders/SmartDiskImageLoader.cs @@ -26,7 +26,7 @@ protected sealed override async Task SaveToGlobalCache(string url, byte[] imageB var bitmap = new Bitmap(memoryStream); var entry = new BitmapEntry(url, bitmap); - BitmapStore.Instance.Add(entry); + BitmapStore.Instance.TryAdd(entry); var path = Path.Combine(_cacheFolder, CreateMD5(url)); @@ -41,7 +41,7 @@ protected sealed override Task SaveToGlobalCache(string url, byte[] imageBytes) var bitmap = new Bitmap(memoryStream); var entry = new BitmapEntry(url, bitmap); - BitmapStore.Instance.Add(entry); + BitmapStore.Instance.TryAdd(entry); var path = Path.Combine(_cacheFolder, CreateMD5(url)); Directory.CreateDirectory(_cacheFolder); diff --git a/AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs b/AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs index 6f0e38a..4d06bca 100644 --- a/AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs +++ b/AsyncImageLoader.Avalonia/Loaders/SmartImageLoader.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Concurrent; -using System.IO; using System.Net.Http; -using System.Threading; using System.Threading.Tasks; +using AsyncImageLoader.Memory.Services; +using Avalonia.Platform.Storage; namespace AsyncImageLoader.Loaders; @@ -36,18 +36,11 @@ public class SmartImageLoader : BaseWebImageLoader, ICoordinatedImageLoader response.EnsureSuccessStatusCode(); - using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var ms = new MemoryStream(); - - var buffer = new byte[81920]; - int read; - while ((read = await responseStream.ReadAsync(buffer, 0, buffer.Length) - .ConfigureAwait(false)) > 0) - { - ms.Write(buffer, 0, read); - } - - return ms.ToArray(); + var bytes = await response.Content + .ReadAsByteArrayAsync() + .ConfigureAwait(false); + + return bytes; } catch (OperationCanceledException) { @@ -62,4 +55,37 @@ public class SmartImageLoader : BaseWebImageLoader, ICoordinatedImageLoader } } + 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/Services/BitmapCacheCoordinator.cs b/AsyncImageLoader.Avalonia/Memory/Services/BitmapCacheCoordinator.cs index 154b4c8..d31b5a5 100644 --- a/AsyncImageLoader.Avalonia/Memory/Services/BitmapCacheCoordinator.cs +++ b/AsyncImageLoader.Avalonia/Memory/Services/BitmapCacheCoordinator.cs @@ -16,23 +16,22 @@ public BitmapCacheCoordinator(IBitmapEvictionPolicy policy) { _ = CleanupLoop(_cts.Token); } - public async Task GetOrAdd(string key, Func> factory) { + public async Task GetOrAdd(string key, Func> factory) { if (BitmapStore.Instance.TryGet(key, out var result)) - return new BitmapLease(result); + return result; - var bitmap = await factory(); - var entry = new BitmapEntry(key, bitmap); + var entry = new BitmapEntry(key, await factory()); - BitmapStore.Instance.Add(entry); + BitmapStore.Instance.TryAdd(entry); - return new BitmapLease(entry); + return entry; } private async Task CleanupLoop(CancellationToken token) { while (!token.IsCancellationRequested) { - await Task.Delay(TimeSpan.FromSeconds(1), token); + await Task.Delay(TimeSpan.FromSeconds(5), token); foreach (var entry in BitmapStore.Instance.EnumerateFromOldest()) { diff --git a/AsyncImageLoader.Avalonia/Memory/Services/BitmapStore.cs b/AsyncImageLoader.Avalonia/Memory/Services/BitmapStore.cs index 2ef41ae..b720eda 100644 --- a/AsyncImageLoader.Avalonia/Memory/Services/BitmapStore.cs +++ b/AsyncImageLoader.Avalonia/Memory/Services/BitmapStore.cs @@ -2,91 +2,64 @@ 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 BitmapStore { - private readonly Dictionary> _map = new(); - private readonly LinkedList _lru = new(); - private readonly object _lock = new(); - +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) { - lock (_lock) + if (_bitmaps.TryGetValue(key, out var node)) { - if (_map.TryGetValue(key, out var node)) - { - MoveToFront(node); - entry = node.Value; - return true; - } - - entry = null!; - return false; + entry = node; + return true; } - } - public async Task GetOrAdd(string key, Func factory) { - lock (_lock) { - if (TryGet(key, out var entry)) - return entry.Bitmap; - - var bitmap = factory(); - - if (bitmap == null) - return bitmap; - - entry = new BitmapEntry(key, bitmap); - - Add(entry); - - return bitmap; - } + entry = null!; + return false; } - public void Add(BitmapEntry entry) + public void TryAdd(BitmapEntry entry, bool acquire = false) { - lock (_lock) - { - if (_map.ContainsKey(entry.Key)) - return; - - var node = new LinkedListNode(entry); - _lru.AddFirst(node); - _map[entry.Key] = node; - } + if(acquire) + entry.Acquire(); + + _bitmaps.TryAdd(entry.Key, entry); } public IEnumerable EnumerateFromOldest() { - lock (_lock) - { - return _lru.OrderBy(x => x.RefCount).ToList(); - } + BitmapEntry[] snapshot; + snapshot = _bitmaps.Values.Concat(BitmapEntries).ToArray(); + + return snapshot.OrderBy(x => x.RefCount).ToArray(); } - public void Remove(string key) - { - lock (_lock) - { - if (!_map.TryGetValue(key, out var node)) - return; + public void Remove(string key) { + _bitmaps.TryRemove(key, out var node); + } - _lru.Remove(node); - _map.Remove(key); - } + public void AddBitmapEntry(BitmapEntry bitmapEntry) + { + BitmapEntries.Add(bitmapEntry); } - private void MoveToFront(LinkedListNode node) + public void RemoveBitmapEntry(string url) { - _lru.Remove(node); - _lru.AddFirst(node); + var toRemove = BitmapEntries.Where(x => x.Key == url).ToList(); + + foreach (var entry in toRemove) + BitmapEntries.TryTake(out _); } } @@ -106,13 +79,11 @@ public BitmapEntry(string key, Bitmap bitmap) Bitmap = bitmap; } - public void Acquire() - { + public void Acquire() { Interlocked.Increment(ref _refCount); } - public void Release() - { + public void Release() { if (Interlocked.Decrement(ref _refCount) == 0) LastReleased = DateTime.UtcNow; } diff --git a/AsyncImageLoader.Avalonia/Memory/VisibilityTimeoutPolicy.cs b/AsyncImageLoader.Avalonia/Memory/VisibilityTimeoutPolicy.cs index ecbc0f0..09e2b92 100644 --- a/AsyncImageLoader.Avalonia/Memory/VisibilityTimeoutPolicy.cs +++ b/AsyncImageLoader.Avalonia/Memory/VisibilityTimeoutPolicy.cs @@ -10,9 +10,6 @@ public class VisibilityTimeoutPolicy : IBitmapEvictionPolicy public VisibilityTimeoutPolicy(TimeSpan timeout) => _timeout = timeout; - - public VisibilityTimeoutPolicy() - => _timeout = ImageLoader.DefaultImageLifetime; public bool ShouldEvict(BitmapEntry entry) { return entry.RefCount == 0 && diff --git a/README.md b/README.md index 982e8f3..faed0ae 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,6 @@ You can change the loader by assigning a new instance to [ImageLoader.AsyncImage * [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**: