CommunityToolkit.Mvvm 살펴보기 (완)

Github 주소

CommunityToolkit.Mvvm은 Mvvm 패턴 적용에 유용한 기능들을 모아둔 라이브러리입니다. Mvvm 패턴을 적용해 본 분들이라면 익숙하실 만한 INotifyPropertyChanged나 RelayCommand 구현 및 그 보조 기능을 비롯해 Ioc 컨테이너, 메신저, Validation 등 여러 기능이 포함되어 있습니다.

이 게시물에서는 제가 CommunityToolkit.Mvvm을 사용하며 알아본 여러 기능을 제 나름대로 정리해 보려고 합니다.

25개의 좋아요

1. 선수지식

INotifyPropertyChanged 인터페이스

INotifyPropertyChanged는 이름 그대로 속성이 변경되었음을 알리는 기능을 구현합니다. INotifyPropertyChanged 인터페이스를 구현하는 객체는 속성 값이 변경될 때 발생하는 PropertyChanged라는 이벤트를 가지며, 외부에서 이 이벤트를 구독해 속성 값이 변경될 때마다 알림을 받을 수 있습니다.

닷넷 계열 UI 프레임워크에서 MVVM 패턴을 적용했을 때, UI 업데이트는 일반적으로 View에서 ViewModel의 특정 속성에 데이터 바인딩을 하고 그 값의 변경을 관찰하는 모양새를 갖는데, INotifyPropertyChanged는 이 과정에서 굉장히 핵심적인 기능을 합니다.

일반적으로 INotifyPropertyChanged를 구현하는 모델은 다음과 같은 형태로 구성됩니다.

public class MyViewModel : INotifyPropertyChanged
{
    private string? _id;

    public string? Id
    {
        get => _id;
        set { _id = value; OnPropertyChanged(nameof(Id)); }
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    private void OnPropertyChanged(string propertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

Id 속성의 setter 부분을 살펴보면, _id 필드에 값을 할당한 후 OnPropertyChanged 메서드를 호출하고, OnPropertyChanged 메서드 안에서는 전달받은 Id 속성의 이름을 인수로 해서 PropertyChanged 이벤트를 발생시키는 것을 볼 수 있습니다.

INotifyPropertyChanging 인터페이스

INotifyPropertyChanged 인터페이스가 속성의 값이 변경되었음을 알린다면, INotifyPropertyChanging 인터페이스는 속성의 값이 변경되고 있음을 알리는 기능을 구현합니다.

C# 기반의 UI 프레임워크에서 MVVM 패턴 적용 시 INotifyPropertyChanged가 굉장히 중요하게 다루어지는 것과 반대로 INotifyPropertyChanging 인터페이스는 비교적 찬밥 신세인 편인데요. 값 변경을 핸들링할 수 있는 것도 아니고 일반적인 상황에서의 효용성은 낮다보니 그렇지 않을까 합니다. 그러나 값이 변경될 때 이전 값을 캐싱한다거나 하는 경우에는 유용하게 사용할 수 있겠죠?

클래스에서 INotifyPropertyChanging 인터페이스를 구현하는 경우 PropertyChanging 이벤트가 자동으로 포함되며, 값 변경 전에 앞서 PropertyChanged 이벤트를 발생시킨 방법과 유사한 방법으로 PropertyChanging 이벤트를 발생시킴으로써 기대되는 동작을 적절히 구현할 수 있습니다.

INotifyPropertyChanged 구현에 유용한 팁

CallerMemberNameAttribute는 메서드에 대한 호출자의 메서드 혹은 속성 이름을 자동으로 가져오는 특성입니다. 이를 이용하면, 위에서 INotifyPropertyChanged 인터페이스를 구현한 부분을 조금 더 간단하게 만들 수 있습니다.

public class MyViewModel : INotifyPropertyChanged
{
    private string? _id;

    public string? Id
    {
        get => _id;
        set { _id = value; OnPropertyChanged(); }
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

위 코드에서 OnPropertyChanged 메서드의 매개변수 중 propertyName을 선택적 매개변수로 변경한 후 CallerMemberName 특성을 붙인 모습을 볼 수 있는데요. 이렇게 해 주면 Id 속성의 setter에서 OnPropertyChanged 메서드를 호출할 때 본인의 속성 이름을 인수로 전달해주지 않더라도 알아서 Id의 속성 이름이 전달됩니다.

12개의 좋아요

2. 클래스

1) ObservableObject 클래스

ObservableObject 클래스는 INotifyPropertyChanged와 INotifyProertyChanging 인터페이스의 기본 구현체가 포함된 클래스로, 메신저를 제공하는 ObservableRecipient 클래스, Validation을 제공하는 ObservableValidator의 부모 클래스이기도 합니다.

ObservableObject를 상속받는 클래스에서는 필드 값 변경과 속성 변경 알림을 구현하는 SetProperty 메서드를 사용할 수 있습니다. 예를 들어 Id를 View에 바인딩해 사용하고자 한다면 다음과 같이 사용할 수 있습니다.

private string? _id;

public string? Id
{
    get => _id;
    set => SetProperty(ref _id, value);
}

앞서 설명한 INotifyPropertyChanged 메서드의 일반적인 구현체와 비교해 보면 코드가 굉장히 간략화되었다는 점을 한 눈에 알아볼 수 있습니다.

2) ObservableRecipient 클래스

ObservableRecipient 클래스는 PubSub 패턴의 메신저 관련 기능이 포함된 클래스입니다. 메시지 수신 등록 및 등록한 메시지 수신 시 처리할 동작을 정의하거나, 특정한 메시지를 송신할 수 있습니다. 특히 메모리 누수 방지를 위한 메시지 수신 취소를 제공하므로, 메신저 관련 기능을 사용할 때는 이 클래스를 상속받는 것이 권장됩니다. 이 클래스는 ObservableObject 클래스를 상속받으므로, 앞서 설명한 SetProperty 메서드를 비롯해 이후 소개할 소스 생성기 기반의 특성도 자유롭게 사용할 수 있습니다.

ObservableRecipient 클래스는 기본적인 시그니처의 SetProperty 외에 bool 타입의 변수 broadcast 를 추가 매개변수로 받는 SetProperty 메서드를 제공합니다. 이 값이 true라면 속성 값이 변경될 때 값이 변경되었음을 알리는 메시지 PropertyChangedMessage<T>를 Broadcast 하는데, 이는 조금 더 아래에서 다뤄보도록 하겠습니다.

3) ObservableValidator 클래스

ObservableValidator 클래스는 데이터 유효성 검사를 돕기 위한 기능이 포함된 클래스입니다. 값 변경 시 유효성 검사를 위해 다양한 특성 및 사용자 지정 특성을 사용할 수 있습니다.

ObservableValidator 클래스 역시 ObservableObject 클래스를 상속받으므로 ObservableObject 클래스에서 제공하는 SetProperty 메서드 등의 기능을 사용할 수 있는데요. ObservableValidator 클래스는 기본적인 시그니처의 SetProperty 외에 bool 타입의 변수 validate를 추가 매개변수로 받는 SetProperty 메서드를 제공합니다. 만약 이 값이 true라면 SetProperty 메서드 안에서 지정된 유효성 검사를 진행합니다.

예를 들어 길이가 3 이상인 문자열만 입력받으려고 한다면 다음과 같이 구성할 수 있습니다

ViewModel

public class LoginViewModel : ObservableValidator
{
    private string? _id;

    [MinLength(3)]
    public string? Id
    {
        get => _id;
        set { SetProperty(ref _id, value, true); }
    }
}

View

<TextBox Text="{Binding Id, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"/>

image

2글자 입력 시 위와 같이 유효성 검사에 실패했다는 경고가 뜨게 됩니다.

12개의 좋아요

3. 특성

CommunityToolkit.Mvvm에서는 소스 생성기가 포함되어 있는데요. 이 패키지에서 제공하는 여러 특성을 추가하면 그에 걸맞는 코드를 자동으로 생성해 줍니다. 이는 속성을 구현하는 클래스의 소스 코드를 생성하는 방식으로 동작하기 때문에, 특성을 추가하기 위해서는 클래스를 partial로 선언해야 합니다.

이 단락에서는 CommunityToolkit.Mvvm 패키지에서 제공하는 여러 특성을 소개하고 그 기능을 간략하게 알아보도록 하겠습니다.

1) ObservableProperty

ObservableProperty 특성은 ObservableObject를 상속하는 클래스 또는 INotifyPropertyChanged 등의 특성을 추가한 클래스에서 사용할 수 있습니다. 이 특성은 필드에 추가할 수 있으며, 특성 추가 시 앞서 설명한 INotifyPropertyChanged 인터페이스 기반의 값 변경 알림을 비롯해 INotifyPropertyChanging의 값 변경 중 알림 등을 구현하는 속성을 자동으로 생성합니다.

이 때 자동 생성되는 속성의 이름은 ObservableProperty 특성이 추가된 필드 변수명을 PascalCase(UpperCamelCase)로 치환한 이름이 됩니다. 따라서 ObservableProperty 특성을 추가할 필드의 변수명은 대문자로 시작할 수 없으며, lowerCamelCase여야 합니다. 다만 _나 m_의 접두사는 인식됩니다.

