2017年11月18日土曜日

MVVMのサンプルプログラム - TwitterViewer

前回の記事でMVVMとは何ぞやという説明をしましたが、今回はMVVMで実装する例としてTwitterのサンプルプログラムでも作ってみようかと思います。MVVMライブラリはLivetを使っています。

具体的なイメージを最初に持ってもらうために、完成形を最初に示しておきます。


短縮URLやRTの展開などはしていませんが、なかなかそれっぽいクライアントに見えるかと思います。

1. メインウィンドウのView

メインウィンドウのXAMLはこんな感じです。

<Window x:Class="TwitterViewer.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:TwitterViewer.Views"
        xmlns:vm="clr-namespace:TwitterViewer.ViewModels"
        Title="MainWindow" Height="1000" Width="500">
    
    <Window.DataContext>
        <vm:MainWindowViewModel/>
    </Window.DataContext>
    
    <i:Interaction.Triggers>
    
        <!--WindowのContentRenderedイベントのタイミングでViewModelのInitializeメソッドが呼ばれます-->
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize"/>
        </i:EventTrigger>

        <!--Windowが閉じたタイミングでViewModelのDisposeメソッドが呼ばれます-->
        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction/>
        </i:EventTrigger>

        <!--WindowのCloseキャンセル処理に対応する場合は、WindowCloseCancelBehaviorの使用を検討してください-->

    </i:Interaction.Triggers>
    
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        <Button Grid.Row="0" Content="Load" >
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="Click" >
                    <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="LoadButtonClicked" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Button>
        <ListBox Grid.Row="1" ItemsSource="{Binding Statuses}" ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.CanContentScroll="False" >
            <ListBox.ItemTemplate>
                <DataTemplate DataType="{x:Type vm:TwitterStatusViewModel}">
                    <DockPanel HorizontalAlignment="Stretch">
                        <DockPanel.Background>
                            <LinearGradientBrush StartPoint="0,0" EndPoint="0,1" >
                                <GradientStop Color="White" Offset="0" />
                                <GradientStop Color="#FFF0F0F0" Offset="1" />
                            </LinearGradientBrush>
                        </DockPanel.Background>
                        <Image DockPanel.Dock="Left" Source="{Binding IconSource}" Width="50" VerticalAlignment="Top" />
                        <DockPanel DockPanel.Dock="Top">
                            <StackPanel DockPanel.Dock="Left" Orientation="Horizontal">
                                <TextBlock Text="@" Foreground="Blue" FontSize="12" />
                                <TextBlock Text="{Binding ScreenName}" Foreground="Blue" FontSize="12" />
                                <TextBlock Text="{Binding UserName}" Foreground="Blue" FontSize="12" FontWeight="Bold" Margin="5,0,0,0" />
                            </StackPanel>
                            <TextBlock DockPanel.Dock="Right" Text="{Binding CreatedAt}" Foreground="DarkGray" FontSize="12" HorizontalAlignment="Right"/>
                        </DockPanel>
                        <TextBlock DockPanel.Dock="Bottom">
                            via 
                            <Hyperlink ToolTip="{Binding ViaLink}" >
                                <TextBlock Text="{Binding ViaName}" />
                                <i:Interaction.Triggers>
                                    <i:EventTrigger EventName="Click">
                                        <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="ViaLinkClicked" />
                                    </i:EventTrigger>
                                </i:Interaction.Triggers>
                            </Hyperlink>
                        </TextBlock>
                        <TextBlock Text="{Binding Text}" FontSize="14" TextWrapping="Wrap" Margin="0,5,0,5" />
                    </DockPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
            <ListBox.ItemContainerStyle>
                <Style TargetType="ListBoxItem">
                    <Setter Property="HorizontalContentAlignment" Value="Stretch"></Setter>
                </Style>
            </ListBox.ItemContainerStyle>
        </ListBox>
    </Grid>
</Window>

