JXUG で話した MVVM の活用の解説を

JXUGC #9 Xamarin.Forms Mvvm 実装方法 Teachathon – connpass
http://jxug.connpass.com/event/22840/

にて、田淵さんコードに「マサカリ」を投げまくって来ました。実は、ピアレビューという手法があって、できるだけきめ細かくコードをレビューしていくという手法があります。本来ならばインスペクションの形式を使うのですが、「人を攻撃する」のではなくて、コードのみをバシバシと叩いて向上させる方法ですね。コードを個人の成果物ではなくて、共同の成果物として仕立てあげることが最終目標です。

ひとまず、MVVM とは何ぞ?なり、Xamarin.Forms とは?という話はすっ飛ばして直接コードから入ったのは良かったと思います。ペアプロとか、M-VM-M の分業体制なんかはあんな感じで進めるとうまくいくでしょう。

私の発表したサンプルコードは以下にあるので、ざっと解説をつけておきます。

http://github.com/moonmile/JXUG

プロジェクトの構造

コードにはちょっとだけでも単体テスト用のコードを付けるようにしています。私が TDD を使う目的としては、テストの効率化の他に、「オブジェクト/ライブラリの使い方」を示すためにも作っています。そのクラスをどのように使うのか、かつ、クラスを使う時にどのようなインターフェースにしたら使いづらくはなならないか、の検討用に単体テストコードを使っています。
また、実際にクラスを動かすときのサンプルとして、いきなり Xamarin のコードでは大変(実機でしか動作しないパターンなど)なので、最初に WPF や Windows フォームを使った小さなサンプルを用意します。これでいくつか実験した後で、実際のモバイルコードに直す、あるいは組み合わせていいきます。こうすると、プロジェクトの最後のほうになって実機コードが複雑になっても、実験アプリによって少しテストをしながら、という手法が取れます。

  • /Test/MStopWatch.Test — 単体テストコード
  • /Test/MStopWatch.WPF — WPF による実験コード
  • /ViewModel/MStopWatch.VM — WPF/Xamarin.Forms の共通の VM
  • /ViewModel/MStopWatchFsharp.VM — 試しに F# で書き直した VM
  • MStopWatch — Xamarin.Froms の共通 PCL
  • MStopWatch.Droid — Android 用
  • MStopWatch.iOS — iOS 用
  • MStopWatch.WinPhone — Windows Phone 用

最後の、Droid/iOS/WinPhone は Xamarin.Forms を使うと手を入れずに済みます。シミュレータの場合は、Windows Phone が Hyper-V を使って一番早く動きます。

これで、MVVM パターンを形作るわけですが、いくつかの仕掛けが入っています。ストップウオッチのタイマの場所を何処に置くのかによって VM の書き方が変わります。勉強会のときにも強調しましたが、特に正解があるわけではありません。とあるコードやプロジェクトによって、「そこが最適であろう」という推測はできますが、実際に置かなければならないということではありません。ふさわしい場所がある、というだけです。

ストップウォッチタイマを ViewModel に置く

