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
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
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 でテストが可能です。
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 をテストするコードを書くことも可能です。
/// <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 が複雑にならなくて済みます。
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 と書き方が違うので注意が必要です。
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 サンプルコードですので、ぜひ活用してください。