  • text, _text, m_text = 정상 인식(생성되는 속성명: Text)
  • Text = 컴파일 에러 발생

또한 이 특성을 추가하는 경우 값이 변경되는 중이나 값이 변경된 후 호출되는 부분 메서드를 사용할 수 있습니다. 이해를 돕기 위해 소스 생성기에 의해 생성되는 부분 클래스의 코드를 첨부해 보겠습니다.

public partial class LoginViewModel : ObservableObject
{
    [ObservableProperty]
    private string? _id;
}

// 소스 생성기로 자동 생성된 부분 클래스
partial class LoginViewModel
{
    public string? Id
    {
        get => _id;
        set
        {
            if (!global::System.Collections.Generic.EqualityComparer<string?>.Default.Equals(_id, value))
            {
                OnIdChanging(value);
                OnPropertyChanging(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangingArgs.Id);
                _id = value;
                OnIdChanged(value);
                OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.Id);
            }
        }
    }
    partial void OnIdChanging(string? value);
    partial void OnIdChanged(string? value);
}

Id 속성의 setter를 확인해 보면 앞서 설명한 값 변경에 관련된 이벤트를 발생할 메서드인 OnPropertyChanging, OnPropertyChanged 메서드 외에 OnIdChainging, OnIdChanged 메서드가 호출되는 것을 볼 수 있으며, 그 밑에는 해당 메서드가 부분 메서드의 형태로 선언되어 있는 것을 확인할 수 있습니다.

따라서 개발자는 해당 부분 메서드를 구현함으로써 해당 속성 값이 변경되는 중이나 변경된 후 추가적인 동작을 하도록 만들 수 있습니다.

public partial class LoginViewModel : ObservableObject
{
    [ObservableProperty]
    private string? _id;

	public bool CanLogin => string.IsNullOrWhiteSpace(Id);

	partial void OnIdChanged(string? value)
	{
		OnPropertyChanged(nameof(CanLogin));
	}
}

위 코드는 Id가 변경된 후 Id와 연관된 bool 타입 CanLogin 속성의 변경도 함께 Notify합니다.

8개의 좋아요

2) NotifyPropertyChangedFor

NotifyPropertyChangedFor 특성은 ObservabelObject를 상속하는 클래스 또는 INotifyPropertyChanged 등의 특성을 추가한 클래스에서 사용할 수 있습니다. 이 특성은 필드에 추가할 수 있으며, 단독으로 사용할 수 없고 ObservableProperty 특성과 함께 추가되어야 합니다. 특성 추가 시 다른 속성에 대한 값 변경 알림이 함께 구현됩니다.

public partial class LoginViewModel : ObservableObject
{
	[ObservableProperty]
	[NotifyPropertyChangedFor(nameof(CanLogin))]
	private string? _id;

	public bool CanLogin => !string.IsNullOrWhiteSpace(Id);
}

이 코드는 바로 위의 댓글에서 첨부한 코드와 동일한 동작을 합니다. Id 값 변경 시 Id 속성뿐 아니라, CanLogin 속성 변경도 함께 Notify합니다.

3) INotifyPropertyChanged, ObservableObject, ObservableRecipient

위 세 개의 특성은 클래스에 추가할 수 있습니다. 특성 추가 시 해당 클래스를 상속받지 않더라도 그 기능들을 사용할 수 있게 됩니다.

public class BaseClass
{
}

[INotifyPropertyChanged]
public partial class DerivedClass : BaseClass
{
	[ObservableProperty]
	private string? _name;
}

위 코드에서 DerivedClass는 ObservableObject 계열이 아닌 다른 클래스를 상속받음에도, INotifyPropertyChanged 특성을 추가하자 INotifyPropertyChanged 기반의 속성 변경 알림 메서드가 구현되어 ObservableProperty 특성을 추가할 수 있게 됩니다.

7개의 좋아요

