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 サンプルコードですので、ぜひ活用してください。