乐于分享
好东西不私藏

WPF 股票盯盘工具五(附源码):Quartz.NET 定时任务 + 全局异常处理

WPF 股票盯盘工具五(附源码):Quartz.NET 定时任务 + 全局异常处理

前言

盯盘软件的核心循环是什么?每隔几秒,自动刷新行情数据。

用 Timer 行不行?行。但 Timer 有几个问题:

  • 定时不精准,两个 Timer 同时触发可能竞争同一个资源

  • 不支持 cron 表达式("每周一到周五9:30到15:00每10秒刷新"这种复杂调度做不了)

  • 异常直接炸进程,没有重试机制

Quartz.NET 是一个成熟的企业级定时任务调度库,支持持久化任务、Job 链、集群模式。本项目用最简单的模式:内存调度,Job 注入 Autofac Service,每2秒刷新一次。

这章把 Quartz 集成、三层异常处理、日志架构全部讲透。

一、Quartz.NET 基础概念

概念
说明
Scheduler
调度器,全局唯一,启动后接管所有 Job 的调度
IJob
任务接口,Execute(IJobExecutionContext) 是入口
IJobDetail
Job 的元数据(类型、名称、分组、参数)
ITrigger
触发器,定义执行时机(立即、单次、重复、Cron)
JobDataMap
Job 执行时传入的键值参数
执行流程:
Scheduler.Start()    │    ├──→ Trigger 触发时机到了    │         │    │         └──→ Scheduler 查找对应的 JobDetail    │                   │    │                   └──→ JobExecutionContext 包装执行环境    │                             │    │                             └──→ IJob.Execute(context)    │                                       │    │                                       └──→ 执行业务逻辑    │    └──→ 定时器持续运行,直到 Scheduler.Shutdown()

二、QuartzHelper:调度器封装

// Common/QuartzHelper.cspublic static class QuartzHelper{    private static IScheduler _scheduler;    private static readonly object LockObj = new object();    publicstatic Task InitQuartzAsync()    {        lock (LockObj)        {            if (_scheduler != null) return Task.CompletedTask;            return InitSchedulerInternalAsync();        }    }    // 兼容旧同步版本(内部调用异步实现)    [Obsolete("Use InitQuartzAsync instead.")]    publicstaticasyncvoidInitQuartz()    {        try        {            await InitQuartzAsync().ConfigureAwait(false);        }        catch (SchedulerException ex)        {            Logger.LogError(ex, "Quartz initialization failed");        }        catch (Exception ex)        {            Logger.LogCritical(ex, "Quartz unexpected error");        }    }    privatestaticasync Task InitSchedulerInternalAsync()    {        StdSchedulerFactory factory = new StdSchedulerFactory();        _scheduler = await factory.GetScheduler().ConfigureAwait(false);        // 注册两个 Job:自选股刷新 + 大盘指数刷新        IJobDetail stockJob = JobBuilder.Create<TaskGetStockDataJob>()            .WithIdentity("StockJob", "StockGroup")            .Build();        IJobDetail stockIndexJob = JobBuilder.Create<TaskGetStockIndexJob>()            .WithIdentity("StockIndexJob", "StockGroup2")            .Build();        // 每2秒触发一次,立即开始,永不停止(RepeatForever)        ITrigger stockTrigger = TriggerBuilder.Create()            .WithIdentity("StockTrigger", "StockGroup")            .StartNow()            .WithSimpleSchedule(x => x                .WithIntervalInSeconds(2)                .RepeatForever())            .Build();        ITrigger stockIndexTrigger = TriggerBuilder.Create()            .WithIdentity("StockTrigger2", "StockGroup2")            .StartNow()            .WithSimpleSchedule(x => x                .WithIntervalInSeconds(2)                .RepeatForever())            .Build();        // 先注册指数(确保指数先刷新,再刷新个股)        await _scheduler.ScheduleJob(stockIndexJob, stockIndexTrigger).ConfigureAwait(false);        await _scheduler.ScheduleJob(stockJob, stockTrigger).ConfigureAwait(false);        await _scheduler.Start().ConfigureAwait(false);        Logger.LogInformation("Quartz scheduler started (interval: 2s)");    }    publicstaticasync Task ShutdownQuartzAsync()    {        lock (LockObj)        {            if (_scheduler == null) return;        }        try        {            Logger.LogInformation("Shutting down Quartz...");            await _scheduler.Shutdown(waitForJobsToComplete: true).ConfigureAwait(false);            Logger.LogInformation("Quartz shut down cleanly");        }        catch (Exception ex)        {            Logger.LogError(ex, "Error shutting down Quartz");        }    }}