4) NotifyPropertyChangedRecipients

NotifyPropertyChangedRecipients 특성은 ObservableRecipient 클래스를 상속하거나 ObservableRecipient 특성이 추가된 클래스에서만 사용할 수 있습니다. 이 특성은 필드에 추가할 수 있으며, 추가하면 값 변경 시 값이 변경되었음을 알리는 PropertyChangedMessage 메시지를 BroadCast하며, 이 메시지를 수신 등록한 클래스에서 이 값이 변경되었음을 알리는 메시지를 수신할 수 있습니다.

public partial class LoginViewModel : ObservableRecipient
{
	[ObservableProperty]
	[NotifyPropertyChangedRecipients]
	private string? _id;
}

public partial class MyViewModel : ObservableRecipient
{
    public MyViewModel()
    {
		// 메시지 수신 등록 및 활성화
		Messenger.Register<PropertyChangedMessage<string>>(this, HandleId);
    }

	private void HandleId(object o, PropertyChangedMessage<string> message)
	{
		// 메시지 수신 시 이 메서드가 실행되며,
		// 메시지에는 변경된 속성 이름 및 새 값이 포함됨
	}
}

5) NotifyDataErrorInfo

NotifyDataErrorInfo 특성은 ObservableValidator 클래스를 상속하는 클래스에서 사용할 수 있습니다. 이 특성은 ObservableProperty 특성이 추가된 필드에 추가할 수 있으며, 추가 시 입력 값 유효성 검사 및 결과 Notify를 자동으로 수행합니다. 다만 유효성 검사에 관한 특성이 추가되어 있지 않다면 컴파일 오류가 발생합니다.

ObservableValidator 클래스를 소개하며 예시로 첨부한 유효성 검사 예제는 이 특성을 이용해 다음과 같이 구현할 수 있습니다.

public partial class LoginViewModel : ObservableValidator
{
	[ObservableProperty]
	[NotifyDataErrorInfo]
	[MinLength(3)] // 입력값의 길이가 3 미만이라면 유효성 검사 오류 발생
	private string? _id;
}
7개의 좋아요

6) RelayCommand

MVVM 패턴을 이용한 개발을 해보셨다면 익숙할 만한, ICommand의 보편적인 구현체인 RelayCommand입니다. CommunityToolkit.Mvvm 패키지에서는 일반적인 RelayCommand 클래스뿐 아니라 비동기 타입의 AsyncRelayCommand 클래스도 제공하며, 커맨드를 자동 생성해주는 RelayCommand 특성도 제공합니다. 이 특성은 어떠한 클래스든 partial로 선언되었다면 사용할 수 있으며, 메서드에 추가할 수 있습니다.

RelayCommand 특성을 이용하면 Command 필드를 만들어 속성으로 제공하는 과정을 간소화할 수 있습니다.

전통적인 커맨드 노출 방식

public partial class LoginViewModel
{
	private RelayCommand? _loginCommand;

	public ICommand LoginCommand => _loginCommand ??= new RelayCommand(Login);

	private void Login()
	{
	}
}

RelayCommand 특성을 이용한 방식

public partial class LoginViewModel
{
	[RelayCommand]
	private void Login()
	{
	}
}

동기 메서드 뿐만 아니라 비동기 메서드에 대한 Command 생성도 지원합니다.

[RelayCommand]
private async Task LoginAsync()
{
}

이때 자동 생성되는 Command의 이름은 기본적으로 메서드 이름에 접미사 Command를 붙인 형태가 되는데, 구체적으로는 다음과 같은 과정을 거쳐 결정됩니다.

  1. 메서드 이름이 On으로 시작하는 경우 - On 제거
  2. 메서드 이름이 Async로 끝나며 return type이 Task인 경우 - Async 제거

즉 Task를 반환하는 메서드 OnMessageAsync에 RelayCommand 특성을 추가했을 때 생성되는 Command 이름은 MessageCommand가 됩니다.

유의점은 Async 접미사가 붙은 경우 이것이 제거되는지 여부는 메서드가 비동기인지 여부가 아니라, return type이 Task인지 여부에 따라 결정된다는 것입니다.

[RelayComand]
private Task OnMessageAsync() // CommandName: MessageCommand

[RelayCommand]
private async void OnMessageAsync() // CommandName: MessageAsyncCommand

또한 Command의 실행 가능 여부를 제어하는 CanExecute에 대한 핸들링도 쉽게 할 수 있습니다. 예를 들어 Id의 길이가 3 이상인 경우에만 LoginCommand를 실행할 수 있게 하고 싶다고 가정해 봅시다.

ViewModel

public partial class LoginViewModel : ObservableObject
{
	[ObservableProperty]
	[NotifyPropertyChangedFor(nameof(CanLogin))]
	private string? _id;

	public bool CanLogin => !string.IsNullOrWhiteSpace(Id) && Id.Length >= 3;

	[RelayCommand(CanExecute = nameof(CanLogin))]
	private void Login()
	{
	}
}

View

<StackPanel Width="100"
			VerticalAlignment="Center">
	<TextBox Text="{Binding Id, UpdateSourceTrigger=PropertyChanged}"
			 Margin="0 10"/>
	<Button Height="40"
			Content="Login"
			Command="{Binding LoginCommand}"/>
</StackPanel>

image

위에서 보는 바와 같이 TextBox에 값이 들어있지 않을 때 Login 버튼이 비활성화 된 것을 볼 수 있습니다.

그런데 위와 같은 상태에서 TextBox에 값을 입력해 보면 이상한 점을 발견할 수 있는데요. 분명 CanLogin은 Id 속성의 길이가 3 이상일 때 true를 반환하는데, 입력값을 3자 이상 입력하더라도 Login 버튼이 활성화되지 않습니다. _id 필드에 CanLogin 속성의 변경 알림도 함께 구현했음에도 불구하고 말이죠.

그 이유는, ObservableProperty 및 NotifyPropertyChangedFor 특성은 속성의 값 변경 관련 알림만 구현할 뿐, 특정한 Command의 CanExecute 변경 알림은 구현하지 않기 때문입니다. 사실 이 상태에서 필드나 속성은 자신이 어떤 Command와 연관이 있는지도 알지 못합니다.

그렇다면 Command의 CanExecute 값 변경을 쉽게 알리는 방법은 없을까요?

당연히 있습니다.

7) NotifyCanExecuteChangedFor

