在单独的线程中创建启动屏幕窗体






3.32/5 (17投票s)
在单独的线程上创建启动屏幕窗体。
本文由 MSDN 提供。
摘要
本示例演示如何从主窗体创建启动屏幕窗体。启动屏幕将定期更新“忙碌”动画,并在窗体加载完成或指定时间过去(以较晚者为准)时关闭。
目录
引言
此示例演示如何创建在与主线程分开的线程上运行的启动屏幕。这样做是为了让主线程能够处理加载和初始化,而无需显式更新进度动画。让启动屏幕窗体运行在单独的线程上也是有利的,这样它就可以处理自己的消息,从而在其他应用程序干扰其绘图时接收到绘制消息。主窗体将不会接收到这些消息,因为它理论上正在忙于加载函数,没有处理消息。
启动屏幕由静态背景和从左到右在图像底部移动的 8 帧动画组成,其帧率由硬编码的速率决定。动画的设计方式是为了保持代码的简单性,但仍允许它通过仅更新先前动画绘制周期中无效的背景区域来演示如何优化绘制例程。此优化还有助于在单独的线程上运行时识别接收绘制消息的需求,因为其他应用程序可能会使窗体失效,需要它重绘整个背景。在单线程上,这些绘制消息直到主窗体完成其处理才会被启动屏幕窗体接收 - 除非代码中充斥着 Application.DoEvents()
调用,但这并不是一个理想或一致的解决方案。
忙碌动画由线程计时器(是的,另一个线程)以固定的时间间隔更新。这使得它在等待下一个绘制周期时能够将处理器交还给主窗体。启动屏幕的总持续时间也使用此计时器进行跟踪。
示例源代码演示了以下概念
- 加载和显示静态背景图像
- 加载和显示动画图像
- 在单独的线程上启动两个窗体
- 调用
EventHandler
以在单独的线程上关闭窗体 - 创建线程计时器
- 加载嵌入资源
- 通过跟踪无效(脏)矩形来优化绘图
所有启动屏幕接口均在 SplashForm
窗体中实现,以尽可能保持与主窗体(如其名称所示,MainForm
)的交互的简洁性。此示例的代码有 Visual Basic 和 C# 版本,但为简洁起见,本文档仅引用 C# 示例代码。
在设计器中设置窗体
主窗体和启动屏幕窗体的行为差异很大,因此需要在窗体设计器中修改一些参数。启动屏幕窗体的格式至关重要,以确保其全屏并最大化,因此该窗体的所有初始化工作都在加载函数中以编程方式完成。
MainForm
主窗体模拟实际应用程序,因此我只对其进行了少量修改。下面列出了已修改的 MainForm 属性
- 外观 -> 文本 – Splash Sample
- 设计 -> 名称 – MainForm
- 布局 -> 大小 – 240,320
- 窗口样式 -> 最大化按钮 – false
- 窗口样式 -> 最小化按钮 – false
SplashForm
SplashForm 保持“原样”。
向项目中添加资源
项目中存在两个嵌入资源。这些资源是启动屏幕背景图像和“忙碌”动画图像。有几种方法可以将资源添加到项目中。最简单的方法是将文件放在项目目录中,在解决方案资源管理器中选择“显示所有文件”,突出显示文件,然后从文件的右键单击菜单中选择“包含到项目”。要将这些项变成嵌入资源,请突出显示它们,从右键单击菜单中选择“属性”,然后将“高级 -> 生成操作”属性设置为“嵌入的资源”。
SplashForm 变量
SplashForm 类包含各种成员变量,用于维护和更新启动屏幕的绘制条件。其中许多变量不需要在绘制函数之外可见,但为了缓存而保留为全局变量。
第一个定义是一个常量,指定动画中的帧数。这是一个为了保持代码简单而进行的折衷。实际上,这应该从动画文件中读取,该文件还包含动画速率、帧顺序、图像数据等。
const int kNumAnimationCells = 8;
其余变量将在下面的声明中进行描述
bmpSplash
- 启动屏幕的背景图像bmpAnim
- 启动屏幕的动画图像animPos
- 定义动画在屏幕上的位置g
- 代表窗体客户区的全局Graphics
对象splashRegion
- 表示将在屏幕上绘制背景的区域。这将充当绘制移动动画的剪裁区域splashSrc
- 表示绘制背景的源矩形attr
- 一个ImageAttributes
对象,用于在将动画绘制到背景上方时实现透明度splashTimer
- 表示将用于通知窗体何时应更新屏幕的计时器redrawSrc
- 用于在动画先前占据的位置重新绘制背景的源矩形,即,这是动画在上一个帧中占据的背景区域curAnimCell
- 当前显示的动画帧。动画位图由 8 帧组成,因此一次仅显示位图的 1/8。numUpdates
- 计时器触发Draw
函数的次数。此值与timerInterval_ms
一起用于确定启动屏幕的持续时间timerInterval_ms
- 计时器的间隔(以毫秒为单位)。这将决定屏幕更新的频率
Bitmap bmpSplash = null;
Bitmap bmpAnim = null;
Rectangle animPos = new Rectangle(0,0,0,0);
Graphics g = null;
Rectangle splashRegion = new Rectangle(0,0,0,0);
Rectangle splashSrc = new Rectangle(0,0,0,0);
ImageAttributes attr = new ImageAttributes();
System.Threading.Timer splashTimer = null;
Rectangle redrawSrc = new Rectangle(0,0,0,0);
int curAnimCell = 0;
int numUpdates = 0;
int timerInterval_ms = 0;
SplashForm 方法
SplashForm 窗体的方法负责启动屏幕的完整维护。这包括加载图像、设置更新计时器、绘制背景图像、更新和绘制动画以及关闭窗体。
以下是窗体方法的完整列表以及它们作用的描述
public SplashForm(int timerInterval)
启动屏幕窗体的构造函数负责加载嵌入的Bitmap
对象资源,以及初始化timerInterval_ms
成员。protected override void Dispose( bool disposing )
在窗体关闭之前需要所有资源,因此此函数未修改。public int GetUpMilliseconds()
此函数返回启动屏幕显示了多少毫秒。请注意,其精度仅限于timerInterval_ms
的分辨率。private void SplashForm_Load(object sender, System.EventArgs e)
加载函数负责大部分初始化代码。几乎所有变量都在此函数中设置为其初始状态。此函数还会格式化并显示窗体,并提交一次绘制调用,该调用将绘制整个背景图像和动画的第一帧。protected override void OnPaint(PaintEventArgs e)
启动屏幕窗体的OnPaint
方法强制窗体重绘整个背景和动画 - 而不更新动画位置。protected override void OnPaintBackground(PaintEventArgs e)
OnPaintBackground
函数被存根化,因此窗体不会尝试绘制 Draw 函数中显式实现的内容以外的任何内容。public void KillMe(object o, EventArgs e)
此函数负责停止计时器线程并关闭窗体。protected void Draw(Object state)
此函数仅由计时器调用,因此会增加已发生的更新次数,更新动画的位置,并绘制动画。后两者通过调用重载的Draw
函数来完成。protected void Draw(bool bFullImage, bool bUpdateAnim)
Draw
函数的此重载是实际执行绘制和动画更新的地方。仅当bFullImage
设置为 true 时,此函数才会重绘整个背景,否则它将通过仅更新被动画先前位置“弄脏”的背景来优化绘制。第二个参数bUpdateAnim
决定是否移动动画。
类的构造函数根据父窗体传递给它的值设置计时器的间隔,并将必要的图像加载为嵌入式资源流。背景是 jpg 图像,但是动画是位图,以确保透明度不受压缩的影响。这是一个直接的实现,因此函数将在下方完整显示。
public SplashForm(int timerInterval)
{
timerInterval_ms = timerInterval;
Assembly asm = Assembly.GetExecutingAssembly();
bmpSplash = new Bitmap
(
asm.GetManifestResourceStream(asm.GetName().Name +
".splash.jpg")
);
bmpAnim = new Bitmap
(
asm.GetManifestResourceStream(asm.GetName().Name +
".animation.bmp")
);
InitializeComponent();
}
窗体的 Dispose
方法保持不变,与默认值相同。
protected override void Dispose( bool disposing )
{
base.Dispose( disposing );
}
GetUpMilliseconds
方法返回启动屏幕活动的时间(以毫秒为单位)。这绝不是精确的,精度仅限于计时器的分辨率,但对于其目的来说已经足够了。该函数根据计时器触发次数和计时器间隔来确定时间。
public int GetUpMilliseconds()
{
return numUpdates * timerInterval_ms;
}
在窗体的加载函数期间会发生大量的初始化。事实上,几乎窗体的每个成员都在此时初始化。此函数中的代码将在下方详细介绍。
该函数声明如下
private void Form2_Load(object sender, System.EventArgs e)
为了使绘制正常进行,窗体必须是全屏且处于活动状态,因此函数的第一步是设置窗体的正确属性。
this.Text = "";
this.MaximizeBox = false;
this.MinimizeBox = false;
this.ControlBox = false;
this.FormBorderStyle = FormBorderStyle.None;
this.WindowState = FormWindowState.Maximized;
this.Menu = null;
接下来,窗体将初始化背景图像的源和目标区域。这些被设置为使图像居中显示,并且将绘制整个图像。
splashRegion.X =
(Screen.PrimaryScreen.Bounds.Width - bmpSplash.Width) / 2;
splashRegion.Y =
(Screen.PrimaryScreen.Bounds.Height - bmpSplash.Height) / 2;
splashRegion.Width = bmpSplash.Width;
splashRegion.Height = bmpSplash.Height;
splashSrc.X = 0;
splashSrc.Y = 0;
splashSrc.Width = bmpSplash.Width;
splashSrc.Height = bmpSplash.Height;
然后设置动画位置,使其位于背景的左下角,但尚未可见。这将使其具有从窗体外部进入的效果。图像的剪裁将由背景区域自动处理,因此将屏幕外部的值甚至负值放在这里也是可以的。
animPos.X = splashRegion.X - bmpAnim.Width / kNumAnimationCells;
animPos.Y = splashRegion.Y + splashRegion.Height - bmpAnim.Height;
animPos.Width = bmpAnim.Width / kNumAnimationCells;
animPos.Height = bmpAnim.Height;
背景重绘的源必须在绘制更新期间计算,但宽度和高度现在可以缓存。
redrawSrc.Width = bmpAnim.Width / kNumAnimationCells;
redrawSrc.Height = bmpAnim.Height;
源密钥透明度的像素颜色值存储在动画位图的左上角像素 (0,0)。
attr.SetColorKey(bmpAnim.GetPixel(0,0), bmpAnim.GetPixel(0,0));
现在创建 Graphics 对象并缓存,这样就不必在每次绘制调用时都重新创建它。创建 Graphics 对象后,还可以设置剪裁区域。
g = CreateGraphics();
g.Clip = new Region(splashRegion);
现在全局 Graphics 对象有效,窗体可以强制进行一次绘制,显示整个背景,动画尚未移动。
Draw(true, false);
最后,函数根据构造函数中指定的计时器间隔创建一个计时器。此计时器将在单独的线程上运行,并直接调用重载的绘制函数。
System.Threading.TimerCallback splashDelegate =
new System.Threading.TimerCallback(this.Draw);
this.splashTimer = new System.Threading.Timer(splashDelegate,
null, timerInterval_ms, timerInterval_ms);
OnPaint
函数只需重绘背景图像和动画图像,而不更新动画位置。位置更新应仅由计时器触发的更新完成。OnPaintBackground
被存根化,以免对该窗体产生不利影响。
protected override void OnPaint(PaintEventArgs e)
{
Draw(true, false);
}
protected override void OnPaintBackground(PaintEventArgs e){}
KillMe
函数负责关闭计时器和窗体。这可以通过调用计时器的 Dispose
方法和窗体的 Close
方法来实现。此函数中需要注意的唯一事项是,必须在关闭窗体之前关闭计时器,否则在窗体仍在关闭时计时器可能会被触发。
public void KillMe(object o, EventArgs e)
{
splashTimer.Dispose();
this.Close();
}
Draw
函数的第一个重载仅由计时器调用。此函数增加已发生的更新次数,然后调用另一个 Draw
方法,指定不重绘整个背景(使用优化后的绘制例程)并更新动画的位置。
protected void Draw(Object state)
{
numUpdates++;
Draw(false, true);
}
下面的 Draw 函数是实际负责在窗体上绘制背景和动画图像的重载。此函数接受两个参数 bFullImage
和 bUpdateAnim
,分别指定是否重绘整个背景以及是否移动动画的位置。
该函数声明如下。
protected void Draw(bool bFullImage, bool bUpdateAnim)
有可能收到一个绘制消息,该消息会调用 Draw
,而 Graphics 对象尚未初始化,因此在继续执行绘制代码之前需要验证 g
。
if (g == null)
return;
由于此函数是从计时器线程和 OnPaint
调用,因此可能会发生数据冲突。因此,我们将锁定窗体后再处理绘制例程。
lock (this)
如果函数被要求重绘整个背景,则只需使用加载函数中初始化的区域绘制整个背景图像。
if (bFullImage)
{
g.DrawImage(bmpSplash, splashRegion, splashSrc,
GraphicsUnit.Pixel);
}
如果不需要重绘整个背景,则代码将优化为仅填充动画在上一次绘制中占据的区域。这可以进一步优化,仅重绘上一次绘制的重叠边缘。
else if (bUpdateAnim)
{
redrawSrc.X = animPos.X - splashRegion.X;
redrawSrc.Y = animPos.Y - splashRegion.Y;
g.DrawImage(bmpSplash, animPos, redrawSrc, GraphicsUnit.Pixel);
}
现在背景已更新,是时候移动和绘制动画了。如果调用者要求更新动画位置,则将动画向右移动并增加当前帧。这两个更新都必须检查“溢出”。如果当前动画帧大于位图中的最后一帧,则重新开始。同样,如果位置导致动画的左边缘离开屏幕,则将其重新开始。
if (bUpdateAnim)
{
curAnimCell++;
if (curAnimCell >= kNumAnimationCells)
curAnimCell = 0;
animPos.X += 5;
if (animPos.X > splashRegion.X + splashRegion.Width)
{
animPos.X =
splashRegion.X - bmpAnim.Width / kNumAnimationCells;
}
}
最后,动画将在其当前位置绘制。
g.DrawImage(bmpAnim, animPos,
curAnimCell * bmpAnim.Width / kNumAnimationCells, 0,
bmpAnim.Width / kNumAnimationCells, bmpAnim.Height,
GraphicsUnit.Pixel, attr);
MainForm 交互
此示例的代码结构旨在最大程度地减轻主窗体支持启动屏幕的负担。因此,主窗体和启动屏幕窗体之间所需的交互最少。
MainForm
定义了以下成员
const int kSplashUpdateInterval_ms
启动屏幕更新之间的毫秒间隔。const int kMinAmountOfSplashTime_ms
启动屏幕应显示的最小时间量。如果主窗体提前加载完成,启动屏幕将继续显示,直到此计时器间隔过去。static SplashForm splash
实际的启动屏幕窗体。
StartSplash
函数实例化启动屏幕窗体并启动其消息循环。
static public void StartSplash()
{
// Instance a splash form given the image names
splash = new SplashForm(kSplashUpdateInterval_ms);
// Run the form
Application.Run(splash);
}
OnPaint
和 OnPaintBackground
函数仅需要确保在启动屏幕活动时不进行绘制。这可以根据您应用程序的要求进行修改。要实现这一点,以下代码出现在每个方法中,在调用基类方法之前。
if (splash != null)
return;
负责关闭启动屏幕的函数是 CloseSplash
方法。此方法检查启动屏幕是否存在,如果存在,则调用 SplashForm.KillMe
函数,然后清理启动屏幕窗体的资源。
private void CloseSplash()
{
if (splash == null)
return;
// Shut down the splash screen
splash.Invoke(new EventHandler(splash.KillMe));
splash.Dispose();
splash = null;
}
主窗体的加载函数负责启动启动屏幕线程,并通过休眠启动屏幕生命周期的一半来模拟主窗体的加载和初始化。模拟初始化完成后,函数会检查时间,并等待直到启动屏幕显示的时间达到所需的最小时间段,然后将其关闭。
启动屏幕工作线程被策略性地放置在这里,以避免在多个窗体同时启动时发生竞态条件。在主窗体初始化之前在 Main
中启动此线程似乎是一个理想的解决方案,但可能会出现间歇性错误,涉及启动屏幕窗体的不当初始化。这可能表现为奇怪的绘制剪裁行为或不正确的客户区域初始化。
private void MainForm_Load(object sender, System.EventArgs e)
{
// Create a new thread from which to start the splash screen form
Thread splashThread = new Thread(new ThreadStart(StartSplash));
splashThread.Start();
// Pretend that our application is doing a bunch of loading and
// initialization
Thread.Sleep(kMinAmountOfSplashTime_ms / 2);
// Sit and spin while we wait for the minimum timer interval if
// the interval has not already passed
while (splash.GetUpMilliseconds() < kMinAmountOfSplashTime_ms)
{
Thread.Sleep(kSplashUpdateInterval_ms / 4);
}
// Close the splash screen
CloseSplash();
}
如果主窗体关闭,那么启动屏幕可能会陷入无限处理的危险。为了消除这种情况,OnClosing
方法会捕获关闭事件,并在启动屏幕仍处于活动状态时将其关闭。
protected override void OnClosing
(
System.ComponentModel.CancelEventArgs e
)
{
// Make sure the splash screen is closed
CloseSplash();
base.OnClosing (e);
}
结论
创建和渲染启动屏幕本身的代码很简单,只需要一些基本的 Graphics 类知识。理解此示例最重要的方面是主窗体和启动屏幕窗体之间的交互。在此示例中,通过利用多线程创建完全独立的进程来最小化交互。