このクライアントはツイートを画面に表示する以外に、読み込みボタンとviaクライアントのクリックという2つの操作がユーザーでできるようになっています。
これはLivetのメソッド直接バインディング機能を使って、クリック時にViewModelのメソッドを呼び出すようにしています。

表示はListBoxのカスタマイズで行っています。
個々の要素(ツイート)の表示部分をテンプレートとしてListBox.ItemTemplateに渡しています。
若干複雑に見えるかもしれませんが、上の画像を見ながら追っていけば特段難しくないかと思います。

2. メインウィンドウのViewModel

メインウィンドウのViewModelはこんな感じです。

public class MainWindowViewModel : ViewModel
{
    Model model;

    public MainWindowViewModel()
    {
        model = Model.GetInstance();

        Statuses = model.Statuses.ToSyncedSynchronizationContextCollection(p => new TwitterStatusViewModel(p), System.Threading.SynchronizationContext.Current);
    }

    public void Initialize()
    {
    }

    public async void LoadButtonClicked()
    {
        await model.LoadHomeTimeline();
    }

    #region Statuses変更通知プロパティ

    public SynchronizationContextCollection<TwitterStatusViewModel> Statuses
    {
        get { return _Statuses; }
        set
        {
            if(_Statuses == value)
                return;
            _Statuses = value;
            RaisePropertyChanged(nameof(Statuses));
        }
    }
    private SynchronizationContextCollection<TwitterStatusViewModel> _Statuses;

    #endregion
}

Loadボタンが押されたときにそれを受け取るメソッドと、ツイートの表示内容を示すコレクションを用意しています。

SynchronizationContextCollectionはStatefulModelというライブラリのものです。「特定のコンテキストでコレクションの変更通知を発行するスレッドセーフなコレクション」で、WPFの制約である「単一スレッドでしかUIを触ることができない」を吸収してくれる便利なコレクションです。

ちなみに、Statusesプロパティはmodel.Statuses.ToSyncedSynchronizationContextCollectionメソッドで生成しています。ここでmodel.Statusesという変更通知コレクション(詳しくはModelの項を見てください)のイベントを拾えるようリスナーを登録しています。ViewModel側のStausesではModel側のイベントを拾って自身もそれに同期し変更通知イベントを発生させますが、その際にUIスレッドに転送してイベントを発生させてくれます。まさにMVVMのためのコレクション機構ですね。

3. メインウィンドウのModel

Modelはこちらです。

public class Model : NotificationObject
{
    #region Singleton

    static Model instance;

    public static Model GetInstance()
    {
        if(instance == null)
            instance = new Model();
        return instance;
    }

    #endregion

    TwitterContext twitterContext;

    private Model()
    {
        Statuses = new SortedObservableCollection<TwitterStatus, DateTime>(s => s.CreatedAt, true);

        SingleUserAuthorizer auth = new SingleUserAuthorizer()
        {
            CredentialStore = new SingleUserInMemoryCredentialStore()
            {
                ConsumerKey = "****",
                ConsumerSecret = "****",
                AccessToken = "****",
                AccessTokenSecret = "****",
            }
        };
        twitterContext = new TwitterContext(auth);
    }

    public async Task LoadHomeTimeline()
    {
        await Task.Run(() => {
            foreach(var status in twitterContext.Status.Where(p => p.Type == StatusType.Home && p.Count == 100)) {
                if(!Statuses.Any(p => p.StatusID == status.StatusID))
                    Statuses.Add(new TwitterStatus(status));
            }
        });
    }
    
    #region Statuses変更通知プロパティ

    public SortedObservableCollection<TwitterStatus, DateTime> Statuses
    {
        get { return _Statuses; }
        set
        { 
            if(_Statuses == value)
                return;
            _Statuses = value;
            RaisePropertyChanged(nameof(Statuses));
        }
    }
    private SortedObservableCollection<TwitterStatus, DateTime> _Statuses;

    #endregion        
}