NotifyCanExecuteChangedFor 특성은 ObservableObject 클래스를 상속받거나 ObservableObject 특성이 추가된 클래스에서만 사용할 수 있으며, ObservableProperty 특성이 추가된 필드에 추가할 수 있습니다. 이 특성은 Command의 이름(string)을 매개변수로 받는데, 특성을 추가하면 속성 값 변경 시 해당 Command의 CanExecute 변경도 함께 Notify합니다.

public partial class LoginViewModel : ObservableObject
{
	[ObservableProperty]
	[NotifyPropertyChangedFor(nameof(CanLogin))]
	[NotifyCanExecuteChangedFor(nameof(LoginCommand))]
	private string? _id;

	public bool CanLogin => !string.IsNullOrWhiteSpace(Id) && Id.Length >= 3;

	[RelayCommand(CanExecute = nameof(CanLogin))]
	private void Login()
	{
	}
}

위 코드에서 Login 메서드로부터 생성된 LoginCommand는 CanLogin 속성의 반환값에 따라 실행 여부가 결정되며, CanLogin은 Id 속성에 의해 값이 결정됩니다. 따라서 Id 속성이 변경될 때마다 LoginCommand의 CanExecuteChanged가 호출되며, LoginCommand에서 CanLogin 속성의 반환값을 확인해 Command의 실행 가능 여부를 확인하게 됩니다.

image

이제 입력값이 3자 이상일 때 Login 버튼이 정상적으로 활성화됩니다.

8개의 좋아요

4. 기능

CommunityToolkit.Mvvm 패키지에서는 MVVM 패턴 적용을 위한 여러 기능이 포함되어 있습니다. 앞서 설명한 ObservableProperty 특성을 비롯한 값 변경 외에도 Ioc 컨테이너나, 앞서 간략하게 짚고 넘어갔던 Command나 메신저, 유효성 검사 등이 바로 그것입니다.

MVVM 패턴을 적용해 개발하다 보면 데이터 바인딩만으로는 해결할 수 없는 여러 난관을 마주하게 됩니다. CommunityToolkit.Mvvm에서 제공하는 여러 요소는 이러한 문제 해결에 도움이 될 수 있습니다.

이 단락에서는 CommunityToolkit.Mvvm 패키지에서 제공하는 여러 API를 특정한 주제로 묶어 알아보도록 하겠습니다.

1) Command

앞서 설명한 것처럼 CommunityToolkit.Mvvm 패키지에는 ICommand의 구현체인 RelayCommand가 포함되어 있습니다. 정확히 말하면 IRelayCommand라는 인터페이스 아래 return type이 void인지 Task인지에 따라 RelayCommand와 AsyncRelayCommand로 구체화됩니다.

  • RelayCommand = Action 또는 Action<T>
  • AsyncRelayCommand = Func<Task> 또는 Func<T, Task>

RelayCommand<T>

메서드에 매개변수가 있다면 Command 생성 시 제네릭을 이용해 매개변수를 Command Parameter로 노출할 수 있습니다.

ViewModel

public partial class LoginViewModel : ObservableObject
{
	private RelayCommand<string>? _addCommand;

	public IRelayCommand AddCommand => _addCommand ??= new RelayCommand<string>(Add);

	private void Add(string? line)
	{
	}
}
//or
public partial class LoginViewModel : ObservableObject
{
	[RelayCommand]
	private void Add(string? line)
	{
	}
}

View

<StackPanel VerticalAlignment="Center"
			HorizontalAlignment="Center">
	<TextBox x:Name="InputTextBox"/>
	<Button Width="100"
			Height="40"
			Content="Add"
			Command="{Binding AddCommand}"
			CommandParameter="{Binding Path=Text, ElementName=InputTextBox}"/>
</StackPanel>

위 코드 실행 시, Button을 클릭한다면 TextBox의 Text에 저장된 문자열이 ViewModel의 Add 메서드에 전달됩니다.

AsyncRelayCommand의 취소

비동기 메서드를 익숙하게 사용하시는 분들이라면 CancellationToken을 이용해 Task를 취소하는 기능을 구현해 본 경험이 있으실 텐데요. CommunityToolkit.Mvvm에서 제공하는 AsyncRelayCommand는 CancellationToken을 이용한 Task 취소도 제공합니다. 구현도 매우 간편한 것이, 비동기 메서드의 마지막 매개변수로 CancellationToken 타입 변수가 포함되기만 하면 됩니다.

public partial class LoginViewModel : ObservableObject
{
	private AsyncRelayCommand? _loginCommand;
	private RelayCommand? _cancelCommand;

	public IAsyncRelayCommand LoginCommand => _loginCommand ??= new AsyncRelayCommand(LoginAsync);
	public RelayCommand CancelCommand => _cancelCommand ??= new RelayCommand(Cancel);

	private async Task LoginAsync(CancellationToken cancellationToken)
	{
		try
		{
			// 작업
		}
		catch (TaskCanceledException)
		{
			// Task가 취소되었을 때 핸들링
		}
	}

	private void Cancel()
	{
		LoginCommand.Cancel();
	}
}

AsyncRelayCommand에는 Cancel 메서드가 있는데, 이 Cancel 메서드를 호출하면 매개변수로 넘긴 CancellationToken의 상태가 취소 요청으로 변경되어 이 CancellationToken을 사용하는 비동기 메서드에서 Task 취소가 요청되었음을 알 수 있습니다.

또한 RelayCommand 특성에 포함된 IncludeCancelCommand 속성을 이용하면 별도의 Cancel Command를 만들어주지 않아도 소스 생성기가 취소 커맨드를 알아서 만들어 줍니다. 이때 자동 생성되는 Cancel Command의 이름은 앞서 설명한 RelayCommand 특성 추가 시의 Command 명명 방식과 유사하게 명명되나, 메서드 이름과 Command 접미사 사이에 Cancel이 포함되는 것이 다릅니다.

public partial class LoginViewModel : ObservableObject
{
	// CommandName: LoginCommand
	// CancelCommandName: LoginCancelCommand
	[RelayCommand(IncludeCancelCommand = true)]
	private async Task LoginAsync(CancellationToken cancellationToken)
	{
		try
		{
		}
		catch (TaskCanceledException)
		{
		}
	}
}

CanExecute

앞서 설명했듯이 CommunityToolkit.Mvvm 패키지에서는 Command의 실행 가능 여부를 나타내는 CanExecute 처리를 도와주는 여러 요소를 제공합니다. 앞서 설명한 RelayCommand 특성의 CanExecute 속성과 NotifyCanExecuteChangedFor 특성이 대표적인 요소입니다.

