ViewModel 먼저 개발하기 - 中
이철우
주어진 주기에 따라 0에서 255까지 숫자를 보고하는 계수기를 예제 Model로 골랐다. 계수기는 시작, 종료, 보고 숫자 설정, 숫자 보고, 그리고 주기 설정의 다섯 가지 기능이 있다. 이것을 인터페이스로 먼저 정리해 보자.
// IModel.cs
public interface IModel
{
int Period { get; set; }
event EventHandler<CountChangedEventArgs> CountChanged;
Task Start();
void Stop();
void SetCount(byte count);
public class CountChangedEventArgs : EventArgs
{
public byte Count { get; init; }
public CountChangedEventArgs(byte count)
{
Count = count;
}
public override string ToString() => Count.ToString();
}
// unit millisecond.
public static readonly int DEFAULT_PERIOD = 1000;
}
이것을 Model.cs 에서 구현했다. 타이머 PeriodicTimer에서 속성 Period는 .Net8에서 지원한다. 타이머 작동 중에 주기를 바꿀 수 있어 편리하다.
// Model.cs
public class Model : IModel
{
private bool _isRunning;
private PeriodicTimer? _timer;
private CancellationTokenSource? _cts;
public Model()
{
Count = 0;
_isRunning = false;
Period = IModel.DEFAULT_PERIOD;
}
public byte Count { get; private set; }
public int Period
{
get => _period;
set
{
_period = value;
if (_isRunning)
{
_timer!.Period = TimeSpan.FromMilliseconds(value);
}
}
}
private int _period;
public event EventHandler<IModel.CountChangedEventArgs>? CountChanged;
public async Task Start()
{
if (_isRunning)
{
return;
}
using var cts = new CancellationTokenSource();
_cts = cts;
_isRunning = true;
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(Period));
_timer = timer;
try
{
while (await timer.WaitForNextTickAsync(cts.Token).ConfigureAwait(false))
{
CountChanged!.Invoke(this, new IModel.CountChangedEventArgs(Count++));
}
}
catch (OperationCanceledException)
{
_isRunning = false;
}
}
public void Stop()
{
if (_isRunning)
{
_cts!.Cancel();
}
}
public void SetCount(byte count)
{
Count = count;
}
}
만든 Model을 테스트하기 위해 Program.cs를 조금 고쳤다.
// Program.cs
Console.WriteLine("Hello, World!");
IModel model = new Model();
model.CountChanged += Model_CountChanged;
var isQuitRequired = false;
while (!isQuitRequired)
{
var line = Console.ReadLine()!.Trim();
if (string.IsNullOrEmpty(line))
{
continue;
}
var input = line.Split([' ']);
var paramCount = input.Length;
switch (input[0])
{
case "quit":
model.Stop();
isQuitRequired = true;
break;
case "start":
_ = model.Start().ConfigureAwait(false);
break;
case "stop":
model.Stop();
break;
case "setPeriod":
var period = (paramCount == 1) ? 1000 : int.Parse(input[1]);
model.Period = period;
break;
case "setCount":
var count = (paramCount == 1) ? (byte)0 : byte.Parse(input[1]);
model.SetCount(count);
break;
default:
Console.WriteLine(input);
break;
}
}
model.CountChanged -= Model_CountChanged;
Console.WriteLine("Bye.");
static void Model_CountChanged(object? sender, IModel.CountChangedEventArgs e)
{
Console.WriteLine($"{e} {DateTimeOffset.Now:ss}");
}
만든 Model이 잘 작동한다. 이제 Model 테스트까지 했으니 ‘흉내낸 ViewModel’을 구현하자. Program.cs 안 스위치문의 각 케이스와 이벤트가 ViewModel의 구성원이 된다. CommunityToolkit.Mvvm -Version 8.2.2 를 현재 프로젝트에 설치하자. 아래가 ViewModel 이다.
// ViewModel.cs
public class ViewModel : ObservableObject
{
private readonly Model _model;
public ViewModel()
{
_model = new Model();
_model.CountChanged += Model_CountChanged;
StartCommand = new AsyncRelayCommand(_model.Start);
StopCommand = new RelayCommand(_model.Stop);
SetCountCommand = new RelayCommand(SetCount);
}
~ViewModel() => _model.CountChanged -= Model_CountChanged;
private void Model_CountChanged(object? sender, IModel.CountChangedEventArgs e)
{
Count = e.Count;
Console.WriteLine($"{Count} {DateTimeOffset.Now:ss}");
}
public byte SettingCount
{
get => _settingCount;
set => SetProperty(ref _settingCount, value);
}
private byte _settingCount;
public int Period
{
get => _model.Period;
set => SetProperty(_model.Period, value, _model, (u, n) => u.Period = n);
}
public byte Count
{
get => _count;
set => SetProperty(ref _count, value);
}
private byte _count;
public IAsyncRelayCommand StartCommand { get; }
public IRelayCommand StopCommand { get; }
public IRelayCommand SetCountCommand { get; }
private void SetCount()
{
_model.SetCount(SettingCount);
}
}
그리고 ViewModel을 테스트 하기 위해 Program.cs를 조금 고치자.
// Program.cs
Console.WriteLine("Hello, World!");
ViewModel viewModel = new();
var isQuitRequired = false;
while (!isQuitRequired)
{
var line = Console.ReadLine()!.Trim();
if (string.IsNullOrEmpty(line))
{
continue;
}
var input = line.Split([' ']);
var paramCount = input.Length;
switch (input[0])
{
case "quit":
viewModel.StopCommand.Execute(null);
isQuitRequired = true;
break;
case "start":
_ = viewModel.StartCommand.ExecuteAsync(null).ConfigureAwait(false);
break;
case "stop":
viewModel.StopCommand.Execute(null);
break;
case "setPeriod":
var period = (paramCount == 1) ? 1000 : int.Parse(input[1]);
viewModel.Period = period;
break;
case "setCount":
viewModel.SettingCount = (paramCount == 1) ? (byte)0 : byte.Parse(input[1]);
viewModel.SetCountCommand.Execute(null);
break;
default:
Console.WriteLine(input);
break;
}
}
Console.WriteLine("Bye.");
구현한 ViewModel이 잘 작동한다. 이 계수기 Model은 두 가지 중요한 상태 변화를 담고 있다. Count는 Model 내부에서 변하고, Period는 Model 외부에서 설정한다. 그에 따라 ViewModel 구현이 달라짐을 알 수 있다. 다음 글에서 이 ViewModel을 가지고 몇 개의 View를 만들어 붙여보겠다.