C# WPF 内置解码器实现 GIF 动图控件

64 阅读5分钟

相对于 WinForm PictureBox 控件原生支持动态 GIF,WPF Image 控件却不支持,让人摸不着头脑

常用方法

提到 WPF 播放动图,常见的方法有三种

MediaElement

使用 MediaElement 控件,缺点是依赖 Media Player,且不支持透明

	<MediaElement Source="animation.gif" LoadedBehavior="Play" Stretch="Uniform"/>

WinForm PictureBox

借助 WindowsFormsIntegration 嵌入 WinForm PictureBox,缺点是不支持透明

	<WindowsFormsHost>

	    <wf:PictureBox x:Name="winFormsPictureBox"/>

	</WindowsFormsHost>

WpfAnimatedGif

引用 NuGet 包 WpfAnimatedGif,支持透明

	<Image gif:ImageBehavior.AnimatedSource="Images/animation.gif"/>

作者还有另一个性能更好、跨平台的 [XamlAnimatedGif] www.hefeilaws.com/ (github.com/XamlAnimate…

原生解码方法

WPF 虽然原生 Image 不支持 GIF 动图,但是提供了 GifBitmapDecoder 解码器,可以获取元数据,包括循环信息、逻辑尺寸、所有帧信息等

判断是否循环和循环次数

	int loop = 1;

	bool isAnimated = true;

	var decoder = new GifBitmapDecoder(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.Default);

	var data = decoder.Metadata;

	if (data.GetQuery("/appext/Application") is byte[] array1)

	{

	    string appName = Encoding.ASCII.GetString(array1);

	    if ((appName == "NETSCAPE2.0" || appName == "ANIMEXTS1.0")

	        && data.GetQuery("/appext/Data") is byte[] array2)

	    {

	        loop = array2[2] | array2[3] << 8;// 获取循环次数, 0 表示无限循环

	        isAnimated = array2[1] == 1;

	    }

	}

获取画布逻辑尺寸

	var width = Convert.ToUInt16(data.GetQuery("/logscrdesc/Width"));

	var height = Convert.ToUInt16(data.GetQuery("/logscrdesc/Height"));

获取每一帧信息

	/// https://www.hefeilaws.com/ <summary>当前帧播放完成后的处理方法</summary>

	enum DisposalMethod

	{

	    /// <summary>被全尺寸不透明的下一帧覆盖替换</summary>

	    None,

	    /// <summary>不丢弃, 继续显示下一帧未覆盖的任何像素</summary>

	    DoNotDispose,

	    /// <summary>重置到背景色</summary>

	    RestoreBackground,

	    /// <summary>恢复到上一个未释放的帧的状态</summary>

	    RestorePrevious,

	}

	 

	sealed class FrameInfo

	{

	    public Image Frame { get; }

	    public int DelayTime { get; }

	    public DisposalMethod DisposalMethod { get; }

	 

	    public FrameInfo(BitmapFrame frame)

	    {

	        Frame = new Image { Source = frame };

	        var data = (BitmapMetadata)frame.Metadata;

	        DelayTime = Convert.ToUInt16(data.GetQuery("/grctlext/Delay"));

	        DisposalMethod = (DisposalMethod)Convert.ToByte(data.GetQuery("/grctlext/Disposal"));

	        ushort left = Convert.ToUInt16(data.GetQuery("/imgdesc/Left"));

	        ushort top = Convert.ToUInt16(data.GetQuery("/imgdesc/Top"));

	        ushort width = Convert.ToUInt16(data.GetQuery("/imgdesc/Width"));

	        ushort height = Convert.ToUInt16(data.GetQuery("/imgdesc/Height"));

	        Canvas.SetLeft(Frame, left);

	        Canvas.SetTop(Frame, top);

	        Canvas.SetRight(Frame, left + width);

	        Canvas.SetBottom(Frame, top + height);

	    }

	}

自定义控件完整代码

将所有帧画面按其大小位置和顺序放置在 Canvas 中,结合所有帧的播放处理方法和持续时间,使用关键帧动画,即可实现无需依赖第三方的自定义控件,且性能和 XamlAnimatedGif 相差无几

	using System;

	using System.IO;

	using System.Text;

	using System.Windows;

	using System.Windows.Controls;

	using System.Windows.Media;

	using System.Windows.Media.Animation;

	using System.Windows.Media.Imaging;

	 

	public sealed class GifImage : ContentControl

	{

	    /// <summary>当前帧播放完成后的处理方法</summary>

	    enum DisposalMethod

	    {

	        /// <summary>被全尺寸不透明的下一帧覆盖替换</summary>

	        None,

	        /// <summary>不丢弃, 继续显示下一帧未覆盖的任何像素</summary>

	        DoNotDispose,

	        /// <summary>重置到背景色</summary>

	        RestoreBackground,

	        /// <summary>恢复到上一个未释放的帧的状态</summary>

	        RestorePrevious,

	    }

	 

	    sealed class FrameInfo

	    {

	        public Image Frame { get; }

	        public int DelayTime { get; }

	        public DisposalMethod DisposalMethod { get; }

	 

	        public FrameInfo(BitmapFrame frame)

	        {

	            Frame = new Image { Source = frame };

	            var data = (BitmapMetadata)frame.Metadata;

	            DelayTime = Convert.ToUInt16(data.GetQuery("/grctlext/Delay"));

	            DisposalMethod = (DisposalMethod)Convert.ToByte(data.GetQuery("/grctlext/Disposal"));

	            ushort left = Convert.ToUInt16(data.GetQuery("/imgdesc/Left"));

	            ushort top = Convert.ToUInt16(data.GetQuery("/imgdesc/Top"));

	            ushort width = Convert.ToUInt16(data.GetQuery("/imgdesc/Width"));

	            ushort height = Convert.ToUInt16(data.GetQuery("/imgdesc/Height"));

	            Canvas.SetLeft(Frame, left);

	            Canvas.SetTop(Frame, top);

	            Canvas.SetRight(Frame, left + width);

	            Canvas.SetBottom(Frame, top + height);

	        }

	    }

	 

	    public static readonly DependencyProperty UriSourceProperty =

	        DependencyProperty.Register(nameof(UriSource), typeof(Uri), typeof(GifImage), new PropertyMetadata(null, OnSourceChanged));

	 

	    public static readonly DependencyProperty StreamSourceProperty =

	     https://www.hefeilaws.com/    DependencyProperty.Register(nameof(StreamSource), typeof(Stream), typeof(GifImage), new PropertyMetadata(null, OnSourceChanged));

	 

	    public static readonly DependencyProperty FrameIndexProperty =

	        DependencyProperty.Register(nameof(FrameIndex), typeof(int), typeof(GifImage), new PropertyMetadata(0, OnFrameIndexChanged));

	 

	    public static readonly DependencyProperty StretchProperty =

	        DependencyProperty.Register(nameof(Stretch), typeof(Stretch), typeof(GifImage), new PropertyMetadata(Stretch.None, OnStrechChanged));

	 

	    public static readonly DependencyProperty StretchDirectionProperty =

	        DependencyProperty.Register(nameof(StretchDirection), typeof(StretchDirection), typeof(GifImage), new PropertyMetadata(StretchDirection.Both, OnStrechDirectionChanged));

	 

	    public static readonly DependencyProperty IsLoadingProperty =

	        DependencyProperty.Register(nameof(IsLoading), typeof(bool), typeof(GifImage), new PropertyMetadata(false));

	 

	    public Uri UriSource

	    {

	        get => (Uri)GetValue(UriSourceProperty);

	        set => SetValue(UriSourceProperty, value);

	    }

	 

	    public Stream StreamSource

	    {

	        get => (Stream)GetValue(StreamSourceProperty);

	        set => SetValue(StreamSourceProperty, value);

	    }

	 

	    public int FrameIndex

	    {

	        get => (int)GetValue(FrameIndexProperty);

	        private set => SetValue(FrameIndexProperty, value);

	    }

	 

	    public Stretch Stretch

	    {

	        get => (Stretch)GetValue(StretchProperty);

	        set => SetValue(StretchProperty, value);

	    }

	 

	    public StretchDirection StretchDirection

	    {

	        get => (StretchDirection)GetValue(StretchDirectionProperty);

	        set => SetValue(StretchDirectionProperty, value);

	    }

	 

	    public bool IsLoading

	    {

	        get => (bool)GetValue(IsLoadingProperty);

	        set => SetValue(IsLoadingProperty, value);

	    }

	 

	    private static void OnSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)

	    {

	        ((GifImage)d)?.OnSourceChanged();

	    }

	 

	    private static void OnFrameIndexChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)

	    {

	        ((GifImage)d)?.OnFrameIndexChanged();

	    }

	 

	    private static void OnStrechChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)

	    {

	        if (d is GifImage image && image.Content is Viewbox viewbox)

	        {

	            viewbox.Stretch = image.Stretch;

	        }

	    }

	 

	    private static void OnStrechDirectionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)

	    {

	        if (d is GifImage image && image.Content is Viewbox viewbox)

	        {

	            viewbox.StretchDirection = image.StretchDirection;

	        }

	    }

	 

	    Stream stream;

	    Canvas canvas;

	    FrameInfo[] frameInfos;

	    Int32AnimationUsingKeyFrames animation;

	 

	    public GifImage()

	    {

	        IsVisibleChanged += OnIsVisibleChanged;

	        Unloaded += OnUnloaded;

	    }

	 

	    private void OnUnloaded(object sender, RoutedEventArgs e)

	    {

	        Release();

	    }

	 

	    private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)

	    {

	        if (IsVisible)

	        {

	            StartAnimation();

	        }

	        else

	        {

	            StopAnimation();

	        }

	    }

	 

	    private void StartAnimation()

	    {

	        BeginAnimation(FrameIndexProperty, animation);

	    }

	 

	    private void StopAnimation()

	    {

	        BeginAnimation(FrameIndexProperty, null);

	    }

	 

	    private void Release()

	    {

	        StopAnimation();

	        canvas?.Children.Clear();

	        stream?.Dispose();

	        animation = null;

	        frameInfos = null;

	    }

	 

	    private async void OnSourceChanged()

	    {

	        Release();

	        IsLoading = true;

	        FrameIndex = 0;

	        if (UriSource != null)

	        {

	            stream = await ResourceHelper.GetStream(UriSource);

	        }

	        else

	        {

	            stream = StreamSource;

	        }

	        if (stream != null)

	        {

	            int loop = 1;

	            bool isAnimated = true;

	            var decoder = new GifBitmapDecoder(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.Default);

	            var data = decoder.Metadata;

	            if (data.GetQuery("/appext/Application") is byte[] array1)

	            {

	                string appName = Encoding.ASCII.GetString(array1);

	                if ((appName == "NETSCAPE2.0" || appName == "ANIMEXTS1.0")

	                    && data.GetQuery("/appext/Data") is byte[] array2)

	                {

	                    loop = array2[2] | array2[3] << 8;// 获取循环次数, 0表示无限循环

	                    isAnimated = array2[1] == 1;

	                }

	            }

	            if (!(Content is Viewbox viewbox))

	            {

	                Content = viewbox = new Viewbox

	                {

	                    Stretch = Stretch,

	                    StretchDirection = StretchDirection,

	                };

	            }

	            if (canvas == null || canvas.Parent != Content)

	            {

	                canvas = new Canvas();

	                viewbox.Child = canvas;

	            }

	            canvas.Width = Convert.ToUInt16(data.GetQuery("/logscrdesc/Width"));

	            canvas.Height = Convert.ToUInt16(data.GetQuery("/logscrdesc/Height"));

	            int count = decoder.Frames.Count;

	            frameInfos = new FrameInfo[count];

	            for (int i = 0; i < count; i++)

	            {

	                var info = new FrameInfo(decoder.Frames[i]);

	                Image frame = info.Frame;

	                frameInfos[i] = info;

	                canvas.Children.Add(frame);

	                Panel.SetZIndex(frame, i);

	                canvas.Width = Math.Max(canvas.Width, Canvas.GetRight(frame));

	                canvas.Height = Math.Max(canvas.Height, Canvas.GetBottom(frame));

	            }

	            OnFrameIndexChanged();

	            if (isAnimated)

	            {

	                var keyFrames = new Int32KeyFrameCollection();

	                var last = TimeSpan.Zero;

	                for (int i = 0; i < frameInfos.Length; i++)

	                {

	                    last += TimeSpan.FromMilliseconds(frameInfos[i].DelayTime * 10);

	                    keyFrames.Add(new DiscreteInt32KeyFrame(i, last));

	                }

	                animation = new Int32AnimationUsingKeyFrames

	                {

	                    KeyFrames = keyFrames,

	                    RepeatBehavior = loop == 0 ? RepeatBehavior.Forever : new RepeatBehavior(loop)

	                };

	                StartAnimation();

	            }

	        }

	        IsLoading = false;

	    }

	 

	    private void OnFrameIndexChanged()

	    {

	        if (frameInfos != null)

	        {

	            int index = FrameIndex;

	            frameInfos[index].Frame.Visibility = Visibility.Visible;

	            if (index > 0)

	            {

	                var previousInfo = frameInfos[index - 1];

	                switch (previousInfo.DisposalMethod)

	                {

	                    case DisposalMethod.RestoreBackground:

	                        // 隐藏之前的所有帧

	                        for (int i = 0; i < index - 1; i++)

	                        {

	                            frameInfos[i].Frame.Visibility = Visibility.Hidden;

	                        }

	                        break;

	                    case DisposalMethod.RestorePrevious:

	                        // 隐藏上一帧

	                        previousInfo.Frame.Visibility = Visibility.Hidden;

	                        break;

	                }

	            }

	            else

	            {

	                // 重新循环, 只显示第一帧

	                for (int i = 1; i < frameInfos.Length; i++)

	                {

	                    frameInfos[i].Frame.Visibility = Visibility.Hidden;

	                }

	            }

	        }

	    }

	}