RelayCommand의 CanExecute 속성은 해당 커맨드의 실행 가능 여부(CanExecute)를 나타내는, return type이 bool 인 속성 혹은 메서드의 이름을 넣으면 됩니다.

예를 들어 로그인 폼을 만들고자 할 때 ID가 비어 있지 않고, Password가 6자 이상일 때만 로그인을 시도할 수 있게 만들고 싶다고 가정해 보겠습니다. RelayCommand 특성과 NotifyCanExecuteChangedFor 특성을 적절히 이용하면 다음과 같이 만들 수 있습니다.

public partial class LoginViewModel : ObservableObject
{
	[ObservableProperty]
	[NotifyCanExecuteChangedFor(nameof(LoginCommand))]
	private string? _id;

	[ObservableProperty]
	[NotifyCanExecuteChangedFor(nameof(LoginCommand))]
	private string? _password;

	public bool CanLogin => !string.IsNullOrWhiteSpace(Id) && Password is { Length: >= 6 };

	[RelayCommand(CanExecute = nameof(CanLogin))]
	private void Login()
	{
	}
}

위 코드에서 Id 혹은 Password 속성값이 변경된 경우, 다음 순서로 동작합니다.

  1. Id 혹은 Password 속성값 변경(속성의 setter 호출)
  2. LoginCommand의 NotifyCanExecuteChanged 메서드 호출
  3. 커맨드에 바인딩된 클라이언트에서 LoginCommand.CanExecute() 호출(CanLogin의 getter가 호출됨)
  4. CanLogin이 true인 경우 커맨드에 바인딩된 클라이언트에서 적절한 작업이 수행됨(Button인 경우 Enable/Disable 처리 등)
6개의 좋아요

2) Messenger

Messenger는 Pub/Sub 패턴의 모듈로, 서로 다른 객체간 메시지를 주고 받을 수 있게 합니다. CommunityToolkit.Mvvm 패키지에서는 IMessenger 인터페이스를 기본 규약으로 해서, 약한 참조에 기반한 WeakReferenceMessenger와 강한 참조에 기반한 StrongReferenceMessenger를 제공합니다.

WeakReferenceMessenger는 메시지 수신 대기를 취소하는 과정을 따로 해주지 않아도 자동으로 메모리를 해제하며(다만 공식 문서에서는 그렇더라도 명시적으로 취소하는 것을 권장하고 있습니다.), StrongReferenceMessenger는 메모리 누수를 방지하기 위해 더 이상 메시지 수신이 필요하지 않을 때 명시적으로 등록을 취소하는 작업이 필요한 대신, WeakReferenceMessenger에 비해 더 나은 성능과 더 적은 메모리 사용량을 갖습니다.

메시지 수신 등록

메시지를 등록하는 방법에는 IRecipient 인터페이스를 구현한 후 수신자 클래스 자신을 등록하는 방법과, 특정한 메시지에 대한 메시지 핸들러를 등록하는 두 가지 방법이 있습니다.

문자열 값이 변경되었음을 알리는 ValueChangedMessage<string> 메시지를 수신하려면 다음과 같이 작성할 수 있습니다.

1) IRecipient 인터페이스를 이용한 등록
public partial class AnotherViewModel : ObservableObject, IRecipient<ValueChangedMessage<string>>
{
	[ObservableProperty]
	private string? _receivedValue;

	public AnotherViewModel()
	{
		// IRecipient 인터페이스를 하나만 구현하는 경우
		// 따로 Generic 타입을 지정해주지 않아도 IRecipient<T> 타입을 알아서 인식
		WeakReferenceMessenger.Default.Register(this);
	}

	public void Receive(ValueChangedMessage<string> message)
	{
		ReceivedValue = message.Value;
	}
}
2) Messenger Handler를 지정해 등록하는 방법
public partial class AnotherViewModel : ObservableObject
{
	[ObservableProperty]
	private string? _receivedValue;

	public AnotherViewModel()
	{
		WeakReferenceMessenger.Default.Register<ValueChangedMessage<string>>(this, HandleMessage);
	}

	private void HandleMessage(object recipient, ValueChangedMessage<string> message)
	{
		ReceivedValue = message.Value;
	}
}

만약 수신을 원하는 메시지에 맞게 IRecipient 인터페이스를 구현한다면 메시지 등록을 다음과 같이 간소화할 수 있습니다.

public partial class AnotherViewModel : ObservableObject,
	IRecipient<ValueChangedMessage<bool>>,
	IRecipient<RequestMessage<string>>,
	IRecipient<PropertyChangedMessage<int>>
{
	public AnotherViewModel()
	{
		WeakReferenceMessenger.Default.RegisterAll(this);
	}

	public void Receive(ValueChangedMessage<bool> message)
	{
	}

	public void Receive(RequestMessage<string> message)
	{
	}

	public void Receive(PropertyChangedMessage<int> message)
	{
	}
}

RegisterAll(object recipient) 메서드는 IRecipient 인터페이스로 구현한 모든 메시지의 수신을 등록하기 때문에, AnotherViewModel 클래스는 이제 세 가지 타입의 메시지를 수신할 수 있습니다.

만약 ObservableRecipient 클래스를 상속한다면 더 간소화할 수 있습니다.

public partial class AnotherViewModel : ObservableRecipient,
	IRecipient<ValueChangedMessage<bool>>,
	IRecipient<RequestMessage<string>>,
	IRecipient<PropertyChangedMessage<int>>
{
	public AnotherViewModel()
	{
		// IsActive -> OnActivated -> Messenger.RegisterAll(this)
		IsActive = true;
	}
	// 이하 Receive 메서드는 동일
}

ObservableRecipient 클래스는 내부적으로 IMessenger 타입의 속성 Messenger를 가지며, 생성자 안에서 Messenger에 WeakReferenceMessenger.Default 객체를 할당합니다. 그 외에 ObservableRecipient 클래스의 생성자를 오버로딩함으로써 원하는 IMessenger 객체를 할당할 수도 있습니다.

메시지의 종류

메시지 송수신은 CommunityToolkit.Mvvm 패키지에서 기본적으로 제공하는 메시지를 이용하는 방법과, 사용자 지정 메시지를 이용하는 방법이 있습니다.

CommunityToolkit.Mvvm 패키지에서 기본적으로 제공하는 메시지는 다음과 같습니다.

  • ValueChangedMessage<T> - 값이 변경되었음을 알림
  • PropertyChangedMessage<T> - 속성 값이 변경되었음을 알림
  • RequestMessage<T> - 특정 타입의 값 반환을 요청
  • AsyncRequestMessage<T> - RequestMessage의 비동기 방식
  • CollectionRequestMessage<T> - 특정 타입의 값 반환을 여러 번 요청
  • AsyncCollectionRequestMessage<T> - CollectionRequestMessage의 비동기 방식

