4.19.2017

Winform、WPF共用ViewModel

本文介紹在WPF和WinForms中使用ReactiveUI來共用ViewModel。ViewModel來自ReactiveUI說明文件中的LoginViewModel,但加上了ReactiveUI.Fody的使用。

一般來說,你找得到的大多數.Net MVVM Framework都是基於XAML環境的,以前剛看到ReactiveUI時就想測試它的共用性,如下所示。

下列程式碼基本上展示了在WinForm和WPF程式中,共用同一個ViewModel的方式,View主要是呈現一個可選用Guest或是User登入系統的畫面。

ViewModel

//[InjectValidation]
public class LoginViewModel : ReactiveObject
{
    [Reactive]
    public string User { get; set; }

    [Reactive]
    public string Password { get; set; }

    [Reactive]
    public bool IsUserLogin { get; set; }

    public ReactiveCommand<Unit, Unit> LoginCommand { get; private set; }
    public ReactiveCommand<Unit, Unit> ResetCommand { get; private set; }

    //private readonly LoginViewModelValidator _viewModelValidator;

    public LoginViewModel()
    {
        //_viewModelValidator = new LoginViewModelValidator();

        // assume a user is going to login
        IsUserLogin = true;

        // 可登入條件,當使用者及密碼皆有輸入,且密碼長度大於3,或是Guest時可登入系統
        var canLogin = this.WhenAny(
            vm => vm.User,
            vm => vm.Password,
            vm => vm.IsUserLogin,
            (user, pass, isUser) =>
                !string.IsNullOrWhiteSpace(user.Value) &&
                !string.IsNullOrWhiteSpace(pass.Value) &&
                (pass.Value.Length > 3) ||
                !isUser.Value);
        // 設定登入命令來源及其條件
        LoginCommand = ReactiveCommand.CreateFromObservable(this.LoginAsync, canLogin);

        // 當使用者或密碼欄位不為空時,可清除
        var canReset = this.WhenAny(
            vm => vm.User,
            vm => vm.Password,
            (user, pass) =>
                (!string.IsNullOrWhiteSpace(user.Value) || !string.IsNullOrWhiteSpace(pass.Value))
            );
        // 設定清除命令動作及其條件
        ResetCommand = ReactiveCommand.Create(() =>
        {
            User = string.Empty;
            Password = string.Empty;
        }, canReset);
    }

    // 模擬登入動作
    private IObservable<Unit> LoginAsync() =>
        Observable
            .Return(new Random().Next(0, 2) == 1)
            .Delay(TimeSpan.FromSeconds(1))
            .Do(
                success =>
                {
                    if (!success)
                    {
                        Debug.WriteLine("Failed to login");
                        //MessageBox.Show("Failed to login");
                        //throw new InvalidOperationException("Failed to login.");
                    }
                }
            )
            .Select(_ => Unit.Default);
}

Views

XAML