使用到的从 URL 获取图像流的方法

	using System;

	using System.IO;

	using System.IO.Packaging;

	using System.Net;

	using System.Threading.Tasks;

	using System.Windows;

	 

	public static class ResourceHelper

	{

	    public static Task<Stream> GetStream(Uri uri)

	    {

	        if (!uri.IsAbsoluteUri)

	        {

	            throw new ArgumentException("uri must be absolute");

	        }

	        if (uri.Scheme == Uri.UriSchemeHttps

	            || uri.Scheme == Uri.UriSchemeHttp

	            || uri.Scheme == Uri.UriSchemeFtp)

	        {

	            return Task.Run<Stream>(() =>

	            {

	                using (var client = new WebClient())

	                {

	                    byte[] data = client.DownloadData(uri);

	                    return new MemoryStream(data);

	                }

	            });

	        }

	        else if (uri.Scheme == PackUriHelper.UriSchemePack)

	        {

	            var info = uri.Authority == "siteoforigin:,,,"

	                ? Application.GetRemoteStream(uri)

	                : Application.GetResourceStream(uri);

	            if (info != null)

	            {

	                return Task.FromResult(info.Stream);

	            }

	        }

	        else if (uri.Scheme == Uri.UriSchemeFile)

	        {

	            return Task.FromResult<Stream>(File.OpenRead(uri.LocalPath));

	        }

	        throw new FileNotFoundException(uri.OriginalString);

	    }

	}