CollectionRequestMessage나 AsyncCollectionRequestMessage는 값을 여러 번 요청한다는 것이 선뜻 이해되지 않을 수도 있는데요. 이는 아래에서 예제 코드와 함께 알아보도록 하겠습니다.

7개의 좋아요

메시지 송신

IMessenger.Send<T> 메서드를 통해 메시지를 송신할 수 있습니다. T타입의 메시지를 송신하면 해당 타입 메시지를 등록한 수신자 클래스에서 이를 받아볼 수 있습니다.

public partial class LoginViewModel : ObservableObject
{
	private bool _state;

	[RelayCommand]
	private void OnStateChanged()
	{
		ValueChangedMessage<bool> message = new ValueChangedMessage<bool>(_state);
		WeakReferenceMessenger.Default.Send(message);
	}
}
public partial class AnotherViewModel : ObservableObject
{
    public AnotherViewModel()
    {
		WeakReferenceMessenger.Default.Register<ValueChangedMessage<bool>>(this, (o, m) => OnStateChangedMessage(m));
    }

	private void OnStateChangedMessage(ValueChangedMessage<bool> message)
	{
		// Handle Message...
	}
}

사용자 지정 메시지 송신

CommunityToolkit.Mvvm 패키지에서 기본적으로 제공하는 메시지 외에 사용자 지정 메시지를 송/수신 할 수도 있습니다. 별도의 클래스나 인터페이스를 상속받거나 구현할 필요가 없으며, 메시지의 자료형이 참조 형식이기만 하면 됩니다.

public partial class AnotherViewModel : ObservableRecipient, IRecipient<ProductChangedMessage>
{
	public AnotherViewModel()
	{
		IsActive = true;
	}

	public void Receive(ProductChangedMessage message)
	{
	}
}

public class ProductChangedMessage
{
    public ProductChangedMessage(Product product)
    {
        Product = product;
    }

    public Product Product { get; set; }
}

Request/Reply

CommunityToolkit.Mvvm 패키지에서 제공하는 일부 메시지는 어떠한 값을 요청하고, 수신자 측에서 이 메시지에 답장하는 형식을 통해 값을 전달하는데 사용할 수 있도록 하고 있습니다. 이러한 메시지에는 값을 전달할 수 있는 Reply<T>(T response) 메서드가 포함됩니다.

public partial class SenderViewModel : ObservableObject
{
	[RelayCommand]
	private void Request()
	{
		RequestMessage<string> message = new RequestMessage<string>();
		WeakReferenceMessenger.Default.Send(message);
		HandleResponse(message.Response);
	}
}
public partial class RecipientViewModel : ObservableRecipient,
	IRecipient<RequestMessage<string>>
{
	private string _value = "SomeValue";

	public RecipientViewModel()
	{
		IsActive = true;
	}

	public void Receive(RequestMessage<string> message)
	{
		message.Reply(_value);
	}
}
6개의 좋아요

비동기 Request/Reply

앞서 설명한 Request/Reply는 비동기 방식으로도 이용할 수 있습니다.

public partial class SenderViewModel : ObservableObject
{
	[RelayCommand]
	private async Task RequestAsync()
	{
		AsyncRequestMessage<string> message = new AsyncRequestMessage<string>();

		// Send 혹은 Response 중 하나만 대기해도 가능
		await WeakReferenceMessenger.Default.Send(message);
		var response = await message.Response;

		HandleResponse(response);
	}
}

public partial class RecipientViewModel : ObservableRecipient,
	IRecipient<AsyncRequestMessage<string>>
{
	private string _value = "SomeValue";

	public RecipientViewModel()
	{
		IsActive = true;
	}

	public void Receive(AsyncRequestMessage<string> message)
	{
		message.Reply(ProvideValueAsync());
	}

	private async Task<string> ProvideValueAsync()
	{
		await Task.Delay(5000);
		return _value;
	}
}

CollectionRequestMessage, AsyncCollectionRequestMessage

값 회신이 여러 번 이루어져야 하는 상황이라면 CollectionRequestMessage 및 AsyncCollectionRequestMessage가 유용할 수 있습니다.

이 메시지들이 쓰이는 일은 거의 없을 듯하네요

1) CollectionRequestMessage
public partial class SenderViewModel : ObservableObject
{
	[RelayCommand]
	private void Request()
	{
		CollectionRequestMessage<int> message = new CollectionRequestMessage<int>();
		WeakReferenceMessenger.Default.Send(message);
		foreach (var value in message /* or message.Responses */)
		{
			HandleResponse(value);
		}
	}
}

public partial class RecipientViewModel : ObservableRecipient,
	IRecipient<CollectionRequestMessage<int>>
{
	public RecipientViewModel()
	{
		IsActive = true;
	}

	public void Receive(CollectionRequestMessage<int> message)
	{
		for (int i = 0; i < 5; i++)
		{
			message.Reply(i);
		}
	}
}
2) AsyncCollectionRequestMessage
public partial class SenderViewModel : ObservableObject
{
	[RelayCommand]
	private async Task RequestAsync()
	{
		AsyncCollectionRequestMessage<int> message = new AsyncCollectionRequestMessage<int>();
		WeakReferenceMessenger.Default.Send(message);
		await foreach (var value in message)
		{
			HandleResponse(value);
		}
	}
}

public partial class RecipientViewModel : ObservableRecipient,
	IRecipient<AsyncCollectionRequestMessage<int>>
{
	public RecipientViewModel()
	{
		IsActive = true;
	}

	public void Receive(AsyncCollectionRequestMessage<int> message)
	{
		for (int i = 0; i < 5; i++)
		{
			message.Reply(ProvideValueAtRandomTimeAsync(i));
		}
	}

	private async Task<int> ProvideValueAtRandomTimeAsync(int value)
	{
		int ms = Random.Shared.Next(500, 5000);
		await Task.Delay(ms);
		return value;
	}
}
6개의 좋아요

3) Ioc 컨테이너

CommunityToolkit.Mvvm 패키지에는 제어의 역전(Ioc)를 돕는 thread-safe한 Ioc 클래스가 포함되어 있습니다. Ioc 클래스의 ConfigureServices(IServiceProvider) 메서드를 호출하고 IServiceProvider 객체를 전달함으로써 서비스를 구성할 수 있습니다.

서비스 등록 및 Ioc 컨테이너 구성

