LoginSignup
9
7

More than 3 years have passed since last update.

VSCodeでC++/WinRTを使ってネイティブなのにUIがXAMLな単体exeデスクトップアプリを作成する

Last updated at Posted at 2021-03-02

タイトルが長めだけど、どうやったらこのパッションが上手く伝わるのか分からなくて。

ベースはネイティブ(C++)なデスクトップアプリ(非ストアアプリ・単体exe)でありながら、UIをXAMLのやつに出来れば、軽さとリッチさを兼ね備えたアプリが作れるんじゃないの?
ついでにVSCodeで開発出来れば、開発段階からサクサクじゃないの?

…とずっと思ってて、何とか形になったようなのでご紹介。

実行画面

cppxaml01.png

cppxaml02.png

cppxaml03.png

準備

  1. VSCodeでのC++開発環境
    過去の記事を参考にして構築。
     
  2. 「Microsoft.Windows.CppWinRT」というNuGetパッケージを導入
    NuGetだが、VSCodeのNuGet拡張機能は.NET向けなので、nuget.exeを使ってコマンドラインから手動で導入する。
    nuget.exeはNuGet公式のダウンロードページで入手。左上の方の「nuget.exe - recommended latest ~」のリンクをクリックすると、何とexeが直に落ちてくる。
    作業フォルダに「packages」というフォルダを作り、nuget.exeをそこに入れてターミナルでpackagesフォルダに入り./nuget.exe install Microsoft.Windows.CppWinRTを実行。何やらメッセージが出て、ネットから自動的に最新版のパッケージをダウンロードしてくれる。
    落ち着いた時packagesフォルダの中に「Microsoft.Windows.CppWinRT.2.0.~」みたいなフォルダが出来てたらOK。
    なお、今回ゲットした「Microsoft.Windows.CppWinRT.2.0.~」は使い回しできるので、次回以降はわざわざnuget.exe使わなくてもフォルダごと丸々コピーするだけで使える。最新版が欲しい場合は都度nuget使えば良い。

サンプルコード

あらかじめ注意しておく事としては、リソーススクリプト(*.rc)は文字コードが「UTF-16 LE」である必要があるので、変更しておく。VSCodeだと対象の*.rcファイルを開いておいたうえで下部ステータスバーの右の方に「UTF-8」とか書いてある部分をクリックし、「エンコード付きで保存」→「UTF-16 LE」でOK。

(追記)拡張子rcのファイルをデフォルトでUTF16 LEに設定する方法が判明。別記事を参照されたし。

また、Resource.hは1行目からコードを書くとBOMとくっついてしまい正常に動かないようなので、1行目は空行かコメントにする必要がある。(当サンプルコードでは空コメントにしている。)

cppxaml.vcxproj
<Project DefaultTargets="Build" ToolsVersion="16.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <Import Project="packages\Microsoft.Windows.CppWinRT.2.0.210225.3\build\native\Microsoft.Windows.CppWinRT.props"/>
    <ItemGroup>
        <ProjectConfiguration Include="Debug|Win32">
            <Configuration>Debug</Configuration>
            <Platform>Win32</Platform>
        </ProjectConfiguration>
        <ProjectConfiguration Include="Release|Win32">
            <Configuration>Release</Configuration>
            <Platform>Win32</Platform>
        </ProjectConfiguration>
        <ProjectConfiguration Include="Debug|x64">
            <Configuration>Debug</Configuration>
            <Platform>x64</Platform>
        </ProjectConfiguration>
        <ProjectConfiguration Include="Release|x64">
            <Configuration>Release</Configuration>
            <Platform>x64</Platform>
        </ProjectConfiguration>
    </ItemGroup>
    <Import Project="$(VCTargetsPath)\Microsoft.Cpp.default.props"/>
    <PropertyGroup>
        <ConfigurationType>Application</ConfigurationType>
        <PlatformToolset>v142</PlatformToolset>
        <WindowsTargetPlatformVersion>10.0.19041.0</WindowsTargetPlatformVersion>
        <CharacterSet>Unicode</CharacterSet>
        <IntermediateOutputPath>obj\$(Configuration)\$(Platform)\</IntermediateOutputPath>
        <OutDir>bin\$(Configuration)\$(Platform)\</OutDir>
    </PropertyGroup>
    <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props"/>
    <ItemDefinitionGroup>
        <Manifest>
            <AdditionalManifestFiles>app.manifest;%(AdditionalManifestFiles)</AdditionalManifestFiles>
        </Manifest>
    </ItemDefinitionGroup>
    <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
        <ClCompile>
            <RuntimeLibrary>MultiThreaded</RuntimeLibrary>
        </ClCompile>
        <Link>
            <GenerateDebugInformation>false</GenerateDebugInformation>
        </Link>
    </ItemDefinitionGroup>
    <ItemGroup>
        <ClCompile Include="*.cpp"/>
    </ItemGroup>
    <ItemGroup>
        <ClInclude Include="*.h"/>
    </ItemGroup>
    <ItemGroup>
        <ResourceCompile Include="*.rc"/>
    </ItemGroup>
    <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Targets"/>
    <Import Project="packages\Microsoft.Windows.CppWinRT.2.0.210225.3\build\native\Microsoft.Windows.CppWinRT.targets"/>