StopWatchVM.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class StopWatchVM : BindableBase
{
...
    public void Start()
    {
        _now = DateTime.Now;
        _startTime = _now;
        _items.Clear();
        _loop = true;
        Mode = 1;
        _task = new Task(async () => {
            while (_loop)
            {
                await Task.Delay(100);
                _now = DateTime.Now;
                if (OnTimer != null)
                    OnTimer();
                else
                {
                    this.NowSpan = _now - _startTime;   // 画面を更新
                }
            }
        });
        _task.Start();
    }

タイマは、100 msec で動かしています。非常に遅いように見えますが、画面に表示させるときは 100 msec で十分で、Lap ボタンを押したときには改めて DateTime.Now から取っているので正確な時刻が取得できます。このあたりが、表示用の VM とデータとしての Model の違いになりますね。

ここではスレッド越えを許すためにコールバック関数 OnTimer を定義させていますが、Android でもコールバック関数は必要ではありませんでした。NowSpan プロパティと更新すると、INotifyPropertyChanged で画面に通知されます。

ストップウォッチタイマを Model に置く

StopWatchVM2.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class StopWatchModel
{
...    public DateTime StartTime { get; set; }
    public void Start()
    {
        StartTime = Now = DateTime.Now;
        Items.Clear();
        _loop = true;
        _task = new Task(async () => {
            while (_loop)
            {
                await Task.Delay(100);
                Now = DateTime.Now;
                if (OnTimer != null)
                    OnTimer();
            }
        });
        _task.Start();
    }

Model のほうにタイマを用意した例です。この意図としては、計測機器の割り込みイベントや、外部から定期的に割り込みが入るようなパターンを想定しています。この場合、イベントが Model -> VM -> View へと数珠つなぎになるので、MVVM のまま使うよりも Rx のような方法を取ったほうが楽です。

ストップウォッチタイマを View に置く

ちょっとサンプルには書き忘れましたが、View 自身にタイマーを持たせることもできます。ストップウォッチの場合には、

  • 定期的に人の目に触れる View の時刻を切り替える
  • 内部で持つ時刻データを正確に持つ

の2つに分離できることがわかります。このため、内部データは Lap ボタンを押したタイミングで DateTime.Now を取得すればよいわけで、何も定期的に内部データを更新する必要はありません。なので、画面の表示させる View だけタイマー更新を使うという方法が考えられます。これはちょうどゲームの画面更新(スプライト機能など)を行う場合に、描画はキャラの更新タイミングに合わせるのではなくて、垂直同期にあわせるという方法ですね。たいていのゲームは 50fps 程度あれば十分なので、20 msec 程度で更新させれば十分です。
なので、Lap タイムは msec 単位で持っていても、画面更新は 20 msec 単位程度で十分ということです。

View 単体の更新では、WPF の場合は Storyboard の更新タイミングを使う方法もあります。これらは機会を見てサンプルに付け加えていきましょう。

VM を F# で書く

VM や Model に単体テストが入れば開発効率は非常に上がります。画面であれこれテストしたり、インタプリタで一時的なテストを繰り返すよりも、自動テストができる作り方にするのです。

F# で書いた VM の全文が次になります。これらは、単体テスト MStopWatch.Test でテストが可能です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
type StopWatchVM() =
    let ev = new Event<_,_>()
    let mutable _mode = 0
    let mutable _startTime = DateTime()
    let mutable _now = DateTime()
    let mutable _nowSpan = TimeSpan()
    let mutable _items = new ObservableCollection<LapTime>()
    let mutable _loop = false
    let mutable _task:Task = null
 
    member this.StartButtonText
        with get() =
            match _mode with
            | 0 -> "Start"
            | 1 -> "Stop"
            | 2 -> "Restart"
            | _ -> ""
    member this.Mode
        with get() = _mode
        and set(value) =
            if ( _mode <> value ) then
                _mode <- value
                ev.Trigger(this, PropertyChangedEventArgs("StartButtonText"))
                ev.Trigger(this, PropertyChangedEventArgs("Mode"))
    member this.Items
        with get() = _items
        and set(value) =
            _items <- value
            ev.Trigger(this, PropertyChangedEventArgs("Items"))
    member this.NowSpan
        with get() = _nowSpan
        and set(value) =
            _nowSpan <- value
            ev.Trigger(this, PropertyChangedEventArgs("NowSpan"))
 
 
    member this.ClickStart() =
        match _mode with
        | 0 -> this.Start()
        | 1 -> this.Stop()
        | 2 -> this.Reset()
        | _ -> ()
    member this.ClickLap() = this.Lap()
 
    member this.Start() =
        _now <- DateTime.Now
        _startTime <- _now
        _items.Clear()
        _loop <- true
        this.Mode <- 1
        _task <- new Task( fun () ->
                while ( _loop ) do
                    ( Async.Sleep(100) |> Async.StartAsTask ).Wait()
                    _now <- DateTime.Now
                    this.NowSpan <- _now - _startTime
            )
        _task.Start()
 
    member this.Stop() =
        _now <- DateTime.Now
        this.Items.Add( LapTime( this.Items.Count+1, _now, _now-_startTime))
        _loop <- false
        this.Mode <- 2
 
    member this.Reset() =
        _now <- DateTime.Now
        _startTime <- _now
        this.NowSpan <- TimeSpan(0,0,0)
        this.Items.Clear()
        this.Mode <- 0
 
    member this.Lap() =
        _now <- DateTime.Now
        this.Items.Add( LapTime( this.Items.Count+1, _now, _now-_startTime))
 
    interface INotifyPropertyChanged with
        [<CLIEvent>]
        member this.PropertyChanged = ev.Publish

VM を単体テストする

Model を自動テスト化すると頑丈なコードが書けます。さらに画面に近い VM をテストするコードを書くことも可能です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// <summary>
/// ラップを実行する
/// </summary>
 [TestMethod]
public void TestOneLap()
{
    var vm = new StopWatchVM();
    vm.Start();
    Assert.AreEqual("Stop", vm.StartButtonText);
    System.Threading.Thread.Sleep(1000);
 
    vm.Lap();
    Assert.AreEqual("Stop", vm.StartButtonText);
    // ひとつだけ追加されている
    Assert.AreEqual(1, vm.Items.Count);
 
    System.Threading.Thread.Sleep(1000);
    vm.Stop();
    Assert.AreEqual("Restart", vm.StartButtonText);
}

VM の構造を、UI/View から触るメソッドにうまく対応させてやれば、このようにユーザーのアクションをエミュレートできます。最近では Test Cloud のように実機/エミュレータを使って UI ベースのテストをすることも可能です。全ての UI イベントをエミュレートする必要はありませんが、おまかな動作がテストできると、実機を使った打鍵チェックを減らすことができます。

カスタムコントロールの利用

MVVM パターンを使うと、何にでも Binding を使って表そうとしてしまいますが、その分 View が冗長になってしまいます。勉強会でも話しましたが、本来は XAML をデザイナが記述し、コードビハイドをプログラマが記述するという分業ができる、というのが当時の売りでした。ですが、最初の頃に XAML をデザインするにはすべてをコードでみるしかないという状態に陥っていたため、XAML 自体もプログラマが書くようなスタイルになってしまいました。

Xamarin.Forms で、Button クラスを継承して Mode プロパティで表示が変えられるようなカスタムコーントロールを作ります。こうすることで、コントロール自体をより高機能な部品にすることができます。ここではボタンの表示を Mode プロパティで切り替えているだけですが、画像ファイルを張り付けたり、アニメーションをしたりすることができます。これらの動きを全て XAML で書くような Setter な方法もありますが、カスタムコントロールを作ってしまったほうが XAML の View が複雑にならなくて済みます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CustomButton : Button
{
    public static BindableProperty ModeProperty =
        BindableProperty.Create<CustomButton, int>(
            p => p.Mode,
            0,
            defaultBindingMode: BindingMode.TwoWay,
            propertyChanged: (bindable, oldValue, newValue) =>
            {
                var uc = bindable as CustomButton;
                switch (newValue)
                {
                    case 0: uc.Text = "開始"; break;
                    case 1: uc.Text = "停止"; break;
                    case 2: uc.Text = "リセット"; break;
                }
                ((CustomButton)bindable).Mode = newValue;
            });
    public int Mode
    {
        get { return (int)GetValue(ModeProperty); }
        set { SetValue(ModeProperty, value); }
    }
}

WPF の場合は、DependencyProperty を使うため、若干 Xamarin.Forms と書き方が違うので注意が必要です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class CustomButton : Button
{
    /// <summary>
    /// モードを指定
    /// </summary>
    public static readonly DependencyProperty ModeProperty =
        DependencyProperty.Register(
            "Mode",     // プロパティ名
            typeof(int),    // プロパティの型
            typeof(CustomButton),  // コントロールの型
            new FrameworkPropertyMetadata(   // メタデータ                  
                0,
                new PropertyChangedCallback((o, e) =>
                {
                    var uc = o as CustomButton;
                    if (uc != null)
                    {
                        int v = (int)e.NewValue;
                        switch ( v )
                        {
                            case 0: uc.Content = "開始"; break;
                            case 1: uc.Content = "停止"; break;
                            case 2: uc.Content = "リセット"; break;
                        }
                    }
                })));
    // 依存プロパティのラッパー
    public int Mode
    {
        get { return (int)GetValue(ModeProperty); }
        set { SetValue(ModeProperty, value); }
    }
}

ひとつの VM に複数の View を割り当てる

本来ならば、View と VM はきれいに分離するはずなので、動的に View をロードすることも可能です。インターネット経由で View(XAML)をロードすることも可能なのですが、これは結構難しいです。しかし、一定の View のパターンを持っていて、場合によって XAML 全体を切り替えるということができます。
この方法は、権限の違うユーザ(管理ユーザ、一般ユーザー)では画面をダイナミックに切り替える、ということができます。

Xamarin.Forms の MStopWatch プロジェクトには MyPage.xaml と MyPageV.xaml という2つの View があります。VM が同じであっても、インスタンスを生成するときに Page クラスを切り替えることができます。
ちなみに、MyPageV.xaml は、すべて View のコードビハイドにロジックを入れてしまった例です。

こんな感じで、ちょこちょこと業務ノウハウっぽいものも入れてある Xamarin.Forms の MVVM サンプルコードですので、ぜひ活用してください。

カテゴリー: Xamarin パーマリンク