Microsoft.Extensions.DependencyInjection 패키지를 이용해 IServiceProvider를 만들고, 이를 Ioc 컨테이너에 넣으려면 다음과 같이 할 수 있습니다.

public interface ILoginService
{
	bool Login(string id, string password);
}
public class LoginService : ILoginService
{
    public bool Login(string id, string password)
	{
		return LoginInternal(id, password);
	}
}
public partial class App : Application
{
	public App()
	{
		IServiceProvider serviceProvider = ConfigureServices();
		Ioc.Default.ConfigureServices(serviceProvider);
	}

	private IServiceProvider ConfigureServices()
	{
		ServiceCollection services = new();

		// 원하는 서비스를 전략에 맞는 수명 주기로 등록
		services.AddSingleton<ILoginService, LoginService>();
		services.AddTransient<LoginViewModel>();

		return services.BuildServiceProvider();
	}
}

Ioc 컨테이너를 이용해 의존성 해결하기

만약 LoginViewModel 클래스에서 ILoginService를 주입받고자 한다고 가정해 봅시다. 위와 같이 서비스를 등록한 후 Ioc 컨테이너에 알맞게 등록했다면 다음과 같이 간단하게 의존성 주입에 의한 제어 역전이 달성됩니다.

ViewModel

public partial class LoginViewModel : ObservableObject
{
	private readonly ILoginService _loginService;

	public LoginViewModel(ILoginService loginService)
	{
		// ILoginService의 구현체로 LoginService 클래스를 등록했으므로
		// LoginService 객체가 주입됨
		_loginService = loginService;
		// 생성자 매개변수로 전달받는 대신 아래도 가능합니다.
		// _loginService = Ioc.Default.GetRequiredService<ILoginService>();
	}
}

View

public partial class LoginView : UserControl
{
	public LoginView()
	{
		DataContext = Ioc.Default.GetService<LoginViewModel>();
		InitializeComponent();
	}
}

CommunityToolkit.Mvvm의 Ioc 클래스에서 서비스를 Resolve 하는 방법은 두 가지가 있습니다.

  • GetService<T> - 서비스가 등록되지 않았다면 null 반환
  • GetRequiredService<T> - 서비스가 등록되지 않았다면 InvalidOperationException 예외 발생
6개의 좋아요

이것의 경우 ObservablePropertyAttribute를 주로 사용하는 분들께는

NotifyDataErrorInfoAttribute를 붙여줘야 합니다.


image

엥 여기 써놓으셨네요.

왜 검색하니까 안나왔었지…??

image

3개의 좋아요

4) 유효성 검사

앞서 데이터의 유효성을 검사하기 위해 ObservableValidator에서 제공하는 SetProperty<T>(ref T, T, bool) 메서드를 이용하는 방법과 NotifyDataErrorInfo 특성을 이용하는 방법을 알아봤습니다.

다시 한 번 정리해 보고 갈까요?

SetProperty 메서드 이용
public class LoginViewModel : ObservableValidator
{
    private string? _id;

    [MinLength(3)]
    public string? Id
    {
        get => _id;
        set { SetProperty(ref _id, value, true); }
    }
}
NotifyDataErrorInfo 특성 이용
public partial class LoginViewModel : ObservableValidator
{
	[ObservableProperty]
	[NotifyDataErrorInfo]
	[MinLength(3)]
	private string? _id;
}

그 외에도 System.ComponentModel.DataAnnotations 네임스페이스에 포함된 여러 특성을 이용해 유효성 검사를 진행할 수 있답니다.

유효한 데이터만 업데이트하기

TrySetProperty<T> 메서드를 이용하면 속성 업데이트 시도 시, 지정된 유효성 검사가 성공하는 경우에만 속성 값이 설정되도록(그리고 속성 값 변경 알림이 발생하도록) 할 수 있습니다.

예를 들어 0부터 9까지만 입력받고자 하는 경우, 다음과 같이 작성할 수 있습니다.

public partial class LoginViewModel : ObservableValidator
{
	private string? _numbersOnly;

	[RegularExpression(@"^[0-9]*$")]
	public string? NumbersOnly
	{
		get => _numbersOnly;
		set => TrySetProperty(ref _numbersOnly, value, out var errors);
	}
}

이제 TextBox.Text를 NumersOnly 속성에 바인딩한 후 아무 값이나 입력해 보면 붉은 테두리로 유효성 검사 실패 여부를 알려주던 이전과 달리 유효하지 않은 데이터는 아예 입력되지 않으며, 0부터 9까지의 숫자만 입력되는 것을 확인할 수 있을 것입니다.

5개의 좋아요

사용자 지정 유효성 검사

System.ComponentModel.DataAnnotations 네임스페이스에 포함된 여러 특성 외에 사용자 지정 유효성 검사도 가능합니다. 사용자 지정 특성(ValidationAttribute)을 이용하는 방법과, CustomValidation 특성 및 유효성 검사용 메서드를 이용하는 방법을 이용하는 두 가지 방법이 있습니다.

사용자 지정 유효성 검사 특성을 정의하는 방법

ValidationAttribute를 상속받는 특성을 만들고 IsValid 메서드를 재정의함으로써 개발자가 직접 유효성 검사를 구현할 수 있습니다. 일반적인 사용법대로 특성을 부착하기만 하면 되기 때문에 여러 곳에 재사용할 수 있습니다.

public class IdValidation : ValidationAttribute
{	
	protected override ValidationResult? IsValid(object? id, ValidationContext validationContext)
	// protected override ValidationResult? IsValid(object? id)
	{
		if (MyValidation(id))
		{
			return ValidationResult.Success;
		}

		return new ValidationResult("My error message.");
	}
}

public partial class LoginViewModel : ObservableValidator
{
	[ObservableProperty]
	[NotifyDataErrorInfo]
	[IdValidation]
	private string? _id;
}
CustomValidation 특성과 별도의 유효성 검사용 메서드를 이용하는 방법

CustomValidation 특성은 유효성 검사용 메서드를 포함하는 타입과, 유효성 검사용 메서드의 이름을 필수 매개변수로 받습니다.

유효성 검사에 사용할 메서드는 반드시 ValidationResult 타입을 반환해야 하며, 검사 대상과 동일한 타입을 매개변수로 받아야 합니다. 예를 들어 문자열 타입 속성에 대해 유효성 검사를 진행하는 메서드인데 매개변수 타입이 string이 아닌 int 등이라면 메서드가 호출되지 않습니다. 또한 유효성 검사에 사용할 메서드는 반드시 public이어야 하며, 정적 메서드여야 합니다.