设计要点:

  1. 双重锁单例:_scheduler 只初始化一次,lock 防止并发初始化,Task.CompletedTask 短路已有实例

  2. waitForJobsToComplete: true:关闭时等当前正在运行的 Job 执行完毕再退出,不会截断正在更新的数据

  3. 两个分组(StockGroup / StockGroup2):让指数和个股分开管理,后续可以单独暂停/恢复某一组

三、两个 Job 实现

3.1 TaskGetStockDataJob:自选股刷新

// SchedulerJob/TaskGetStockDataJob.cs[DisallowConcurrentExecution]   // 禁止并发:上一轮没跑完,不启动下一轮public class TaskGetStockDataJob : IJob{    private static readonly ILogger Logger = LoggerHelper.CreateLogger<TaskGetStockDataJob>();    private ViewModelLocator _locator;    public Task Execute(IJobExecutionContext context)    {        try        {            // 懒加载 ViewModelLocator(App 启动时已注册到 Resources)            if (_locator == null)                _locator = Application.Current?.Resources["Locator"] as ViewModelLocator;            if (_locator?.Main == null)            {                Logger.LogWarning("Locator not ready, skipping");                return Task.CompletedTask;            }            var list = _locator.Main.StockList;            if (list == null || list.Count == 0) return Task.CompletedTask;            var stockService = StockServiceFactory.CreateFactory("qq");            bool hiddenName = _locator.Main.HiddenName;            // ⚠️ 关键:Dispatcher.BeginInvoke,把后台线程的操作投递到 UI 线程            return Application.Current.Dispatcher.BeginInvoke(                DispatcherPriority.Background,                new Action(() => stockService.GetStocksDetail(list, hiddenName))            ).Task;        }        catch (Exception ex)        {            Logger.LogError(ex, "TaskGetStockDataJob unexpected error");            return Task.CompletedTask;  // 吞掉异常,不让 Job 彻底崩溃        }    }}

3.2 TaskGetStockIndexJob:大盘指数刷新

// SchedulerJob/TaskGetStockIndexJob.cs[DisallowConcurrentExecution]public class TaskGetStockIndexJob : IJob{    private static readonly ILogger Logger = LoggerHelper.CreateLogger<TaskGetStockIndexJob>();    private ViewModelLocator _locator;    public Task Execute(IJobExecutionContext context)    {        try        {            if (_locator == null)                _locator = Application.Current?.Resources["Locator"] as ViewModelLocator;            if (_locator?.Main == null)            {                Logger.LogWarning("Index Locator not ready, skipping");                return Task.CompletedTask;            }            var list = _locator.Main.StockIndexList;            if (list == null || list.Count == 0) return Task.CompletedTask;            var stockService = StockServiceFactory.CreateFactory("qq");            bool hiddenName = _locator.Main.HiddenName;            return Application.Current.Dispatcher.BeginInvoke(                DispatcherPriority.Background,                new Action(() =>                {                    stockService.GetStockIndexDetail(list, hiddenName);                    UpdateStockIndexString(_locator.Main, list);                })            ).Task;        }        catch (Exception ex)        {            Logger.LogError(ex, "TaskGetStockIndexJob unexpected error");            return Task.CompletedTask;        }    }    // 将指数列表拼成一行字符串,显示在底部状态栏    privatestaticvoidUpdateStockIndexString(MainViewModel vm,        ObservableCollection<StockViewMode> list)    {        var sb = new StringBuilder();        foreach (var item in list)        {            sb.Append(item.Name); sb.Append(": ");            sb.Append(item.CurrentPrice); sb.Append(" ");            sb.Append(item.ChangePrice.ToString("f2")); sb.Append(" ");            sb.Append(item.Percent.ToString("P2")); sb.Append(" ");        }        vm.StockIndexString = sb.ToString();    }}

四、三层异常处理架构

这是整个应用稳定性的核心。

┌─────────────────────────────────────────────────────┐│              App.xaml.cs  OnStartup                  ││  ┌──────────────────────────────────────────────┐   ││  │  AppDomain.UnhandledException               │   ││  │  (任何线程未捕获的异常,包括 Quartz Job)    │   ││  │  → Logger.LogCritical → 写日志 → 决定是否退出│   ││  └──────────────────────────────────────────────┘   ││  ┌──────────────────────────────────────────────┐   ││  │  DispatcherUnhandledException               │   ││  │  (UI 线程绑定错误、空指针等)                │   ││  │  → Logger.LogCritical → args.Handled = true  │   ││  └──────────────────────────────────────────────┘   │└─────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────┐│              QuartzHelper                            ││  ┌──────────────────────────────────────────────┐   ││  │  InitQuartz try/catch                        │   ││  │  → Logger.LogError / LogCritical             │   ││  └──────────────────────────────────────────────┘   │└─────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────┐│              TaskGetStockDataJob.Execute             ││  ┌──────────────────────────────────────────────┐   ││  │  try/catch (内层)                            │   ││  │  → Logger.LogError                          │   ││  │  → return Task.CompletedTask; ← 吞异常      │   ││  └──────────────────────────────────────────────┘   │└─────────────────────────────────────────────────────┘

