這是一個在 WPF 開發中非常實用的需求。為了實現一個健壯(Robust)且高效(Efficient)的 UI 卡頓檢測器,我們需要遵循以下核心原則:
- 獨立的看門狗線程:檢測邏輯不能運行在 UI 線程上,必須在一個後台線程運行。
- 低侵入性:檢測機制本身不能消耗過多的 CPU 資源,不能頻繁打斷 UI 線程。
- 基於 Dispatcher 消息泵:利用
Dispatcher.BeginInvoke向 UI 線程發送“心跳包”,如果在規定時間內沒有執行,則視為卡頓。 - 優雅退出:在應用關閉時,需要安全地停止檢測線程,避免拋出異常。
下面是一個生產環境可用的 UiFreezeDetector 類實現。
1. 核心代碼實現 (UiFreezeDetector.cs)
using System;
using System.Diagnostics;
using System.Threading;
using System.Windows;
using System.Windows.Threading;
namespace WpfApp.Utils
{
/// <summary>
/// UI 卡頓檢測器
/// 原理:後台線程定期向 UI Dispatcher 發送空任務,若超時未執行則判定為卡頓。
/// </summary>
public class UiFreezeDetector : IDisposable
{
private readonly Dispatcher _dispatcher;
private readonly Thread _watchdogThread;
private readonly ManualResetEvent _pingEvent = new ManualResetEvent(false);
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private bool _isDisposed;
/// <summary>
/// 當檢測到卡頓發生時觸發
/// </summary>
public event EventHandler<FreezeEventArgs> FreezeDetected;
/// <summary>
/// 當卡頓結束(UI 恢復響應)時觸發
/// </summary>
public event EventHandler FreezeRecovered;
// 配置參數
private readonly int _timeoutMs; // 判定為卡頓的閾值 (例如 2000ms)
private readonly int _intervalMs; // 兩次檢測之間的間隔 (例如 1000ms)
/// <summary>
/// 初始化檢測器
/// </summary>
/// <param name="timeoutMs">卡頓判定閾值(毫秒),建議 > 2000ms</param>
/// <param name="intervalMs">檢測循環間隔(毫秒),建議 > 1000ms</param>
public UiFreezeDetector(int timeoutMs = 2000, int intervalMs = 1000)
{
_dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
_timeoutMs = timeoutMs;
_intervalMs = intervalMs;
_watchdogThread = new Thread(WatchdogLoop)
{
Name = "UI_Freeze_Watchdog",
IsBackground = true, // 確保主程序退出時線程自動結束
Priority = ThreadPriority.Highest // 確保在 CPU 繁忙時也能檢測
};
}
public void Start()
{
if (_watchdogThread.ThreadState.HasFlag(System.Threading.ThreadState.Unstarted))
{
_watchdogThread.Start();
Debug.WriteLine("UI Freeze Detector Started.");
}
}
public void Stop()
{
_cts.Cancel();
}
private void WatchdogLoop()
{
while (!_cts.Token.IsCancellationRequested && !_isDisposed)
{
// 1. 檢查 Dispatcher 是否還在運行,應用退出則停止
if (_dispatcher.HasShutdownStarted) break;
// 2. 重置信號量
_pingEvent.Reset();
// 3. 向 UI 線程投遞一個低優先級的任務
// 使用 DispatcherPriority.Normal 或 Input,確保能檢測到輸入阻塞
var operation = _dispatcher.BeginInvoke(DispatcherPriority.Normal, new Action(() =>
{
// UI 線程執行到這裏,説明沒有卡死,設置信號量
_pingEvent.Set();
}));
// 4. 在後台線程等待信號量
// 如果 UI 線程順暢,它會很快 Set(),WaitOne 立即返回 true
// 如果 UI 線程卡死,超過 _timeoutMs 後,WaitOne 返回 false
bool signaled = _pingEvent.WaitOne(_timeoutMs);
if (!signaled)
{
// --- 檢測到卡頓 ---
// 只有在沒有取消的時候才報告
if (!_cts.Token.IsCancellationRequested)
{
NotifyFreeze();
// 等待 UI 恢復(防止連續觸發事件,直到本次卡頓結束)
_pingEvent.WaitOne();
// --- 卡頓恢復 ---
FreezeRecovered?.Invoke(this, EventArgs.Empty);
}
}
// 5. 休息一段時間再進行下一次檢測,避免檢測本身佔用過多資源
try
{
Task.Delay(_intervalMs, _cts.Token).Wait();
}
catch (AggregateException) { /* 忽略取消異常 */ }
}
}
private void NotifyFreeze()
{
// 注意:不要在這裏直接調用 UI 元素,因為這裏是後台線程,且 UI 可能正忙
// 此時獲取 UI 線程的堆棧信息比較困難(需要 unsafe 或 Debugger API),
// 但我們可以記錄時間戳。
var args = new FreezeEventArgs
{
DetectedTime = DateTime.Now,
DurationThreshold = _timeoutMs
};
Debug.WriteLine($"[UI FREEZE DETECTED] Time: {args.DetectedTime}");
// 觸發事件
FreezeDetected?.Invoke(this, args);
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
_cts.Cancel();
_pingEvent.Dispose();
_cts.Dispose();
}
}
public class FreezeEventArgs : EventArgs
{
public DateTime DetectedTime { get; set; }
public int DurationThreshold { get; set; }
}
}
2. 使用方法
建議在 App.xaml.cs 的 OnStartup 中啓動它,使其伴隨整個應用程序生命週期。
using System.Windows;
namespace WpfApp
{
public partial class App : Application
{
private WpfApp.Utils.UiFreezeDetector _freezeDetector;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// 初始化檢測器:如果 UI 停頓超過 3 秒則報警,每隔 1 秒檢測一次
_freezeDetector = new WpfApp.Utils.UiFreezeDetector(timeoutMs: 3000, intervalMs: 1000);
_freezeDetector.FreezeDetected += (s, args) =>
{
// 注意:這裏是後台線程!不要直接操作 UI。
// 可以記錄日誌、生成 Dump 文件等。
System.Diagnostics.Debug.WriteLine($"!!! UI 卡頓發生在 {args.DetectedTime} !!!");
// 只有在非常必要時才嘗試強制彈窗(可能會失敗,因為 UI 正忙)
// MessageBox.Show("檢測到界面卡頓!");
};
_freezeDetector.FreezeRecovered += (s, args) =>
{
System.Diagnostics.Debug.WriteLine(">>> UI 已恢復響應 <<<");
};
_freezeDetector.Start();
}
protected override void OnExit(ExitEventArgs e)
{
_freezeDetector.Stop();
_freezeDetector.Dispose();
base.OnExit(e);
}
}
}
3. 代碼健壯性與高效性分析
為什麼這個實現是“高效”的?
- 使用
BeginInvoke而非Invoke:看門狗線程發送消息給 Dispatcher 是異步的,不會阻塞看門狗線程本身。 - 使用
WaitHandle(ManualResetEvent):這是操作系統內核級的同步原語。等待期間,看門狗線程處於 Sleep/Wait 狀態,CPU 佔用率幾乎為 0。 - 休眠間隔 (
_intervalMs):檢測完成後,線程會主動休眠,避免在那兒死循環空轉。
為什麼這個實現是“健壯”的?
- 處理應用退出 (
HasShutdownStarted):WPF 關閉時 Dispatcher 會停止處理消息。代碼中顯式檢查了此狀態,防止在應用關閉時誤報卡頓或拋出 ObjectDisposedException。 - 避免事件洪流:
- 代碼邏輯中:
if (!signaled) { ... _pingEvent.WaitOne(); } - 這行代碼非常關鍵。一旦檢測到卡頓,看門狗會掛起,一直等到那個被阻塞的任務終於執行完畢(即 UI 恢復)後,才進行下一次檢測。這避免了在一次長達 10 秒的卡頓中觸發 5 次“卡頓 2 秒”的報警。
- 代碼邏輯中:
- 獨立的 CancellationToken:使用
CancellationTokenSource來優雅地打斷Task.Delay,實現立即停止。 - 線程優先級:將看門狗線程設為
Highest,防止因為 CPU 滿載(導致 UI 卡頓的常見原因)導致檢測線程本身也拿不到時間片去檢測。
4. 進階:如何獲取卡頓時的堆棧?
這是最難的部分。因為卡頓檢測是在後台線程觸發的,而我們需要的是 UI 線程的堆棧。
在純 .NET (不使用非託管 Debugger API) 中,獲取另一個線程的實時堆棧是非常困難且不安全的(Thread.Suspend 已廢棄且危險)。
推薦的折衷方案:
如果需要定位卡頓原因,建議在 FreezeDetected 事件中:
- 記錄日誌:記錄發生時間。
- 創建 MiniDump:調用 Windows API (
MiniDumpWriteDump) 生成一個內存快照。 - 事後分析:使用 Visual Studio 或 WinDbg 打開 Dump 文件,直接看主線程停在哪裏。這是最準確的方法。
如果僅用於開發階段調試,可以簡單地暫停調試器,但對於生產環境,生成 Dump 是標準做法。