在我們視窗新增、編輯狀態下的時候,我們往往會根據是否修改過的痕跡-也就是臟數據狀態進行跟蹤,如果用戶發生了數據修改,我們在用戶退出視窗的時候,提供用戶是否丟棄修改還是繼續編輯,這樣在一些重要錄入時的時候,可以避免用戶不小心關掉視窗,導致視窗的數據要重新錄入的尷尬場景。本篇隨筆介紹基於WPF開發中,窗... ...
在我們視窗新增、編輯狀態下的時候,我們往往會根據是否修改過的痕跡-也就是臟數據狀態進行跟蹤,如果用戶發生了數據修改,我們在用戶退出視窗的時候,提供用戶是否丟棄修改還是繼續編輯,這樣在一些重要錄入時的時候,可以避免用戶不小心關掉視窗,導致視窗的數據要重新錄入的尷尬場景。本篇隨筆介紹基於WPF開發中,視窗控制項臟數據狀態IsDirty的跟蹤處理操作。
1、WPF的Page頁面、Window視窗對象和視圖模型
MVVM是Model-View-ViewModel的簡寫。類似於目前比較流行的MVC、MVP設計模式,主要目的是為了分離視圖(View)和模型(Model)的耦合。
對於MVVM應用中,MVVM其中包括Model、View、ViewModel三者內容。其中Page或者Window對象,都是屬於視圖View的概念。由於目前我們程式框架大多數情況下採用IOC的控制反轉方式來調用,因此對象和介面的註入是程式開始的重要工作。
.net 中 負責依賴註入和控制反轉的核心組件有兩個:IServiceCollection和IServiceProvider。其中,IServiceCollection負責註冊,IServiceProvider負責提供實例。在註冊介面和類時,IServiceCollection
提供了三種註冊方法,如下所示:
1、services.AddTransient<IDictDataService, DictDataService>(); // 瞬時生命周期 2、services.AddScoped<IDictDataService, DictDataService>(); // 域生命周期 3、services.AddSingleton<IDictDataService, DictDataService>(); // 全局單例生命周期
如果使用AddTransient
方法註冊,IServiceProvider
每次都會通過GetService
方法創建一個新的實例;
如果使用AddScoped
方法註冊, 在同一個域(Scope
)內,IServiceProvider
每次都會通過GetService
方法調用同一個實例,可以理解為在局部實現了單例模式;
如果使用AddSingleton
方法註冊, 在整個應用程式生命周期內,IServiceProvider
只會創建一個實例。
瞭解了這幾個不同的註入方式,有助於我們瞭解WPF的整個註入對象的生命周期,對於頁面來說,由於採用了導航的方式,我們在註入的時候,採用單例的方式,對於彈出的編輯、新增、導入、批量處理的這種常規視圖,我們採用用完就丟棄的AddTransient方式,而視圖模型為了方便,我們也採用單件的方式構建即可。
我們在WPF程式入口的程式代碼App.xaml.cs中註入相關的對象信息。
登錄視窗和主視窗,採用單件註入方式,如下代碼所示。
// 程式導航主窗體及視圖模型 services.AddSingleton<INavigationWindow, MainWindow>(); services.AddSingleton<ViewModels.MainWindowViewModel>(); //登錄視窗 services.AddSingleton<LoginView, LoginView>();
而對於我們程式的視圖或者視圖模型對象來做,我們不可能一一按名字插入,應該通過一種動態的方式來批量處理,也就是根據各自的介面類型/繼承基類來處理即可。
#region 加入視圖頁面和視圖模型 //使用動態方式加入 var types = System.Reflection.Assembly.GetExecutingAssembly().DefinedTypes.Select(type => type.AsType()); //視圖頁面對象 typeof(Page),使用單件模式,每次請求都是一樣的頁面 var viewPageBaseType = typeof(Page); var pageClasses = types.Where(x => x != viewPageBaseType && viewPageBaseType.IsAssignableFrom(x)) .Where(x => x.IsClass && !x.IsAbstract).ToList(); foreach (var page in pageClasses) { services.AddSingleton(page); } //視圖模型對象 typeof(ObservableObject) var viewModelBaseType = typeof(ObservableObject); var viewModels = types.Where(x => x != viewModelBaseType && viewModelBaseType.IsAssignableFrom(x)) .Where(x => x.IsClass && !x.IsAbstract).ToList(); foreach (var view in viewModels) { services.AddSingleton(view); } //視窗對象,使用 Transient 模式,每次請求都是一個新的窗體 var windowBaseType = typeof(Window); var windowClasses = types.Where(x => x != windowBaseType && windowBaseType.IsAssignableFrom(x)) .Where(x => x.IsClass && !x.IsAbstract).ToList(); foreach (var window in windowClasses) { services.AddTransient(window); } #endregion
以上就是普通列表頁面Page類型、視圖模型ViewModel、彈出式視窗的幾種註入的方式,通過介面判斷和基類判斷的方式,自動註入相關的對象。
2、對視窗控制項的修改進行跟蹤
瞭解了上面幾種對象的註入方式,我們來進一步瞭解彈出式視窗對象Window的控制項的修改狀態跟蹤。
由於WPF不能像Winform那種,通過父對象的Controls集合就可以遍歷出來所有的對象,然後進行一一判斷,而WPF對象沒有這個屬性,因此也就無法直接的對控制項的修改狀態進行跟蹤。
那是不是沒有辦法對視窗下麵的控制項進行一一判斷了呢?肯定不是,辦法還是有的,就是通過內置輔助類LogicalTreeHelper,或者VisualTreeHelper的方式,由於前者是所有視窗或者頁面的邏輯控制項都會跟蹤到,後者VisualTreeHelper只是對可視化的控制項進行跟蹤,因此我們這裡選擇LogicalTreeHelper來對控制項進行遍歷處理。
實現的效果,就是對應視窗編輯內容發生變化,如果用戶退出,提示用戶即可,如下界面效果所示。
由於視窗元素都是繼承自Visual這個wpf的基類,而這個基類又是繼承於DependencyObject,如下代碼所示。
public abstract class Visual : DependencyObject
而輔助類,可以通過GetChildren的方法獲取對應的控制項列表,介面如下所示。
IEnumerable GetChildren(DependencyObject current)
稍微封裝下對控制項的遞歸遍歷處理,代碼如下所示。
/// <summary> /// 使用輔助類對視窗控制項進行遍歷處理 /// </summary> /// <typeparam name="T">控制項類型</typeparam> /// <param name="depObj">父對象</param> /// <returns></returns> public static IEnumerable<T> FindLogicalChildren<T>(DependencyObject depObj) where T : DependencyObject { if (depObj != null) { foreach (object rawChild in LogicalTreeHelper.GetChildren(depObj)) { if (rawChild is DependencyObject) { DependencyObject child = (DependencyObject)rawChild; if (child is T) { yield return (T)child; } foreach (T childOfChild in FindLogicalChildren<T>(child)) { yield return childOfChild; } } } } }
這樣我們如果需要獲取父控制項類下麵所有的TextBox控制項列表,只需要如下操作即可。
//文本控制項 var texboxList = FindLogicalChildren<TextBoxBase>(depObj); foreach (var textbox in texboxList) { if (textbox != null && !textbox.IsReadOnly) { textbox.TextChanged += (s, e) => { MainModelHelper.SetIsDirty(true); };//文本變化觸發 } }
其他控制項也是類似的方式處理,例如對於CheckBox和RadioButton,可以對它們共同的基類進行一併處理,如下所示。
//ToggleButton,包含CheckBox、RadioButton var buttonList = FindLogicalChildren<ToggleButton>(depObj); foreach (var toggle in buttonList) { if (toggle != null && toggle.IsEnabled) { toggle.Checked += (s, e) => { MainModelHelper.SetIsDirty(true); };//選擇變化觸發 toggle.Unchecked += (s, e) => { MainModelHelper.SetIsDirty(true); };//選擇變化觸發 } }
這樣我們把它放到一個靜態的輔助類裡面方便使用,如下所示。
/// <summary> /// 對WPF控制項的相關處理,包括遍歷查找等 /// </summary> public static class ControlHelper { /// <summary> /// 對界面的控制項遍歷,並監測狀態變化 /// </summary> /// <param name="depObj">父節點控制項</param> public static void SetDirtyEvent(DependencyObject depObj) { //如果全局禁用,則不跟蹤臟數據狀態 if (App.ViewModel!.DisableDirtyMessage) return; #region 對控制項類型進行監控 //文本控制項 var texboxList = FindLogicalChildren<TextBoxBase>(depObj); foreach (var textbox in texboxList) { if (textbox != null && !textbox.IsReadOnly) { textbox.TextChanged += (s, e) => { MainModelHelper.SetIsDirty(true); };//文本變化觸發 } } //下拉列表 var selectorList = FindLogicalChildren<Selector>(depObj); foreach (var selector in selectorList) { var selectorType = selector.GetType(); if (selector != null && selector.IsEnabled && selectorType != typeof(TabControl) && selectorType != typeof(ListBox)) //排除TabControl和ListBox選擇觸發 { selector.SelectionChanged += (s, e) => { MainModelHelper.SetIsDirty(true); };//選擇變化觸發 } } //ToggleButton,包含CheckBox、RadioButton var buttonList = FindLogicalChildren<ToggleButton>(depObj); foreach (var toggle in buttonList) { if (toggle != null && toggle.IsEnabled) { toggle.Checked += (s, e) => { MainModelHelper.SetIsDirty(true); };//選擇變化觸發 toggle.Unchecked += (s, e) => { MainModelHelper.SetIsDirty(true); };//選擇變化觸發 } } #endregion }
因此,對於視窗的控制項的編輯狀態跟蹤,我們可以在視窗的Loaded或者ContentRendered中實現跟蹤即可,我這裡實現覆蓋OnContentRendered的方式,來對視窗控制項的統一跟蹤。
/// <summary> /// 該事件在loaded之後執行,也是在所有元素渲染結束之後執行 /// </summary> protected override void OnContentRendered(EventArgs e) { base.OnContentRendered(e);//初始化後預設臟數據狀態為false App.ViewModel!.IsDirty = false; //對控制項變化進行跟蹤, 遍歷父級及子級節點 ControlHelper.SetDirtyEvent(this); //如果修改內容,對退出視窗進行確認 this.Closing += (s, e) => { var isDisableDirty = App.ViewModel!.DisableDirtyMessage; //是否禁用了臟數據提示 var isDirty = App.ViewModel!.IsDirty; if (!isDisableDirty && isDirty) { //數據已臟,提示確認 if (MessageDxUtil.ShowYesNoAndWarning("界面控制項數據已編輯過,是否確認丟棄並關閉視窗") != System.Windows.MessageBoxResult.Yes) { e.Cancel = true; } else { App.ViewModel!.IsDirty = false;//取消臟數據狀態 GrowlUtil.ClearTips(); //清空提示 } } }; }
但是每個編輯視窗這樣做,肯定是代碼冗餘的,我們優化一下,把邏輯抽取到一個獨立的輔助類裡面處理,這裡改進後代碼如下所示。
/// <summary> /// 該事件在loaded之後執行,也是在所有元素渲染結束之後執行 /// </summary> protected override void OnContentRendered(EventArgs e) { base.OnContentRendered(e); //在視窗準備完成後,對控制項的內容變化進行監控,如修改過則退出確認 ControlHelper.SetDirtyWindow(this); }
這樣在頁面關閉的時候,我們提示用戶即可。
當然,我們在主視窗視圖模型裡面也是設置了一個總開關,不需要的時候,關閉它即可。
App.ViewModel!.DisableDirtyMessage
我們在上面的代碼邏輯中也可以看到,我們如果確認丟棄修改內容,那麼狀態重置,並清空一些提示信息即可。
App.ViewModel!.IsDirty = false;//取消臟數據狀態 GrowlUtil.ClearTips(); //清空提示
我們前面說到視窗對象的註入都是以Transient的方式,視窗的打開都是以每次構建新對象的方式,視圖模型則是共用方式,因此,我們打開視窗的操作如下所示。
/// <summary> /// 批量添加頁面處理 /// </summary> [RelayCommand] private void BatchAdd() { if (this.ViewModel.SelectDictType == null) { GrowlUtil.ShowInfo("請選擇大類再添加項目"); return; } //獲取新增、編輯頁面介面 var page = App.GetService<BatchAddDictDataPage>(); page!.ViewModel.DictType = this.ViewModel.SelectDictType; page!.ViewModel.Item = new BatchAddDictDataDto(); //模態對話框打開 page.ShowDialog(); }
通過GetService<T>的方式獲取新的一個視窗對象,並賦值對應的視圖模型即可,然後打開模態對話框界面。
如果用戶關閉,則會丟棄該對象,下次請求就是一個新的視窗實例了。
另外,我們視窗還可以配置快捷鍵ESC來關閉視窗,等同於按下關閉按鈕的處理。我們在頁面的Xaml文件中增加按鍵的綁定事件即可,如下代碼所示。
<Window.InputBindings> <KeyBinding Key="Esc" Command="{Binding BackCommand}" Modifiers="" /> </Window.InputBindings>
其中的BackCommand就是我們預設頁面關閉的方法。
/// <summary> /// 關閉窗體 /// </summary> [RelayCommand] private void Back() { this.Close(); }
對於通用的導入視窗,我們也是一樣的處理方式,我們通過一些事件的定義,把一些實現邏輯放在調用類上實現也可以的。
/// <summary> /// 導出內容到Excel /// </summary> [RelayCommand] private void ImportExcel() { var page = App.GetService<ImportExcelData>(); page!.ViewModel.Items?.Clear(); page!.ViewModel.TemplateFile = $"系統用戶信息-模板.xls"; page!.OnDataSave -= ExcelData_OnDataSave; page!.OnDataSave += ExcelData_OnDataSave; //模態對話框打開 page.ShowDialog(); }
例如上面紅色部分的事件,我們就是放在調用類中差異化處理。
這樣就可以差異化不同的內容,同時保留通用模塊的靈活性,導入界面如下所示。
專註於代碼生成工具、.Net/.NetCore 框架架構及軟體開發,以及各種Vue.js的前端技術應用。著有Winform開發框架/混合式開發框架、微信開發框架、Bootstrap開發框架、ABP開發框架、SqlSugar開發框架等框架產品。
轉載請註明出處:撰寫人:伍華聰 http://www.iqidi.com