<Window x:Class="WpfApp1.MainWindow"
        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:vm="clr-namespace:SharedViewModel;assembly=SharedViewModel"
        xmlns:local="clr-namespace:WpfApp1"
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
        mc:Ignorable="d"
        WindowStartupLocation="CenterScreen"
        Title="{x:Static local:MainWindow.WindowTitle}" Height="250" Width="450">

    <Window.DataContext>
        <vm:LoginViewModel/>
    </Window.DataContext>

    <Window.Resources>
        <local:UserTypeConverter x:Key="UserTypeConverter"/>
        <Style TargetType="{x:Type Control}" x:Key="baseStyle">
            <Setter Property="FontSize" Value="20" />
        </Style>
        <Style TargetType="{x:Type Button}" BasedOn="{StaticResource baseStyle}"></Style>
        <Style TargetType="{x:Type Label}" BasedOn="{StaticResource baseStyle}"></Style>
        <Style TargetType="{x:Type TextBox}" BasedOn="{StaticResource baseStyle}"></Style>
        <Style TargetType="{x:Type ListView}" BasedOn="{StaticResource baseStyle}"></Style>
        <Style TargetType="{x:Type RadioButton}" BasedOn="{StaticResource baseStyle}"></Style>
    </Window.Resources>

    <Border Padding="10">
        <StackPanel>
            <StackPanel.Resources>
                <DataTemplate DataType="{x:Type ValidationError}">
                    <TextBlock FontStyle="Italic" Foreground="Red" HorizontalAlignment="Right" Margin="4" Text="{Binding Path=ErrorContent}" />
                </DataTemplate>
            </StackPanel.Resources>

            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <RadioButton Grid.Column="0" HorizontalAlignment="Right" GroupName="UserType" 
                         Content="Guest"
                         Margin="0 0 20 0"
                         IsChecked="{Binding IsUserLogin,  
                                     Converter={StaticResource UserTypeConverter}}"/>
                <RadioButton Grid.Column="1" HorizontalAlignment="Left" GroupName="UserType" 
                         Content="Member"
                         Margin="10 0 0 0"
                         IsChecked="{Binding IsUserLogin}"/>
            </Grid>

            <Grid Margin="0 10 0 0">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <TextBlock Text="Account:" FontSize="18" VerticalAlignment="Center" Grid.Column="0" Margin="0 0 10 0" HorizontalAlignment="Center"/>
                <TextBox IsEnabled="{Binding IsUserLogin}" Text="{Binding User, UpdateSourceTrigger=LostFocus, ValidatesOnNotifyDataErrors=True, NotifyOnValidationError=True}" Grid.Column="1" Grid.ColumnSpan="2" Margin="0 0 20 0">
                    <Validation.ErrorTemplate>
                        <ControlTemplate>
                            <StackPanel Orientation="Horizontal">
                                <AdornedElementPlaceholder x:Name="textBox"/>
                                <TextBox Margin="5" Text="{Binding [0].ErrorContent}" Foreground="Red"></TextBox>
                            </StackPanel>
                        </ControlTemplate>
                    </Validation.ErrorTemplate>
                </TextBox>
            </Grid>

            <Grid Margin="0 10 0 0">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <TextBlock Text="Password:" FontSize="18" VerticalAlignment="Center" Grid.Column="0" Margin="0 0 10 0" HorizontalAlignment="Center"/>
                <TextBox IsEnabled="{Binding IsUserLogin}" Text="{Binding Password, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True, NotifyOnValidationError=True}" Grid.Column="1" Grid.ColumnSpan="2" Margin="0 0 20 0">
                    <Validation.ErrorTemplate>
                        <ControlTemplate>
                            <StackPanel Orientation="Horizontal">
                                <AdornedElementPlaceholder x:Name="textBox"/>
                                <TextBox Margin="5" Text="{Binding [0].ErrorContent}" Foreground="Red"></TextBox>
                            </StackPanel>
                        </ControlTemplate>
                    </Validation.ErrorTemplate>
                </TextBox>
            </Grid>

            <Grid Margin="0 10">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>

                <Button Command="{Binding LoginCommand}" Margin="10 0" Grid.Column="1" Content="登入"></Button>
                <Button x:Name="btnExit" Click="BtnExit_OnClick" Margin="10 0" Grid.Column="2" Content="離開"></Button>
                <Button Command="{Binding ResetCommand}" Grid.Column="3" Content="Reset"></Button>
            </Grid>

        </StackPanel>
    </Border>
</Window>

WinForm

基本上WinForm看起來就跟WPF一樣,不是重點,就不列出了。

Code behind

WPF

/// <summary>
/// MainWindow.xaml 的互動邏輯
/// </summary>
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        DataContext = new LoginViewModel();
    }

    public static string WindowTitle = "Login";
    public static string ShowText { get { return "show text"; } }

    private void BtnExit_OnClick(object sender, RoutedEventArgs e)
    {
        this.Close();
    }
}

WinForm

public partial class LoginForm : Form, IViewFor<LoginViewModel>
{
    public LoginForm()
    {
        InitializeComponent();

        ViewModel = new LoginViewModel();
        // 將ViewModel的屬性綁定至UI控制項上
        this.Bind(ViewModel, x => x.IsUserLogin, x => x.rdoUser.Checked);
        this.Bind(ViewModel, x => x.User, x => x.txtAccount.Text);
        this.Bind(ViewModel, x => x.Password, x => x.txtPassword.Text);

        // 將ViewModel的命令綁定至UI控制項
        this.BindCommand(ViewModel, x => x.LoginCommand, x => x.btnLogin);
        this.BindCommand(ViewModel, x => x.ResetCommand, x => x.btnReset);

        this.OneWayBind(ViewModel, x => x.IsUserLogin, x => x.txtAccount.Enabled);
        this.OneWayBind(ViewModel, x => x.IsUserLogin, x => x.txtPassword.Enabled);

        /*
        this.OneWayBind(ViewModel, 
            x => x.IsUserLogin, 
            x => x.txtAccount.BackColor, 
            x => x ? Color.Green : Color.BlueViolet); */
    }