TwitterにアクセスするのにはLinq2Twitterを使っています。
SortedObservableCollectionはStatefulModelの提供するクラスの1つです。タイムラインは常に時間順でソートされるべきですので、そうなるようにSortedなコレクションを使っています。ViewModel側でこれをウォッチしてUIのスレッドで変更通知をViewに伝えるのです。

ちなみに、このModelはシングルトンパターンを取っています。
ここはちょっとしたこだわりですが、ViewModelがModelを作るわけではないので、その気持ちを込めてこういう構造にしています。


4. ツイートのViewModel

さて、ここまで来てメインウィンドウに対応するViewModel、Model以外にもTwitterStatusViewModelとTwitterStatusというクラスがあることに気づいたことでしょう。個々のツイートに対応するViewがありますから、それに対応するViewModelを作るのは自然かと思います。

public class TwitterStatusViewModel : ViewModel
{
    TwitterStatus Source;

    public TwitterStatusViewModel(TwitterStatus source)
    {
        Source = source;

        Text = Source.Text;
        IconSource = Source.IconSource;
        ScreenName = Source.ScreenName;
        UserName = Source.UserName;
        CreatedAt = Source.CreatedAt;
        ViaName= Source.ViaName;
        ViaLink = Source.ViaLink;
    }

    #region Text変更通知プロパティ

    public string Text
    {
        get { return _Text; }
        set
        {
            if(_Text == value)
                return;
            _Text = value;
            RaisePropertyChanged(nameof(Text));
        }
    }
    private string _Text;

    #endregion

    #region IconSource変更通知プロパティ
    private string _IconSource;

    public string IconSource
    {
        get
        { return _IconSource; }
        set
        {
            if(_IconSource == value)
                return;
            _IconSource = value;
            RaisePropertyChanged(nameof(IconSource));
        }
    }
    #endregion

    #region ScreenName変更通知プロパティ

    public string ScreenName
    {
        get { return _ScreenName; }
        set
        {
            if(_ScreenName == value)
                return;
            _ScreenName = value;
            RaisePropertyChanged(nameof(ScreenName));
        }
    }
    private string _ScreenName;

    #endregion

    #region UserName変更通知プロパティ

    public string UserName
    {
        get { return _UserName; }
        set
        {
            if(_UserName == value)
                return;
            _UserName = value;
            RaisePropertyChanged(nameof(UserName));
        }
    }
    private string _UserName;

    #endregion
    
    #region CreatedAt変更通知プロパティ

    public DateTime CreatedAt
    {
        get { return _CreatedAt; }
        set
        { 
            if(_CreatedAt == value)
                return;
            _CreatedAt = value;
            RaisePropertyChanged(nameof(CreatedAt));
        }
    }
    private DateTime _CreatedAt;

    #endregion
    
    #region ViaName変更通知プロパティ

    public string ViaName
    {
        get { return _ViaName; }
        set
        {
            if(_ViaName == value)
                return;
            _ViaName = value;
            RaisePropertyChanged(nameof(ViaName));
        }
    }
    private string _ViaName;

    #endregion

    #region ViaLink変更通知プロパティ

    public string ViaLink
    {
        get { return _ViaLink; }
        set
        {
            if(_ViaLink == value)
                return;
            _ViaLink = value;
            RaisePropertyChanged(nameof(ViaLink));
        }
    }
    private string _ViaLink;

    #endregion

    public void ViaLinkClicked()
    {
        Source.OpenViaLink();
    }
}


やたら長いですが、大部分が変更通知プロパティです。
反射的に変更通知ができるように作ってしまいましたが、ツイートは一度つぶやくと後から変更できないので、Immutableに作っても良かってもよかったかもしれません。

今回は面倒なので実装しませんでしたが、例えば、ツイート時刻の相対表記(何秒前のツイートかを表示するもの)だったら定期的に表示内容を変えなければならないので、そういうものはModelの変更通知を監視し、変更されたら自身を更新するように作り替えねばなりません。

