이번글은 Microsoft WPF Doc 내용중 비동기 프로그래밍 - 비동기 MVVM 내용에 관련하여
간략한 예제와 설명 글 입니다.
다들 잘 아시는 얘기지만 WPF에서 바인딩을 통해 데이터를 표시할때 시간이 오래 걸리는 데이터는 비동기로 처리해야 합니다.
이런 상황에서 비동기로 처리되는 속성을 바인딩하여 처리 하는 방법에 대해 살펴봅니다.
Task<T>타입, IsAsync속성 사용 처리
가장 간편하게는 Task<T>타입의 속성을 바인딩으로 처리하고 IsAsync속성을 사용하는 방법이 있습니다.
[WindowViewModel.cs]
public class WindowViewModel
{
public WindowViewModel()
{
Name = this.GetNameAsync();
}
public string DelayName { get; set; } = "Loading..";
public Task<string> Name { get; set; }
public async Task<string> GetNameAsync()
{
return await Task.Run(() =>
{
Thread.Sleep(5000);
return "Foo";
});
}
}
위와 같이 Task<string> 타입 속성을 정의하고 해당 속성을 IsAsync=True를 사용하여 바인딩 처리 합니다. DelayName속성은 Name속성 값이 할당 되기 전에 미리 표시될 속성입니다.
<TextBlock Grid.Row="0"
Grid.Column="0"
FontSize="25">
<TextBlock.Text>
<PriorityBinding>
<Binding Path="Name.Result" IsAsync="True" />
<Binding Path="DelayName" />
</PriorityBinding>
</TextBlock.Text>
</TextBlock>
PriorityBinding로 바인딩 우선순위를 지정하여 위에서 부터 제일 우선순위로 처리 됩니다. 즉 Name.Result 값이 null인 경우 다음 우선순위인 DelayName값이 표시 됩니다.
이후 비동기 작업 완료 결과값인 Name.Result가 표시 됩니다. 참고로 이런 처리는 간단하게 FallbackValue 속성으로 처리해도 동일한 결과를 볼 수 있습니다.
Text="{Binding Name.Result, IsAsync=True, FallbackValue='Loading..'}"
하지만 바인딩 속성의 IsAsync=True는 UI를 차단하지 않도록 해줄뿐이지 실질적으로 async/await 동작과는 무관하고 또한 Task결과를 Result로 접근하는 것은 Deadlock(교착 상태)이 발생될 여지가 있으므로 좋지 않은 방식입니다.
Task.Result를 통한 Deadlock(교착 상태)
참고 : Don’t Block on Async Code
코드 리펙토링
위 좋지 않은 코드를 고친다면 다음과 같이 처리해볼 수 있습니다.
[WindowViewModel.cs]
public class WindowViewModel : ObservableObject // Microsoft.Toolkit.Mvvm
{
private string _name = "Loading..";
public string Name
{
get => _name;
set
{
SetProperty(ref _name, value);
}
}
private AsyncRelayCommand _loadedCommand;
public AsyncRelayCommand LoadedCommand
{
get
{
return _loadedCommand ??
(_loadedCommand = new AsyncRelayCommand(async () => {
Name = await this.GetNameAsync();
}));
}
}
public async Task<string> GetNameAsync()
{
return await Task.Run(() =>
{
Thread.Sleep(5000);
return "Foo";
});
}
}
<Window
<!--Microsoft.Xaml.Behaviors-->
xmlns:behaviors="http://schemas.microsoft.com/xaml/behaviors">
<behaviors:Interaction.Triggers>
<behaviors:EventTrigger EventName="Loaded">
<behaviors:InvokeCommandAction Command="{Binding LoadedCommand}" />
</behaviors:EventTrigger>
</behaviors:Interaction.Triggers>
<TextBlock Grid.Row="0"
Grid.Column="0"
FontSize="25"
Text="{Binding Name}" />
</Window>
위와 같이 Window Loaded이벤트에서 AsyncRelayCommand를 통해 비동기 Task를 await으로 대기하고 결과를 받아서 바인딩 속성에 값을 할당해 주도록 변경했습니다.
더 나은 방식
사실 닷넷의 Task에 System.ComponentModel.INotifyPropertyChanged 가 구현되지 않아 바인딩 처리가 매끄럽게 되지 않고, Result를 통해
결과를 받아 처리하여 UI스레드 자체가 차단된다는 점이 근본적인 문제입니다.
이런 점을 고려해서 직접 비동기로 처리 후 변경 통보가 되도록 커스텀하게 클래스를 구현해서 사용해볼 수 있습니다.
Task를 넘겨서 작업이 완료 될때까지 대기하고 완료시 System.ComponentModel.INotifyPropertyChanged 호출하는 역할인 NotifyTaskCompletion클래스를 다음과 같이 구현 합니다.
[NotifyTaskCompletion.cs]
public sealed class NotifyTaskCompletion<TResult> : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private TResult _defaultValue;
public NotifyTaskCompletion(Task<TResult> task, TResult defaultValue)
{
_defaultValue = defaultValue;
Task = task;
if (!task.IsCompleted)
{
var _ = WatchTaskAsync(task);
}
}
private async Task WatchTaskAsync(Task task)
{
try
{
await task;
}
catch
{
}
var propertyChanged = PropertyChanged;
if (propertyChanged == null)
return;
propertyChanged(this, new PropertyChangedEventArgs("Status"));
propertyChanged(this, new PropertyChangedEventArgs("IsCompleted"));
propertyChanged(this, new PropertyChangedEventArgs("IsNotCompleted"));
if (task.IsCanceled)
{
propertyChanged(this, new PropertyChangedEventArgs("IsCanceled"));
}
else if (task.IsFaulted)
{
propertyChanged(this, new PropertyChangedEventArgs("IsFaulted"));
propertyChanged(this, new PropertyChangedEventArgs("Exception"));
propertyChanged(this,
new PropertyChangedEventArgs("InnerException"));
propertyChanged(this, new PropertyChangedEventArgs("ErrorMessage"));
}
else
{
propertyChanged(this,
new PropertyChangedEventArgs("IsSuccessfullyCompleted"));
propertyChanged(this, new PropertyChangedEventArgs("Result"));
}
}
public Task<TResult> Task { get; private set; }
public TResult Result
{
get
{
if(Task.Status == TaskStatus.RanToCompletion)
{
return Task.Result;
}
else if(_defaultValue != null)
{
return _defaultValue;
}
else
{
return default(TResult);
}
}
}
public TaskStatus Status { get { return Task.Status; } }
public bool IsCompleted { get { return Task.IsCompleted; } }
public bool IsNotCompleted { get { return !Task.IsCompleted; } }
public bool IsSuccessfullyCompleted
{
get
{
return Task.Status ==
TaskStatus.RanToCompletion;
}
}
public bool IsCanceled { get { return Task.IsCanceled; } }
public bool IsFaulted { get { return Task.IsFaulted; } }
public AggregateException Exception { get { return Task.Exception; } }
public Exception InnerException
{
get
{
return (Exception == null) ?
null : Exception.InnerException;
}
}
public string ErrorMessage
{
get
{
return (InnerException == null) ?
null : InnerException.Message;
}
}
}
NotifyTaskCompletion<TResult> 클래스의 중요한 부분은 NotifyTaskCompletion<TResult> 타입의 속성이 바인딩으로 사용되고,
NotifyTaskCompletion에 비동기로 처리되는 Task를 넘기면 await으로 작업이 완료 될때 까지 대기 합니다.
동시에 xaml 바인딩에 의해 NotifyTaskCompletion클래스의 Result속성 get이 호출되는데 Task가 완료 되지 않았다면 기본값인 _defaultValue를 반환하고
작업이 완료 되었다면 실제 Task의 Result를 통해 결과값을 반환하게 됩니다. 이때 Result는 이미 작업이 완료 되었기 때문에 차단되지 않습니다.
그리고 NotifyTaskCompletion<TResult> 클래스는 다음과 같이 사용할 수 있습니다.
다음 예제는 총 4개의 데이터를 비동기로 처리하고 해당 결과를 바인딩하여 화면에 표시되는 예제 입니다.
[Window.xaml.cs]
public partial class Window : Window
{
WindowViewModel _viewModel = new WindowViewModel();
public Window()
{
this.DataContext = _viewModel;
InitializeComponent();
}
// NOTE : MVVM에선 Command로 처리
private void xAdd_Click(object sender, RoutedEventArgs e)
{
// 비동기 완료 후 Task<ObservableCollection<FooModel>> GetData04() 메서드를 다시 한번 호출하지 않는지 체크
_viewModel.Data04.Result.Add(new FooModel() { Name = "추가" });
}
}
Data04 데이터 표시 이후 Task<ObservableCollection<FooModel>> GetData04() 메서드를 다시 한번 호출하지는 않는지 확인하기 위해 수동으로 데이터 추가 코드를 삽입했습니다.
[WindowViewModel.cs]
public class WindowViewModel
{
public WindowViewModel()
{
Data01 = new NotifyTaskCompletion<string>(Task<string>.Run(() =>
{
Thread.Sleep(new Random().Next(1000, 10000));
return "Data load completed";
}), "Loading01..");
Data02 = new NotifyTaskCompletion<DateTime?>(Task<DateTime?>.Run(() =>
{
Thread.Sleep(new Random().Next(1000, 15000));
return new DateTime?(DateTime.Now);
}), null);
Data03 = new NotifyTaskCompletion<int>(Task<DateTime>.Run(() =>
{
Thread.Sleep(new Random().Next(1000, 7000));
return new Random().Next(1, 45);
}), 0);
Data04 = new NotifyTaskCompletion<ObservableCollection<FooModel>>(this.GetData04(), null);
}
public NotifyTaskCompletion<string> Data01 { get; private set; }
public NotifyTaskCompletion<DateTime?> Data02 { get; private set; }
public NotifyTaskCompletion<int> Data03 { get; private set; }
public NotifyTaskCompletion<ObservableCollection<FooModel>> Data04 { get; private set; }
private async Task<ObservableCollection<FooModel>> GetData04()
{
ObservableCollection<FooModel> result = new ObservableCollection<FooModel>();
await Task.Run(() =>
{
foreach (var item in Enumerable.Range(0, 100))
{
Thread.Sleep(50);
result.Add(new FooModel() { Name = $"Name - {item}", Description = System.IO.Path.GetRandomFileName() });
};
});
return result;
}
}
[FooModel.cs]
public class FooModel
{
public string Name { get; set; }
public string Description { get; set; }
}
[Window.xaml]
<Window>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="{Binding Data01.Result}"
FontSize="25"/>
<TextBlock Grid.Row="0"
Grid.Column="1"
Text="{Binding Data02.Result}"
FontSize="25"/>
<TextBlock Grid.Row="1"
Grid.Column="0"
Text="{Binding Data03.Result}"
FontSize="25"/>
<ListView Grid.Row="1"
Grid.Column="1"
ItemsSource="{Binding Data04.Result}">
<ListView.View>
<GridView>
<GridViewColumn Width="180"
Header="Name">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"
FontSize="15"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Width="180"
Header="Description">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Description}"
FontSize="15"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
<Button x:Name="xAdd"
Grid.Row="1"
Grid.Column="1"
Content="Add"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Height="20"
Width="100"
Click="xAdd_Click"/>
</Grid>
</Window>
다음과 같이 화면이 멈추지 않고 바인딩 개별적으로 비동기로 처리되고 결과가 화면에 표시 되는 것을 확인 할 수 있습니다.
추가로 StephenCleary의 블로그 비동기 속성 및 AsyncEx 라이브러리의 AsyncLazy<T> 글을 읽어 보면 많은 도움이 될 것 같습니다.
비동기 속성 및 AsyncEx 라이브러리 AsyncLazy<T>
AsyncEx 라이브러리 GitHub