4.1 异常处理详细代码

// App.xaml.csprotectedoverridevoidOnStartup(StartupEventArgs e){    AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;    DispatcherUnhandledException += (_, args) =>    {        Logger.LogCritical(args.Exception, "Dispatcher unhandled exception");        args.Handled = true;  // ← 关键:标记已处理,窗口不崩溃    };    base.OnStartup(e);    System.Text.Encoding.RegisterProvider(        System.Text.CodePagesEncodingProvider.Instance);  // GB2312 编码支持    QuartzHelper.InitQuartz();  // 旧同步 API,内部已是 async/await}protectedoverridevoidOnExit(ExitEventArgs e){    try    {        Logger.LogInformation("Shutting down...");        _ = QuartzHelper.ShutdownQuartzAsync();  // fire and forget,退出不阻塞        LoggerHelper.Dispose();    }    catch (Exception ex)    {        System.Diagnostics.Debug.WriteLine($"OnExit error: {ex.Message}");    }    base.OnExit(e);}privatestaticvoidOnUnhandledException(object sender, UnhandledExceptionEventArgs args){    if (args.ExceptionObject is Exception ex)        Logger.LogCritical(ex,            "AppDomain unhandled (terminating: {0})", args.IsTerminating);}

五、LoggerHelper:Microsoft.Extensions.Logging 封装

// Common/LoggerHelper.cspublic static class LoggerHelper{    private static ILoggerFactory _factory;    publicstaticvoidEnsureInitialized()    {        if (_factory != null) return;        _factory = LoggerFactory.Create(builder =>        {            builder.SetMinimumLevel(LogLevel.Information);            builder.AddDebug();         // Debug 输出窗口            builder.AddConsole();        // 控制台        });    }    publicstatic ILogger CreateLogger<T>()        => _factory.CreateLogger<T>();    publicstatic ILogger CreateLogger(Type type)        => _factory.CreateLogger(type);}

为什么用 Microsoft.Extensions.Logging?

这是 .NET 生态里的标准日志抽象层,主流日志库(NLog、Serilog、log4net)都有 ILogger 的适配器。切换日志实现只需要改 EnsureInitialized 里的配置,不需要动业务代码:

// 切换到 Serilog(示例)_factory = LoggerFactory.Create(builder =>{    builder.AddSerilog(new SerilogLoggerSink配置...);});

六、Quartz 集成 Autofac

Autofac.Extras.Quartz 这个 NuGet 包让 Quartz Job 能自动注入 Service:

// Common/AutoFacHelper.cspublicstatic ContainerBuilder InitAutofac(){    var builder = new ContainerBuilder();    InitDependency(builder);    // 关键:注册 Quartz.Autofac 集成模块    // Job 从容器解析 Service,Service 又是单例 → 所有 Job 共享同一个 Service 实例    builder.RegisterModule(new QuartzAutofacFactoryModule());    return builder;}
这样 Job 就可以直接注入 Service 了(代码里因为直接用 StockServiceFactory.CreateFactory("qq") 没走注入,但理论上可以改成注入 IStockService):
public classTaskGetStockDataJob : IJob{    private readonly IStockService _stockService;  // Autofac 注入    public TaskGetStockDataJob(IStockService stockService)    {        _stockService = stockService;    }}

七、实际运行效果

[INFO] 14:29:30.001 Quartz scheduler started successfully (interval: 2s)[INFO] 14:29:30.101 TaskGetStockIndexJob: refreshed 4 indices[INFO] 14:29:30.102 TaskGetStockDataJob: refreshed 5 stocks[INFO] 14:29:32.002 TaskGetStockIndexJob: refreshed 4 indices[INFO] 14:29:32.102 TaskGetStockDataJob: refreshed 5 stocks[INFO] 14:29:34.003 TaskGetStockIndexJob: refreshed 4 indices[INFO] 14:29:34.104 TaskGetStockDataJob: refreshed 5 stocks...[WARN]  14:30:05.001 HTTP GET failed for http://qt.gtimg.cn/ (timeout)[ERROR] 14:30:05.002 TaskGetStockDataJob unexpected error: HttpRequestException
每一轮刷新日志只有 4 行(4 指数 + 5 自选股),开销极小。HTTP 超时有 HttpHelper 兜底,返回空字符串,服务层 GetStocksDetail 对空结果直接跳过,不影响整体刷新。

八、系列总结

五篇文章覆盖了一个完整 WPF 应用的开发全过程:

篇
主题
核心代码量
关键技术
一
项目框架 + MVVM 架构
App.xaml / ViewModelLocator
Autofac DI / 无边框窗口 / 三层异常
二
股票服务层设计
IStockService / QqService / SinaService
模板方法 / 工厂模式 / HttpClient 缓存
三
K 线自定义控件
StockKline / Fundline
DrawingVisual / MatrixTransform / 中午休市偏移
四
自选股管理 + 持久化
MainViewModel CRUD
ObservableCollection / Messenger / JSON / NPOI
五
Quartz 定时任务
TaskGetStockDataJob / TaskGetStockIndexJob
DisallowConcurrent / Dispatcher.BeginInvoke / ILogger
完整项目结构:
OfficeDingPan/├── App.xaml / App.xaml.cs          ← DI 启动 + 全局异常 + Quartz 初始化├── MainWindow.xaml / .cs           ← 主窗体 + Messenger 订阅├── Controls/                        ← DrawingVisual 自定义控件│   ├── StockKline.cs               ← 分时图│   └── Fundline.cs                  ← 资金流├── DataMode/                        ← 数据传输对象├── Model/StockViewMode.cs          ← 视图模型(ViewModelBase)├── Service/                        ← 服务层(核心业务逻辑)│   ├── IStockService.cs│   ├── StockServiceBase.cs│   ├── QqStockService.cs           ← 腾讯行情(主力)│   ├── SinaStockService.cs         ← 新浪财经(备用)│   └── StockServiceFactory.cs├── Common/                          ← 工具层│   ├── AutoFacHelper.cs            ← DI 容器 + Quartz 集成│   ├── HttpHelper.cs               ← HttpClient 缓存│   ├── JsonHelper.cs               ← 序列化封装│   ├── FileHelper.cs               ← JSON 文件持久化│   ├── ExcelHepler.cs              ← NPOI Excel 读取│   ├── LoggerHelper.cs             ← ILogger 封装│   └── QuartzHelper.cs             ← 调度器封装├── SchedulerJob/                    ← Quartz Job│   ├── TaskGetStockDataJob.cs      ← 自选股定时刷新│   └── TaskGetStockIndexJob.cs    ← 大盘指数定时刷新├── View/                            ← 窗口│   ├── KLineWindow.xaml / .cs      ← K 线窗体(异步轮询)│   ├── HistoryWindow.xaml / .cs   ← 历史记录│   ├── FundLineWindow.xaml / .cs  ← 资金分析│   ├── HiddenWindow.xaml / .cs    ← 隐藏盯盘│   └── AboutWindow.xaml / .cs     ← 关于├── ViewModel/                       ← ViewModel 层│   ├── MainViewModel.cs            ← 主窗体逻辑(CRUD + 命令)│   ├── HistoryViewModel.cs         ← 历史记录逻辑│   └── ViewModelLocator.cs         ← DI 容器解析├── Skins/                           ← 样式资源│   ├── MainSkin.xaml│   └── ButtonStyle.xaml├── Interface/IDependency.cs         ← DI 标记接口├── Converters/ForeColorConverter.cs ← 涨跌幅颜色转换└── Enums/EnumStockType.cs          ← 股票类型枚举
技术栈清单:
框架       .NET 8.0-windows + WPFMVVM       MVVM Light(ViewModelBase / RelayCommand / Messenger)DI         Autofac + Autofac.Extras.QuartzHTTP       HttpClient(静态字典缓存)+ Referer 伪装K线绘制    DrawingVisual + DrawingContext + MatrixTransform定时任务   Quartz.NET(2s 间隔,DisallowConcurrent)Excel      NPOIJSON       Newtonsoft.Json日志       Microsoft.Extensions.Logging(Console + Debug)持久化     JSON 文件(系统临时目录)
从框架到数据,从绘图到任务,从持久化到日志监控——这套架构在 .NET 桌面应用里是标准的生产级写法,改一改需求(比如换成天气、换成物流追踪),核心框架完全不用动。
本期所有内容到此为止,后续如有改动再向合集中继续追加。
附项目源码:
链接: https://v.v8l.cn/s/rFDawR3 提取码: h5fn
如有其他问题,请留言

相关学习资料