接了一個小雜毛專案,大概情形是這樣的:ZWT先生開的店是賣拆片機的,Z先生不僅賣機器,還貼心地提供一項服務:可以根據顧客需要修改兩個電機的轉向和轉速(機器廠家有給SDK的,但Z自己不會寫程式)。廠家有配套一個調節器,調整引數時連線到拆片機的串列埠上,然後旋轉按鈕可以調速,撥碼開關可以設定電機正轉還是反轉。但Z覺得調速器長得像個馬桶水箱似的,既不好看也不便攜帶。最慘的是,有的客戶的車間裡有很多臺機子,一臺一臺的連上串列埠調很費勁。於是,學過電工的Z不知道哪搞了一個串列埠 Hub,電腦向 Hub 發資料,然後會將資料群發給所有連線的拆片機(這種Hub忘了叫什麼,十幾年前老周曾在某工廠裡見過)。
Z先生經過 N 箇中間人介紹,找到老周。去Z的店參觀了一下,瞭解情況後。當時老周可能被蚊子叮昏了頭腦,覺得這不簡單嗎,便答應Z老闆說三天可以完成。不過,這做起來確實不復雜,基礎程式碼當天晚上用了兩個多小時就搞定了。串列埠發資料這事也不難,就一個 SerialPort 類就搞定的。協議方面有文件,一個位元組陣列,代入引數就行。每臺拆片機的編組號和引數就用一人 Sqlite 資料庫存放就完事,配上 EF Core 讀寫起來那簡直是入門級的難度。
不過,在第一次實測時遇到一個問題,每臺機器的引數是不同的,需要提供一個介面,讓使用者可以增/刪/改/查。也就是說,機器引數不能用類來封裝,靜態語言又不能動態生成屬性。當然,這也不是靜態語言的錯,而是思路的問題。我幹嗎非要用類封裝呢?不封裝不就更可以動態變化了嗎?然後,老周的生理 CPU 飛速運轉,眼前浮現著一張張熟悉的面孔—— 用字典來儲存,用JSON來做,用XML來做……避免產生過多不必要的資料檔案,老周決定在資料庫加個表,一個選項(引數)就是一條資料記錄。欄位如選項名稱,選項值,備註,新增時間,最後修改時間,新增人(誰操作的)等。
隨後,又有問題了,雖然在資料庫中我把選項(引數)值統一用文字來表示,但在使用者介面上,總不能全放個 TextBox 在那裡。最重要的是全用 TextBox 的話,驗證的程式碼就要寫很多了。比如:你輸入的是日期嗎?你輸入的是整數嗎?Bool 值的東西為什麼還要讓人填呢,直接CheckBox不好嗎?現在的情況是,機器引數是使用者動態新增的,在佈局介面時我不能寫死了,畢竟事先不能確定用下拉選單還是文字框。那就在執行時動態呈現控制元件吧。
思路有了,但方法有很多種。老周當初吹下了三天完工的牛皮,所以,建立新控制元件的思路可能更花時間,而且感覺也沒必要。經過短暫的計劃,老周的想法:
1、在編輯介面上用一個 ListBox 或 ItemsControl,這樣可以免去許多排版的功夫。就把介面當成一個列表框控制元件,每個被編輯的選項就是一個列表項。這樣新增和刪除也方便,直接 Binding 就好了。
2、為 ItemsControl 定義列表項模板,是 DataTemplate,不是控制元件模板。重做控制元件模板跟創作新控制元件一樣,費時間。
3、DataTemplate 中,一個 TextBlock 用來顯示專案標籤(不可修改的文字),然後值呢?
4、選項值用的控制元件是不固定的,事先不可知,老周就放了個 ContentControl 在那裡,就當作佔位符用吧。為什麼選它呢?因為它有 Content 屬性,可以放任何東西,而且配套一個 ContentTemplate 屬性,型別正是 DataTemplate。當然,也可以用類似功能的控制元件。
老周的想法就是,用 ContentTemplate 去引用其他的資料模板,當然資料模板也不能寫死的。即要定義多個模板,裡面放不同的控制元件,需要根據選項的需求選擇不同的模板。於是,有一個類可以用上——DataTemplateSelector。
這個類有一個虛方法叫 SelectTemplate,能夠根據相同資料運態返回資料模板。派生類只要重寫這個方法就 OK。
哦,補充一下,什麼是拆片機。大夥伴看看你身上穿的戰袍,是不是由一塊塊布組成?服裝廠先要依據圖紙,用選定的線織成一塊塊的“散片”,針織機有橫機和縱機,橫向的多見。以前是手動操作,現在是電腦控制。這些“散片”可交給縫盤工人(做縫盤的一般是女孩,以前那些手動織機的話,男女都有)“組裝”起來。下圖是常見的縫盤機(不是縫紉機,二者不同的),老周家裡還有三臺,23年前的型號,有兩臺還是全新未拆封的。
縫盤機的套口編號要與針織機的匹配使用,如果混用會出問題的,以前內銷毛衣常見的12針(這個簡單說就是代表線眼的疏密)一般會配用14號的縫盤機(當然也不是絕對,看工人經驗,有時候接近編號的也行)。
從上世紀 90 年代起,老周家裡做過十幾年的毛衣加工,現在是租給別人做,近20臺電腦橫機,全天24小時工作。所以,服裝方面的東西,老周還是懂一點點的。
拆片就是把編織好的衣片拆解,變回一筒一筒的毛線。這個原因就很複雜,可能針織時效果不好,可能拿錯圖紙了,可能多出來不用的……把衣片重新變回毛線,還可以重複用,不浪費。有些片可能被機器裡的潤滑油汙染,以前有的師傅懶得處理,是直接不要的(在農村,以前有些人家用來生火煮水煮飯),不過,這些毛料燃燒起來是環境汙染,而且味道讓人很難受。現在很少有人這麼幹。畢竟,別說燃氣灶,連電磁爐在農村都很普及了。直接生火做飯的很少,就算要生火,也會選一些天然的柴木。
拆片機結構很簡單,用夾子固定衣片,先人工抽出部分絲線,在橡皮輪上纏繞二三十圈,然後開動電機就行,工作起來有點像打毛機。拆片機現在並沒有標準的產品,都是個別有“本事”的工廠或者像Z先生這樣的,自己做的。
------------------------------------------------------------------------------------------------------------------------------------------------------------------
不得了,這次扯的廢話太長了。不能再說了,不然就真變成水文了。下面說重點。
步驟一:定義選項包裝類
哦,老週上面只是說,不用類來封裝選項資料,但每條選項記錄還是要封裝的。這個類與資料庫中的選項表對映。為了簡單,老周刪除了像備註、操作員、時間、父級選項等無關的屬性。
public class OptionItem : INotifyPropertyChanged { private string _labelName; private string _objValue; private bool _required; private UIElementType _uiType; private string _listItems; public int Id { get; set; } /// <summary> /// 選項的標籤文字 /// </summary> public string LabelName { get => _labelName; set { _labelName = value; OnMyPropertyChanged(); } } /// <summary> /// 選項的值 /// </summary> public string ObjectValue { get { return _objValue; } set { _objValue = value; OnMyPropertyChanged(); } } /// <summary> /// 是否為必填 /// </summary> public bool Required { get => _required; set { _required = value; OnMyPropertyChanged(); } } /// <summary> /// 介面控制元件型別 /// </summary> public UIElementType UIType { get => _uiType; set { _uiType = value; OnMyPropertyChanged(); } } /// <summary> /// 顯示在下拉選單中的內容,只有需要下拉選單控制元件時才用到 /// </summary> public string ListItems { get => _listItems; set { _listItems = value; OnMyPropertyChanged(); } } // 屬性更改後呼叫的方法 private void OnMyPropertyChanged([CallerMemberName] string propertyName = null) { if (string.IsNullOrEmpty(propertyName)) { return; } PropertyChanged?.Invoke(this, new(propertyName)); } // 屬性更改通知事件 public event PropertyChangedEventHandler PropertyChanged; }
實現 INotifyPropertyChanged 介面,這個不用多介紹,可以讓繫結物件能實時獲得更改通知。UIElementType 是一個列舉型別,表示要呈現什麼樣的控制元件,對映到資料庫表時用整型儲存。
public enum UIElementType { TextBox, // 普通文字框 NumberBoxInt, // 文字框,但輸入整數用 NumberBoxFloat, // 文字框,但輸入浮點數用 CheckBox, // 核取方塊 DropdownList, // 下拉選單框 DatePicker // 日期選擇器 }
這老周專案所需要用的控制元件,你可以根據實際新增成員。WPF的編輯控制元件其實很少,比 WinForms 還少。大概開發團隊覺得 WPF 的控制元件可以自己設計。雖然控制元件模板方便換,但做起來也要一定工作量的,反正嘛,非必要不創作控制元件。有大夥伴會問:怎麼不用開源的第三方控制元件?如果老周自己用的話,倒是可以的。可是專案一旦是商用的就……有些開源協議是很讓人無語的,而且不少專案在商用上要授權的。比如那個 Qt,到處告人,“一不小心就會一團糟”。所以咱們這邊做上位機、客戶端什麼的基本用 .NET,Qt 幾乎沒幾個人用,很多公司都不敢用,怕被找上門。
購買授權這種事不要問老周,因為老周也搞不懂。但老周大膽懷疑,這些王X蛋是不是故意的,想透過打官司來發家致富?其實大夥常用的手機 App 表面免費,實際上你如果有心情去閱讀一下使用者協議,你會冒出一身冷汗。處處給你設坑,你想不跳都不行。比如微信輸入法什麼的,你如果用它來寫小說,那小心,可能你的版權會變成別人的。還有一些不要臉的套殼 AI 服務,在你我不知情的前提下,把咱們的東西變成它們的資料、模型的一部分,產權歸誰?科學技術很強大,可有沒有質疑過,現在搞科技創新的初心是什麼?為了割矮腳白菜?還是為戰爭服務?還是……
不過,老周從 2010 年至今,接的很多專案 90% 是用 .NET 做的,至少目前沒出什麼授權上的問題,也沒收到過“綠屍寒”。微軟還是靠譜的。
為了在資料庫中能夠相容,表示選項值的 ObjectValue 屬性定義為字串型別,在資料庫也是用文字存放。因為選項值可能是浮點數和日期,但文字很強大,啥值它都能表示。LabelName 屬性是顯示在使用者介面上的,不能被編輯。Required 屬性表示此欄位值是不是必需的,如果是,會在標籤旁邊顯示一個星號(*)。在繫結時,用 BoolValueConverter 轉換器把 bool 值轉為控制元件的 Visibility 屬性值,這個轉換器是 .NET 類庫自有的,不用我們自己寫。
ListItems 屬性只有當控制元件是 ComboBox 時才用到,字串內容,每個項用逗號分隔,顯示在下拉選單中。由於它的值是一個字串,而 ComboBox 的 item source 是列表型別。需要寫一個轉換器,把字串分割成一個陣列。
public class StrToListConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { string list = (string)value; var splis = list.Split(',').Select(s => s.Trim()); return splis.ToArray(); } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { return null; } }
因為繫結是單向的,所以 ConvertBack 不需要,返回 null 了事。
同樣的道理,如果用的是 CheckBox 控制元件,需要 bool 和 string 之間的轉換。
public class BoolValueConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { // 由於資料的值都用字串表示,所以這裡可以直接轉成字串 string strValue = (string)value; if (bool.TryParse(strValue, out bool val)) { return val; } return false; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { // 轉回去的時候,都是字串 return value.ToString(); } }
由於 CheckBox 控制元件用於編輯,需要雙向繫結,所以 ConvertBack 方法要實現。
步驟二:從TextBox派生一個類
儘管原則上不創作新控制元件,但由於 TextBox 除輸入任意文字外,有些選項只能讓使用者輸入數字。有大夥伴會說,用 InputScope 不就行了嗎。還真的不行。input scope 只對觸控螢幕鍵盤有效,對於實體鍵盤無法限制。因此,為了方便,只好派生出一個類,然後重寫 OnPreviewTextInput 方法,把非數字字元排除。其實,處理 TextBox 的 PreviewTextInput 事件也可以的,不過派生一個類來處理,完整性更好,也方便直接把控制元件程式碼複製到別的專案使用。Preview開頭的事件是【隧道】事件,即從 XAML 樹的外層向內引發,而 TextInput 事件是冒泡式的,從內向外引發。鍵盤事件由 WM_KEY*** 相關訊息產生,作業系統會將其傳遞給視窗。視窗處理了再到裡面的控制元件處理。我們應該處理 Preview 事件,當輸入的字元不是數字時,不讓它向下傳播,這樣 TextBox 就不會接收這個字元了。
public class NumberTextBox : TextBox { public NumberTextBox() { TextNumberType = NumberType.Integer; // 禁用輸入法 InputMethod.SetIsInputMethodEnabled(this, false); // 設定輸入範圍是數字,這隻對觸控鍵盤有效 InputScope ipscope = new(); ipscope.Names.Add(new InputScopeName(InputScopeNameValue.Number)); this.InputScope = ipscope; } /// <summary> /// 獲取/設定數字型別 /// </summary> public NumberType TextNumberType { get; set; } protected override void OnPreviewTextInput(TextCompositionEventArgs e) { Debug.WriteLine($"Text={e.Text}, ControlText={e.ControlText}"); string pattern = TextNumberType == NumberType.Integer ? "[0-9]+" : "[0-9.]+"; var rgxres = Regex.Match(e.Text, pattern); if (!rgxres.Success) { e.Handled = true; return; } base.OnPreviewTextInput(e); } } public enum NumberType { /// <summary> /// 整數 /// </summary> Integer, /// <summary> /// 浮點數 /// </summary> Floating }
老周這裡就用正規表示式來匹配,實際上它只匹配一個字元。只要有字元輸入,TextInput 相關的事件就會引發,而關聯的文字只是剛剛輸入的那個字元,而不是 TextBox 中的所有字元。也就是說,這段程式碼只是限制,並不能驗證輸入的對不對。其實這程式碼還不嚴謹,如果使用者已經輸入過小數點了,也可能出現輸入 N 個小數點的問題。我們也可以最佳化一下。
protected override void OnPreviewTextInput(TextCompositionEventArgs e) { …… if (TextNumberType == NumberType.Floating && e.Text == ".") { // 看看他輸入了幾個小數點 TextBox tb = (TextBox)e.OriginalSource; int index = tb.Text.LastIndexOf('.'); // 索引如果有效,說明之前輸入過小數點 if(index > -1) { e.Handled= true; return; } } base.OnPreviewTextInput(e); }
e.Handled = true 會阻止事件向下傳播,就能達到阻止輸入某些字元的目的。注意,TextInput 相關事件引發時,一般我們只能獲取剛輸入的字元,要結合 TextBox 現有的文字來分析,就必須從 TextBox.Text 屬性獲取。上面程式碼是先判斷當前輸入的是浮點數,且當前輸入的字元是小數點,如果是,就從 TextBox 現有文字中查詢小數點。如果找到,說明之前輸入過,就不能再輸入了。
當然了,限制並不能代替數驗證功能。如果需要,還要進一步用程式碼檢查輸入的值是否有效。
步驟三:準備一堆資料模板
要用模板選擇器篩選要用的資料模板,首先得準備一系列待選模板,放進資源字典中。
<Window.Resources> <!--轉換器--> <BooleanToVisibilityConverter x:Key="btvCvt" /> <local:BoolValueConverter x:Key="boolValCvt"/> <local:StrToListConverter x:Key="strToListCvt"/> <!--下面幾個模板是用於選擇的--> <DataTemplate x:Key="selTempAnyTxt"> <TextBox Text="{Binding Path=ObjectValue, Mode=TwoWay}" InputMethod.IsInputMethodEnabled="False"/> </DataTemplate> <DataTemplate x:Key="selTempNumberInt"> <local:NumberTextBox Text="{Binding Mode=TwoWay, Path=ObjectValue}" TextNumberType="Integer"/> </DataTemplate> <DataTemplate x:Key="selTempNumberFloat"> <local:NumberTextBox Text="{Binding Mode=TwoWay, Path=ObjectValue}" TextNumberType="Floating"/> </DataTemplate> <DataTemplate x:Key="selTempList"> <ComboBox ItemsSource="{Binding Path=ListItems, Converter={StaticResource strToListCvt}}" Text="{Binding Path=ObjectValue, Mode=TwoWay}" IsReadOnly="True" IsEditable="True"/> </DataTemplate> <DataTemplate x:Key="selTempCheckBox"> <CheckBox IsChecked="{Binding Path=ObjectValue,Mode=TwoWay,Converter={StaticResource boolValCvt}}" Content=""/> </DataTemplate> <DataTemplate x:Key="selTempDatePicker"> <DatePicker Text="{Binding Path=ObjectValue, Mode=TwoWay}" SelectedDateFormat="Short"/> </DataTemplate> <!--END--> …… </Window.Resources>
要定義一堆模板,看著複雜,其實很簡單。
步驟四:編寫模板選擇器
public class MyTemplateSelector : DataTemplateSelector { public override DataTemplate SelectTemplate(object item, DependencyObject container) { OptionItem opt = (OptionItem)item; switch (opt.UIType) { case UIElementType.TextBox: return TextTemplate; case UIElementType.NumberBoxInt: return IntNumTemplate; case UIElementType.NumberBoxFloat: return FloatNumTemplate; case UIElementType.DropdownList: return DropListTemplate; case UIElementType.CheckBox: return CheckBoxTemplate; case UIElementType.DatePicker: return DatePickerTemplate; } return TextTemplate; } #region 公共屬性,用於引用模板 public DataTemplate TextTemplate { get; set; } public DataTemplate IntNumTemplate { get; set; } public DataTemplate FloatNumTemplate { get; set; } public DataTemplate CheckBoxTemplate { get; set#endregion }
注意那六大屬性,待會把選擇器放到XAML資源中,可以引用我們前面定義的那些模板。重寫 SelectTemplate 方法是核心。根據 UIType 屬性來判斷要用的模板。這是咱們前面定義選項類時用的列舉。
步驟五:套用
<!--模板選擇器--> <local:MyTemplateSelector x:Key="tempSelt" TextTemplate="{StaticResource selTempAnyTxt}" IntNumTemplate="{StaticResource selTempNumberInt}" FloatNumTemplate="{StaticResource selTempNumberFloat}" DropListTemplate="{StaticResource selTempList}" CheckBoxTemplate="{StaticResource selTempCheckBox}" DatePickerTemplate="{StaticResource selTempDatePicker}" /> <DataTemplate x:Key="itemTemp" DataType="local:OptionItem"> <Grid MinWidth="300" HorizontalAlignment="Center" Margin="0,5"> <Grid.ColumnDefinitions> <ColumnDefinition Width="100"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <StackPanel Orientation="Horizontal" Grid.Column="0" HorizontalAlignment="Right" Margin="0,0,5,0"> <TextBlock> <Run Text="{Binding Mode=TwoWay, Path=LabelName}"/> <Run Text=":"/> </TextBlock> <TextBlock Visibility="{Binding Required, Converter={StaticResource btvCvt}}" Foreground="Red" FontSize="16">*</TextBlock> </StackPanel> <ContentControl Grid.Column="1" Content="{Binding}" ContentTemplateSelector="{StaticResource tempSelt}"/> </Grid> </DataTemplate>
Key 為 itemTemp 的模板是給 ItemsControl 控制元件用的。而前面的那些模板只是給 ContentControl 用的。也就是說,ItemsControl 控制元件的列表項套用 itemTemp 模板,而 itemTemp 模板裡面的 ContentControl 控制元件又套用前面定義的模板(由模板選擇器決定)。
注意,繫結 Required 屬性的 TextBlock 只有一個“*”字元,當 Required 屬性為true時,該控制元件顯示星號,表示此欄位必填。
步驟六:介面佈局
在介面上放一個 ItemsControl 控制元件就能顯示各個選項欄位資訊了。
<Grid> <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <ScrollViewer Grid.Row="0"> <ItemsControl x:Name="itOpt" ItemTemplate="{StaticResource itemTemp}" /> </ScrollViewer> <TextBox Grid.Row="1" x:Name="txtDisp"/> </Grid>
txtDisp 用來每 3 秒顯示一次選項類列表,這個只是用來觀察繫結有沒有雙向更新,並無實際用途。
下面是程式碼部分。
public partial class MainWindow : Window { private ObservableCollection<OptionItem> _optionItems = new(); private DispatcherTimer timer = new DispatcherTimer(); public MainWindow() { InitializeComponent(); timer.Interval = TimeSpan.FromSeconds(3); timer.Tick += OnTimer; _optionItems.Add(new() { Id = 1, Required = true, LabelName = "banch", UIType = UIElementType.NumberBoxInt, ObjectValue = "3" }); _optionItems.Add(new() { Id = 2, LabelName = "factor", UIType = UIElementType.NumberBoxFloat, ObjectValue = "0.1", Required = false }); _optionItems.Add(new() { Id = 3, LabelName = "tcc1", ObjectValue = "s-i-2", Required = true, UIType = UIElementType.TextBox }); _optionItems.Add(new() { Id = 4, LabelName = "tcc2", ObjectValue = "k9", Required = true, UIType = UIElementType.TextBox }); _optionItems.Add(new() { Id = 5, LabelName = "wt_mode", ObjectValue = "nor", Required = false, UIType = UIElementType.DropdownList,// 下拉選單 ListItems = "cls,nor,slw,wdw" }); _optionItems.Add(new() { Id = 6, LabelName = "flash", UIType = UIElementType.CheckBox, ObjectValue = "True", Required = false }); _optionItems.Add(new() { Id = 7, LabelName = "size", ObjectValue = "8", UIType = UIElementType.NumberBoxInt, Required = false }); _optionItems.Add(new() { Id = 8, LabelName = "reg_0xD3", Required = false, ObjectValue = "29", UIType = UIElementType.NumberBoxInt }); // 繫結 itOpt.ItemsSource = _optionItems; timer.Start(); } private void OnTimer(object sender, EventArgs e) { StringBuilder bd = new StringBuilder(); foreach (OptionItem oi in _optionItems) { bd.AppendLine($"{oi.LabelName} = {oi.ObjectValue}"); } txtDisp.Text = bd.ToString(); } }
DispatchTimer 只是用來週期性更新顯示,沒其他用途。
執行程式後,你會發現。程式會根據各個選項的設定,生成編輯欄位。
是不是很好用呢?現在,把 factor 欄位改為 1.5,等3秒鐘後,看看有沒有更改。
已經更新了。
再試試,把 flash 的 CheckBox 中的勾去掉,看會不會變成 false。
好了,已經有效果了,這樣一來,使用者愛新增啥引數就隨他的便。