</Project>

まずはプロジェクトファイル。
今回ファイル名を「cppxaml.vcxproj」にしたので、プロジェクト名は自動的に「cppxaml」となり、生成されるexeもデフォルトで「cppxaml.exe」となる。

C++/WinRTならではの追加としては、2行目、および最後から2行目にNuGetで獲ってきたパッケージを読み込む<Import>文がある。またWinRTを使うにあたってはmaxversiontestedというものをマニフェストに設定しないと実行時エラーで怒られるので、app.manifestに記述して、このプロジェクトファイルでは<AdditionalManifestFiles>タグのところで取り込んでいる。

app.manifest
<?xml version="1.0" encoding="UTF-8"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
    <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
        <application>
            <!-- Windows 10 -->
            <maxversiontested Id="10.0.18362.0"/>
            <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
        </application>
    </compatibility>
</assembly>

先ほど述べたapp.manifest。

Resource.h
//
#define IDR_XAML_MAIN 101

1行目のコメント行は必須。空行でも良し。

cppxaml.rc
#include "resource.h"

IDR_XAML_MAIN XAMLFILE "main.xaml"

今回は、XAMLファイル(main.xaml)を丸々リソースに埋め込んで、それをコードで取り出してUIとして使う方式にしている。

main.xaml
<StackPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
    <Grid Name="grid1" Background="PaleGreen">
        <TextBlock Name="textblock1" Text="Hello World from Xaml Islands!" FontSize="48" Foreground="Blue" HorizontalAlignment="Center"/>
    </Grid>
    <Button Name="button1" Content="ボタン" HorizontalAlignment="Right"/>
</StackPanel>

WPF等ではおなじみのXAMLファイル。
メインウインドウはすでに存在するので、最上位階層にはパネル系(今回は<StackPanel>)が来る。xmlns属性はおまじないだが、無いと怒られる。
大抵のコントロールや属性が使えるようだが、(今回の方式では少なくとも)イベントハンドラ系の属性(Clickとか)は使えない。なのでC++コードの方で追加しないといけない。

main.cpp
#include <windows.h>
#include <windows.ui.xaml.hosting.desktopwindowxamlsource.h>    //DesktopWindowXamlSource

//名称かぶり対策(前半)ここから
#pragma push_macro("GetCurrentTime")
#pragma push_macro("TRY")
#undef GetCurrentTime
#undef TRY
//名称かぶり対策(前半)ここまで

#include <winrt/windows.ui.xaml.hosting.h>  //WindowsXamlManager
#include <winrt/windows.ui.xaml.markup.h>   //XamlReader
#include <winrt/windows.ui.xaml.controls.h>
#include <winrt/windows.ui.xaml.controls.primitives.h>  //Click
#include <winrt/windows.ui.xaml.media.h>    //SolidColorBrush
#include <winrt/windows.foundation.h>   //IAsyncAction
#include <winrt/windows.security.cryptography.h>    //CryptographicBuffer

//名称かぶり対策(後半)ここから
#pragma pop_macro("TRY")
#pragma pop_macro("GetCurrentTime")
//名称かぶり対策(後半)ここまで

#include "resource.h"

using namespace winrt;
using namespace Windows::UI;
using namespace Windows::UI::Xaml;
using namespace Windows::UI::Xaml::Hosting;
using namespace Windows::UI::Xaml::Markup;
using namespace Windows::UI::Xaml::Controls;
using namespace Windows::UI::Xaml::Media;
using namespace Windows::Foundation;
using namespace Windows::Security::Cryptography;

//名前からコントロールを特定するテンプレート
template<typename T> T Element(const wchar_t *name) {
    return _xamlroot.Content().as<Panel>().FindName(name).as<T>();
};

LRESULT CALLBACK WindowProc(HWND, UINT, WPARAM, LPARAM);
IAsyncAction OnButtonClick(IInspectable const &, RoutedEventArgs const &);

HWND _hWnd;
HWND _hWndXamlIsland = nullptr;
XamlRoot _xamlroot = { nullptr };