    object IViewFor.ViewModel
    {
        get { return ViewModel; }
        set { ViewModel = (LoginViewModel)value; }
    }

    public LoginViewModel ViewModel { get; set; }

    private void btnExit_Click(object sender, EventArgs e)
    {
        this.Close();
    }
}

基本上,ReactiveUI在這兩種GUI架構上的差別僅在WinForm要有一個綁定的動作,而資料驅動導向的WPF基本上在XAML中就直接完成綁定的動作。不過在WinForm上到是帶來了很多的好處,在資料綁定的部份不再產生”MagicString”(以前在處理類似功能時,還特別找其它的函式庫來應用);更可以直接綁定控制項和命令,減少了很多View端的CodeBehind程式碼。

而在面對多個控制項的不同狀態的互動時,一般可能會採用類Mediator模式來處理,現在也可以透過自訂狀態類別來對映至各個不同控制項,讓程式更簡潔。

Written with StackEdit.

4.05.2017

Rx應用

本文介紹來自:http://rehansaeed.com/reactive-extensions-part1-replacing-events/及其相關系列

原文作者在學習Rx時也有跟我一樣的疑問,它到底適用在那裡?

Event的部份也可參考:http://mark-dot-net.blogspot.tw/2014/04/reactive-extensions-observables-versus.html
PS:ReactiveUI也是使用Rx的一個有趣的可參考的框架

替換 C# Events

公開一個Event

一般的Event使用

public class JetFighter
{
    public event EventHandler<JetFighterEventArgs> PlaneSpotted;

    public void SpotPlane(JetFighter jetFighter)
    {
        EventHandler<JetFighterEventArgs> eventHandler = this.PlaneSpotted;
        if (eventHandler != null)
        {
            eventHandler(this, new JetFighterEventArgs(jetfighter));
        }
    }
}

換用Rx

public class JetFighter
{
    private Subject<JetFighter> planeSpotted = new Subject<JetFighter>();

    public IObservable<JetFighter> PlaneSpotted
    {
        get { return this.planeSpotted.AsObservable(); }
    }

    public void SpotPlane(JetFighter jetFighter)
    {
        this.planeSpotted.OnNext(jetFighter);
    }
}

此行

return this.planeSpotted.AsObservable();

是為了讓使用者不能透過將Subject介面轉型來自己發送訊息。

當然,Rx還可以提供錯誤及完成的通知:

public class JetFighter
{
    private Subject<JetFighter> planeSpotted = new Subject<JetFighter>();

    public IObservable<JetFighter> PlaneSpotted
    {
        get { return this.planeSpotted; }
    }

    public void AllPlanesSpotted()
    {
        this.planeSpotted.OnCompleted();
    }

    public void SpotPlane(JetFighter jetFighter)
    {
        try
        {
            if (string.Equals(jetFighter.Name, "UFO"))
            {
                throw new Exception("UFO Found")
            }

            this.planeSpotted.OnNext(jetFighter);
        }
        catch (Exception exception)
        {
            this.planeSpotted.OnError(exception);
        }
    }
}

使用Event

標準事件使用方式

public class BomberControl : IDisposable
{
    private JetFighter jetfighter;

    public BomberControl(JetFighter jetFighter)
    {
        jetfighter.PlaneSpotted += this.OnPlaneSpotted;
    }

    public void Dispose()
    {
        jetfighter.PlaneSpotted -= this.OnPlaneSpotted;
    }

    private void OnPlaneSpotted(object sender, JetFighterEventArgs e)
    {
        JetFighter spottedPlane = e.SpottedPlane;
    }
}

Rx的使用方式

public class BomberControl : IDisposable
{
    private IDisposable planeSpottedSubscription;

    public BomberControl(JetFighter jetFighter)
    {
        this. planeSpottedSubscription = jetfighter.PlaneSpotted.Subscribe(this.OnPlaneSpotted);
    }

    public void Dispose()
    {
        this.planeSpottedSubscription.Dispose();
    }

    private void OnPlaneSpotted(JetFighter jetFighter)
    {
        JetFighter spottedPlane = jetfighter;
    }
}

Rx還可以

jetfighter.PlaneSpotted.Where(x => string.Equals(x.Name, “Eurofighter”)).Subscribe(this.OnPlaneSpotted);

結論

