1. 程式人生 > 其它 >.NET6: 開發基於WPF的摩登三維工業軟體 (8) - MVVM

.NET6: 開發基於WPF的摩登三維工業軟體 (8) - MVVM

基於WPF開發介面的一個很大優勢是可以方便地基於MVVM設計模式開發應用。本文從應用的角度基於MVVM實現引數化管材的建立介面。

1 MVVM

MVVM是Model-View-ViewModel的簡寫,即模型-檢視-檢視模型。網上有若干對MVVM的介紹,本文在此不做過多的贅述,本文將從具體的是應用案例讓大家來體會MVVM的優勢,即實現UI部分的程式碼與核心業務邏輯、資料模型分離,達到高耦合低內聚的軟體架構目標。

來自網上的截圖

2 介面設計

我們希望開啟一個對話方塊,在其中可以顯示管材模型;修改管材的引數能夠實時看到管材形狀的變化。如下圖所示:

其中管子的外徑由管子的內徑加上管子壁厚,不需要使用者輸入。
當然也可以實現使用者修改外徑,減掉管壁來得到內徑。這個可以根據業務需要來調整。

3 程式設計

基於MVVM設計模式,我們實現這樣的類設計:

其中:

  • AddSectionBarDlg

基於XAML實現的UI佈局相關程式碼,即View層;

  • SectionBarVM

實現ViewModel層,即View和Model的橋樑,業務邏輯檢查,比如半徑不能小於0,壁厚不能小於0等。

  • ShapeElement

基於AnyCAD的資料儲存類ShapeElement實現Model層。

4 程式實現

我們採用自底向上的實現順序,逐步實現Model、ViewModel和View。

4.1 Model實現

由於是基於AnyCAD內建的元件,可以直接略過。

ShapeElement 可以用來儲存TopoShape物件外,可以儲存使用者

自定義的引數。比如管材的長度、內徑、厚度等。重點關注以下方法:

//設定引數
void SetParameter (String name, ParameterValue val);
//查詢引數
ParameterValue 	FindParameter (String name);

4.2 ViewModel實現

4.2.1 更新介面的能力

SectionBarVM從INotifyPropertyChanged繼承,獲得PropertyChanged的能力,即通知View層說:
“嗨,兄弟,該更新介面啦!"

//SectionBarVM.cs
    public class SectionBarVM : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler? PropertyChanged;
        public void OnPropertyChanged(string e)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(e));
        }
        ...
    }

4.2.2 更新資料能力

基於屬性機制實現。當外部更新,會呼叫屬性set方法的時候,對資料進行合法檢查。
若符合要求,更新Model,並呼叫OnPropertyChanged發起通知。

//SectionBarVM.cs
        private ShapeElement mModel;

        public SectionBarVM(ShapeElement model)
        {
            mModel = model;
        }

        public static string NAME = "Name";
        public string Name {
            get { return mModel.GetName(); }
            set { 
                if(value != "")
                {
                    mModel.SetName(value); 
                    OnPropertyChanged(NAME);
                }
                else
                {
                    throw new ArgumentException("名稱不能為空。");
                }
            }
        }

尺寸引數屬性實現:

//SectionBarVM.cs
        public static string INNER_RADIUS = "InnerRadius";
        public static string THICKNESS = "Thickness";
        public static string LENGTH = "Length";
        public static string OUTTER_RADIUS = "OutterRadius";

         public double InnerRadius { 
            get { return ParameterCast.Cast(mModel.FindParameter(INNER_RADIUS), 100.0); }
            set {
                if (value > 0)
                {
                    mModel.SetParameter(INNER_RADIUS, ParameterCreator.Create(value));
                    OnPropertyChanged(INNER_RADIUS);
                    OnPropertyChanged(OUTTER_RADIUS);
                }
                else
                {
                    throw new ArgumentException("半徑太小。");
                }
            } 
        }
        public double Thickness { 
            get { return ParameterCast.Cast(mModel.FindParameter(THICKNESS), 5.0);  }
            set { 
                if (value > 0)
                {
                    mModel.SetParameter(THICKNESS, ParameterCreator.Create(value));
                    OnPropertyChanged(THICKNESS);
                    OnPropertyChanged(OUTTER_RADIUS);
                }
                else
                {
                    throw new ArgumentException("厚度太小。");
                }
            } 
        }

        public double OutterRadius
        {
            get { return InnerRadius + Thickness; }
        }
        public double Length { 
            get { return ParameterCast.Cast(mModel.FindParameter(LENGTH), 1000.0);  }
            set { 
                if (value > 0)
                {
                    mModel.SetParameter(LENGTH, ParameterCreator.Create(value));
                    OnPropertyChanged(LENGTH);
                }
                else
                {
                    throw new ArgumentException("長度太小。");
                }
            } 
        }