int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nShowCmd) {
    //ウインドウクラス登録
    const wchar_t szWindowClass[] = L"Win32DesktopApp";
    WNDCLASSEX windowClass = {};
    windowClass.cbSize = sizeof(WNDCLASSEX);
    windowClass.lpfnWndProc = WindowProc;
    windowClass.hInstance = hInstance;
    windowClass.lpszClassName = szWindowClass;
    windowClass.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
    windowClass.hIconSm = ::LoadIcon(nullptr, IDI_APPLICATION);
    if (::RegisterClassEx(&windowClass) == 0) {
        ::MessageBox(nullptr, L"Windows registration failed!", L"Error", MB_OK);
        return 0;
    }

    //ウインドウ作成
    _hWnd = ::CreateWindow(
        szWindowClass,
        L"Windows c++ Win32 Desktop App",
        WS_OVERLAPPEDWINDOW | WS_VISIBLE,
        CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
        nullptr,
        nullptr,
        hInstance,
        nullptr
    );
    if (_hWnd == nullptr) {
        ::MessageBox(nullptr, L"Call to CreateWindow failed!", L"Error", MB_OK);
        return 0;
    }

    //C++/WinRTのおまじない
    init_apartment(apartment_type::single_threaded);
    WindowsXamlManager winxamlmanager = WindowsXamlManager::InitializeForCurrentThread();

    //XAMLホスト準備
    DesktopWindowXamlSource desktopSource;
    auto interop = desktopSource.as<IDesktopWindowXamlSourceNative>();
    check_hresult(interop->AttachToWindow(_hWnd));
    interop->get_WindowHandle(&_hWndXamlIsland);

    //XAMLホストのサイズをメインウインドウに合わせる
    RECT rcClient;
    ::GetClientRect(_hWnd, &rcClient);
    ::SetWindowPos(_hWndXamlIsland, nullptr, 0, 0, rcClient.right - rcClient.left, rcClient.bottom - rcClient.top, SWP_SHOWWINDOW);

    //リソースからXAMLコードを取り出してXAMLホストにセット
    auto hResInfo = ::FindResource(hInstance, MAKEINTRESOURCE(IDR_XAML_MAIN), L"XAMLFILE");
    auto res_size = ::SizeofResource(hInstance, hResInfo);
    std::vector<uint8_t> xaml_array(res_size, 0);
    auto hGlobal = ::LoadResource(hInstance, hResInfo);
    char *xaml_bin = (char *)::LockResource(hGlobal);
    ::memcpy_s(xaml_array.data(), res_size, xaml_bin, res_size);
    auto xaml_buf = CryptographicBuffer::CreateFromByteArray(xaml_array);
    auto xamlstr = CryptographicBuffer::ConvertBinaryToString(BinaryStringEncoding::Utf8, xaml_buf);
    auto xamlContainer = XamlReader::Load(xamlstr).as<Panel>();
    desktopSource.Content(xamlContainer);
    _xamlroot = xamlContainer.XamlRoot();

    //ボタンにイベントハンドラ登録
    Element<Button>(L"button1").Click({ &OnButtonClick });

    //メインウインドウ表示
    ::ShowWindow(_hWnd, nShowCmd);
    ::UpdateWindow(_hWnd);

    //メッセージループ
    MSG msg = {};
    while (::GetMessage(&msg, nullptr, 0, 0)) {
        ::TranslateMessage(&msg);
        ::DispatchMessage(&msg);
    }

    return 0;
}

//ウインドウプロシージャ
LRESULT CALLBACK WindowProc(HWND hWnd, UINT messageCode, WPARAM wParam, LPARAM lParam) {
    RECT rcClient;

    switch (messageCode) {
        case WM_CREATE:
            //return 0;
            break;

        case WM_DESTROY:
            ::PostQuitMessage(0);
            break;

        case WM_SIZE:   //メインウインドウのサイズが変わったらXAMLホストも追従
            ::GetClientRect(hWnd, &rcClient);
            ::SetWindowPos(_hWndXamlIsland, nullptr, 0, 0, rcClient.right - rcClient.left, rcClient.bottom - rcClient.top, SWP_SHOWWINDOW);

            return 0;
            break;

        default:
            return ::DefWindowProc(hWnd, messageCode, wParam, lParam);
            break;
    }

    return 0;
}

//ボタンクリックのイベントハンドラ
IAsyncAction OnButtonClick(IInspectable const &sender, RoutedEventArgs const &e) {
    auto dlg = ContentDialog();
    dlg.Title(box_value(L"慟哭"));
    dlg.Content(box_value(L"ほげー?"));
    dlg.PrimaryButtonText(L"OK");
    dlg.CloseButtonText(L"Cancel");
    dlg.XamlRoot(_xamlroot);
    auto result = co_await dlg.ShowAsync();
    if (result == ContentDialogResult::Primary) {
        Element<Grid>(L"grid1").Background(SolidColorBrush{ Colors::Pink() });
        Element<TextBlock>(L"textblock1").Text(L"ほげー!");
    }
}