Event也是觀察者模式的實作,但由上可知,它較Rx少了錯誤和完成的通知,當然,若是不需要的話也沒有差別,而Rx除了這兩種通知,它讓訂閱端多了對來源操作的可能性,如上述的Where操作。

包裝Event

包裝EventHandler

public event EventHandler BunnyRabbitsAttack;

public IObservable<object> WhenBunnyRabbitsAttack
{
    get
    {
        return Observable
            .FromEventPattern(
                h => this.BunnyRabbitsAttack += h,
                h => this.BunnyRabbitsAttack -= h);
    }
}

包裝帶參數的EventHandler

public event EventHandler<BunnRabbitsEventArgs> BunnyRabbitsAttack;
public IObservable<BunnRabbits> WhenBunnyRabbitsAttack
{
    get
    {
        return Observable
            .FromEventPattern<BunnRabbitsEventArgs>(
                h => this.BunnyRabbitsAttack += h,
                h => this.BunnyRabbitsAttack -= h)
            .Select(x => x.EventArgs.BunnRabbits);
    }
}

包裝自訂的EventHandler

public event BunnRabbitsEventHandler BunnyRabbitsAttack;

public IObservable<BunnRabbits> WhenBunnyRabbitsAttack
{
    get
    {
        return Observable
            .FromEventPattern<BunnRabbitsEventHandler, BunnRabbitsEventArgs>(
                h => this.BunnyRabbitsAttack += h,
                h => this.BunnyRabbitsAttack -= h)
            .Select(x => x.EventArgs.BunnRabbits);
    }
}

使用明確的介面實作以隱藏現存事件

public abstract class NotifyPropertyChanges : INotifyPropertyChanged
{
    event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged
    {
        add { this.propertyChanged += value; }
        remove { this.propertyChanged -= value; }
    }

    private event PropertyChangedEventHandler propertyChanged;

    public IObservable<string> WhenPropertyChanged
    {
        get
        {
            return Observable
                .FromEventPattern<PropertyChangedEventHandler, PropertyChangedEventArgs>(
                    h => this.propertyChanged += h,
                    h => this.propertyChanged -= h)
                .Select(x => x.EventArgs.PropertyName);
        }
    }

