dotnet 讀 WPF 源代碼 聊聊 DispatcherTimer 的實現


本文來告訴大家在 WPF 框架里面,是如何實現 DispatcherTimer 的功能。有小伙伴告訴我,讀源代碼系列的博客看不動,原因是太底層了。我嘗試換一個方式切入邏輯,通過提問題和解決問題的方法,一步步告訴大家 WPF 是如何實現 DispatcherTimer 的功能

假定咱是 WPF 框架的開發者(雖然我就是,盡管是格式化代碼工程師)咱需要實現一個 DispatcherTimer 的功能,請問可以如何寫呢

在 Windows 上有很多方式來實現計時器的功能,但是 DispatcherTimer 和其他的計時器有一點不同的在於,畢竟這是 Dispatcher 的,看到 Dispatcher 就可以了解到,這是一個需要在主線程執行的定時器

在那么如何在定時器里面回到主線程呢?假定咱現在啥都沒有,畢竟咱現在是在從零開發 WPF 框架的,那有什么可以使用呢?在 Windows 上提供了 SetTimer 這個放在 User32.dll 的函數,通過這個 Win32 方法可以調用 Windows 提供的底層定時器的功能

寫過 Win32 代碼的小伙伴就知道,如果直接使用 Win32 的方法,無論是參數還是需要了解的知識都是非常多的。作為一個有追求的框架,咱肯定是需要再做一層封裝,讓調用更加簡單。回到 SetTimer 這個 Win32 函數的功能上,咱可以調用 SetTimer 給定一個窗口句柄以及計時的時間,接下來 Windows 將會定時發送 WM_Timer 給到咱的窗口

假定咱已經有了接收窗口消息的統一入口,接受窗口調度的模塊的功能就是調度執行,也就是 Dispatcher 的一個功能。那不妨就將 WM_Timer 的處理也放在 Dispatcher 里面吧。剛好咱選用的 SetTimer 是發送窗口消息,自然就是被主線程收到了,咱也就不需要去嘗試解決后台線程的計時器需要調度到主線程

對於上層的 API 封裝呢?給開發者使用的計時器肯定是需要封裝一個類,那就叫 DispatcherTimer 好了。至於 DispatcherTimer 里面有哪些 API 呢,就抄 WPF 的設計好了

這里有一個問題是,假定我使用的是 DispatcherTimer 有多個,我使用其中的一個 DispatcherTimer 通過 SetTimer 這個 Win32 函數進行定時,在 Dispatcher 收到 WM_Timer 消息時,如果知道是需要調用哪個 DispatcherTimer 來執行?

通過分析需求,事實上這個問題不好解決,因為 Win32 的 WM_Timer 消息是不會告訴咱這個消息是被哪個邏輯調用的 SetTimer 方法調用的,不能通過 WM_Timer 獲取 DispatcherTimer 對象

但是從需求分析,其實咱不需要關注收到消息對應的是哪個 DispatcherTimer 對象,因為 DispatcherTimer 對象的功能是執行 Tick 事件,而只要是時間剛好到達,就需要執行 Tick 事件了。為了實現此功能,咱也就需要有一個集合用來管理當前主線程所有的 DispatcherTimer 對象,用來了解在收到 WM_Timer 需要調用的 DispatcherTimer 對象有哪些

這個 DispatcherTimer 集合為了方便調用管理,不妨先放在 Dispatcher 類里面,畢竟一個線程就剛好有一個 Dispatcher 對象

    public sealed class Dispatcher
    {
        private List<DispatcherTimer> _timers = new List<DispatcherTimer>();

        internal void AddTimer(DispatcherTimer timer)
        {
            lock(_instanceLock)
            {
               // 忽略代碼
               _timers.Add(timer);
            }

            // 忽略代碼
        }
    }