ちなみに、一番最後にはviaのリンクをクリックされたときに呼ばれるメソッドがありますが、これはそのままModelに横流ししています。

5. ツイートのModel

最後にツイートのModelです。

public class TwitterStatus : NotificationObject
{
    static Regex regViaUrl = new Regex("href=\"([^\"]+)\"", RegexOptions.Compiled | RegexOptions.IgnoreCase);
    static Regex regViaName = new Regex(">([^<]+)<", RegexOptions.Compiled | RegexOptions.IgnoreCase);

    public TwitterStatus(Status Source)
    {
        Text = Source.Text;
        StatusID = Source.StatusID;
        IconSource = Source.User.ProfileImageUrlHttps;
        ScreenName = Source.User.ScreenNameResponse;
        UserName = Source.User.Name;
        CreatedAt = Source.CreatedAt.ToLocalTime();

        Match m = regViaName.Match(Source.Source);
        ViaName = m.Success ? m.Groups[1].Value : Source.Source;

        m = regViaUrl.Match(Source.Source);
        ViaLink = m.Success ? m.Groups[1].Value : string.Empty;
    }

    public string Text { get; }

    public ulong StatusID { get; }

    public string IconSource { get; }

    public string ScreenName { get; }

    public string UserName { get; }

    public DateTime CreatedAt { get; }

    public string ViaName { get; }

    public string ViaLink { get; }

    public void OpenViaLink()
    {
        try {
            System.Diagnostics.Process.Start(ViaLink);
        }
        catch(Win32Exception) {
            throw;
        }
        catch(FileNotFoundException) {
            throw;
        }
    }
}

基本的にViewModelに対応させていますが、viaのURLとクライアント名を分離するためのロジックをコンストラクタにちょろっと入れました。

また、viaのリンクを開くロジックもここに書いています。まあ、Process.Startを呼ぶだけなのですが。
ですが、何かしらの理由で開けないことがあるかと思います。例えばTwitterの仕様変更でURLが含まれなくなったり、WindowsにURLを開くアプリケーションが関連付けられていなかったり。そういう場合はProcess.Startメソッドが例外を吐いてきますが、その処理はModel側でやるべきです(今回はめんどいんで結局再スローしちゃっています)。 「URLが開けませんでした」みたいなエラーメッセージは改めてModel側からイベントを発生させてそれをViewModel経由で表示させるべきですね。ViewModelはあくまでもUIの制約を吸収するためだけの層です。URLが開けなかったというのはUIの制約じゃないですよね。ということは、それをViewModelに押し付けるのは間違っています。

まとめ

今回は初めて連載っぽい記事にしましたがいかがでしたでしょうか。
ここまできて気づいた方も多いかと思いますが、ViewModelとModelは同じようなプロパティやメソッドがたくさん登場してきます。当然です。なぜならば、ViewModelはViewの制約を吸収するための層なので、制約がない部分は素通りさせるべきなのです。なので、素通りする部分にとっては一層余計に挟んでいるような状態になってしまい、同じようなコードが生まれてしまうのです。

プログラマーと言えばコピペコードを嫌う生物ですから、このようなスタイルに懐疑の念を抱く人は多いかと思います。知ったうえで「俺はこっちのほうがいい」と思ったのなら、MVVMになっていないスタイルでプログラムを書いてもいいでしょう。ただし、非同期プログラミングで泣いても私は知りません。

少なくとも、ここまで読んでくださった方たちはMVVMが何かというのはわかってきたかと思います。

MVVMは問題領域の切り分け方です。UIの制約はViewModelに押し付けて、後はすべてModelが引き受ける、それだけです。なので、おそらくはWPFでMVVMを着崩していくよりかは、MVVMの形をキープしながら様々な便利なライブラリなどのアクセサリを身に着けていくのが一番スマートなやり方なのではないかなと私は思います。

0 件のコメント:

コメントを投稿