Microsoft MVP성태의 닷넷 이야기
.NET Framework: 558. WPF - ICommand 동작 방식 [링크 복사], [링크+제목 복사]
조회: 9907
글쓴 사람
정성태 (kevin13@chol.net)
홈페이지
첨부 파일

WPF - ICommand 동작 방식

그러고 보니, 제 블로그에서 ICommand를 다룬 적이 한번도 없군요. ^^;

이에 대해서는, 일단 다음의 글이 시작점으로 좋습니다.

ICommand is like a chocolate cake
; http://blogs.msdn.com/b/mikehillberg/archive/2009/03/20/icommand-is-like-a-chocolate-cake.aspx

정리해 보면, ICommand는 3개의 멤버를 포함하는 간단한 인터페이스입니다.

public interface ICommand
{
    void Execute(object parameter);
    bool CanExecute(object parameter);
    event EventHandler CanExecuteChanged;
}

인터페이스이니까, ICommand가 의미가 있으려면 호출하는 측과 호출당하는 측 모두 ICommand에 대한 계약을 완료해야 합니다.

우선, 호출당하는 측은 ICommand를 구현한 클래스를 정의하고 이에 대한 인스턴스를 호출하는 측으로 전달해야 합니다. 대충 다음과 같이 구현할 수 있습니다.

using System;
using System.Windows.Input;

class Program
{
    static void Main(string[] args)
    {
        RelayCommand cmd = new RelayCommand();
    }
}

public class RelayCommand : ICommand
{
    public event EventHandler CanExecuteChanged;

    public bool CanExecute(object parameter)
    {
        return true;
    }

    public void Execute(object parameter)
    {
        Console.WriteLine(DateTime.Now + ": RelayCommand.Execute - " + parameter);
    }
}

그럼, 호출하는 측과 함께 ICommand 인터페이스를 이용해 서로 연동하는 부분도 구현해 보겠습니다.

using System;
using System.Windows.Input;

class Program
{
    static void Main(string[] args)
    {
        RelayCommand cmd = new RelayCommand();

        ConsolePrompt prompt = new ConsolePrompt();
        prompt.Command = cmd;
        prompt.Run();
    }
}

public class ConsolePrompt
{
    public ICommand Command
    {
        get; set;
    }

    public void Run()
    {
        while (true)
        {
            string txt = Console.ReadLine();
            if (string.IsNullOrEmpty(txt) == true)
            {
                break;
            }

            Command.Execute(txt);
        }
    }
}

간단하죠? ^^ 저런 식으로 ICommand 인터페이스가 서로 다른 객체간에 명령 전달 및 처리를 구현할 수 있습니다. ICommand의 구현 의미는, 위에서 보는 바와 같이 ConsolePrompt로부터 "실행해야 할 코드"를 외부로 분리했다는 정도입니다. 물론, 기존에도 delegate를 전달하거나 event를 정의함으로써 유사한 구현을 했습니다. 단지, ICommand는 이것을 인터페이스로 체계화했을 뿐입니다. 따라서, ICommand를 사용하는 WPF 계열 응용 프로그램을 만드는 경우가 아니라면 사실 알 필요는 거의 없습니다.




위에서 구현하지 않은 ICommand.CanExecute에 좀더 살을 붙여볼까요? 가령 하루 중 오전 9시 ~ 10시 사이에는 명령어 처리를 할 수 없다고 가정해 다음과 같이 코딩할 수 있습니다.

public class ConsolePrompt
{
    // ... [생략] ...

    public void Run()
    {
        while (true)
        {
            string txt = Console.ReadLine();
            if (string.IsNullOrEmpty(txt) == true)
            {
                break;
            }

            if (Command.CanExecute(null) == true)
            {
                Command.Execute(txt);
            }
        }
    }
}

public class RelayCommand : ICommand
{
    public event EventHandler CanExecuteChanged;

    public bool CanExecute(object parameter)
    {
        return DateTime.Now.Hour != 9;
    }

    public void Execute(object parameter)
    {
        Console.WriteLine(DateTime.Now + ": RelayCommand.Execute - " + parameter);
    }
}

즉, 호출하는 측에서도 CanExecute를 호출해 상황 파악을 하는 배려가 있어야 상호 동작이 의도한대로 잘 되는 것입니다.

그렇다면 CanExecuteChanged는 언제 사용할까요?