那在啥時候需要調用 AddTimer 呢?在 DispatcherTimer 對象創建的時候?如果我只是創建一個空的 DispatcherTimer 對象,這個對象啥都不干,好像加入到 Dispatcher 的 _timers 也不合適。不如就在 DispatcherTimer 啟動的時候添加

    public class DispatcherTimer
    {
        public void Start()
        {
            lock(_instanceLock)
            {
                if(!_isEnabled)
                {
                    _isEnabled = true;

                    _dispatcher.AddTimer(this);
                }
            }
        }

        private Dispatcher _dispatcher;
        private bool _isEnabled;

        // 忽略代碼
    }

在收到 WM_Timer 事件,就需要 Dispatcher 去遍歷所有的 DispatcherTimer 對象,看哪個對象當前需要被執行了。為了了解哪個 DispatcherTimer 需要被執行,就需要讓 DispatcherTimer 記錄兩個信息,一個是距離下次執行的時間和調用執行 Start 函數的時間。通過判斷調用 Start 的時間加上距離下次執行的時間是否小於或等於當前的時間,就可以判斷當前的 DispatcherTimer 是否需要執行

咱來加一點代碼在 DispatcherTimer 里面,在啟動時記錄時間

        public void Start()
        {
            lock(_instanceLock)
            {
                if(!_isEnabled)
                {
                    _isEnabled = true;

                    _dispatcher.AddTimer(this);

                    _startTime = DateTime.Now;
                }
            }
        }

        private DateTime _startTime;
        private TimeSpan _interval;

作為一個追求性能的框架,自然咱需要在每個地方都追求一下性能,例如獲取當前時間,是否有更快的方法?通過 Environment.TickCount 屬性可以獲取更快的時間,使用 Environment.TickCount 獲取的是毫秒數,表示的是開機到當前的時間,相對來說抽象一點,不過也剛好不會受到用戶修改當前系統時間的影響,自然也就更穩定一些啦

既然都使用 Environment.TickCount 了,不如將 判斷調用 Start 的時間加上距離下次執行的時間 合在一起計算吧,這樣后續每次 WM_Timer 消息過來的時候,就不用每次都做一次加法了,直接判斷值的大小即可

        public void Start()
        {
            lock(_instanceLock)
            {
                if(!_isEnabled)
                {
                    _isEnabled = true;

                    _dispatcher.AddTimer(this);

                    // 如果只是記錄當前調用 Start 方法的時間,也就是 Environment.TickCount 時間。那么后續收到 WM_Timer 消息,都需要判斷當前時間加上 _interval 的時間之后是否小於等於當前的時間。而這個加法計算是每次都需要調用的,為了性能優化,不如一開始就加上,后續就只需要判斷大小
                    _dueTimeInTicks = Environment.TickCount + (int) _interval.TotalMilliseconds;
                }
            }
        }

        // 刪除 DateTime 的定義,因為獲取的性能不夠,而且用戶也許修改系統時間
        // private DateTime _startTime;
        private TimeSpan _interval;

        // 用這個代替 DateTime 的方法,單位是毫秒。其實字段從規范來說是不應該 internal 公開的,然而在 WPF 里面,古老的開發者為了減少改動就公開了這個字段
        internal int _dueTimeInTicks; // used by Dispatcher

在 Dispatcher 里面就可以通過 DispatcherTimer 的 _dueTimeInTicks 字段和當前的時間比較大小而決定是否觸發 DispatcherTimer 的事件。從規范的角度來說,是不能公開 DispatcherTimer 的 _dueTimeInTicks 字段的,然而在 WPF 里面,古老的開發者為了減少改動就公開了這個字段

