博客 / 詳情

返回

WPF UI卡頓自動檢測器

這是一個在 WPF 開發中非常實用的需求。為了實現一個健壯(Robust)高效(Efficient)的 UI 卡頓檢測器,我們需要遵循以下核心原則:

  1. 獨立的看門狗線程:檢測邏輯不能運行在 UI 線程上,必須在一個後台線程運行。
  2. 低侵入性:檢測機制本身不能消耗過多的 CPU 資源,不能頻繁打斷 UI 線程。
  3. 基於 Dispatcher 消息泵:利用 Dispatcher.BeginInvoke 向 UI 線程發送“心跳包”,如果在規定時間內沒有執行,則視為卡頓。
  4. 優雅退出:在應用關閉時,需要安全地停止檢測線程,避免拋出異常。

下面是一個生產環境可用的 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.csOnStartup 中啓動它,使其伴隨整個應用程序生命週期。

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. 代碼健壯性與高效性分析

為什麼這個實現是“高效”的?

  1. 使用 BeginInvoke 而非 Invoke:看門狗線程發送消息給 Dispatcher 是異步的,不會阻塞看門狗線程本身。
  2. 使用 WaitHandle (ManualResetEvent):這是操作系統內核級的同步原語。等待期間,看門狗線程處於 Sleep/Wait 狀態,CPU 佔用率幾乎為 0
  3. 休眠間隔 (_intervalMs):檢測完成後,線程會主動休眠,避免在那兒死循環空轉。

為什麼這個實現是“健壯”的?

  1. 處理應用退出 (HasShutdownStarted):WPF 關閉時 Dispatcher 會停止處理消息。代碼中顯式檢查了此狀態,防止在應用關閉時誤報卡頓或拋出 ObjectDisposedException。
  2. 避免事件洪流
    • 代碼邏輯中:if (!signaled) { ... _pingEvent.WaitOne(); }
    • 這行代碼非常關鍵。一旦檢測到卡頓,看門狗會掛起,一直等到那個被阻塞的任務終於執行完畢(即 UI 恢復)後,才進行下一次檢測。這避免了在一次長達 10 秒的卡頓中觸發 5 次“卡頓 2 秒”的報警。
  3. 獨立的 CancellationToken:使用 CancellationTokenSource 來優雅地打斷 Task.Delay,實現立即停止。
  4. 線程優先級:將看門狗線程設為 Highest,防止因為 CPU 滿載(導致 UI 卡頓的常見原因)導致檢測線程本身也拿不到時間片去檢測。

4. 進階:如何獲取卡頓時的堆棧?

這是最難的部分。因為卡頓檢測是在後台線程觸發的,而我們需要的是 UI 線程的堆棧。

在純 .NET (不使用非託管 Debugger API) 中,獲取另一個線程的實時堆棧是非常困難且不安全的(Thread.Suspend 已廢棄且危險)。

推薦的折衷方案:
如果需要定位卡頓原因,建議在 FreezeDetected 事件中:

  1. 記錄日誌:記錄發生時間。
  2. 創建 MiniDump:調用 Windows API (MiniDumpWriteDump) 生成一個內存快照。
  3. 事後分析:使用 Visual Studio 或 WinDbg 打開 Dump 文件,直接看主線程停在哪裏。這是最準確的方法。

如果僅用於開發階段調試,可以簡單地暫停調試器,但對於生產環境,生成 Dump 是標準做法。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.