プログラム本体。
Win32APIごりごりでGUIアプリを作る感じのコードに、XAMLを扱うためのコードが追加されてるような感じ。
コメントを入れてるので、何が起こってるのか大まかには分かると思う。

追加説明としては、

  • winrt/windows.ui.hとかwinrt/windows.ui.xaml.hとかは、下位のwinrt/windows.ui.xaml.hosting.h等の中で#includeされてるので、かぶせて#includeする必要は無さそう。
  • XamlRootってやつを保持しておくと、それを基準にすべてのコントロールにアクセス出来るので便利。
    コントロールそのものを保持すればいいやんって思ったけど、C++/WinRTのおまじないの前にコントロール等のクラスを触ろうとするとエラーになるので無理だった。変数宣言すら無理。
  • リソースからXAMLコードを取り出す部分がめちゃくちゃ長くなってしまった。最終的にはUTF-16な文字列が欲しいのだが、元がUTF-8なバイト列(当然ヌル終端無し)なので大変だった。
    最初はバイト配列に無理矢理ヌル終端付けたりMultiByteToWideChar使ったりしてたが、CryptographicBuffer::ConvertBinaryToStringなる便利なものを発見してしまったのでありがたく使わせてもらった。IBufferクラスを介する必要はあるが、バイナリ→hstringへの変換とエンコードの変換が一発で行える。
  • main.cppの序盤にある「名称かぶり対策」という部分が無いと「warning C4002: 関数に似たマクロ呼び出し 'GetCurrentTime' の引数が多すぎます」みたいな警告が出る。どうやらWinAPIとWinRTの双方に名前のかぶってるマクロとメソッドがあるのが原因のようだ。
    これはMS既知の不具合らしいので、公式おすすめの方法で回避している。
  • VSCode(のC/C++拡張機能)のIntelliSenseがco_awaitを認識してくれなくて激怒する。実害は無いのだが気持ち悪い。
    MSは見切り発車でC++にコルーチン実装するのは良いけど、VSCodeも拡張機能も同じ会社製なんだから統一してほしい…。

まとめ

以上、MS公式の解説を元にして試行錯誤し、VSCodeで動かせるまでに仕上げてみた。
思っていたほど環境構築のプラスが多くなくて驚いている。

MSの説明では、上記説明の他に「Microsoft.Toolkit.Win32.UI.SDK」というNuGetパッケージと、SDKのUnionMetadataにあるというWindows.winmdの参照が必要、みたいな感じで書かれていたが、どちらも無くても動いた。
それから、「展開用にアプリケーションを MSIX パッケージにパッケージ化しない場合は、アプリを実行するコンピューターに Visual C++ ランタイムがインストールされている必要があります。」ということだったが、プロジェクトの設定(<RuntimeLibrary>MultiThreaded</RuntimeLibrary>)でランタイムを埋め込むことで、実行側にランタイム無くてもイケてる気がする。

下記、こまごまとした内容。

  • #include部分を分離してプリコンパイル済みヘッダーとかにしてみたところ、中間ファイルにサイズがGB単位にもなるpchファイルが爆誕したうえ、いつまでたってもコンパイルが終わらなかったのでそっ閉じしてpchはやめた。 たまたま調子が悪かっただけのようだった。 やっちゃいけないことをやっていたようだった。まず#include "pch.h"の前には何も書いちゃいけない、つまり(コメント行等を除いて)必ずファイルの冒頭に置くべき。あとpch.hの中身を編集した場合はリビルドすべき。
    pchファイルが大きいのは仕方無いが、それとひきかえに2回目以降のビルドが爆速になる。
  • XAMLといえばStoryBoardとか使ったリッチなアニメーションも魅力だけど未検証で、どこまで動くのか。今後暇なら弄ってみたい。
    ちょっとだけ試してみた。
  • キーボードフォーカスの管理とか…とても面倒臭そうだけど、全部XAMLにしてしまえば問題無いのでは?未検証。
  • 今回はUIの事ばっかり騒いだが、Windows.Web.Http名前空間にHttpClientとかあるし、夢が広がっている。
    →試してみた。普通にHTTPアクセス出来る。取得したコンテンツはIBufferの形式なので、XAMLの読み込みでも使ったCryptographicBuffer::ConvertBinaryToStringが凄く役に立つ。
  • 今回のサンプルで気が付いたけど、ContentDialogから(ボタンを押して)復帰するのに何かタイムラグがある気がする。(WPFでもそうなのかなと思って検証しようと思ったら、そもそもContentDialog使えなかった。)
    なおMessageDialogはそんな事無かった。

関連記事

C++/WinRT(非ストアアプリ)でデータバインディング
C++/WinRT(非ストアアプリ)でコレクションのデータバインディング
C++/WinRT(非ストアアプリ)でアクリル素材とリップル(っぽい)ボタン

9
7
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
7