在 Dispatcher 里面的代碼如下

    public sealed class Dispatcher
    {
        private List<DispatcherTimer> _timers = new List<DispatcherTimer>();

        private IntPtr WndProcHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
        	WindowMessage message = (WindowMessage) msg;

            // 忽略代碼
        	if(message == WindowMessage.WM_TIMER && (int) wParam == TIMERID_TIMERS)
            {
            	// 忽略代碼
                PromoteTimers(Environment.TickCount);
            }
        }

        internal void PromoteTimers(int currentTimeInTicks)
        {
                    DispatcherTimer timer = null;
                    int iTimer = 0;
                    var timersVersion = _timersVersion;

                    do
                    {
                        lock(_instanceLock)
                        {
                            timer = null;

                            // If the timers collection changed while we are in the middle of
                            // looking for timers, start over.
                            if(timersVersion != _timersVersion)
                            {
                            	// 如果在循環過程,有其他邏輯加入了 _timers 的元素,意味着 _timers 的數量變更了
                            	// 需要重新開始
                                timersVersion = _timersVersion;
                                iTimer = 0;
                            }

                            while(iTimer < _timers.Count)
                            {
                                // WARNING: this is vulnerable to wrapping
                                if(_timers[iTimer]._dueTimeInTicks - currentTimeInTicks <= 0)
                                {
                                    timer = _timers[iTimer];

                                    // 忽略代碼
                                    break;
                                }
                                else
                                {
                                    iTimer++;
                                }
                            }
                        }

                        // Now that we are outside of the lock, promote the timer.
                        if(timer != null)
                        {
                            timer.Promote();
                        }
                    } while(timer != null);
        }
    }

以上判斷是通過 _timers[iTimer]._dueTimeInTicks - currentTimeInTicks <= 0 決定是否當前的 Timer 需要執行。因為 _timers[iTimer]._dueTimeInTicks - currentTimeInTicks <= 0 等價於 _timers[iTimer]._dueTimeInTicks <= currentTimeInTicks 也就是在 DispatcherTimer 下次執行的時間,小於或等於當前的時間,這個 DispatcherTimer 就應該被執行。因為相同的時間需要執行的 DispatcherTimer 也許有多個,因此就做了兩重循環。而同時為了解決在 DispatcherTimer 執行過程,也許有其他邏輯再加入新的 DispatcherTimer 因此也就需要判斷一下 _timersVersion 當前版本適合和進入的版本相同,如果不同,就證明有其他邏輯更改了集合,需要重新開始

從上面代碼可以看到,咱判斷 DispatcherTimer 是否需要被執行,如果需要執行,調用 DispatcherTimer 的 Promote 方法進行執行,最簡單的方法執行就是通過調用 Tick 事件觸發,簡單的代碼如下

        private void FireTick()
        {
            // 忽略代碼
            if(Tick != null)
            {
                Tick(this, EventArgs.Empty);
            }
        }

        internal void Promote() // called from Dispatcher
        {
            FireTick();
        }

既然所有的 DispatcherTimer 都被 Dispatcher 放在一起,那是否可以共用一個 Win32 的計時器,不需要每個 DispatcherTimer 都獨立調用。如上面的代碼,其實都是在判斷統一的時間,不需要多個 Win32 計時器也能實現效果

只需要有一個 Win32 計時器,定時是當前的 DispatcherTimer 里面最短的時間,就可以實現多個 DispatcherTimer 使用相同的一個 Win32 計時器。那這個邏輯可以放在哪呢?是否還記得咱在啟動計時器時加入到 Dispatcher 里面,既然咱期望多個 DispatcherTimer 使用相同的一個 Win32 計時器,不妨找到一對多的關系,剛好這里的一就是 Dispatcher 類,這里的多就是 DispatcherTimer 類。 因此這個 Win32 計時器的管理,放在 Dispatcher 里面就剛好。啟動或者重新設置 Win32 計時器可以放在 Dispatcher 的 AddTimer 方法里面


    public sealed class Dispatcher
    {
        private List<DispatcherTimer> _timers = new List<DispatcherTimer>();
        private long _timersVersion;

        internal void AddTimer(DispatcherTimer timer)
        {
            lock(_instanceLock)
            {
                _timers.Add(timer);
                _timersVersion++;
            }
            UpdateWin32Timer();
        }
    }

