戰(zhàn):讓老牌WPF UI調(diào)試工具在.NET 4.5下重獲新生)
簡介ManagedSpy是一款專為.NET Framework 4.5開發(fā)的托管代碼調(diào)試與分析工具面向需要深入理解運(yùn)行時(shí)行為、內(nèi)存分配與性能瓶頸的.NET開發(fā)人員。它基于反射與元數(shù)據(jù)接口工作可在不重新編譯、不附加目標(biāo)進(jìn)程的前提下實(shí)時(shí)監(jiān)控應(yīng)用程序的類實(shí)例、方法調(diào)用、內(nèi)存分配等關(guān)鍵信息尤其適合排查異步編程、多線程與垃圾回收相關(guān)問題。壓縮包共51個(gè)文件體積僅102KB內(nèi)部以C#源碼和C原生工程為主另有界面位圖、圖標(biāo)、資源文件以及配置文檔結(jié)構(gòu)清晰便于研讀與二次開發(fā)。該版本已在VS2015環(huán)境下驗(yàn)證可用并解決了從32位進(jìn)程訪問64位進(jìn)程的難題擴(kuò)展了調(diào)試分析的應(yīng)用場景。已有272人學(xué)習(xí)下載通過閱讀源碼可以掌握事件攔截、屬性代理、跨進(jìn)程通訊等實(shí)現(xiàn)細(xì)節(jié)對于想構(gòu)建同類工具或深入理解.NET運(yùn)行機(jī)制的開發(fā)者很有價(jià)值。 說出來你可能不信我在給一個(gè)運(yùn)行在.NET 4.5上的老WPF項(xiàng)目做UI自動(dòng)化測試時(shí)找了一圈現(xiàn)成的UI檢查工具居然沒有一款能像當(dāng)年Visual Studio自帶的ManagedSpy那樣既輕量又能直接看到完整的可視化元素樹。Spy能看Win32句柄但看不了WPF的依賴屬性Snoop確實(shí)強(qiáng)大但裝到客戶現(xiàn)場環(huán)境里總是多出不少鋪墊工作。后來我干脆把微軟當(dāng)年那套ManagedSpy源碼翻出來自己動(dòng)手編譯了一個(gè)適配.NET 4.5的版本整個(gè)過程踩了不少坑但最終的效果相當(dāng)值得。這篇文章就是想把如何讓ManagedSpy在.NET 4.5下跑起來這件事說透。適合有WPF/WinForms調(diào)試需求或者正準(zhǔn)備做UI自動(dòng)化、控件級定位測試的.NET開發(fā)者。如果你只是想要一個(gè)能看的UI樹工具看這篇也一樣有用因?yàn)槲視丫幾g、啟動(dòng)、附加進(jìn)程、排查問題這條鏈路全部攤開來講。1. ManagedSpy是什么以及它和Spy的本質(zhì)區(qū)別先說清楚工具定位。ManagedSpy是微軟發(fā)布的一個(gè)托管版Spy專門用來查看WPF和WinForms應(yīng)用程序的可視化元素樹。它不是一個(gè)增強(qiáng)版Spy而是走了一條完全不同的技術(shù)路徑。Spy靠的是Win32 API那一層遍歷的是窗口句柄HWND和消息機(jī)制它能看到窗口矩形、類名、樣式這些和操作系統(tǒng)強(qiáng)相關(guān)的東西。但到了WPF時(shí)代界面被分成了可視化樹Visual Tree和邏輯樹Logical Tree大量UI信息根本不在HWND層面比如Button的Content、DataContext、綁定表達(dá)式、附加屬性Spy一概看不到。ManagedSpy的思路是直接在目標(biāo)進(jìn)程內(nèi)注入一個(gè)管道通信端把托管堆里的可視化樹對象、屬性值、綁定狀態(tài)序列化后傳回給工具端界面展示。所以你真正拿到的是托管對象層面的實(shí)時(shí)快照這也意味著它對框架版本的依賴非常高。原版ManagedSpy官方發(fā)布時(shí)基于.NET 1.x/2.0那套編譯環(huán)境代碼里很多地方用到了老式的屬性反射和WinForms設(shè)計(jì)器特性在.NET 4.x運(yùn)行時(shí)里直接編譯運(yùn)行會遇到不少看起來是源碼錯(cuò)誤實(shí)際是架構(gòu)代差的問題。從應(yīng)用場景來看如果只是調(diào)試Win32控件、排查窗口消息阻塞Spy就夠了但如果你是排查WPF的DataTemplate沒生效、綁定的Path寫錯(cuò)了、某個(gè)控件的實(shí)際RenderSize為什么是0那ManagedSpy這種能看到依賴屬性實(shí)時(shí)值的工具才是正解。我這個(gè)改造版本依然保留了這兩類用途的完整能力。2. 原版ManagedSpy在.NET 4.5環(huán)境下面臨的三個(gè)硬傷網(wǎng)上能找到的ManagedSpy源碼大多是CodePlex時(shí)代流傳下來的archive。直接下載、編譯、運(yùn)行通常會遇到三個(gè)繞不過去的問題。理解這三個(gè)問題你就知道為什么網(wǎng)上很多人說ManagedSpy過時(shí)了其實(shí)不是思路過時(shí)而是編譯目標(biāo)不對。2.1 目標(biāo)框架版本過低編譯鏈直接走不通原版工程的TargetFramework是.NET Framework 2.0甚至更老。在VS里打開csproj也會有兼容性提示但真正讓人頭疼的是它引用了一堆已經(jīng)改版的程序集。比如System.Design、System.Windows.Forms.Design在.NET 4.0之后拆分和改名過原工程文件里的引用路徑根本對不上新版本GAC。你硬編出來的結(jié)果是編譯大量報(bào)錯(cuò)明明代碼邏輯沒問題但程序集引用就是找不到。解決辦法不是逐條改HintPath而是直接用Visual Studio的升級向?qū)О压こ剔D(zhuǎn)換到新格式然后把TargetFrameworkVersion手動(dòng)改成v4.5。這樣系統(tǒng)會自動(dòng)把老引用映射到新程序集大多數(shù)編譯錯(cuò)誤會一次性消失。別在原工程上小打小鬧地修轉(zhuǎn)換格式是正規(guī)做法。2.2 跨進(jìn)程消息協(xié)議里的窗口消息數(shù)字段ManagedSpy的通信機(jī)制是目標(biāo)進(jìn)程創(chuàng)建一個(gè)隱藏窗口通過RegisterWindowMessage注冊一個(gè)全局消息然后工具端用SendMessage把請求發(fā)過去。這個(gè)機(jī)制本身沒問題問題出在原生代碼里用了一個(gè)int類型的字段保存消息編號和窗口句柄而64位系統(tǒng)上窗口句柄是64位的。如果直接把原代碼編譯成AnyCPU再以64位進(jìn)程模式運(yùn)行句柄被強(qiáng)轉(zhuǎn)成int后高位被截?cái)嗄繕?biāo)窗口根本收不到消息。這個(gè)問題很隱蔽因?yàn)榫幾g不會報(bào)錯(cuò)只有運(yùn)行時(shí)附加進(jìn)程后才出現(xiàn)一直等不到響應(yīng)的現(xiàn)象。我的處理方式是把通信相關(guān)的結(jié)構(gòu)體全部改為IntPtr類型同時(shí)在編譯時(shí)固定以x86目標(biāo)平臺輸出。兩個(gè)方法二選一即可但如果還要顧及后續(xù)擴(kuò)展建議把IntPtr改造做了。2.3 時(shí)間戳溢出導(dǎo)致的假超時(shí)原版源碼里有個(gè)細(xì)節(jié)每次發(fā)消息前會記錄一個(gè)時(shí)間戳用的是Environment.TickCount。這個(gè)值是個(gè)int單位是毫秒滿打滿算只能表示大約24.9天。如果目標(biāo)機(jī)器開機(jī)時(shí)間超過這個(gè)數(shù)值TickCount會變成負(fù)數(shù)ManagedSpy的超時(shí)判斷就會錯(cuò)亂表現(xiàn)就是第一次能連上第二次必超時(shí)或者偶爾超時(shí)。排查這個(gè)問題時(shí)我一度以為是消息沒發(fā)出去最后翻源碼才鎖定它。替換成Environment.TickCount64.NET 4.5不支持這個(gè)方法需要自己調(diào)用GetTickCount64 API或者干脆用DateTime.UtcNow.Ticks來比較問題就消失了。這個(gè)小bug在當(dāng)年不算什么但現(xiàn)在服務(wù)器動(dòng)不動(dòng)就幾個(gè)月不重啟不改的話工具基本沒法用。3. 改造步驟從源碼到可運(yùn)行的.NET 4.5版本這一節(jié)給出可直接復(fù)現(xiàn)的完整操作流程。我用的是VS2022理論上前面的版本也一樣能操作只要支持.NET Framework 4.5編譯目標(biāo)就可以。3.1 獲取源碼并解決工程格式問題先去GitHub搜索ManagedSpy關(guān)鍵字找star量相對高、且最近有issue反饋的分支把源碼clone下來。如果找不到理想分支也可以直接去官方archive服務(wù)器下載原始zip包。兩種方式我都試過git版本通常已經(jīng)是別人修過格式的省一點(diǎn)事。拿到源碼后不要急著編譯先看目錄結(jié)構(gòu)。ManagedSpy大概分兩個(gè)工程ManagedSpy工具端界面和ManagedSpyLib公共庫還有一個(gè)Injector或者Hook相關(guān)的輔助工程用于把通信DLL注入到目標(biāo)進(jìn)程。我建議先編譯ManagedSpyLib因?yàn)樗粌蛇吂餐盟幾g通過后面兩個(gè)工程就順了。打開csproj后如果是老格式用VS的轉(zhuǎn)換向?qū)?。轉(zhuǎn)換完成后立刻把目標(biāo)框架設(shè)置為.NET Framework 4.5。這里有個(gè)注意點(diǎn)不要選.NET Core或.NET 5以上格式因?yàn)榇a里的AppDomain、Remoting等API在Core和后續(xù)版本里行為變化很大依賴它們的話改動(dòng)成本就失控了。4.5是最穩(wěn)妥的平衡點(diǎn)。3.2 代碼級改動(dòng)清單這個(gè)清單是我反復(fù)改了幾輪之后沉淀下來的最小集每一條都有明確原因目標(biāo)平臺固定為x86。這個(gè)直接避免64位句柄截?cái)鄦栴}改動(dòng)量最小。通信結(jié)構(gòu)體中的窗口句柄字段改為IntPtr。如果你不想固定x86這條必須做。移除所有過時(shí)的AppDomain.CreateDomain調(diào)用。原版用這個(gè)方式做進(jìn)程間通信的初始化.NET 4.5下容易觸發(fā)安全異常換成Marshal.GetActiveObject或者直接以管道命名做服務(wù)發(fā)現(xiàn)。時(shí)間戳獲取統(tǒng)一替換為GetTickCount64平臺的API調(diào)用。屬性值展示部分的TypeDescriptor.GetConverter在.NET 4.5下部分自定義類型會找不到轉(zhuǎn)換器需要在工具端捕獲異常后走ToString兜底。上面這些改動(dòng)中第5條比較容易忽略。因?yàn)镸anagedSpy的UI樹把屬性值渲染成字符串靠的是TypeConverter機(jī)制。現(xiàn)代WPF控件里有大量自定義DependencyProperty的類型沒有配套Converter一渲染就拋異常導(dǎo)致整個(gè)樹斷裂。我加了一層try/catch并退回到value.ToString()實(shí)測下來樹完整度從大約七成提升到接近百分之百。3.3 編譯后的基礎(chǔ)驗(yàn)證編譯成功后先別急著附加到真實(shí)項(xiàng)目先跑一遍自帶的Demo程序。如果源碼目錄里沒有Demo就自己用VS新建一個(gè)WPF窗體放一個(gè)帶DataTemplate的ListBox。啟動(dòng)Demo然后以管理員身份運(yùn)行ManagedSpy在主窗口選擇Demo進(jìn)程點(diǎn)擊Attach。如果順利左側(cè)能看到邏輯樹右側(cè)能看到選中元素的屬性列表。如果這一關(guān)都過不了多半是前面的改動(dòng)漏了某一條。最典型的現(xiàn)象是點(diǎn)擊Attach后長時(shí)間沒反應(yīng)這時(shí)候打開任務(wù)管理器看目標(biāo)進(jìn)程里是否多了一個(gè)名為msvcm80.dll之類的注入模塊。如果從頭到尾沒有新增模塊說明消息在進(jìn)程間沒打通優(yōu)先回頭檢查窗口消息編號和句柄類型。4. UI元素樹加載失敗一個(gè)完整的排查鏈路這是我這套改造中印象最深的一次排查值得單獨(dú)拿出來講。首次附加到真實(shí)WPF項(xiàng)目時(shí)ManagedSpy的窗口能彈出來進(jìn)程列表也能看到目標(biāo)exe但點(diǎn)擊Attach后左側(cè)樹一直是空的右側(cè)屬性區(qū)也是空白沒有報(bào)錯(cuò)。這種靜默失敗比直接拋異常難查得多。我先懷疑消息超時(shí)于是改了超時(shí)時(shí)間重新附加仍然空白。接著懷疑注入失敗用Process Explorer觀察目標(biāo)進(jìn)程模塊列表確認(rèn)ManagedSpyLib已經(jīng)被加載了。這說明消息已經(jīng)到達(dá)目標(biāo)進(jìn)程但排查端拿不到數(shù)據(jù)。隨后我想到一個(gè)可能跨進(jìn)程數(shù)據(jù)返回用的是WM_COPYDATA這個(gè)機(jī)制在UIPI用戶界面特權(quán)隔離下可能出現(xiàn)問題——如果目標(biāo)程序以管理員權(quán)限運(yùn)行而ManagedSpy不是那么SendMessage會被系統(tǒng)默默攔截。于是我用管理員身份重新運(yùn)行ManagedSpy再附加到以管理員身份啟動(dòng)的WPF程序問題立刻消失。這個(gè)坑在Windows 10及以后版本尤其常見因?yàn)閺腤indows Vista開始UIPI就一直存在。后來我把這個(gè)邏輯固化成了一個(gè)規(guī)則ManagedSpy和目標(biāo)程序必須以同級或更高的權(quán)限運(yùn)行否則凡是涉及跨進(jìn)程SendMessage的功能都可能被靜默丟棄。第二層問題是加了權(quán)限后UI樹可以顯示但只顯示了邏輯樹可視化樹Visual Tree沒有內(nèi)容。看了ManagedSpy源碼覺得問題出現(xiàn)在它遍歷時(shí)依賴VisualTreeHelper而這個(gè)Helper在跨進(jìn)程注入場景下有些子元素會因?yàn)闆]有激活PresentationSource而無法枚舉。解決辦法是在注入端主動(dòng)調(diào)用一次HwndSource.FromHwnd并強(qiáng)制SourcesChanged事件讓根元素徹底進(jìn)入已連接的視覺狀態(tài)再開始遍歷。這個(gè)處理并不是官方方案但實(shí)際驗(yàn)證非常有效尤其是對隱藏窗口和TabControl里的內(nèi)容。5. 附加進(jìn)程后進(jìn)程無響應(yīng)的坑與雙向通信限制ManagedSpy的工作模式不是單次查詢工具端發(fā)起請求后目標(biāo)進(jìn)程會在自己的UI線程里同步處理并返回?cái)?shù)據(jù)。如果目標(biāo)進(jìn)程的UI線程正處于忙碌狀態(tài)——比如在跑一個(gè)耗時(shí)的啟動(dòng)邏輯、彈了一個(gè)模態(tài)對話框、或者陷入了死循環(huán)——ManagedSpy就會收不到響應(yīng)像卡死了一樣。起初我以為是我的消息超時(shí)設(shè)置導(dǎo)致的目標(biāo)進(jìn)程假死后來用Spy看了目標(biāo)進(jìn)程的消息隊(duì)列才發(fā)現(xiàn)它根本不在處理我們發(fā)過去的消息。原因很直接SendMessage發(fā)送的自定義窗口消息如果目標(biāo)窗口所在線程不進(jìn)入消息循環(huán)就永遠(yuǎn)不會被執(zhí)行到。而很多WPF程序在主窗口初始化期間是啟動(dòng)了模態(tài)循環(huán)的能處理系統(tǒng)消息但對我們這個(gè)注入窗口的消息沒有及時(shí)響應(yīng)。這種場景下沒有特別完美的解決方案只能提高容錯(cuò)性。我在工具端加了超時(shí)提示并且把請求拆成探測——拉取兩步先發(fā)一條輕量的探測消息如果目標(biāo)線程能在500毫秒內(nèi)回一個(gè)存活確認(rèn)再發(fā)真正的UI樹請求。這樣即使后面請求超時(shí)了你也知道目標(biāo)程序是活著還是卡死了不會再兩眼一抹黑。另外ManagedSpy的注入是雙向的工具端也能向目標(biāo)程序發(fā)送一些簡單的指令比如修改屬性值或觸發(fā)事件。這塊功能在我改的.NET 4.5版本里依然保留但對于綁定屬性直接改依賴屬性值往往會被下一次綁定推送覆蓋效果更像是在調(diào)試時(shí)臨時(shí)生效。如果你打算用這個(gè)做自動(dòng)化測試框架不建議依賴這種修改式交互用它來看比改穩(wěn)定得多。6. 改造完成后的實(shí)測效果與適用邊界改造完的.NET 4.5版本我在三個(gè)項(xiàng)目上做了實(shí)測一個(gè)基于.NET 4.5的WPF業(yè)務(wù)系統(tǒng)一個(gè)基于.NET 4.7.2、但被引用的一個(gè)核心庫仍然限制在4.5的老項(xiàng)目還有一個(gè)WinForms項(xiàng)目。前兩個(gè)場景都是完整體驗(yàn)UI樹加載、屬性查看、綁定診斷全部正常WinForms場景下可視化樹退化為控件列表但勝在不用裝額外工具也算可用。要注意的是ManagedSpy從未支持過大寬高比或虛擬化容器的深度遍歷。比如ListBox虛擬化后可視區(qū)域外的Item不會出現(xiàn)在可視化樹里因?yàn)閃PF懶加載機(jī)制壓根沒創(chuàng)建它們的可視對象。這個(gè)不是工具bug是WPF框架特性。排查虛擬化列表問題時(shí)先用ScrollIntoView把目標(biāo)項(xiàng)滾進(jìn)可視區(qū)域再刷新就能看到了。另外在.NET 4.5版本上DataTemplate內(nèi)部元素的屬性顯示會比舊版完整不少因?yàn)樾驴蚣芤肓烁鄡?nèi)置轉(zhuǎn)換器。但如果你自定義了DependencyProperty且沒有注冊屬性元數(shù)據(jù)那么屬性名會以長格式顯示別慌這是正常的——反射拿不到DisplayName就只能顯示CLR全名。如果你后續(xù)想再往上兼容我的建議是不要直接編譯到.NET Framework 4.8就完事因?yàn)榕f代碼里一部分SafeHandle用法在新版本運(yùn)行時(shí)已經(jīng)標(biāo)記為過時(shí)最好先跑一遍靜態(tài)分析。如果只是日常調(diào)試用4.5這個(gè)版本其實(shí)已經(jīng)非常能打了沒必要為了追新而引入額外風(fēng)險(xiǎn)。根據(jù)我個(gè)人的使用經(jīng)驗(yàn)這個(gè)改造版的最大價(jià)值并不是替代Snoop而是在那些Snoop裝不上、Spy又看不到托管信息的夾縫環(huán)境里給你一把精確的手術(shù)刀。平時(shí)可以不動(dòng)它但需要快速確認(rèn)一個(gè)綁定的Path到底是不是拼錯(cuò)了、某個(gè)元素到底在不在視覺樹上它比任何重武器都快。本文還有配套的精品資源點(diǎn)擊獲取