调用示例

	<gif:GifImage UriSource="C:\animation.gif"/>

ImageAnimator

WinForm 中播放 GIF 用到了 ImageAnimator,利用它也可以在 WPF 中实现 GIF 动图控件,但其是基于 GDI 的方法,更推荐性能更好、支持硬解的解码器方法

	// 将多帧图像显示为动画,并触发事件

	ImageAnimator.Animate(Image, EventHandler)

	 

	// 暂停动画

	ImageAnimator.StopAnimate(Image, EventHandler)

	 

	// 判断图像是否支持动画

	ImageAnimator.CanAnimate(Image)

	 

	// 在图像中前进帧,下次渲染图像时绘制新帧

	ImageAnimator.UpdateFrames(Image)

透明 GIF

GIF 本身只有 256 色,没有 Alpha 通道,但其仍支持透明,是通过其特殊的自定义颜色表调色盘实现的

上图是一张单帧透明 GIF,使用 Windows 自带画图打开,会错误显示为橙色背景

放入 WinForm PictureBox 中,Win7 和较旧的 Win10 也会错误显示为橙色背景

但最新的 Win11 和 Win10 上会显示为透明背景,猜测是近期 Win11 在截图工具中推出了录制 GIF 功能时顺手更新了 .NET System.Drawing GIF 解析方法,Win10 也收到了这次补丁更新

不过使用 WPF 解码器方法能过获得正确的背景