在加入 AddTimer 調用 UpdateWin32Timer 更新計時器時間,原因是如果我原有一個是定時是 10 秒的計時器在啟動了。接下來運行了 5 秒,我再加入一個需要等 1 秒的計時器,那么原有的 Win32 計時器是不是就需要更新一下時間?從原來的等待 10 秒,判斷距離現在還有 5 秒才執行,而新加入的等待 1 秒的計時器,在接下來的 1 秒就需要執行,那么就需要更新 Win32 計時器,修改定時時間

而如果原有一個是定時是 10 秒的計時器在啟動了。接下來運行了 9 秒,我再加入一個需要等 3 秒的計時器,顯然新加入的計時器還需要等待 3 秒才執行,而原有的計時器,只需要再等待 1 秒就足夠 10 秒了,可以執行。此時的 Win32 計時器自然是不需要重新啟動的

似乎上面的邏輯稍微有一點繞,但是看起來代碼也是很簡單的

    public sealed class Dispatcher
    {
        private int _dueTimeInTicks;
    	private bool _dueTimeFound;

        internal void UpdateWin32Timer() // Called from DispatcherTimer
        {
                    bool oldDueTimeFound = _dueTimeFound;
                    int oldDueTimeInTicks = _dueTimeInTicks;
                    _dueTimeFound = false;
                    _dueTimeInTicks = 0;

                    if(_timers.Count > 0)
                    {
                        // We could do better if we sorted the list of timers.
                        for(int i = 0; i < _timers.Count; i++)
                        {
                            DispatcherTimer timer = _timers[i];

                            if(!_dueTimeFound || timer._dueTimeInTicks - _dueTimeInTicks < 0)
                            {
                                _dueTimeFound = true;
                                _dueTimeInTicks = timer._dueTimeInTicks;
                            }
                        }
                    }

                    if(_dueTimeFound)
                    {
                        if(!_isWin32TimerSet || !oldDueTimeFound || (oldDueTimeInTicks != _dueTimeInTicks))
                        {
                            SetWin32Timer(_dueTimeInTicks);
                        }
                    }
                    else if(oldDueTimeFound)
                    {
                        KillWin32Timer();
                    }
        }
    }

大概這樣就算完成了 DispatcherTimer 的核心實現了,不過此時讓咱去天台將性能優化組救下。性能優化組說如果有連續的多個 DispatcherTimer 在執行,此時界面上就卡不動了。因為咱上面的代碼,多個 DispatcherTimer 執行之間是沒有切換調度的,也就是說剛好有多個 DispatcherTimer 都在執行,那么主線程的資源都在去處理其他業務邏輯里,沒有資源去處理界面渲染等

產品大佬也加了需求,要求在 DispatcherTimer 可以加入優先級,優先級相等於 Dispatcher 的優先級,於是咱的邏輯代碼也需要改改

在 DispatcherTimer 的 Promote 方法里面,看起來不能調用 FireTick 開始執行代碼邏輯,而是需要有優先級調度,也需要有切換調度,不能將全部的 DispatcherTimer 一次性執行。最簡單的方法自然就是 Dispatcher.InvokeAsync 等方法來實現優先級調度等功能

