65.9K
CodeProject 正在变化。 阅读更多。
Home

页面内进度及取消示例

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.29/5 (9投票s)

2006年3月2日

CPOL

6分钟阅读

viewsIcon

179039

downloadIcon

250

一个带有进度显示和取消按钮的 ASP.NET 页面示例。单个页面即可启动一个长时间运行的进程、显示进度,并显示进程完成的消息。

Sample Image - import-running.jpg

引言

多年来,我编写了多个允许用户启动长时间运行进程的应用程序。对于 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)来模拟一个典型的业务对象。为了支持取消操作,会发生两件事:

  1. MLS.CancelImport 设置一个标志。
  2. public void CancelImportListings()
    {
        _importCanceled = true;
    }
  3. MLS.ImportListings 尽可能频繁地检查这个标志。
  4. 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),单元格内容中始终使用 &nbsp;。

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 的调用总能立即生效就好了,但通常我们必须等待所有工作线程都关闭。所以,除非所有线程都立即停止(即 ImportStatusIdle),否则我们就调用 AddMetaRefresh,让浏览器继续刷新,直到导入被取消。

运行演示

在 Visual Studio 2005 中以网站形式打开项目,然后按 Ctrl+F5 启动(不进行调试)。起初,你看不到“导入房源”按钮,因为编辑屏幕处于插入模式,而在这个应用程序中,你只有在 MLS 添加到数据库后才能导入房源(该死的现实世界,把一切都搞得这么复杂)。要模拟更新模式下的编辑屏幕(并看到“导入房源”按钮),请在 URL 后面附加查询字符串 "?MLSID=1"。完整的 URL 应该看起来像这样:

https://:37519/InPageProgressWithCancel/Default.aspx?MLSID=1
© . All rights reserved.