页面内进度及取消示例






4.29/5 (9投票s)
一个带有进度显示和取消按钮的 ASP.NET 页面示例。单个页面即可启动一个长时间运行的进程、显示进度,并显示进程完成的消息。
引言
多年来,我编写了多个允许用户启动长时间运行进程的应用程序。对于 1 或 2 秒的操作,你可以用一个沙漏图标来应付,但对于任何更长的时间,你都需要一个进度显示。在桌面 GUI 中,标准的进度显示是一个带有进度条和一个醒目的“取消”按钮的屏幕或对话框。通常,对话框上会有一些空间来显示一些文本、图形或其他表示计算机正在处理内容的元素。以进度窗口为核心的两个经典应用程序示例是 Windows 安装程序和 Windows 磁盘碎片整理程序。
我四处寻找为我的 ASP.NET 应用程序创建类似进度显示的方法。我找到了进度条和进度对话框,但没有一个能真正将所有功能整合在一起。所以,这里就是一个实现这些功能的页面示例。该设计的一些亮点:
- ASP.NET 2.0。
- 单个页面即可启动进程、显示进度,并显示进程完成的消息。我发现这种设计非常适合我的用户界面和编程风格。
- 跨浏览器兼容(我试过 IE、Firefox 和 Opera)。
- 可用 CSS 轻松更换皮肤。
- 无 JavaScript。
用户界面设计
一些截图展示了示例 UI:
这个示例 UI 是我正在开发的一个项目中特定屏幕的简化版本。这是一个 MLS 对象的编辑器屏幕。如你所见,我已将“导入房源”功能集成到了 MLS 编辑器屏幕中。我本可以为“导入房源”设置一个单独的屏幕(或选项卡),但对于这个应用程序,我更喜欢将与 MLS 相关的所有功能都放在一个页面上。
示例代码亮点
我发现,好的进度显示取决于所涉及的进程。有些进度显示只需要一个简单的文本字符串即可。有些可以很好地估计剩余时间。而有些则完全无法估计。因此,我将我的示例代码更多地设计成一种设计模式(和示例),而不是一个可重用的组件。如果你更喜欢一个功能固定、可重用的进度对话框,那么实现它应该是一个相对直接的编程练习。
为了讨论代码,我们从底层开始向上看。第一个值得关注的类/文件是 JobQueueEventArgs
。在我的许多应用程序中,长进程由一个作业队列处理。作业由某种初始化例程放入作业队列中。然后,一组工作线程从队列中取出作业,执行作业,并通过更新一个全局的 JobQueueEventArgs
对象并触发一个 JobQueueEvent
来更新进度显示。
在此应用程序中,每当一个作业完成时,工作线程就会将该作业所花费的时间加到 JobQueueEventArgs.TimeCompleted
中。
for (int i = 0; i < importedListings; i++)
{
DateTime start = DateTime.Now;
// Remove job from queue ... run job
_importStatus.JobsCompleted++;
_importStatus.JobsRemaining--;
_importStatus.TimeCompleted += DateTime.Now - start;
}
然后,通过计算每个作业的平均时间乘以剩余作业数量来估算剩余时间。我们将这一切除以正在运行的线程数,因为两个线程完成作业的速度应该是两倍,对吧?
private TimeSpan _timeCompleted = TimeSpan.Zero;
public TimeSpan TimeCompleted
{
get { return _timeCompleted; }
set
{
// MT: Obviously, this can get a little off
_timeCompleted = value;
_timeRemaining = new TimeSpan(0, 0,
(int)((_timeCompleted.TotalSeconds * _jobsRemaining) /
_jobsCompleted) / _threadsRunning);
}
}
我在这里没有做任何锁定操作,但这应该没问题。这只是进度信息,而不是某人的银行账户余额。
我认为为一个真实的作业队列展示所有那些繁琐的工作没有意义,所以我让 MLS.ImportListings
模拟了一个带有一个工作线程的作业队列。此外,由于 ASP.NET 应用程序必须轮询状态(当页面停留在用户浏览器中时,它无法从作业队列接收事件),我没有费心去创建和触发一个实际的事件。如果 MLS
将来要用于 GUI 应用程序,你会希望添加这段代码。ASP.NET 应用程序通过读取 MLS.ImportStatus
属性来轮询状态。另外,在 MLS
中还有一些简单的属性(MLSID
, MLSListingsURL
)和方法(Insert
, Update
)来模拟一个典型的业务对象。为了支持取消操作,会发生两件事:
MLS.CancelImport
设置一个标志。MLS.ImportListings
尽可能频繁地检查这个标志。
public void CancelImportListings()
{
_importCanceled = true;
}
for (int i = 0; i < importedListings; i++)
{
if (_importCanceled)
{
_importStatus.Status = JobQueueEventArgs.StatusType.Idle;
_importStatus.QueueMessage = "MLS Listings Import canceled by user.";
return;
}
// Remove job from queue ... run job
}
ProgressBar
是显示进度条的控件。我最初使用了这篇 CodeProject 文章中的 ProgressBar。我主要使用流式布局,所以我修改了控件,使其创建的表格带有 width="100%"。行高由属性或其内容(例如图像)设置。为了增强浏览器兼容性(Firefox),单元格内容中始终使用 。
Default.aspx 是将所有东西整合在一起的示例页面。大部分代码是典型的 ASP.NET 业务对象代码。操作从这里开始:
protected void ImportListings_Click(object sender, EventArgs e)
{
MLS mls = GetMLS();
PageToMLS(mls);
mls.Update();
Session[_importSessionKey] = mls;
mls = (MLS)Session[_importSessionKey];
Thread thread = new Thread(new ThreadStart(mls.ImportListings));
thread.Start();
UpdateControls();
UpdateProgress();
AddMetaRefresh();
}
这看起来很简单,而且大部分确实如此,但有几点需要注意:
如你所见,MLS
对象被添加到了 Session 中。不太明显的是,这只在默认的 sessionState 模式 InProc 下才有效。其他 sessionState 模式要求对象被序列化和反序列化。虽然 MLS
可以被序列化,但你不能序列化一个正在运行的线程(!),所以任何反序列化后的对象都无法获取状态更新。如果你不能使用 InProc(比如你的应用程序运行在服务器集群上),你就需要将 ImportListings
函数移到一个单独的进程中,并设计某种 IPC 机制,比如 SOAP。可以考虑三层架构。
ImportListings_Click
中另一个有趣的地方是调用了 AddMetaRefresh
。下面是发生的情况:
protected void AddMetaRefresh()
{
MLS mls = (MLS)Session[_importSessionKey];
int refreshSeconds = mls != null &&
mls.ImportStatus.TimeRemaining.TotalSeconds > 10 ? 5 : 1;
Literal metaRefresh = new Literal();
metaRefresh.Text = string.Format("<meta http-equiv=\"refresh\" content=\"{0}\">",
refreshSeconds);
Header.Controls.Add(metaRefresh);
}
你可能见过一些其他实现方式。这篇 MSDN 文章使用了 JavaScript(我想是因为作者陷入了必须传入 URL 的思维定式)。我发现 "meta refresh" 方法最简洁。我会根据估计的剩余时间来调整刷新秒数,以尽可能降低刷新请求的频率。这个算法可以进一步优化,但由于估计时间通常相当粗略,没有必要搞得太复杂,而且你也不想等待太久。创建一个 Literal
控件并将其添加到 Header.Conrols.Add
是个好办法,因为这个页面并不总是需要 "meta refresh" 标签。而且,当 Default.aspx 改为使用 ASP.NET 母版页时,这段代码仍然有效。
那么,取消功能是如何工作的呢?代码如下:
protected void CancelImportListings_Click(object sender, EventArgs e)
{
MLS mls = (MLS)Session[_importSessionKey];
if (mls != null)
{
mls.CancelImportListings();
UpdateProgress();
if (mls.ImportStatus.Status == JobQueueEventArgs.StatusType.Idle)
Session[_importSessionKey] = null;
else
AddMetaRefresh();
}
UpdateControls();
}
如果对 mls.CancelImportListings
的调用总能立即生效就好了,但通常我们必须等待所有工作线程都关闭。所以,除非所有线程都立即停止(即 ImportStatus
为 Idle
),否则我们就调用 AddMetaRefresh
,让浏览器继续刷新,直到导入被取消。
运行演示
在 Visual Studio 2005 中以网站形式打开项目,然后按 Ctrl+F5 启动(不进行调试)。起初,你看不到“导入房源”按钮,因为编辑屏幕处于插入模式,而在这个应用程序中,你只有在 MLS 添加到数据库后才能导入房源(该死的现实世界,把一切都搞得这么复杂)。要模拟更新模式下的编辑屏幕(并看到“导入房源”按钮),请在 URL 后面附加查询字符串 "?MLSID=1"。完整的 URL 应该看起来像这样:
https://:37519/InPageProgressWithCancel/Default.aspx?MLSID=1