產品大佬的需求實現了,但性能優化組還在天台上,咱還需要再優化一下。既然都將 DispatcherTimer 加入到 Dispatcher 里面了,那為什么還需要 Dispatcher.InvokeAsync 調度呢?最簡單的方法就是在 DispatcherTimer 啟動的時候,將任務加入到 Dispatcher 里面,但是設置優先級為不執行。當 DispatcherTimer 的 Promote 調用時,設置剛才的加入的任務的優先級為 DispatcherTimer 的執行優先級,自然就會被 Dispatcher 進行調度了

    public class DispatcherTimer
    {
        public DispatcherTimer(DispatcherPriority priority) // NOTE: should be Priority
        {
            _priority = priority;
        }

        private void Start()
        {
            lock(_instanceLock)
            {
                if (_operation != null)
                {
                    // Timer has already been restarted, e.g. Start was called form the Tick handler.
                    return;
                }

                // BeginInvoke a new operation.
                _operation = _dispatcher.BeginInvoke(
                    DispatcherPriority.Inactive,
                    new DispatcherOperationCallback(FireTick),
                    null);

                
                _dueTimeInTicks = Environment.TickCount + (int) _interval.TotalMilliseconds;
                
                _dispatcher.AddTimer(this);
            }
} // 這確實是 WPF 的格式化,這是花括號前面沒有空格

        internal void Promote() // called from Dispatcher
        {
            lock(_instanceLock)
            {
                // Simply promote the operation to it's desired priority.
                if(_operation != null)
                {
                    _operation.Priority = _priority;
                }
            }
        }

        private object FireTick(object unused)
        {
            if(Tick != null)
            {
                Tick(this, EventArgs.Empty);
            }

            return null;
        }

        private DispatcherPriority _priority;  // NOTE: should be Priority
    }

通過上面的代碼,性能優化組從天台上下來了,但產品大佬又說,有一些用戶喜歡在 Tick 里面里面將 DispatcherTimer 停下,而以上的代碼,其實咱沒有實現停下的功能,剛好兩個功能一起做

在 DispatcherTimer 里面定義 IsEnabled 屬性,咱需要支持在 IsEnabled 里面進行賦值從而進行停止或啟動計時器

    public class DispatcherTimer
    {
        /// <summary>
        ///     Gets or sets whether the timer is running.
        /// </summary>
        public bool IsEnabled
        {
            get
            {
                return _isEnabled;
            }

            set
            {
                lock(_instanceLock)
                {
                    if(!value && _isEnabled)
                    {
                        Stop();
                    }
                    else if(value && !_isEnabled)
                    {
                        Start();
                    }
                }
            }
        }

        private bool _isEnabled;
    }

既然有不斷的開啟和停止,那不如就再加一個 Restart 方法好了,讓 Start 方法調用 Restart 方法

    public class DispatcherTimer
    {
        public void Start()
        {
            lock(_instanceLock)
            {
                if(!_isEnabled)
                {
                    _isEnabled = true;

                    Restart();
                }
            }
        }

        private void Restart()
        {
            lock(_instanceLock)
            {
                if (_operation != null)
                {
                    // Timer has already been restarted, e.g. Start was called form the Tick handler.
                    return;
                }

                // BeginInvoke a new operation.
                _operation = _dispatcher.BeginInvoke(
                    DispatcherPriority.Inactive,
                    new DispatcherOperationCallback(FireTick),
                    null);

                
                _dueTimeInTicks = Environment.TickCount + (int) _interval.TotalMilliseconds;
                
                _dispatcher.AddTimer(this);
            }
}
    }

那 Stop 方法呢?其實就是從 Dispatcher 隊列里面干掉 _operation 對象

    public class DispatcherTimer
    {
        public void Stop()
        {
              if(_operation != null)
              {
                   _operation.Abort();
                   _operation = null;
              }
        }
    }

當然了,如果當前計時最短就是當前的被 Stop 的 DispatcherTimer 那還需要更新一下 Win32 的計時器時間。例如當前已設置了最短的計時是 1 秒的 DispatcherTimer 被 Stop 了,而后續的 DispatcherTimer 是再等 5 秒,此時就需要修改 Win32 的計時器,關閉等待 1 秒的計時器,再開啟等待 5 秒的計時器。另外咱將 DispatcherTimer 加入到 Dispatcher 的一個集合里面,自然就需要在 Stop 里面移除,否則將會讓 DispatcherTimer 對象無法釋放

咱更改 Stop 方法,加上告訴 Dispatcher 的方法

    public class DispatcherTimer
    {
        public void Stop()
        {
            bool updateWin32Timer = false;
            
            lock(_instanceLock)
            {
                if(_isEnabled)
                {
                    _isEnabled = false;
                    updateWin32Timer = true;

                    // If the operation is in the queue, abort it.
                    if(_operation != null)
                    {
                        _operation.Abort();
                        _operation = null;
                    }
}
            }

            if(updateWin32Timer)
            {
                _dispatcher.RemoveTimer(this);
            }
        }
    }