這裡需要注意的是OutterRadius的實現。由於OutterRadius依賴了InnerRadius和Thickness屬性,當被依賴的屬性修改後,也需要觸發依賴屬性的訊息。否則介面OutterRadius的值不會再更新。

4.3 View實現

4.3.1 介面佈局

增加一個視窗AddSectionBarDlg.xaml,按照設計要求進行佈局。

  • 資料雙向繫結

Path="InnerRadius"將會跟SectionBarVM的InnerRadius繫結。當UI修改的時候會呼叫InnerRadius set; 當介面初始化和資料更新的時候,UI會呼叫InnerRadius get。

    <TextBox Width="150">
        <Binding Path="InnerRadius">
            <Binding.ValidationRules>
                <ExceptionValidationRule/>
            </Binding.ValidationRules>
        </Binding>
    </TextBox>
  • 資料單向繫結

Mode="OneWay" 表示UI只會從ViewModel獲取資料。

    <TextBox Width="150" IsEnabled="False">
        <Binding Path="OutterRadius" Mode="OneWay">
        </Binding>
    </TextBox>

XAML完整程式碼:

//AddSectionBarDlg.xaml
<Window x:Class="Rapid.Sketch.Plugin.UI.AddSectionBarDlg"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Rapid.Sketch.Plugin.UI"
        xmlns:anycad="clr-namespace:AnyCAD.WPF;assembly=AnyCAD.WPF.NET6"
        mc:Ignorable="d"
        Title="建立型材" Height="450" Width="650" ResizeMode="NoResize" Icon="/Rapid.Common.Res;component/Image/SectionBar.png">
    <Grid Margin="7">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="400"></ColumnDefinition>
            <ColumnDefinition Width="Auto"></ColumnDefinition>
        </Grid.ColumnDefinitions>
        
        <anycad:RenderControl Name="mView3d"  Grid.Column="0" ViewerReady="MView3d_ViewerReady"/>

        <Grid Grid.Column="1" Margin="7">
            <Grid.RowDefinitions>
                <RowDefinition Height="360"></RowDefinition>
                <RowDefinition Height="28"></RowDefinition>
            </Grid.RowDefinitions>
       
            <StackPanel Grid.Row="0">
                <StackPanel Orientation="Horizontal">
                    <Label Width="60" Content="名稱:"></Label>
                    <TextBox Width="150">
                        <Binding Path="Name">
                        </Binding>
                    </TextBox>
                </StackPanel>
                <StackPanel Orientation="Horizontal" Margin="0,7,0,0">
                    <Label Width="60" Content="內徑:"></Label>
                    <TextBox Width="150">
                        <Binding Path="InnerRadius">
                            <Binding.ValidationRules>
                                <ExceptionValidationRule/>
                            </Binding.ValidationRules>
                        </Binding>
                    </TextBox>
                </StackPanel>
                <StackPanel Orientation="Horizontal" Margin="0,7,0,0">
                    <Label Width="60" Content="厚度:"></Label>
                    <TextBox Width="150">
                        <Binding Path="Thickness">
                            <Binding.ValidationRules>
                                <ExceptionValidationRule/>
                            </Binding.ValidationRules>
                        </Binding>
                    </TextBox>
                </StackPanel>
                <StackPanel Orientation="Horizontal" Margin="0,7,0,0">
                    <Label Width="60" Content="外徑:"></Label>
                    <TextBox Width="150" IsEnabled="False">
                        <Binding Path="OutterRadius" Mode="OneWay">
                        </Binding>
                    </TextBox>
                </StackPanel>                
                <StackPanel Orientation="Horizontal" Margin="0,7,0,0">
                    <Label Width="60" Content="長度:"></Label>
                    <TextBox Width="150">
                        <Binding Path="Length">
                            <Binding.ValidationRules>
                                <ExceptionValidationRule/>
                            </Binding.ValidationRules>
                        </Binding>
                    </TextBox>
                </StackPanel>
            </StackPanel>
            <StackPanel Orientation="Horizontal" Grid.Row="1" HorizontalAlignment="Right" Margin="0,0,0,7">
                <Button Content="取消" Width="60" Margin="7,0,7,0"></Button>
                <Button Content="確定" Width="60" Margin="7,0,7,0"></Button>
            </StackPanel>
        </Grid>
    </Grid>