위에서 ConsolePrompt는 명령을 실행하기 전 매번 CanExecute를 호출해 명령 처리가 가능한지를 조회해야 했는데요. 이를 CanExecute를 호출해야 할 필요가 있을 때에만, 즉 객체의 내부 환경에서 Command 실행에 영향을 주는 상태가 변화한 딱 그 시점에만 호출하도록 할 수 있습니다.

콘솔 예제라서 다소 억지스럽지만 타이머를 이용해 8시에서 9시로, 9시에서 10시로 변경하는 그 시점에만 ConsolePrompt가 CanExecute로 조회하도록 다음과 같이 바꿀 수 있습니다.

public class ConsolePrompt
{
    public ICommand Command
    {
        get; set;
    }

    public void Run()
    {
        _commandEnabled = Command.CanExecute(null);
        Command.CanExecuteChanged += Command_CanExecuteChanged;

        while (true)
        {
            string txt = Console.ReadLine();
            if (string.IsNullOrEmpty(txt) == true)
            {
                break;
            }

            if (_commandEnabled == true)
            {
                Command.Execute(txt);
            }
        }
    }

    bool _commandEnabled = false;

    private void Command_CanExecuteChanged(object sender, EventArgs e)
    {
        _commandEnabled = Command.CanExecute(null);
    }
}

public class RelayCommand : ICommand
{
    public event EventHandler CanExecuteChanged;

    public RelayCommand()
    {
        Thread t = new Thread((e) =>
        {
            RelayCommand cmd = e as RelayCommand;
            int old = DateTime.Now.Hour;

            while (true)
            {
                int current = DateTime.Now.Hour;

                if ((old == 8 && current == 9) || (old == 9 && current == 10))
                {
                    old = 10;
                    cmd.CanExecuteChanged(cmd, EventArgs.Empty); // 명령어를 처리할 수 있는지에 대한 상태 변화를 알림.
                }

                old = current;

                Thread.Sleep(1000);
            }
        });

        t.IsBackground = true;
        t.Start(this);
    }

    public bool CanExecute(object parameter)
    {
        return DateTime.Now.Hour != 9;
    }

    public void Execute(object parameter)
    {
        Console.WriteLine(DateTime.Now + ": RelayCommand.Execute - " + parameter);
    }
}

위의 코드는 CanExecuteChanged 구현 사례를 보이기 위해 다소 억지스러운 상황을 가정한 것이지만, 어쨌든 기능은 대충 저렇게 한다고 보시면 됩니다.




이제 WPF로 이야기를 넘어갈까요?

위의 예에서 ICommand 인터페이스를 내부적으로 지원하는 ConsolePrompt와 같은 클래스들이 바로 WPF에서 제공해 주는 각종 컨트롤들입니다. 대표적으로 ButtonBase를 상속받은 것들이 이에 해당하는데, 이들은 모두 Command 속성을 구현하게 됩니다.

ButtonBase.Command Property
; https://msdn.microsoft.com/en-us/library/system.windows.controls.primitives.buttonbase.command(v=vs.110).aspx

Button 객체들의 Command 속성은 내부적으로 ConsolePrompt가 구현했던 것과 유사한 방식으로 동작합니다.

일례로, 다음과 같이 Command 속성에 RelayCommand 객체를 할당해 줄 수 있습니다.

// MainWindow.xaml

<Window x:Class="WpfApplication1.MainWindow"
        ...[생략]...
        xmlns:local="clr-namespace:WpfApplication1"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Button Command="{Binding ClickCommand}" Content="Click" />
    </Grid>
</Window>

// MainWindow.xaml.cs

using System;
using System.Windows;
using System.Windows.Input;

namespace WpfApplication1
{
    public partial class MainWindow : Window
    {
        ICommand _clickCommand = new RelayCommand();
        public ICommand ClickCommand
        {
            get { return _clickCommand; }
        }

        public MainWindow()
        {
            InitializeComponent();
        }
    }

    public class RelayCommand : ICommand
    {
        public event EventHandler CanExecuteChanged;

        public bool CanExecute(object parameter)
        {
            return true;
        }

        public void Execute(object parameter)
        {
            MessageBox.Show("RelayCommand.Execute called!");
        }
    }
}

나아가서 UI를 가진 Button의 성격상, 이전에 살펴본 ConsolePrompt 객체보다 ICommand를 더 잘 활용합니다. 일례로 CanExecute 메서드에서 false를 반환하도록 하면,

public class RelayCommand : ICommand
{
    // ...[생략]...

    public bool CanExecute(object parameter)
    {
        return false;
    }

    // ...[생략]...
}

Button은 자신의 상태를 비활성화시켜버려 사용자가 누르지 못하게 만듭니다. 그런데, 버튼은 CanExecute를 최초 한번만 실행시켜 상태를 알아본 후 그 다음부터는 호출하지 않도록 되어 있습니다. 대신 CanExecuteChanged 이벤트를 구독하고 있기 때문에 상태가 바뀌는 경우 명시적으로 CanExecuteChanged 이벤트를 통해 알려야 합니다. 예를 들어, 화면에 텍스트 상자가 있어 그 안에 사용자가 텍스트를 입력한 경우에만 Button을 클릭 가능하도록 만들고 싶다면 다음과 같이 할 수 있습니다.

<Window x:Class="WpfApplication1.MainWindow"
        ...[생략]...
        xmlns:local="clr-namespace:WpfApplication1"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="25" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <TextBox Name="_txtBox" />
        <Button Grid.Row="1" Command="{Binding ClickCommand}" Content="Click" />
    </Grid>
</Window>

using System;
using System.Windows;
using System.Windows.Input;

namespace WpfApplication1
{
    public partial class MainWindow : Window
    {
        ICommand _clickCommand = new RelayCommand();
        public ICommand ClickCommand
        {
            get { return _clickCommand; }
        }

        public MainWindow()
        {
            InitializeComponent();

            _txtBox.TextChanged += _txtBox_TextChanged;
        }

        private void _txtBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
        {
            (_clickCommand as RelayCommand).FireChanged(_txtBox.Text.Length != 0);
        }
    }

    public class RelayCommand : ICommand
    {
        bool _canExceute = false;

        public event EventHandler CanExecuteChanged;

        public bool CanExecute(object parameter)
        {
            return _canExceute;
        }

        public void Execute(object parameter)
        {
            MessageBox.Show("RelayCommand.Execute called!");
        }

        public void FireChanged(bool cond)
        {
            _canExceute = cond; 
            CanExecuteChanged(this, EventArgs.Empty);
        }
    }
}

그런데, 상태가 바뀔때마다 일일이 저런 식으로 알림 이벤트를 호출하는 것이 여간 귀찮은 작업이 아닐 수 없습니다. 그래서 WPF 수준에서 의존 속성들에 대한 상태 변경을 스스로 알 수 있기 때문에 그런 경우 발생하는 알림 이벤트를 별도로 정의해 두었는데 그것이 바로 CommandManager.RequerySuggested 이벤트입니다.

CommandManager.RequerySuggested Event
; https://msdn.microsoft.com/en-us/library/system.windows.input.commandmanager.requerysuggested(v=vs.110).aspx

이를 이용하면 RelayCommand를 좀더 간단하게 구성할 수 있습니다.

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace WpfApplication1
{
    public partial class MainWindow : Window
    {
        ICommand _clickCommand;
        public ICommand ClickCommand
        {
            get
            {
                if (_clickCommand == null)
                {
                    _clickCommand = new RelayCommand(this._txtBox);
                }

                return _clickCommand;
            }
        }

        public MainWindow()
        {
            
            InitializeComponent();
        }
    }

    public class RelayCommand : ICommand
    {
        TextBox _target;

        public RelayCommand(TextBox txtBox)
        {
            _target = txtBox;
        }

        public event EventHandler CanExecuteChanged
        {
            add
            {
                CommandManager.RequerySuggested += value;
            }

            remove
            {
                CommandManager.RequerySuggested -= value;
            }
        }

        public bool CanExecute(object parameter)
        {
            return string.IsNullOrEmpty(_target.Text) == false;
        }

        public void Execute(object parameter)
        {
            MessageBox.Show("RelayCommand.Execute called!");
        }
    }
}

Button 객체는 Command 속성에 ICommand 상속 객체가 할당되면 그 인스턴스의 CanExecuteChanged 이벤트를 구독합니다. 따라서, 위의 코드에서는 Button 객체가 CanExecuteChanged.add를 호출하게 되고 Button 측에서 전달한 이벤트 핸들러 메서드를 가리키는 value는 다시 CommandManager.RequerySuggested 이벤트로 전달하게 되므로 자연스럽게 WPF 내부에서 RequerySuggested 이벤트를 발생시킬때마다 버튼은 CanExecuteChanged 알림을 받게 되고 이어서 CanExecute를 호출해 객체의 상태를 조회하게 됩니다.




그렇다면 WPF의 RoutedCommand는 도대체 뭘까요?

이 글에서 우리는 ICommand 객체를 구현한 RelayCommand를 사용하고 있는데요. 마찬가지로 WPF는 스스로 사용할 목적으로 RoutedCommand를 구현해 놓은 것 뿐입니다. 즉, ICommand의 구현체 중 하나에 속합니다.

그럼, RoutedUICommand는 또 뭘까요? 이에 대해서는 다음의 글에 잘 설명되어 있습니다.

WPF - RoutedCommand vs.RoutedUICommand 
; http://compilewith.net/2008/03/wpf-routedcommand-vsrouteduicommand.html

RoutedUICommand는 RoutedCommand를 상속받은 것으로 역시 WPF가 구현해 놓고 있습니다. 그리고 그 차이점은 Text 속성을 갖는다는 것 뿐입니다. 이것이 언제 쓰이냐면??? 바로 MenuItem같은 HeaderedItemsControl을 상속받은 객체들이 Command에 RoutedUICommand를 설정하면 그 MenuItem의 Header값으로 RoutedUICommand.Text 값을 사용하게 됩니다.

간단한 예를 만들어 보면!

<Window x:Class="WpfApplication1.MainWindow"
        ...[생략]...
        xmlns:local="clr-namespace:WpfApplication1"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Menu>
            <Menu.CommandBindings>
                <CommandBinding Command="local:MainWindow.CopyCommand" 
                            Executed="CopyCommand_Executed" CanExecute="CopyCommand_CanExecute" />
            </Menu.CommandBindings>
            
            <MenuItem Header="Menu1">
                <MenuItem Command="local:MainWindow.CopyCommand" />
            </MenuItem>
        </Menu>
    </Grid>
</Window>

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace WpfApplication1
{
    public partial class MainWindow : Window
    {
        public static readonly RoutedUICommand CopyCommand =
                new RoutedUICommand("MyCopy", "CopyFunc", typeof(MainWindow), null);

        public MainWindow()
        {
            InitializeComponent();
        }

        private void CopyCommand_CanExecute(object sender, CanExecuteRoutedEventArgs e)
        {
            e.CanExecute = true;
        }

        private void CopyCommand_Executed(object sender, ExecutedRoutedEventArgs e)
        {
            MessageBox.Show("RoutedUICommand.Execute called!");
        }
    }
}

보는 바와 같이 <MenuItem Command="local:MainWindow.CopyCommand" /> 에는 Header 속성이 정의되어 있지 않지만, 그것의 Command로 할당한 RoutedUICommand 객체의 생성자에 전달된 "MyCopy"라는 문자열이 있기 때문에 메뉴가 보여지는 경우 "MyCopy"로 보이게 됩니다. 물론, <MenuItem Header="MyCopy2" Command="local:MainWindow.CopyCommand" />라고 명시적인 Header 값을 부여하는 것도 가능합니다. 이런 경우 RoutedUICommand에 설정된 값을 무시합니다.



ICommand가 뜨는 이유는, 이것을 활용하는 경우 멋있는 MVVM(Model-View-ViewModel) 구조의 응용 프로그램 작성이 가능하기 때문입니다. 이에 대해서는 이야기가 또 한가득이니 일단 접고.

문제는, 기존 컨트롤들이 정의한 이벤트들이 모두 ICommand로 제공하지는 않는다는 점입니다. 일례로, Window의 크기가 변경되는 SizeChanged 이벤트에 대한 ICommand 속성은 없습니다. 애당초 SizeChangedCommand라는 속성으로 이벤트 대신 ICommand 용 속성이 제공된다면 좋을텐데 모든 이벤트를 그렇게 중복 정의해 놓는 것도 그리 바람직하지는 않습니다. 게다가, 이벤트라는 것은 '알림'에 가까운 특징이 있는 반면 ICommand는 말 그대로 '명령 실행'이라는 점에서 그 구현 배경이 달라지는 점도 있습니다.

하지만, MVVM의 ViewModel을 이용해 UI와 코드의 분리를 확실하게 이루기 위해서는 기존 이벤트들에 대한 ICommand로의 처리 방안이 필요합니다. 이 때문에 나온 것들이 바로 다양한 MVVM 프레임워크에서 제공해주는 EventToCommand 같은 구현체들입니다.

MVVM - Commands, RelayCommands and EventToCommand
; https://msdn.microsoft.com/en-us/magazine/dn237302.aspx

이에 대한 고민을 좀더 확실히 느끼고 싶은 분들은 다음의 4부작 글을 추천해드립니다. ^^

View의 Event를 ViewModel에서 핸들링하기 - 개요  WPF & C# 
; http://blog.naver.com/vactorman/220516516289

View의 Event를 ViewModel에서 핸들링하기 - EventTrigger
; http://blog.naver.com/vactorman/220516524223

View의 Event를 ViewModel에서 핸들링하기 - ACB
; http://blog.naver.com/vactorman/220516529243

View의 Event를 ViewModel에서 핸들링하기 - Markup
; http://blog.naver.com/vactorman/220516534284

EventToCommand를 이용한 ViewModel 이벤트 핸들링
; http://blog.naver.com/vactorman/221064433289

이 정도면 왠만큼 정리된 것 같군요. ^^

(첨부한 파일은 이 글의 예제 코드를 포함합니다.)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]





[최초 등록일: ]
[최종 수정일: 8/7/2017 ]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:kevin13@chol.com

비밀번호

댓글 쓴 사람
 



2017-08-07 05시28분
[Denial] 훌륭한 정리네요. 감사합니다.
[손님]

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
11700정성태9/21/201837Graphics: 25. Unity - shader의 직교 투영(Orthographic projection) 행렬(UNITY_MATRIX_P)을 수작업으로 구성
11699정성태9/21/201826오류 유형: 488. Add-AzureAccount 실행 시 "No subscriptions are associated with the logged in account in Azure Service Management (RDFE)." 오류
11698정성태9/21/201820오류 유형: 487. 윈도우 성능 데이터를 원격 SQL에 저장하는 경우 "Call to SQLAllocConnect failed with %1." 오류 발생
11697정성태9/20/201833Graphics: 24. Unity - unity_CameraWorldClipPlanes 내장 변수 의미
11696정성태9/19/201869.NET Framework: 793. C# - REST API를 이용해 NuGet 저장소 제어파일 다운로드1
11695정성태9/21/201861Graphics: 23. Unity - shader의 원근 투영(Perspective projection) 행렬(UNITY_MATRIX_P)을 수작업으로 구성
11694정성태9/17/201866오류 유형: 486. nuget push 호출 시 405 Method Not Allowed 오류 발생
11693정성태9/16/201892VS.NET IDE: 128. Unity - shader 코드 디버깅 방법
11692정성태9/18/2018118Graphics: 22. Unity - shader의 Camera matrix(UNITY_MATRIX_V)를 수작업으로 구성
11691정성태9/13/2018153VS.NET IDE: 127. Visual C++ / x64 환경에서 inline-assembly를 매크로 어셈블리로 대체하는 방법 - 두 번째 이야기
11690정성태9/13/201890사물인터넷: 43. 555 타이머의 단안정 모드파일 다운로드1
11689정성태9/13/2018108VS.NET IDE: 126. 디컴파일된 소스에 탐색을 사용하도록 설정(Enable navigation to decompiled sources)
11688정성태9/11/201883오류 유형: 485. iisreset - The data is invalid. (2147942413, 8007000d) 오류 발생
11687정성태9/11/201884사물인터넷: 42. 사물인터넷 - 트랜지스터 다중 전압 테스트파일 다운로드1
11686정성태9/8/2018108사물인터넷: 41. 다중 전원의 소스를 가진 회로파일 다운로드1
11685정성태9/6/2018224사물인터넷: 40. 이어폰 소리를 capacitor로 필터링파일 다운로드1
11684정성태9/6/2018317개발 환경 구성: 396. pagefile.sys를 비활성화시켰는데도 working set 메모리가 줄어드는 이유파일 다운로드1
11683정성태9/5/2018190개발 환경 구성: 395. Azure Web App의 이벤트 로그를 확인하는 방법
11682정성태9/5/2018170오류 유형: 484. Fakes를 포함한 단위 테스트 프로젝트를 빌드 시 CS1729 관련 오류 발생
11681정성태9/5/2018260Windows: 149. 다른 컴퓨터의 윈도우 이벤트 로그를 구독하는 방법 [2]
11680정성태9/2/2018202Graphics: 21. shader - _Time 내장 변수를 이용한 UV 변동 효과파일 다운로드1
11679정성태8/31/2018270.NET Framework: 792. C# COM 서버에 구독한 COM 이벤트를 C++에서 받는 방법파일 다운로드1
11678정성태8/29/2018206오류 유형: 483. 닷넷 - System.InvalidProgramException
11677정성태8/29/2018176오류 유형: 482. TFS - Could not find a part of the path '...\packages\Microsoft.AspNet.WebApi.5.2.5\.signature.p7s'.
11676정성태8/29/2018258.NET Framework: 791. C# - ElasticSearch를 위한 Client 라이브러리 제작파일 다운로드1
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...