diff --git a/Plain Craft Launcher 2/Modules/Base/ModBase.vb b/Plain Craft Launcher 2/Modules/Base/ModBase.vb index e444e9f97..959b3cd56 100644 --- a/Plain Craft Launcher 2/Modules/Base/ModBase.vb +++ b/Plain Craft Launcher 2/Modules/Base/ModBase.vb @@ -1,3 +1,4 @@ +Imports System.ComponentModel Imports System.Globalization Imports System.IO.Compression Imports System.Reflection @@ -5,6 +6,7 @@ Imports System.Runtime.CompilerServices Imports System.Security.Cryptography Imports System.Security.Principal Imports System.Text.RegularExpressions +Imports System.Threading.Tasks Imports System.Xaml Imports Newtonsoft.Json @@ -3208,4 +3210,258 @@ Public Class InverseBooleanConverter End Function End Class +''' +''' 异步加载的网络图片源,需传入 Url,最终内容使用 MyBitmap 解析。
+''' Source - 源 Url,必须指定。
+''' FallbackSource - 备用 Url;在设计理念上,返回的内容应当与主 Url 相同。
+''' LoadingSource - 加载时显示的图片,合法值为 空 / 可被 MyBitmap 解析的字符串 / ImageSource。
+''' EnableCache - 是否启用缓存(默认启用),不启用的话每次都会联网获取图片。
+''' FileCacheExpiredTime - 缓存到期时间,默认为七天,遵循 TimeSpan 的格式解析。
+''' Result - 可绑定,用于存储输出的 ImageSource。
+''' XamlReferenceType - 设置为 Instance 即可拿到 AsyncImageSourceImpl 而非 Binding 对象。

+''' 在 xaml 中引用的语法为:Source="{local:AsyncImageSource https://example.com/example.png}",
+''' 此时效果相当于将 Source 属性绑定到了一个动态改变的值上。 +'''
+Public Class AsyncImageSourceExtension + Inherits Markup.MarkupExtension + Private Shared ReadOnly _TimeSpanConverter As New TimeSpanConverter + Private Shared ReadOnly _LoadingSourceDefault As ImageSource = New MyBitmap("pack://application:,,,/images/Icons/NoIcon.png") + + Private _LoadingSource As ImageSource = _LoadingSourceDefault + Private _FileCacheExpiredTime As TimeSpan = TimeSpan.FromDays(7) + + Public Property Source As String + + Public Property FallbackSource As String + + Public WriteOnly Property LoadingSource As Object + Set(value As Object) + If value Is Nothing Then + _LoadingSource = Nothing + ElseIf TypeOf value Is String Then + _LoadingSource = New MyBitmap(DirectCast(value, String)) + Else + _LoadingSource = CType(value, ImageSource) + End If + End Set + End Property + + Public Property EnableCache As Boolean = True + + Public WriteOnly Property FileCacheExpiredTime As Object + Set(value As Object) + _FileCacheExpiredTime = _TimeSpanConverter.ConvertFrom(value) + End Set + End Property + + Public Property XamlReferenceType As XamlReferenceTypeEnum = XamlReferenceTypeEnum.Binding + Public Enum XamlReferenceTypeEnum + Binding + Instance + End Enum + + Shared Sub New() + _LoadingSourceDefault.Freeze() + End Sub + + Public Sub New() + End Sub + + Public Sub New(Source As String) + Me.Source = Source + End Sub + + Public Overrides Function ProvideValue(serviceProvider As IServiceProvider) As Object + If Source Is Nothing Then Throw New InvalidOperationException("AsyncImageSource.Source 未被设置。") + Dim Result As New AsyncImageSourceImpl(Source) With { + .FallbackSource = FallbackSource, + .LoadingSource = _LoadingSource, + .EnableCache = EnableCache, + .FileCacheExpiredTime = _FileCacheExpiredTime + } + Select Case XamlReferenceType + Case XamlReferenceTypeEnum.Binding + ProvideValue = New Binding("Result") With {.Source = Result}.ProvideValue(serviceProvider) + Case XamlReferenceTypeEnum.Instance + ProvideValue = Result + Case Else + Throw New InvalidOperationException("不具意义的 AsyncImageSource.XamlReferenceType。") + End Select + Result.StartLoad() + End Function +End Class + +Public Class AsyncImageSourceImpl + Implements INotifyPropertyChanged + ''' + ''' 工具类,接受同样的标识符时始终返回同一个对象,除非该对象已被回收。 + ''' + Private Class InstanceProvider(Of T As Class) + Private ReadOnly _InstanceSupplier As Func(Of T) + Private ReadOnly _ExistingInstances As New Concurrent.ConcurrentDictionary(Of Object, WeakReference(Of T)) + Private ReadOnly _CleanupTimer As New Timer(AddressOf CleanupGoneInstances, Nothing, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)) + + Public Sub New(InstanceSupplier As Func(Of T)) + If InstanceSupplier Is Nothing Then Throw New ArgumentNullException("InstanceSupplier") + _InstanceSupplier = InstanceSupplier + End Sub + + Public Function GetFrom(Key As Object) As T + If Key Is Nothing Then Throw New ArgumentNullException("Key") + GetFrom = Nothing + While True + Dim Wr As WeakReference(Of T) = Nothing + If _ExistingInstances.TryGetValue(Key, Wr) Then + If Wr.TryGetTarget(GetFrom) Then + Exit While + Else + GetFrom = _InstanceSupplier.Invoke() + If _ExistingInstances.TryUpdate(Key, New WeakReference(Of T)(GetFrom), Wr) Then + Exit While + End If + End If + Else + GetFrom = _InstanceSupplier.Invoke() + If _ExistingInstances.TryAdd(Key, New WeakReference(Of T)(GetFrom)) Then + Exit While + End If + End If + End While + If GetFrom Is Nothing Then Throw New Exception("获取实例意外失败。") + End Function + + Private Sub CleanupGoneInstances() + Try + _ExistingInstances _ + .Where(Function(e) Not e.Value.TryGetTarget(Nothing)) _ + .Select(Function(e) e.Key) _ + .ToList() _ + .ForEach(AddressOf AttemptRemoveGoneInstance) + Catch ex As Exception + Log(ex, $"清理失效 {GetType(T).Name} 实例意外失败") + End Try + End Sub + + Private Function AttemptRemoveGoneInstance(Key As Object) As Boolean + Dim Wr As WeakReference(Of T) = Nothing + If Not _ExistingInstances.TryGetValue(Key, Wr) Then Return False + If Wr.TryGetTarget(Nothing) Then Return False + Return CType(_ExistingInstances, ICollection(Of KeyValuePair(Of Object, WeakReference(Of T)))) _ + .Remove(New KeyValuePair(Of Object, WeakReference(Of T))(Key, Wr)) + End Function + End Class + + Private Shared ReadOnly FileCacheDirectory As String = $"{PathTemp}MyImage\" + Private Shared ReadOnly SemaphoreProvider As New InstanceProvider(Of SemaphoreSlim)(Function() New SemaphoreSlim(1, 1)) + + Public ReadOnly Source As String + Public ReadOnly TempDownloadingPath As String + Public FallbackSource As String + Public LoadingSource As ImageSource + Public EnableCache As Boolean + Public FileCacheExpiredTime As TimeSpan + + Private _Result As ImageSource + Public Property Result As ImageSource + Get + Return _Result + End Get + Set(value As ImageSource) + _Result = value + RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs("Result")) + End Set + End Property + + Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged + + Public Sub New(Source As String) + Me.Source = Source + TempDownloadingPath = $"{FileCacheDirectory}_{GetHash(Source)}.png" + End Sub + + Public Sub StartLoad() + Windows.Application.Current.Dispatcher.InvokeAsync(AddressOf LoadAsync) + End Sub + + Private Async Function LoadAsync() As Task + Try + Result = LoadingSource '加载中占位符 + Dim LoadSemaphore = Await Task.Run(Function() SemaphoreProvider.GetFrom(TempDownloadingPath)) + Await LoadSemaphore.WaitAsync() '保证使用同样文件缓存路径的实例串行加载 + Try + '尝试使用缓存 + Dim ResultFromCache = Await Task.Run(AddressOf TryLoadCache) + If ResultFromCache IsNot Nothing Then + Result = ResultFromCache + Exit Function + End If + '缓存无效 + Await Task.Run(AddressOf DownloadImage) '从网络下载图片 + Result = Await Task.Run(Function() LoadFileViaMyBitmap(TempDownloadingPath)) '加载图片 + Finally + LoadSemaphore.Release() + End Try + Catch ex As Exception + Log(ex, $"异步网络图片加载失败(图片源:{Source},备用源:{If(FallbackSource, "无")})", LogLevel.Hint) + Result = Nothing + End Try + End Function + + ''' + ''' 从缓存获取 ImageSource,缓存未启用/不存在/过期/损坏或运行失败返回 Nothing,不会抛出异常。 + ''' + Private Function TryLoadCache() As ImageSource + Try + If Not EnableCache Then Return Nothing '未启用缓存 + '判断缓存是否有效 + Dim CacheAvailable As Boolean + With New FileInfo(TempDownloadingPath) + CacheAvailable = .Exists AndAlso (Date.Now - .LastWriteTime < FileCacheExpiredTime) + End With + If CacheAvailable Then + '缓存有效 + Try + Return LoadFileViaMyBitmap(TempDownloadingPath) + Catch + 'MyBitmap 从文件解析失败 + File.Delete(TempDownloadingPath) + End Try + End If + Catch ex As Exception + Log(ex, $"读取网络图片缓存(缓存位置 {TempDownloadingPath},源 {Source})时预期之外的异常") + End Try + Return Nothing + End Function + + ''' + ''' 下载图片至本地缓存文件,失败后若指定了 FallbackSource 会再尝试,再失败后抛出异常。 + ''' + Private Sub DownloadImage() + Dim TargetUrl As String = Source, Retried As Boolean = False + Try +DownloadRetry: + Directory.CreateDirectory(IO.Path.GetDirectoryName(TempDownloadingPath)) + Using Client As New WebClient() + Client.DownloadFile(TargetUrl, TempDownloadingPath) + End Using + Catch ex As Exception When (Not Retried) AndAlso (FallbackSource IsNot Nothing) + Log(ex, $"下载图片可重试地失败({Source})", LogLevel.Developer) + TargetUrl = FallbackSource + Retried = True + GoTo DownloadRetry + Catch ex As Exception + Throw New Exception("下载图片失败。", ex) + End Try + End Sub + + ''' + ''' 使用 MyBitmap 从文件路径创建 ImageSource 并 Freeze 住。 + ''' + Private Shared Function LoadFileViaMyBitmap(FilePath As String) As ImageSource + LoadFileViaMyBitmap = New MyBitmap(FilePath) + LoadFileViaMyBitmap.Freeze() + End Function + +End Class + #End Region