在 Dispatcher 的里面,先從集合里面將 DispatcherTimer 移除。當然,從這里也可以看到,即使在業務代碼里面沒有對 DispatcherTimer 進行引用,但是只要這個 DispatcherTimer 還在運行,那么 DispatcherTimer 的對象就不會被釋放。接着在 Dispatcher 更新計時器

    public sealed class Dispatcher
    {
        internal void RemoveTimer(DispatcherTimer timer)
        {
            lock(_instanceLock)
            {
                if(!_hasShutdownFinished) // Could be a non-dispatcher thread, lock to read
                {
                    _timers.Remove(timer);
                    _timersVersion++;
                }
            }
            UpdateWin32Timer();
        }
    }

再接下來,產品大佬告訴咱,加需求。可以讓開發者修改 DispatcherTimer 的計時時間,在修改 Interval 屬性時,需要咱自己去更新 Dispatcher 的計時器

在 IsEnabled 開啟時,如果用戶修改 Interval 屬性,那么需要告訴 Dispatcher 更新計時器。而如果沒有開啟計時器,那更新 Dispatcher 做什么

    public class DispatcherTimer
    {
        /// <summary>
        ///     Gets or sets the time between timer ticks.
        /// </summary>
        public TimeSpan Interval
        {
            get
            {
                return _interval;
            }

            set
            {
                bool updateWin32Timer = false;

                lock(_instanceLock)
                {
                    _interval = value;

                    if(_isEnabled)
                    {
                        _dueTimeInTicks = Environment.TickCount + (int)_interval.TotalMilliseconds;
                        updateWin32Timer = true;
                    }
                }

                if(updateWin32Timer)
                {
                    _dispatcher.UpdateWin32Timer();
                }
            }
        }

        private TimeSpan _interval;
    }

當然了,作為對外公開的 API 還需要判斷一下調皮的用戶的行為。如果傳入的時間是負數呢?如果傳入的時間太長了,例如超過 int 的 MaxValue 也就是說這個 DispatcherTimer 是不執行的吧,是不是就需要告訴用戶

        public TimeSpan Interval
        {
            set
            {
                bool updateWin32Timer = false;
                
                if (value.TotalMilliseconds < 0)
                    throw new ArgumentOutOfRangeException("value", SR.Get(SRID.TimeSpanPeriodOutOfRange_TooSmall));

                if (value.TotalMilliseconds > Int32.MaxValue)
                    throw new ArgumentOutOfRangeException("value", SR.Get(SRID.TimeSpanPeriodOutOfRange_TooLarge));

                // 忽略代碼
            }
        }

產品大佬還說,咱的 DispatcherTimer 是允許在后台線程啟動的,畢竟不想讓用戶需要寫 Dispatcher 調度到主線程再開啟 DispatcherTimer 計時,允許在后台線程開啟。如上面代碼,其實咱加了很多鎖了,問題也不大。這部分邏輯實現太簡單了,這里就不告訴大家了

以上大概就是 DispatcherTimer 的核心邏輯,可以看到 DispatcherTimer 里面的細節還是很多的。實際的 WPF 代碼里面也有很多細節部分是本文沒有告訴大家的,還請大家自己去閱讀 WPF 源代碼

更多 DispatcherTimer 請看: WPF 如何知道當前有多少個 DispatcherTimer 在運行

當前的 WPF 在 https://github.com/dotnet/wpf 完全開源,使用友好的 MIT 協議,意味着允許任何人任何組織和企業任意處置,包括使用,復制,修改,合並,發表,分發,再授權,或者銷售。在倉庫里面包含了完全的構建邏輯,只需要本地的網絡足夠好(因為需要下載一堆構建工具),即可進行本地構建


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM