File-based App으로 프로토타이핑하는 WPF

앞의 글 ( File-based App으로 프로토타이핑하는 Windows Forms ) 에 이어서 WPF도 소개합니다. :smiley:

#:sdk Microsoft.NET.Sdk
#:property OutputType=WinExe
#:property TargetFramework=net10.0-windows
#:property PublishAot=False
#:property UseWPF=True
#:property UseWindowsForms=False
#:package CommunityToolkit.Mvvm@8.4.0

using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

// https://github.com/dotnet/winforms/issues/5071#issuecomment-908789632
Thread.CurrentThread.SetApartmentState(ApartmentState.Unknown);
Thread.CurrentThread.SetApartmentState(ApartmentState.STA);

var countText = new TextBlock
{
    FontSize = 24,
    Margin = new Thickness(8),
    HorizontalAlignment = HorizontalAlignment.Center,
};
countText.SetBinding(TextBlock.TextProperty, new Binding(nameof(CounterViewModel.Count)));

var incButton = new Button { Content = "Increment (+1)", Margin = new Thickness(8), };
incButton.SetBinding(Button.CommandProperty, new Binding(nameof(CounterViewModel.IncrementCountCommand)));

var decButton = new Button { Content = "Decrement (-1)", Margin = new Thickness(8), };
decButton.SetBinding(Button.CommandProperty, new Binding(nameof(CounterViewModel.DecrementCountCommand)));

var root = new StackPanel { Margin = new Thickness(16) };
root.Children.Add(countText);
root.Children.Add(incButton);
root.Children.Add(decButton);

var window = new Window
{
    Title = $"Hello, World! - {Thread.CurrentThread.GetApartmentState()}",
    Width = 320,
    Height = 240,
    Content = root,
    DataContext = new CounterViewModel(),
    WindowStartupLocation = WindowStartupLocation.CenterScreen,
};

var app = new Application
{
    ShutdownMode = ShutdownMode.OnMainWindowClose,
};
app.DispatcherUnhandledException += (_, e) =>
{
    MessageBox.Show(e.Exception.ToString(), "Unhandled", MessageBoxButton.OK, MessageBoxImage.Error);
    e.Handled = true;
};
app.Run(window);

public sealed partial class CounterViewModel : ObservableObject
{
    [ObservableProperty]
    private int _count = 0;

    [RelayCommand]
    private void IncrementCount()
        => Count++;

    [RelayCommand]
    private void DecrementCount()
        => Count--;
}

Windows Forms 사례와 크게 다르지는 않지만, 여기서는 CommunityToolkit.Mvvm을 사용했습니다. 소스 제너레이터가 이 환경에서도 매우 잘 작동할 뿐 아니라, 아래 그림처럼 인텔리센스에도 제대로 반영이 되고, 만들어진 소스 코드도 확인할 수 있습니다.

좀 더 테스트해봐야겠지만, Directory.Build.Props 파일을 같은 위치에 넣어둔다면 XAML 파일도 리소스로 포함시키게 지정할 수 있을 것으로 보입니다. 이것도 후속으로 테스트해보고 공유드려보겠습니다. :smiley: 있습니다. 그러나 예상했던 대로 현재 FBA 문법의 한계와 마주하는 부분이라 다소 아쉽지만, 기술적으로는 가능하다는 것에 의미를 두어도 충분할 것 같습니다. :smiley: (하단 글 참조)

10 Likes

WPF XAML을 FBA에서 불러다 쓰는 방법

File-based App의 이점이 퇴색되는 문제가 있어 아쉽습니다만, FBA 환경에서도 XAML을 로딩해서 쓰는 것이 가능한 것도 확인했습니다.

Directory.Build.props 파일

TargetFramework 속성을 net10.0-windows 으로 지정하고, UseWPF 속성을 True로 지정했을 때의 기본 동작 덕분인지, XAML 파일들은 굳이 태그를 써서 포함시키려 하지 않아도 자동으로 모두 닷넷 어셈블리 안에 포함되는 것 같습니다. 그래서 아래와 같이 코드 비하인드 파일들만 포함하도록 Directory.Build.props 파일을 구성해주면 연결이 잘 됩니다.

<Project>
    <ItemGroup>
        <!-- .xaml 파일은 자동으로 Page 태그로 포함됨 -->
        <Compile Include="**/*.xaml.cs" />
    </ItemGroup>
</Project>

만약 명시적으로 XAML 파일을 추가/관리하기 원한다면 FBA 서두에 다음과 같이 지시자를 넣어주면 목적을 달성할 수 있습니다. (혹은 Directory.Build.props의 PropertyGroup 태그 아래에 넣어도 동일합니다.)

#:property EnableDefaultPageItems=False

MainWindow.xaml 샘플

코드 비하인드가 잘 연결되는지 확인하고, 동시에 코드 비하인드에서 InitializeComponent 메서드를 호출해주어야 제대로 XAML의 내용이 렌더링되기 때문에 명확한 확인을 위해서 MVVM이 아닌 이벤트 핸들러를 사용하는 버튼을 하나 넣었습니다.

<Window x:Class="SampleApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="샘플 윈도우" Height="200" Width="300">
    <Grid>
        <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
            <TextBlock Text="안녕하세요, WPF XAML 샘플입니다."
                       FontSize="16"
                       HorizontalAlignment="Center"
                       Margin="0,0,0,20"/>
            <Button Content="클릭하세요"
                    Width="120" Height="40"
                    Click="Button_Click"/>
        </StackPanel>
    </Grid>
</Window>

여기서 x:Class 부분에 지정한 네임스페이스와 클래스 이름, 그리고 클릭하세요 버튼의 Button_Click 이벤트 핸들러 이름을 잘 메모해둡니다.

코드 비하인드 (MainWindow.xaml.cs)

using System.Windows;

namespace SampleApp;

partial class MainWindow
{
    // 중요: 이 부분이 포함되어있어야 XAML 코드가 제대로 렌더링됨
    public MainWindow()
        : base()
    {
        InitializeComponent();
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        MessageBox.Show("Hello");
    }
}

여기서 이 클래스가 partial 클래스임을 선언하는 것, 그리고 생성자의 역할이 매우 중요합니다. 코드 비하인드 자체가 누락되거나, InitializeComponent() 호출을 빼게 되면 창만 만들어지고 내용은 없는 빈 껍데기 상태로 렌더링됩니다.

그리고 InitializeComponent()는 추적해보면 자동 생성되는 C# 파일 안의 코드라는 것을 알 수 있습니다. 자동 생성되는 클래스와 우리가 만들 코드가 한 본의 클래스임을 매칭시켜주는 것이 포인트가 되겠습니다.

그리고 Button_Click 이벤트 핸들러는 WPF의 일반적인 이벤트 핸들러의 시그니처를 그대로 따릅니다.

제대로 인식이 되었다면 아래 그림처럼 참조 갯수가 0개가 아닌 N개 이상으로 표현이 됩니다.

Program.cs

#!/usr/bin/env dotnet
#:sdk Microsoft.NET.Sdk
#:property OutputType=WinExe
#:property TargetFramework=net10.0-windows
#:property PublishAot=False
#:property UseWPF=True
#:property UseWindowsForms=False

// Run with dotnet run --no-cache SampleWpf.cs

using System.Windows;
using SampleApp;

Thread.CurrentThread.SetApartmentState(ApartmentState.Unknown);
Thread.CurrentThread.SetApartmentState(ApartmentState.STA);

var app = new Application
{
    ShutdownMode = ShutdownMode.OnMainWindowClose
};

// 원하는 윈도우 지정
var window = new MainWindow();
app.Run(window);

앞 글 ( File-based App으로 프로토타이핑하는 Windows Forms ) 에서 살펴본 것과 마찬가지로 STA 스레드로 시작해야 함을 명시적으로 지정하고, App.xaml은 굳이 따로 만들지 않고 코드 레벨에서 생성해서 WPF 애플리케이션을 시작합니다.

빌드하고 실행하는 방법 (중요)

정적 소스 생성기와 달리, XAML 기반 소스 생성기는 FBA 환경에서 다소 일관성 없는 모습을 보이는 것 같습니다. 그래서 전체 빌드 캐시를 명시적으로 비우고 다시 빌드하도록 하기 위해 dotnet run --no-cache Program.cs 라고 --no-cache 스위치를 따로 넣어 빌드합니다.

그러면 아래 그림과 같이 XAML과 함께 메시지 박스 표시까지 잘 되는 것을 볼 수 있습니다.

정리

FBA 지시자는 아쉽게도 ItemGroup에 대응되는 지시자 명령어 체계를 가지고 있지 않습니다. 그러다보니 굳이 Directory.Build.props 파일을 따로 두어야 하는 수고로움이 있고, 사실 여기까지 오면 그냥 csproj 파일을 만들어 정규 프로젝트로 두는 편이 더 나을 수도 있습니다.

그래도 FBA를 한계까지 사용해 볼 수 있었고 내부 동작을 이해하는데는 충분한 실험이었다고 생각하여 만족스럽게 마무리합니다. :smiley:

2 Likes