动画 GIF 帧速率似乎低于预期

Animated GIF's framerate seems lower than expected

我有一个 winforms 应用程序,上面有一个 gif,让用户了解进程停滞。

问题是它的播放速度比在其他应用程序(chrome、Internet Explorer)上看起来慢得多。

我已经在 PictureBoxLabel 上尝试了 gif,但结果速度是一样的。然后经过一些研究,我遇到了 this 传奇人物@Hans Passant 的问题和答案,但不幸的是,应用他建议的样板代码没有任何区别。

下面是简单的重现代码:

public partial class Form1 : Form
{
    public Form1 ()
    {
        InitializeComponent();
        timeBeginPeriod(timerAccuracy);
    }

    protected override void OnFormClosed ( FormClosedEventArgs e )
    {
        timeEndPeriod(timerAccuracy);
        base.OnFormClosed(e);
    }

    // Pinvoke:
    private const int timerAccuracy = 10;
    [System.Runtime.InteropServices.DllImport("winmm.dll")]
    private static extern int timeBeginPeriod ( int msec );
    [System.Runtime.InteropServices.DllImport("winmm.dll")]
    public static extern int timeEndPeriod ( int msec );
}

如果需要,还有设计器代码:

partial class Form1
{
    private System.ComponentModel.IContainer components = null;

    protected override void Dispose ( bool disposing )
    {
        if (disposing && (components != null))
        {
            components.Dispose();
        }
        base.Dispose(disposing);
    }

    #region Windows Form Designer generated code

    private void InitializeComponent ()
    {
        System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1));
        this.pictureBox1 = new System.Windows.Forms.PictureBox();
        this.label1 = new System.Windows.Forms.Label();
        ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).BeginInit();
        this.SuspendLayout();
        //
        // pictureBox1
        //
        this.pictureBox1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
        this.pictureBox1.Image = ((System.Drawing.Image)(resources.GetObject("pictureBox1.Image")));
        this.pictureBox1.Location = new System.Drawing.Point(8, 9);
        this.pictureBox1.Name = "pictureBox1";
        this.pictureBox1.Size = new System.Drawing.Size(166, 119);
        this.pictureBox1.SizeMode = System.Windows.Forms.PictureBoxSizeMode.CenterImage;
        this.pictureBox1.TabIndex = 0;
        this.pictureBox1.TabStop = false;
        //
        // label1
        //
        this.label1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
        this.label1.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
        this.label1.Image = ((System.Drawing.Image)(resources.GetObject("label1.Image")));
        this.label1.Location = new System.Drawing.Point(180, 9);
        this.label1.Name = "label1";
        this.label1.Size = new System.Drawing.Size(158, 119);
        this.label1.TabIndex = 1;
        //
        // Form1
        //
        this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
        this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
        this.ClientSize = new System.Drawing.Size(346, 134);
        this.Controls.Add(this.label1);
        this.Controls.Add(this.pictureBox1);
        this.Name = "Form1";
        this.Text = "Form1";
        ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).EndInit();
        this.ResumeLayout(false);

    }

    #endregion

    private System.Windows.Forms.PictureBox pictureBox1;
    private System.Windows.Forms.Label label1;
}

两个 gif 的播放速度相同,但低于实际 gif。应用此代码时还有其他需要注意的地方吗?

你只能猜测,我怀疑任何人都不会很幸运地得到一个重现:

  • timeBeginPeriod() 在技术上可能会失败,尽管当您要求 10 毫秒时这是非常不寻常的,请验证它 returns 0。
  • 如果图片很大,那么更新速度可能不够快。或者您的 UI 线程被其他任务占用了太多。 gif 的像素格式与现代机器上视频适配器的像素格式不匹配。每次更新帧时都会进行转换。这是相当昂贵的,尤其是如果您还强制重新缩放图像(即 PictureBox.SizeMode != Normal)。使用任务管理器验证您的 UI 线程未消耗 100% 核心。
  • 您可以通过提升的命令提示符 运行 powercfg /energy 获得关于有效计时器周期的第二个意见。在您的应用 运行 时执行此操作。它会滚动一分钟,然后生成一个 HTML 文件,您可以使用浏览器查看该文件。在 "Platform Timer Resolution:Timer Request Stack" 标题下报告,Requested Period 值应为 10000。请注意其他进程或驱动程序也可能发出请求。

更新2021-04-02

PictureBox 以低帧率设置动画背后的根本原因是因为它在幕后使用了 ImageAnimator class,它仅以 20 FPS 设置动画。它在此处的工作线程方法中有一个硬编码的 50 毫秒 Thread.Sleep()

https://referencesource.microsoft.com/#System.Drawing/commonui/System/Drawing/ImageAnimator.cs,333

最初在编写以下内容时我无法访问 Windows 表单源,但现在我可能会 subclass PictureBox 并使其使用不同的ImageAnimator 实施以将 Thread.Sleep() 降低到合理的值。 (ImageAnimator 是一个密封的 class,所以您必须将代码复制到一个新的 class 中,然后在您的 PictureBox 中引用它,而不是为了更流畅的动画。)

原答案:

PictureBox 是一个相当重量级的控件,我建议您使用 Panel 之类的东西来代替您的动画 GIF。此外,我了解到 PictureBox 的内部动画计时器分辨率较低,这意味着选择 <100 毫秒的更新间隔会导致它四舍五入为 100 毫秒更新。

相反,您可以自己控制绘画和动画。这使用 PInvoke,因为它使用了一些内核定时器方法。示例代码如下:

using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Windows.Forms;

...

public partial class Form1 : Form
{
    [DllImport("kernel32.dll")]
    static extern bool CreateTimerQueueTimer(out IntPtr phNewTimer,
        IntPtr TimerQueue, WaitOrTimerDelegate Callback, IntPtr Parameter,
        uint DueTime, uint Period, uint Flags);
    [DllImport("kernel32.dll")]
    static extern bool ChangeTimerQueueTimer(IntPtr TimerQueue, IntPtr Timer,
        uint DueTime, uint Period);
    [DllImport("kernel32.dll")]
    static extern bool DeleteTimerQueueTimer(IntPtr TimerQueue, 
        IntPtr Timer, IntPtr CompletionEvent);
    public delegate void WaitOrTimerDelegate(IntPtr lpParameter, 
        bool TimerOrWaitFired);

    // Holds a reference to the function to be called when the timer
    // fires
    public static WaitOrTimerDelegate UpdateFn;

    public enum ExecuteFlags
    {
        /// <summary>
        /// The callback function is queued to an I/O worker thread. This flag should be used if the function should be executed in a thread that waits in an alertable state.
        /// The callback function is queued as an APC. Be sure to address reentrancy issues if the function performs an alertable wait operation.
        /// </summary>
        WT_EXECUTEINIOTHREAD = 0x00000001,
    };

    private Image gif;
    private int frameCount = -1;
    private UInt32[] frameIntervals;
    private int currentFrame = 0;
    private static object locker = new object();
    private IntPtr timerPtr;

    public Form1()
    {
        InitializeComponent();
        // Attempt to reduce flicker - all control painting must be
        // done in overridden paint methods
        this.SetStyle(ControlStyles.AllPaintingInWmPaint |
            ControlStyles.OptimizedDoubleBuffer, true);
        // Set the timer callback
        UpdateFn = new WaitOrTimerDelegate(UpdateFrame);
    }

    private void Form1_Load(object sender, EventArgs e)
    {
        // Replace this with whatever image you're animating
        gif = (Image)Properties.Resources.SomeAnimatedGif;
        // How many frames of animation are there in total?
        frameCount = gif.GetFrameCount(FrameDimension.Time);
        // Retrieve the frame time property
        PropertyItem propItem = gif.GetPropertyItem(20736);
        int propIndex = 0;
        frameIntervals = new UInt32[frameCount];
        // Each frame can have a different timing - retrieve each of them
        for (int i = 0; i < frameCount; i++)
        {
            // NB: intervals are given in hundredths of a second, so need
            // multiplying to match the timer's millisecond interval
            frameIntervals[i] = BitConverter.ToUInt32(propItem.Value, 
                propIndex) * 10;
            // Point to the next interval stored in this property
            propIndex += 4;
        }

        // Show the first frame of the animation
        ShowFrame();
        // Start the animation. We use a TimerQueueTimer which has better
        // resolution than Windows Forms' default one. It should be used
        // instead of the multimedia timer, which has been deprecated
        CreateTimerQueueTimer(out this.timerPtr, IntPtr.Zero, UpdateFn, 
            IntPtr.Zero, frameIntervals[0], 100000,
            (uint)ExecuteFlags.WT_EXECUTEINIOTHREAD);
    }

    private void UpdateFrame(IntPtr lpParam, bool timerOrWaitFired)
    {
        // The timer has elapsed
        // Update the number of the frame to show next
        currentFrame = (currentFrame + 1) % frameCount;
        // Paint the frame to the panel
        ShowFrame();
        // Re-start the timer after updating its interval to that of
        // the new frame
        ChangeTimerQueueTimer(IntPtr.Zero, this.timerPtr,
            frameIntervals[currentFrame], 100000);
    }

    private void ShowFrame()
    {
        // We need to use a lock as we cannot update the GIF at the
        // same time as it's being drawn
        lock (locker)
        {
            gif.SelectActiveFrame(FrameDimension.Time, currentFrame);
        }

        this.panel1.Invalidate();
    }

    private void panel1_Paint(object sender, PaintEventArgs e)
    {
        base.OnPaint(e);

        lock (locker)
        {
            e.Graphics.DrawImage(gif, panel1.ClientRectangle);
        }
    }

    private void Form1_FormClosing(object sender, FormClosingEventArgs e)
    {
        DeleteTimerQueueTimer(IntPtr.Zero, timerPtr, IntPtr.Zero);
    }
}

注意:我们将定时器调用的 Period 设置为 100000,因为如果将其设置为 0(表示一次性计时),它只会触发一次,即使您随后调用 ChangeTimerQueueTimer.

计时器仍然不适合超精确计时,但这仍然可以为您提供比 PictureBox.

更快的更新速度