    protected void OnPropertyChanged(string propertyName)
    {
        PropertyChangedEventHandler eventHandler = this.propertyChanged;
        if (eventHandler != null)
        {
            eventHandler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

替換Timers

現存的.NET Timers

一般Timer的使用

public void StartTimer()
{
    Timer timer = new Timer(5000);
    timer.Elapsed += this.OnTimerElapsed;
    timer.Start();
}

private void OnTimerElapsed(object sender, ElapsedEventArgs e)
{
    // Do Stuff Here
    Console.WriteLine(e.SignalTime);
    // Console WriteLine Prints
    // 11/03/2014 10:58:35
    // 11/03/2014 10:58:40
    // 11/03/2014 10:58:45
    // ...
}

Rx的Timers

public void StartTimer()
{
    Observable
        .Interval(TimeSpan.FromSeconds(5))
        .Subscribe(
            x =>
            {
                // Do Stuff Here
                Console.WriteLine(x);
                    // Console WriteLine Prints
                    // 0
                    // 1
                    // 2
                    // ...
            });
}

5秒後執行一次

public void StartTimerAndFireOnce()
{
    Observable
        .Timer(TimeSpan.FromSeconds(5))
        .Subscribe(
            x =>
            {
                // Do Stuff Here
                Console.WriteLine(x);
                // Console WriteLine Prints
                // 0
            });
}

一分鐘後,每5秒執行一次

public void StartTimerInOneMinute()
{
    Observable
        .Timer(TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5))
        .Subscribe(
            x =>
            {
                // Do Stuff Here
                Console.WriteLine(x);
                // Console WriteLine Prints
                // 0
                // 1
                // 2
                // ...
            });
}

選擇在那一個Schedule中執行程式,如下所示在UI執行緒中執行,因此不需要用Invoke的方式

public void StartTimerOnUIThread()
{
    Observable
        .Interval(TimeSpan.FromSeconds(5), DispatcherScheduler.Current)
        .Subscribe(
            x =>
            {
                // Do UI Stuff Here
            });
}

Task ToObservable

將Tasks轉成可觀察序列

ToObservable擴充函式可讓你將Task或Task<T>轉成IObserverable<T>,在Task上呼叫ToObservable函式會回傳一IObservable<Unit>,Unit是一個不做任何事的空物件,存在的原因是因為不存在不需回傳值的介面。

IObservable<Unit> observable = Task.Run(() => Console.WriteLine("Working")).ToObservable();

IObservable<string> observableT = Task<string>.Run(() => "Working").ToObservable();

如果你訂閱上述可觀察序列,它們只會回傳一個值然後結束。

Putting It All Together

範例

public Task<string> GetHelloString()
{
    return Task.Run(
        async () =>
        {
            await Task.Delay(500);
            return "Hello";
        });
}

public Task<string> GetWorldString()
{
    return Task.Run(
        async () =>
        {
            await Task.Delay(1000);
            return "World";
        });
}

若是要取得第一個回傳的結果
TPL way

public async Task<string> WaitForFirstResultAndReturn()
{
    Task<string> task1 = this.GetHelloString();
    Task<string> task2 = this.GetWorldString();

    return await Task.WhenAny(task1, task2).Result;
}

Rx way

public async Task<string> WaitForFirstResultAndReturn()
{
    IObservable<string> observable1 = this.GetHelloString().ToObservable();
    IObservable<string> observable2 = this.GetWorldString().ToObservable();

    return await observable1.Merge(observable2).FirstAsync();
}

兩個方式很像,但TPL更簡單些。

再來,我們等待兩個Task完成並組合。
TPL way

public async Task<string> WaitForAllResultsAndReturnCombinedResult()
{
    Task<string> task1 = this.GetHelloString();
    Task<string> task2 = this.GetWorldString();

    return string.Join(" ", await Task.WhenAll(task1, task2));
}

Rx way

public async Task<string> WaitForAllResultsAndReturnCombinedResult()
{
    IObservable<string> observable1 = this.GetHelloString().ToObservable();
    IObservable<string> observable2 = this.GetWorldString().ToObservable();

    return await observable1.Zip(observable2, (x1, x2) => string.Join(" ", x1, x2));
}

兩個方式很像,但TPL仍然更簡單。

再來,我們等待第一個結果完成,但加上逾時限制。

TPL way

public async Task<string> WaitForFirstResultAndReturnResultWithTimeOut()
{
    Task<string> task1 = this.GetHelloString();
    Task<string> task2 = this.GetWorldString();
    Task timeoutTask = Task.Delay(100);

    Task completedTask = await Task.WhenAny(task1, task2, timeoutTask);
    if (completedTask == timeoutTask)
    {
        throw new TimeoutException("The operation has timed out");
    }

    return ((Task<string>)completedTask).Result;
}

Rx way

public async Task<string> WaitForFirstResultAndReturnResultWithTimeOut()
{
    IObservable<string> observable1 = this.GetHelloString().ToObservable();
    IObservable<string> observable2 = this.GetWorldString().ToObservable();

    return await observable1.Merge(observable2).Timeout(TimeSpan.FromMilliseconds(100)).FirstAsync();
}

當前的狀況,Rx勝出

如果我們要結合兩個目的。

public async Task<string> WaitForFirstResultAndReturnResultWithTimeOut2()
{
    Task<string> task1 = this.GetHelloString();
    Task<string> task2 = this.GetWorldString();

    return await Task
        .WhenAny(task1, task2)
        .ToObservable()
        .Timeout(TimeSpan.FromMilliseconds(1000))
        .FirstAsync();
}

對事件取樣

有時對事件的註冊會導致程式UI凍結。

this.TextBox.TextChanged += this.OnTextBoxTextChanged;

private void OnTextBoxTextChanged(object sender, TextChangedEventArgs e)
{
    // Heavy User Interface updates that can cause the application to lock up.
}

Rx可以簡單的對事件取樣以降低事件觸發的頻率。

public IObservable<TextChangedEventArgs> WhenTextChanged
{
    get
    {
        return Observable
            .FromEventPattern<TextChangedEventHandler, TextChangedEventArgs>(
                h => this.TextBox.TextChanged += h,
                h => this.TextBox.TextChanged -= h)
            .Select(x => x.EventArgs);
    }
}

this.WhenTextChanged
    .Sample(TimeSpan.FromSeconds(3))
    .Subscribe(x => Debug.WriteLine(DateTime.Now + " Text Changed"));

逾時

public async Task<string> WaitForFirstResultWithTimeOut()
{
    Task<string> task = this.DownloadTheInternet();

    return await task
        .ToObservable()
        .Timeout(TimeSpan.FromMilliseconds(1000))
        .FirstAsync();
}

Written with StackEdit.