在 Web 開發中,img 標簽用來呈現圖片,而且一般來說,瀏覽器是會對這些圖片進行緩存的。 比如訪問百度,我們可以發現,圖片、腳本這種都是從緩存(記憶體緩存/磁碟緩存)中載入的,而不是再去訪問一次百度的伺服器,這樣一方面改善了響應速度,另一方面也減輕了服務端的壓力。 但是,對於 WPF 和 UWP ...
在 Web 開發中,img 標簽用來呈現圖片,而且一般來說,瀏覽器是會對這些圖片進行緩存的。
比如訪問百度,我們可以發現,圖片、腳本這種都是從緩存(記憶體緩存/磁碟緩存)中載入的,而不是再去訪問一次百度的伺服器,這樣一方面改善了響應速度,另一方面也減輕了服務端的壓力。
但是,對於 WPF 和 UWP 開發來說,原生的 Image 控制項是只有記憶體緩存的,並沒有磁碟緩存的,所以一旦程式退出了,下次再重新啟動程式的話,那還是得從伺服器上面取圖片的。因此,打造一個具備緩存(尤其是磁碟緩存)的 Image 控制項還是有必要的。
在 WPF 和 UWP 中,我們都知道 Image 控制項 Source 屬性的類型是 ImageSource,但是,如果我們使用數據綁定的話,是可以綁定一個字元串的,在運行的時候,我們會發現 Source 屬性變成了一個 BitmapImage 類型的對象。那麼可以推論出,是框架給我們做了一些轉換。經過查閱 WPF 的相關資料,發現是 ImageSource 這個類型上有一個 TypeConverterAttribute:
查看 ImageSourceConverter 的源碼(https://referencesource.microsoft.com/#PresentationCore/Core/CSharp/System/Windows/Media/ImageSourceConverter.cs,0f008db560b688fe),我們可以看到這麼一段
因此,在對 Source 屬性進行綁定的時候,我們的數據源是可以使用:string、Stream、Uri、byte[] 這些類型的,當然還有它自身 ImageSource(BitmapImage 是 ImageSource 的子類)。
雖然有 5 種這麼多,然而最終我們需要的是 ImageSource。另外 Uri 就相當於 string 的轉換。再仔細分析的話,我們大概可以得出下麵的結論:
string –> Uri –> byte[] –> Stream –> ImageSource
其中 Uri 到 byte[] 就是相當於從 Uri 對應的地方載入圖片數據,常見的就是 web、磁碟和程式內嵌資源。
在某些節點我們是可以加上緩存的,如碰到一個 http/https 的地址,那可以先檢查本地是否有緩存文件,有就直接載入不去訪問伺服器了。
經過整理,基本可以得出如下的流程圖。
可以看出,流程是一個自上而下,再自下而上的流程。這裡就相當於是一個管道處理模型。每一行等價於一個管道,然後整個流程相當於整個管道串聯起來。
在代碼的實現過程中,我借鑒了 asp.net core 中的 middleware 的處理過程。https://docs.microsoft.com/zh-cn/aspnet/core/fundamentals/middleware/?view=aspnetcore-2.1&tabs=aspnetcore2x
在 asp.net core 中,middleware 的其中一種寫法如下:
public class AspNetCoreMiddleware { public async Task InvokeAsync(HttpContext context, RequestDelegate next) { // before await next(context); // after } }
先建立一個類似 HttpContext 的上下文,用於在這個管道模型中處理,我就叫 LoadingContext:
public class LoadingContext<TResult> where TResult : class { private byte[] _httpResponseBytes; private TResult _result; public LoadingContext(object source) { if (source == null) { throw new ArgumentNullException(nameof(source)); } OriginSource = source; Current = source; } public object Current { get; set; } public byte[] HttpResponseBytes { get => _httpResponseBytes; set { if (_httpResponseBytes != null) { throw new InvalidOperationException("value has been set."); } _httpResponseBytes = value; } } public object OriginSource { get; } public TResult Result { get => _result; set { if (_result != null) { throw new InvalidOperationException("value has been set."); } _result = value; } } }
這裡有四個屬性,OriginSource 代表輸入的原始 Source,Current 代表當前的 Source 值,在一開始是與 OriginSource 一致的。Result 代表了最終的輸出,一般不需要用戶手動設置,只需要到達管道底部的話,如果 Result 仍然為空,那麼將 Current 賦值給 Result 就是了。HttpResponseBytes 一旦設置了就不可再設置。
可能你們會問,為啥要單獨弄 HttpResponseBytes 這個屬性呢,不能在下載完成的時候緩存到磁碟嗎?這裡考慮到下載回來的不一定是一幅圖片,等到後面成功了,得到一個 ImageSource 對象了,那才能認為這是一個圖片,這時候才緩存。
另外為啥是泛型,這裡考慮到擴展性,搞不好某個 Image 的 Source 類型就不是 ImageSource 呢(*^_^*)
而 RequestDelegate 是一個委托,簽名如下:
public delegate System.Threading.Tasks.Task RequestDelegate(HttpContext context);
因此我仿照,代碼里就建一個 PipeDelegate 的委托。
public delegate Task PipeDelegate<TResult>([NotNull]LoadingContext<TResult> context, CancellationToken cancellationToken = default(CancellationToken)) where TResult : class;
NotNullAttribute 是來自 JetBrains.Annotations 這個 nuget 包的。
另外微軟爸爸說,支持取消的話,那是好做法,要表揚的,因此加上了 CancellationToken 參數。
接下來那就可以準備我們自己的 middleware 了,代碼如下:
public abstract class PipeBase<TResult> : IDisposable where TResult : class { protected bool IsInDesignMode => (bool)DesignerProperties.IsInDesignModeProperty.GetMetadata(typeof(DependencyObject)).DefaultValue; public virtual void Dispose() { } public abstract Task InvokeAsync([NotNull]LoadingContext<TResult> context, [NotNull]PipeDelegate<TResult> next, CancellationToken cancellationToken = default(CancellationToken)); }
跟 asp.net core 的 middleware 很像,這裡我加了一個 IsInDesignMode 屬性,畢竟在設計器模式下麵,就沒必要跑緩存相關的分支了。
那麼,我們自己的 middleware,也就是 Pipe 有了,該怎麼串聯起來呢,這裡我們可以看 asp.net core 的源碼
public RequestDelegate Build() { RequestDelegate app = context => { context.Response.StatusCode = 404; return Task.CompletedTask; }; foreach (var component in _components.Reverse()) { app = component(app); } return app; }
其中 _components 的定義如下:
private readonly IList<Func<RequestDelegate, RequestDelegate>> _components = new List<Func<RequestDelegate, RequestDelegate>>();
Func<RequestDelegate, RequestDelegate> 代表輸入了一個委托,返回了一個委托。而上面 app 就相當於管道的最底部了,因為無法處理了,因此就賦值為 404 了。至於為啥要反轉一下列表,這個大家可以自己手動試試,這裡也不好解析。
因此,我編寫出如下的代碼來組裝我們的 Pipe。
internal static PipeDelegate<TResult> Build<TResult>(IEnumerable<Type> pipes) where TResult : class { PipeDelegate<TResult> end = (context, cancellationToken) => { if (context.Result == null) { context.Result = context.Current as TResult; } if (context.Result == null) { throw new NotSupportedException(); } return Task.CompletedTask; }; foreach (var pipeType in pipes.Reverse()) { Func<PipeDelegate<TResult>, PipeDelegate<TResult>> handler = next => { return (context, cancellationToken) => { using (var pipe = CreatePipe<TResult>(pipeType)) { return pipe.InvokeAsync(context, next, cancellationToken); } }; }; end = handler(end); } return end; }
代碼比 asp.net core 的複雜一點,先看上面 end 的初始化。因為到達了管道的底部,如果 Result 仍然是空的話,那麼嘗試將 Current 賦值給 Result,如果執行後還是空,那說明輸入的 Source 是不支持的類型,就直接拋出異常好了。
在下麵的迴圈體中,handler 等價於上面 asp.net core 的 component,接受了一個委托,返回了一個委托。
委托體中,根據當前管道的類型創建了一個實例,並執行 InvokeAsync 方法。
構建管道的代碼也有了,因此載入邏輯也沒啥難的了。
private async Task SetSourceAsync(object source) { if (_image == null) { return; } _lastLoadCts?.Cancel(); if (source == null) { _image.Source = null; VisualStateManager.GoToState(this, NormalStateName, true); return; } _lastLoadCts = new CancellationTokenSource(); try { VisualStateManager.GoToState(this, LoadingStateName, true); var context = new LoadingContext<ImageSource>(source); var pipeDelegate = PipeBuilder.Build<ImageSource>(Pipes); var retryDelay = RetryDelay; var policy = Policy.Handle<Exception>().WaitAndRetryAsync(RetryCount, count => retryDelay, (ex, delay) => { context.Reset(); }); await policy.ExecuteAsync(() => pipeDelegate.Invoke(context, _lastLoadCts.Token)); if (!_lastLoadCts.IsCancellationRequested) { _image.Source = context.Result; VisualStateManager.GoToState(this, OpenedStateName, true); ImageOpened?.Invoke(this, EventArgs.Empty); } } catch (Exception ex) { if (!_lastLoadCts.IsCancellationRequested) { _image.Source = null; VisualStateManager.GoToState(this, FailedStateName, true); ImageFailed?.Invoke(this, new ImageExFailedEventArgs(source, ex)); } } }
我們的 ImageEx 控制項裡面必然需要有一個原生的 Image 控制項進行承載(不然咋顯示)。
這裡我定義了 4 個 VisualState:
Normal:未載入,Source 為 null 的情況。
Opened:載入成功,並引發 ImageOpened 事件。
Failed:載入失敗,並引發 ImageFailed 事件。
Loading:正在載入。
在這段代碼中,我引入了 Polly 這個庫,用於重試,一旦出現異常,就重置 context 到初始狀態,再重新執行管道。
而 _lastLoadCts 的類型是 CancellationTokenSource,因為如果 Source 發生快速變化的話,那麼先前還在執行的就需要放棄掉了。
最後奉上源代碼(含 WPF 和 UWP demo):
https://github.com/h82258652/HN.Controls.ImageEx
先聲明,如果你在真實項目中使用出了問題,本人一概不負責的說。
本文只是介紹了一下具體關鍵點的實現思路,諸如磁碟緩存、Pipe 的服務註入(弄了一個很簡單的)這些可以參考源代碼中的實現。
另外源碼中值得改進的地方應該是有的,希望大家能給出一些好的想法和意見,畢竟個人能力有限。