</Window>

4.3.2 View與ViewModel繫結

把ViewModel物件設定給Window的DataContext屬性,即可實現UI與ViewModel的關聯。

另外我們希望更改資料後也能更新三維視窗,在這裡我們先用比較笨的辦法實現,即硬編碼實現引數與三維模型的聯動。詳見SbVM_PropertyChanged方法的實現。

    /// <summary>
    /// AddSectionBarDlg.xaml 的互動邏輯
    /// </summary>
    public partial class AddSectionBarDlg : Window
    {
        SectionBarVM m_Bar;
        public AddSectionBarDlg(SectionBarVM sbVM)
        {
            InitializeComponent();
            this.Owner = App.Current.MainWindow;
            this.DataContext = sbVM;

            sbVM.PropertyChanged += SbVM_PropertyChanged;

            m_Bar = sbVM;
        }

        private void SbVM_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
           if(e.PropertyName == SectionBarVM.THICKNESS ||
                e.PropertyName == SectionBarVM.INNER_RADIUS ||
                e.PropertyName == SectionBarVM.LENGTH)
            {
                mView3d.View3D.ClearAll();

                var shape = m_Bar.CreateShape();
                mView3d.ShowShape(shape, ColorTable.LightGrey);

                mView3d.View3D.ZoomAll(1.6f);
            }
        }

        private void MView3d_ViewerReady()
        {
            mView3d.View3D.SetBackgroundColor(30.0f / 255, 30.0f / 255, 30.0f / 255, 0);

            var shape = m_Bar.CreateShape();
            mView3d.ShowShape(shape, ColorTable.LightGrey);
            mView3d.View3D.ZoomAll(1.6f);
        }
    }

5 功能整合

暫時在草圖專案中增加一個按鈕,可以呼叫對話方塊:

    <Fluent:RibbonGroupBox Header="型材" IsLauncherVisible="False" Margin="7,0,0,0">
        <Fluent:Button Header="管材" Icon="/Rapid.Common.Res;component/Image/SectionBar.png" Size="Large" Command="{x:Static local:SketchRibbonTab.ExecuteCommand}"
                                   CommandParameter="pipeTube" Margin="0,0,7,0"/>
    </Fluent:RibbonGroupBox>

    case "pipeTube":
        {
            //臨時建立一個物件
            var se = new ShapeElement();
            se.SetName("管子");
            var dlg = new AddSectionBarDlg(new SectionBarVM(se));
            dlg.ShowDialog();
        }

執行效果:

6 總結

從實現程式碼的結構來看,使用MVVM設計模式確實可以讓程式碼層次更清楚,介面類不再臃腫不堪。Microsoft設計XAML之初的一個目標是希望做UI佈局的UX與寫程式碼邏輯的開發能夠分工協作,甚至為此開發了獨立的設計工具Blend給UX使用,以讓開發能夠直接重用UX實現的XAML……
雖然現實並沒有想象的那麼美好,但基於MVVM模式確實可以實現介面佈局和核心業務邏輯分離,甚至把不同層的功能分給不同水平的程式設計師來實現。