유효성 검사용 메서드 구현 시 주의사항

  • 메서드는 ValidationResult 타입을 반환해야 함

  • 검사 대상 속성과 동일한 타입을 매개변수로 받아야 함

  • 메서드는 반드시 publicstatic이어야 함


사용자 지정 특성을 이용하는 방법과 비교해 유효성 검사 메서드를 이용하는 방법의 특징은 유효성 검사를 수행하는 객체의 필드, 속성, 메서드 등에 쉽게 접근할 수 있다는 것입니다. 메서드의 매개변수로 ValidationContext 타입을 받은 후, 해당 변수의 ObjectInstance 속성을 통해 객체에 접근할 수 있는 것은 두 방법 모두 동일하지만, 필연적으로 대상의 타입에 대한 의존성이 발생한다는 점을 고려할 때 다른 어셈블리에 위치할 수도 있는 사용자 지정 특성보다는 해당 타입 안에서 유효성 검사를 구현하는 이 방법이 조금 더 깔끔하다고 생각됩니다.

public partial class LoginViewModel : ObservableValidator
{
	private readonly ILoginService _loginService;

	[ObservableProperty]
	[NotifyDataErrorInfo]
	[CustomValidation(typeof(LoginViewModel), nameof(ValidateId))]
	private string? _id;

	public LoginViewModel(ILoginService loginService)
	{
		_loginService = loginService;
	}

	public static ValidationResult? ValidateId(string? id, ValidationContext context)
	{
		if (context.ObjectInstance is not LoginViewModel viewModel)
		{
			return new ValidationResult("Invalid operation.");
		}

		bool isValid = viewModel._loginService.ValidateId(id);
		if (isValid)
		{
			return ValidationResult.Success;
		}

		return new ValidationResult("My validation error message.");
	}
}

위 코드는 뷰 모델에 주입된 서비스를 이용해 문자열을 Validation 하는 예제 코드입니다. 만약 유효성 검사에 성공했다면 ValidationResult.Success를 반환하고, 실패했다면 에러 메시지를 반환합니다.

5개의 좋아요

제가 소개할 CommunityToolkit.Mvvm 패키지의 구성 요소 및 활용법은 여기까지입니다.

CommunityToolkit.Mvvm 패키지는 플랫폼 독립적이며, 사용하기에 따라 굉장히 간단하면서도 유연한 방법으로 MVVM 패턴을 적용할 수 있습니다. 또한 오픈소스로 작업되며 꾸준히 업데이트되고 있어 버그 수정 및 성능 향상에 유리한 점이 많습니다.

제가 미처 소개하지 못한 다른 활용법도 있을 수 있으며 역량에 따라 무궁무진한 가능성이 있다고 생각하기 때문에, MVVM 프레임워크 선택 시 CommunityToolkit.Mvvm을 선택지에 넣는 것을 적극 권장하고 싶습니다.

또한 CommunityToolkit.Mvvm을 사용하고 계신 분들께서 이 글을 읽어 주신다면 각자만의 크고 작은 팁을 공유해 보는 것도 괜찮을 것 같습니다:blush:

긴 글 읽어 주신 모든 분들께 감사드립니다.

8개의 좋아요

@루나시아 "살펴보기"가 아니라 “마스터하기” 아닌가요?

내용 하나하나가 주옥 같습니다. :smile:
두고두고 읽겠습니다. 감사합니다!!

3개의 좋아요

5. 변경사항

1) 8.2.0

ObservableProperty 특성의 부분 메서드 확장

ObservableProperty 특성이 부착된 속성의 OnChanging, OnChanged 부분 메서드는 값이 변경되는 중이나 값이 변경된 후 호출되어 값 변경 시 추가적인 로직을 실행할 수 있게 해줍니다. 그러나 8.1.0까지의 부분 메서드는 속성에 새로 할당되는 신규 값만을 받기 때문에 일부 시나리오에서 불편한 점이 존재했는데요.

가령 이전 값과 신규 값이 모두 필요한 일부 상황에서는 다음과 같이 구현해야 했습니다.

public partial class MyViewModel : ObservableObject
{
	private string? _oldMyProperty;

	[ObservableProperty]
	private string? _myProperty;

	partial void OnMyPropertyChanging(string? value)
	{
		_oldMyProperty = MyProperty;
	}

	partial void OnMyPropertyChanged(string? value)
	{
		HandleMyProperty(_oldMyProperty, value);
	}
}

8.2.0부터는 기존의 부분 메서드에 더해, 이전 값과 신규 값을 모두 전달받는 부분 메서드 구현을 지원합니다.

partial void OnMyPropertyChanging(string? value);
partial void OnMyPropertyChanging(string? oldValue, string? newValue);
partial void OnMyPropertyChanged(string? value);
partial void OnMyPropertyChanged(string? oldValue, string? newValue);

패키지 버전을 8.2.0으로 업그레이드 했다면, 앞서 살펴본 시나리오는 이제 다음과 같이 구현할 수 있습니다.

public partial class MyViewModel : ObservableObject
{
	[ObservableProperty]
	private string? _myProperty;

	partial void OnMyPropertyChanged(string? oldValue, string? newValue)
	{
		HandleMyProperty(oldValue, newValue);
	}
}

RelayCommand 특성에 대한 사용자 지정 특성

8.2.0부터는 RelayCommand 특성에 의해 생성되는 Command 속성에 사용자 지정 특성을 적용할 수 있습니다. RelayCommand를 부착할 때 property: 특성을 함께 부착하면, 소스 생성기에 의해 생성되는 Command 속성에 해당 특성이 부착됩니다.

public partial class MyViewModel : ObservableObject
{
	[RelayCommand)]
	[property: MyAttribute]
	private void Foo()
	{

	}
}

위 코드는 다음 코드를 생성합니다.

// 소스 생성기에 의해 생성되는 코드
partial class MyViewModel
{
    [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.RelayCommandGenerator", "8.2.0.0")]
    private global::CommunityToolkit.Mvvm.Input.RelayCommand? fooCommand;instance wrapping <see cref="Foo"/>.</summary>

    [global::System.CodeDom.Compiler.GeneratedCode("CommunityToolkit.Mvvm.SourceGenerators.RelayCommandGenerator", "8.2.0.0")]
    [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
    [global::MyNamespace.MyAttribute()] // <- 사용자 지정 특성
    public global::CommunityToolkit.Mvvm.Input.IRelayCommand FooCommand => fooCommand ??= new global::CommunityToolkit.Mvvm.Input.RelayCommand(new global::System.Action(Foo));
}
6개의 좋아요