現代 Win32 程式開發的起手式

本文使用的開發環境為 Visual Studio 2019 Community Edition。

範例程式碼下載:https://github.com/riddleling/HelloWin32

第一部分:起步走

打開 Visual Studio Installer,勾選安裝「使用 C++ 的桌面開發」、「通用 Windows 平台開發」:


在 Manage Extensions 裡搜尋「C++/WinRT templates and visualizer for VS2019」並安裝之:


重新啟動 Visual Studio 2019 後,選擇 Create a new project,然後建立一個「Windows Desktop Application (C++/WinRT)」專案。


「Windows Desktop Application (C++/WinRT)」 範本產生的程式碼是用 Win32 的 MessageBox 跟 UWP 的 Windows.Foundation.Uri 顯示一個對話框。

不過,專案剛建立好時,會看到有「cannot open source file “winrt/Windows.XXXXX.h”」等錯誤訊息,如下圖所示:

雖然有不能開啟 WinRT 標頭檔的錯誤訊息,但是按下 F5 編譯執行是可以通過編譯並正常執行的,這會是 Visual Studio 2019 的 bug 嗎?這時候把 Visual Studio 關閉重開,重新開啟建立好的那個專案,「cannot open source file “winrt/Windows.XXXXX.h”」的錯誤訊息就會不見了,真奇怪。總之,我遇到出現這種「cannot open source file “winrt/Windows.XXXXX.h”」的錯誤訊息時,會先編譯執行一次,關閉 Visual Studio,再重新開啟專案,這種錯誤訊息就會不見了。 (真的很怪吧)


範本產生的程式碼是顯示一個對話框,讓我們編譯執行看看。如下圖,可以看到對話框內的文字跟按鈕上的文字都糊糊的,這要回溯到 Win32 出現的那個年代,那時大部分的螢幕都是 96 DPI,所以 Win32 預設的 DPI 為 96。不過現代螢幕幾乎都是高 DPI,所以 Windows 後來加入了 DPI 感知模式 (DPI Awareness Mode),Win32 程式需要宣告自身是否為 DPI 感知的程式,如果沒有宣告為 DPI 感知程式, Windows 會先用 96 DPI 去繪製程式,然後再依照目前螢幕的 DPI 去放大程式畫面,所以才會看起來糊糊的。

要把程式宣告為 DPI 感知很簡單,從 Solution Explorer 找到名稱格式為 <專案名稱>.exe.manifest 的檔案 (例如:我的專案名稱是 HelloWin32,所以該檔案的名稱會是 HelloWin32.exe.manifest),然後把內容修改如下:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
  <asmv3:application>
    <asmv3:windowsSettings>
      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
      <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
    </asmv3:windowsSettings>
  </asmv3:application>
</assembly>

修改完 manifest 檔案後,重新編譯執行,可以看到文字不會糊糊的了:


接下來,我們來寫一個視窗程式吧,修改 WinMain.cpp,內容如下:

#include "pch.h"

using namespace winrt;
using namespace Windows::Foundation;

LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);

int WINAPI wWinMain(
    _In_ HINSTANCE hInstance,
    _In_opt_ HINSTANCE,
    _In_ LPWSTR,
    _In_ int)
{
    init_apartment(apartment_type::single_threaded);

    LPCWSTR lpszClassName = L"MainWindow";
    LPCWSTR lpWindowName = L"Window";  // window title

    WNDCLASSW wc{};
    wc.style = CS_HREDRAW | CS_VREDRAW;
    wc.cbClsExtra = 0;
    wc.cbWndExtra = 0;
    wc.lpszClassName = lpszClassName;
    wc.hInstance = hInstance;
    wc.hbrBackground = GetSysColorBrush(COLOR_3DFACE);
    wc.lpszMenuName = nullptr;
    wc.lpfnWndProc = WndProc;
    wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
    wc.hIcon = LoadIcon(nullptr, IDI_APPLICATION);

    RegisterClassW(&wc);

    HWND hWnd = CreateWindowExW(
        0,
        wc.lpszClassName,
        lpWindowName,
        WS_OVERLAPPEDWINDOW | WS_VISIBLE,
        100, 100, 400, 300,  // x, y, width, height 
        nullptr,
        nullptr,
        hInstance,
        nullptr
    );

    ShowWindow(hWnd, SW_SHOWNORMAL);
    UpdateWindow(hWnd);

    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    return (int)msg.wParam;
}

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg) {
    case WM_DESTROY: {
        PostQuitMessage(0);
    } break;
    }
    return DefWindowProcW(hwnd, msg, wParam, lParam);
}

然後刪除 pch.h 裡有 Windows.UI 的那幾行:

#pragma once

#include <windows.h>
#ifdef GetCurrentTime
#undef GetCurrentTime
#endif
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.System.h>
#include <winrt/Windows.UI.Xaml.h>            //<-- 刪除這行
#include <winrt/Windows.UI.Xaml.Controls.h>   //<-- 刪除這行
#include <winrt/Windows.UI.Xaml.Hosting.h>    //<-- 刪除這行
#include <winrt/Windows.UI.Xaml.Media.h>      //<-- 刪除這行
#include <Windows.UI.Xaml.Hosting.DesktopWindowXamlSource.h>  //<-- 刪除這行

編譯執行,畫面如下:

不過,這個視窗的大小其實不正確。如前面所述,現代螢幕都是高 DPI,而 Win32 預設是用 96 DPI,所以還需要加入判斷 DPI 的程式碼才行。在 Win32 裡,可以用 GetDpiForWindow 函數取得當前的 DPI 值,然後用 MulDiv 函數計算出實際的座標與尺寸值。

在 WinMain.cpp 裡添加以下這個函數:

void SetWindowSize(HWND hWnd)
{
    int dpi = GetDpiForWindow(hWnd);
    int dpiScaledX = MulDiv(100, dpi, 96);  // x
    int dpiScaledY = MulDiv(100, dpi, 96);  // y
    int dpiScaledW = MulDiv(400, dpi, 96);  // width
    int dpiScaledH = MulDiv(300, dpi, 96);  // height

    SetWindowPos(
        hWnd,
        hWnd,
        dpiScaledX,
        dpiScaledY,
        dpiScaledW,
        dpiScaledH,
        SWP_NOZORDER | SWP_NOACTIVATE
    );
}

修改 WndProc 函數的內容,加入處理 WM_CREATE 訊息的區塊,並呼叫 SetWindowSize 函數:

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg) {
    case WM_CREATE: {
        SetWindowSize(hwnd);  // 設定 Window 的起始座標與尺寸
    } break;
    case WM_DESTROY: {
        PostQuitMessage(0);
    } break;
    }
    return DefWindowProcW(hwnd, msg, wParam, lParam);
}

修改 CreateWindowExW 函數的傳入參數,把 x、y、width、height 都改成 0:

HWND hWnd = CreateWindowExW(
    0,
    wc.lpszClassName,
    lpWindowName,
    WS_OVERLAPPEDWINDOW | WS_VISIBLE,
    0, 0, 0, 0,   // 這裡四個值都改成 0
    nullptr,
    nullptr,
    hInstance,
    nullptr
);

重新編譯執行,這時的視窗大小應該是正確的了。


接下來,讓我們在視窗上加入一個 Label 跟 Button。加入的子視窗控制項一樣需要判斷 DPI,並計算實際的座標與尺寸值。在 WinMain.cpp 加入以下兩個函數:

void SetLabelPos(HWND hWnd, HWND hLabel)
{
    int dpi = GetDpiForWindow(hWnd);
    int dpiScaledX = MulDiv(40, dpi, 96);
    int dpiScaledY = MulDiv(40, dpi, 96);
    int dpiScaledW = MulDiv(200, dpi, 96);
    int dpiScaledH = MulDiv(30, dpi, 96);

    SetWindowPos(
        hLabel,
        hLabel,
        dpiScaledX,
        dpiScaledY,
        dpiScaledW,
        dpiScaledH,
        SWP_NOZORDER | SWP_NOACTIVATE
    );
}

void SetButtonPos(HWND hWnd, HWND hButton)
{
    int dpi = GetDpiForWindow(hWnd);
    int dpiScaledX = MulDiv(40, dpi, 96);
    int dpiScaledY = MulDiv(80, dpi, 96);
    int dpiScaledW = MulDiv(100, dpi, 96);
    int dpiScaledH = MulDiv(28, dpi, 96);

    SetWindowPos(
        hButton,
        hButton,
        dpiScaledX,
        dpiScaledY,
        dpiScaledW,
        dpiScaledH,
        SWP_NOZORDER | SWP_NOACTIVATE
    );
}

修改 WndProc 函數的內容,在 WM_CREATE 訊息的區塊裡,建立一個 Label 跟 Button:

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg) {
    case WM_CREATE: {
        SetWindowSize(hwnd);
        
        // 建立一個 Label:
        HWND hLabel = CreateWindowW(
            L"static",
            L"This is a label.",
            WS_VISIBLE | WS_CHILD,
            0, 0, 0, 0,
            hwnd,
            (HMENU)ID_LABEL,
            nullptr,
            nullptr
        );
        if (hLabel) {
            // 設定 Label 的座標與尺寸:
            SetLabelPos(hwnd, hLabel);
        }

        // 建立一個 Button:
        HWND hButton = CreateWindowW(
            L"Button",
            L"Say hello",
            WS_VISIBLE | WS_CHILD,
            0, 0, 0, 0,
            hwnd,
            (HMENU)ID_BUTTON,
            nullptr,
            nullptr
        );
        if (hButton) {
            // 設定 Button 的座標與尺寸:
            SetButtonPos(hwnd, hButton);
        }
    } break;

    // 下略...

在 WinMain.cpp 的上方,定義子視窗控制項的 ID:

#include "pch.h"

using namespace winrt;
using namespace Windows::Foundation;

// 定義子視窗的 ID:
#define ID_LABEL   10
#define ID_BUTTON  11

編譯執行,畫面如下:

Label 跟 Button 的字型看起來有點醜,來修改一下字型吧,在 WinMain.cpp 裡加入以下函數:

BOOL CALLBACK EnumChildProc(HWND hWnd, LPARAM)
{
    HFONT hfDefault = (HFONT)GetStockObject(DEFAULT_GUI_FONT);  // 系統預設的字型
    SendMessage(hWnd, WM_SETFONT, (WPARAM)hfDefault, MAKELPARAM(TRUE, 0));
    return TRUE;
}

然後在執行 ShowWindow 函數後,執行 EnumChildWindows 函數,如下所示:

ShowWindow(hWnd, SW_SHOWNORMAL);
EnumChildWindows(hWnd, EnumChildProc, 0);  // 加入這行
UpdateWindow(hWnd);

編譯執行,這時出現了「LNK2019 unresolved external symbol」的錯誤訊息,如下圖:

看來是連結時缺少了包含 GetStockObject 函數的 Library。從微軟的文件得知,GetStockObject 函數需要連結到 Gdi32.lib:

從選單列選擇:Project -> <專案名稱> Properties,然後選擇 Configuration Properties -> Linker -> Input。上方的 Configuration 下拉式方塊 (Combo Box) 選擇「All Configuration 」,Platform 下拉式方塊選擇「All Platform」。然後修改 Additional Dependencies 的內容:

加入 Gdi32.lib,勾選 Inherit from parent or project defaults,按下 OK 按鈕:

再次編譯執行,文字的部分有比較好看一些了:


接下來要幫這個程式加上一個功能:按下 Say hello 按鈕時,Label 的文字會變成「Hello Win32!」。

先在 WinMain.cpp 加入兩個 HWND 全域變數,用來儲存 Label 跟 Button 的 handle 值:

#include "pch.h"

using namespace winrt;
using namespace Windows::Foundation;

#define ID_LABEL   10
#define ID_BUTTON  11

// 加入這兩個全域變數:
HWND hLabel  = nullptr;
HWND hButton = nullptr;

修改 WndProc 函數的內容,把 hLabel 跟 hButton 前的 HWND 宣告移除,並加入處理 WM_COMMAND 訊息的區塊:

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg) {
    case WM_CREATE: {
        SetWindowSize(hwnd);

        hLabel = CreateWindowW(  // 移除 HWND 宣告,handle 值改成儲存到全域變數 hLabel 裡
            L"static",
            L"This is a label.",
            WS_VISIBLE | WS_CHILD,
            0, 0, 0, 0,
            hwnd,
            (HMENU)ID_LABEL,
            nullptr,
            nullptr
        );
        if (hLabel) {
            SetLabelPos(hwnd, hLabel);
        }

        hButton = CreateWindowW(  // 移除 HWND 宣告,handle 值改成儲存到全域變數 hButton 裡
            L"Button",
            L"Say hello",
            WS_VISIBLE | WS_CHILD,
            0, 0, 0, 0,
            hwnd,
            (HMENU)ID_BUTTON,
            nullptr,
            nullptr
        );
        if (hButton) {
            SetButtonPos(hwnd, hButton);
        }
    } break;
    case WM_COMMAND: {  // 加入處理 WM_COMMAND 訊息的區塊:
        if (LOWORD(wParam) == ID_BUTTON) {
            if (hLabel) {
                SetWindowTextW(hLabel, L"Hello Win32!");
            }
        }
    } break;

    // 下略...

在處理 WM_COMMAND 訊息的區塊裡,判斷訊息是否來自 Say hello 按鈕 (ID_BUTTON),如果是,就把 Label 的文字改成「Hello Win32!」:

case WM_COMMAND: {
    if (LOWORD(wParam) == ID_BUTTON) {
        if (hLabel) {
            SetWindowTextW(hLabel, L"Hello Win32!");
        }
    }
} break;

編譯執行,按下 Say hello 按鈕,畫面如下:

第二部分:用物件導向的方式重構程式

用全域變數儲存子視窗控制項 handle 值的方式看起來不是很漂亮,讓我們利用 C++ 的物件導向功能來修改這個程式吧。

在 Solution Explorer 裡的 Source Files 按下滑鼠右鍵,選擇 Add -> New Item…:

選擇 Visual C++ -> Code -> C++ Class,下方的 Name 輸入 Window,按下 Add 按鈕:

按下 OK 按鈕:

編輯 Window.h 檔案,把內容改成如下:

#pragma once

class Window final
{
public:
	Window(HINSTANCE hInstance, LPCWSTR lpszClassName, LPCWSTR lpWindowName);

private:
	static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
	static BOOL CALLBACK EnumChildProc(HWND hWnd, LPARAM);

	HWND hWnd_;
	HWND hLabel_;
	HWND hButton_;
	
	void SetWindowSize();
	void SetLabelPos();
	void SetButtonPos();
	void SayHello();
};

編輯 Window.cpp,內容改成如下:

#include "pch.h"
#include "Window.h"

#define ID_LABEL   10
#define ID_BUTTON  11

Window::Window(HINSTANCE hInstance, LPCWSTR lpszClassName, LPCWSTR lpWindowName)
{
}

LRESULT CALLBACK Window::WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
}

BOOL CALLBACK Window::EnumChildProc(HWND hWnd, LPARAM)
{
}

void Window::SetWindowSize()
{
}

void Window::SetLabelPos()
{
}

void Window::SetButtonPos()
{
}

void Window::SayHello()
{
}

在 Window Class 裡,設了三個 HWND 私有變數來儲存主視窗跟子視窗控制項的 handle 值。WndProc 跟 EnumChildProc 這兩個 CALLBACK 函數必須是 Class 的靜態函數。

接下來,把原本寫在 WinMain.cpp 的程式邏輯移到 Window Class 裡。

編輯 Window.cpp,在 Window::Window 建構函數裡加入以下內容:

Window::Window(HINSTANCE hInstance, LPCWSTR lpszClassName, LPCWSTR lpWindowName)
{
    hLabel_  = nullptr;
    hButton_ = nullptr;

    WNDCLASSW wc{};
    wc.style = CS_HREDRAW | CS_VREDRAW;
    wc.cbClsExtra = 0;
    wc.cbWndExtra = 0;
    wc.lpszClassName = lpszClassName;
    wc.hInstance = hInstance;
    wc.hbrBackground = GetSysColorBrush(COLOR_3DFACE);
    wc.lpszMenuName = nullptr;
    wc.lpfnWndProc = WndProc;
    wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
    wc.hIcon = LoadIcon(nullptr, IDI_APPLICATION);

    RegisterClassW(&wc);

    hWnd_ = CreateWindowExW(
        0,
        wc.lpszClassName,
        lpWindowName,
        WS_OVERLAPPEDWINDOW | WS_VISIBLE,
        0, 0, 0, 0,
        nullptr,
        nullptr,
        hInstance,
        this      // 這裡要傳入 this
    );

    ShowWindow(hWnd_, SW_SHOWNORMAL);
    EnumChildWindows(hWnd_, EnumChildProc, 0);
    UpdateWindow(hWnd_);
}

Window::Window 建構函數負責建立主視窗。注意:CreateWindowExW 函數的最後一個參數要改成 this。

把 WinMain.cpp 裡的 EnumChildProc 函數的內容移到 Window::EnumChildProc 函數裡:

BOOL CALLBACK Window::EnumChildProc(HWND hWnd, LPARAM)
{
    HFONT hfDefault = (HFONT)GetStockObject(DEFAULT_GUI_FONT);
    SendMessage(hWnd, WM_SETFONT, (WPARAM)hfDefault, MAKELPARAM(TRUE, 0));
    return TRUE;
}

把 WinMain.cpp 裡的 WndProc 函數的內容移到 Window::WndProc 函數裡,並修改如下:

LRESULT CALLBACK Window::WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    Window* pThis = nullptr;

    if (msg == WM_NCCREATE) {
        CREATESTRUCT* pCreate = reinterpret_cast<CREATESTRUCT*>(lParam);
        pThis = reinterpret_cast<Window*>(pCreate->lpCreateParams);
        SetWindowLongPtr(hwnd, GWLP_USERDATA, (LONG_PTR)pThis);
        pThis->hWnd_ = hwnd;
    }
    else {
        LONG_PTR ptr = GetWindowLongPtr(hwnd, GWLP_USERDATA);
        pThis = reinterpret_cast<Window*>(ptr);
    }

    switch (msg) {
    case WM_CREATE: {
        pThis->SetWindowSize();

        pThis->hLabel_ = CreateWindowW(
            L"static",
            L"This is a label.",
            WS_VISIBLE | WS_CHILD,
            0, 0, 0, 0,
            hwnd,
            (HMENU)ID_LABEL,
            nullptr,
            nullptr
        );
        if (pThis->hLabel_) {
            pThis->SetLabelPos();
        }

        pThis->hButton_ = CreateWindowW(
            L"Button",
            L"Say hello",
            WS_VISIBLE | WS_CHILD,
            0, 0, 0, 0,
            hwnd,
            (HMENU)ID_BUTTON,
            nullptr,
            nullptr
        );
        if (pThis->hButton_) {
            pThis->SetButtonPos();
        }
    } break;
    case WM_COMMAND: {
        if (LOWORD(wParam) == ID_BUTTON) {
            pThis->SayHello();
        }
    } break;
    case WM_DESTROY: {
        PostQuitMessage(0);
    } break;
    }
    return DefWindowProcW(hwnd, msg, wParam, lParam);
}

Window::WndProc 函數的內容說明:

Window::WndProc 函數一開始需要先取得 Window Class 實例的指標。我們在建立主視窗時, CreateWindowExW 函數的最後一個參數是傳入 this (把 Window Class 實例的指標傳給 CreateWindowExW),現在我們可以透過 WM_NCCREATE 訊息把這個指標取出來。

WM_NCCREATE 訊息的 lParam 參數是一個 CREATESTRUCT 結構的指標,CREATESTRUCT 結構包含著我們傳給 CreateWindowExW 函數的指標,透過 reinterpret_cast 轉型取得了 this 指標後,再用 SetWindowLongPtr 函數把 this 指標儲存在主視窗的實例資料裡:

Window* pThis = nullptr;

if (msg == WM_NCCREATE) {
    CREATESTRUCT* pCreate = reinterpret_cast<CREATESTRUCT*>(lParam);
    pThis = reinterpret_cast<Window*>(pCreate->lpCreateParams);
    SetWindowLongPtr(hwnd, GWLP_USERDATA, (LONG_PTR)pThis);
    pThis->hWnd_ = hwnd;
}
else {
    // 下略...

把 this 指標儲存在主視窗的實例資料裡後,之後就可以透過 GetWindowLongPtr 函數來取回 this 指標:

Window* pThis = nullptr;

if (msg == WM_NCCREATE) {
    // 略...    
}
else {
    LONG_PTR ptr = GetWindowLongPtr(hwnd, GWLP_USERDATA);
    pThis = reinterpret_cast<Window*>(ptr);
}

之後的訊息處理程式碼就可以透過 pThis 來存取 Window Class 實例的成員變數或是呼叫成員函數:

switch (msg) {
case WM_CREATE: {
    pThis->SetWindowSize();

    pThis->hLabel_ = CreateWindowW(
        L"static",
        L"This is a label.",
        WS_VISIBLE | WS_CHILD,
        0, 0, 0, 0,
        hwnd,
        (HMENU)ID_LABEL,
        nullptr,
        nullptr
    );
    if (pThis->hLabel_) {
        pThis->SetLabelPos();
    }

    pThis->hButton_ = CreateWindowW(
        L"Button",
        L"Say hello",
        WS_VISIBLE | WS_CHILD,
        0, 0, 0, 0,
        hwnd,
        (HMENU)ID_BUTTON,
        nullptr,
        nullptr
    );
    if (pThis->hButton_) {
        pThis->SetButtonPos();
    }
} break;
case WM_COMMAND: {
    if (LOWORD(wParam) == ID_BUTTON) {
        pThis->SayHello();
    }
} break;

把 Window Class 剩下的成員函數的內容補完:

void Window::SetWindowSize()
{
    int dpi = GetDpiForWindow(hWnd_);
    int dpiScaledX = MulDiv(100, dpi, 96);
    int dpiScaledY = MulDiv(100, dpi, 96);
    int dpiScaledW = MulDiv(400, dpi, 96);
    int dpiScaledH = MulDiv(300, dpi, 96);
    SetWindowPos(
        hWnd_,
        hWnd_,
        dpiScaledX,
        dpiScaledY,
        dpiScaledW,
        dpiScaledH,
        SWP_NOZORDER | SWP_NOACTIVATE
    );
}

void Window::SetLabelPos()
{
    int dpi = GetDpiForWindow(hWnd_);
    int dpiScaledX = MulDiv(40, dpi, 96);
    int dpiScaledY = MulDiv(40, dpi, 96);
    int dpiScaledW = MulDiv(200, dpi, 96);
    int dpiScaledH = MulDiv(30, dpi, 96);

    SetWindowPos(
        hLabel_,
        hLabel_,
        dpiScaledX,
        dpiScaledY,
        dpiScaledW,
        dpiScaledH,
        SWP_NOZORDER | SWP_NOACTIVATE
    );
}

void Window::SetButtonPos()
{
    int dpi = GetDpiForWindow(hWnd_);
    int dpiScaledX = MulDiv(40, dpi, 96);
    int dpiScaledY = MulDiv(80, dpi, 96);
    int dpiScaledW = MulDiv(100, dpi, 96);
    int dpiScaledH = MulDiv(28, dpi, 96);

    SetWindowPos(
        hButton_,
        hButton_,
        dpiScaledX,
        dpiScaledY,
        dpiScaledW,
        dpiScaledH,
        SWP_NOZORDER | SWP_NOACTIVATE
    );
}

void Window::SayHello()
{
    if (hLabel_) {
        SetWindowTextW(hLabel_, L"Hello Win32!");
    }
}

最後,來清理 WinMain.cpp,把 WinMain.cpp 的內容修改如下:

#include "pch.h"
#include "Window.h"

using namespace winrt;
using namespace Windows::Foundation;

int WINAPI wWinMain(
    _In_ HINSTANCE hInstance,
    _In_opt_ HINSTANCE,
    _In_ LPWSTR,
    _In_ int)
{
    init_apartment(apartment_type::single_threaded);

    auto window = std::make_unique<Window>(hInstance, L"MainWindow", L"Window");
    
    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    return (int)msg.wParam;
}

編譯執行:

完整的範例程式碼:https://github.com/riddleling/HelloWin32

結語

目前微軟的教學文件裡是用「Windows Desktop Wizard」來建立 Win32 桌面應用程式專案,但我認為現在如果要開發新的 Win32 桌面應用程式,使用「Windows Desktop Application (C++/WinRT)」來建立專案會是比較好的選擇,C++/WinRT 提供了 UWP API 的 C++ 語言投影,所以能用 Win32 結合 UWP API 的方式來撰寫桌面應用程式。

不過,或許有人會覺得奇怪,都西元 2022 年了,為何還要用 Win32 來寫桌面應用程式呢?

我覺得 Win32 目前還是有它的優勢在,特別是當你想建立記憶體使用量很小的應用程式時 (例如:常駐程式)。或是想用 UWP API 提供的功能寫一些很簡單的小工具,這時用 Win32 來寫 GUI 的部分或許會是不錯的選擇。

將 Windows 10 PC 變成 iBeacon:使用 windows-rs 實作

這幾天在練習使用 windows-rs,寫了一個讓 Windows 10 廣播 iBeacon 訊號的小程式。

完整的程式碼:https://github.com/riddleling/beacon-publisher-on-windows

這個程式很簡單, 使用 UWP API 的 BluetoothLEAdvertisementPublisher 發布 iBeacon 廣告:

let manufacturer_data = BluetoothLEManufacturerData::new()?;

// 設定 iBeacon 的 Manufacturer Data:
manufacturer_data.SetCompanyId(0x004C)?;  // Company ID
let data:[u8; 23] = [
    // Type:
    0x02,
    // Data Length:
    0x15,
    // Proximity UUID:
    0xE3, 0x0A, 0xC8, 0xFE,
    0x75, 0xB8, 0x47, 0x21,
    0x4B, 0x5D, 0x56, 0xB7, 
    0x07, 0x64, 0x25, 0xA9,
    // Major:
    0x00, 0x02,
    // Minor:
    0x00, 0x03,
    // TX Power:
    0xC8
];

let writer = DataWriter::new()?;
writer.WriteBytes(&data)?;

let buffer = writer.DetachBuffer()?;
manufacturer_data.SetData(buffer)?;

let publisher = BluetoothLEAdvertisementPublisher::new()?;

// 把 Manufacturer Data 加入 publisher:
publisher.Advertisement()?.ManufacturerData()?.Append(manufacturer_data)?;

// 開始廣播:
publisher.Start()?;

停止廣播:

publisher.Stop()?;

寫了一個 serial tool app

最近用 Rust 寫了一個 serial tool app 給自己用,GUI 的部分使用 gtk-rs, serial port 通訊的部分使用 tokio-serial (serialport-rs 的 tokio 運行時版本),USB hotplug 的部分使用 rusb (libusb 的 Rust 包裝)。

不過,因為 libusb 不支持 Windows 上的 hotplug,所以在 Windows 上執行時,我使用一個 loop 每隔一秒列舉一次 USB 裝置,來判斷是否有 USB 裝置插入或拔出。

serial tool app 的原始碼:https://github.com/riddleling/serial-tool

然後可以用 Arduino 寫一個 serial echo 程式來測試,把寫入 serial port 的字元回傳回來:

// Arduino serial echo

void setup() {
    Serial.begin(115200);
}

void loop() {    
    if (Serial.available() > 0) {
        Serial.print((char)Serial.read());
    }
}

在 Linux (Raspberry Pi OS) 上執行 serial tool app 的畫面:

在 Windows 上執行的 demo 影片 (透過 serial port 傳輸指令,打開或關閉 LED):

用 Raspberry Pi 打造一台 Spotify 播放器 – Part 2

延續上一篇的內容,要接著說明控制端的 GUI app 如何編譯執行。

在開始之前,先用 ssh 連到 Raspberry Pi,然後安裝 mDNS 的套件包與修改主機名稱。

$ ssh pi@<Raspberry Pi's IP address>
$ sudo apt -y install avahi-daemon
$ sudo raspi-config

執行 sudo raspi-config 後,會看到一個文字介面的選單,選擇 System Options => Hostname,然後把 Hostname 改成 spotifypi (或是你喜歡的名字)。改完後請重新開幾。


Raspberry Pi 開啟 mDNS 的服務後,我們就可以透過 <hostname>.local 這個網址來取代 IP 位址,例如:假設 Raspberry Pi 的 IP 位址是 192.168.1.110,那我們透過 WebSocket 去控制時的 URL 是 ws://192.168.1.110:9487,現在有了 mDNS,可以把 URL 改成 ws://spotifypi.local:9487。

不過,如果控制端的電腦不是 macOS (macOS 內建 mDNS 的支持),那還需要安裝一些軟體。在 Linux 上,需要安裝 avahi。Windows 則比較麻煩,以前在 Windows 上安裝 Apple 的 Bonjour Print Services 就可以開始用 mDNS,後來在某次的 Windows 10 更新後, Bonjour 就不能用了,還需要修改 Windows 登錄機碼才行,有興趣的人可以參考這裡


開始來說明控制端的 GUI app。我這個 app 是用 Rust 寫的,GUI 的部分使用 gtk-rs,WebSocket 的部分使用 tokio-tungstenite。

編譯之前需要先設定開發環境,首先,先安裝 rustup。然後需要安裝 GTK 3 的開發套件包。

在 macOS 上安裝 GTK 3 的開發套件包:

$ brew install gtk+3
$ brew install gnome-icon-theme

在 Debian / Ubuntu 上安裝 GTK 3 的開發套件包:

$ sudo apt install libgtk-3-dev build-essential

在 Windows 上安裝 GTK 3 的開發環境:請參考這篇


下載控制端 GUI app 的程式碼,並且編譯:

$ git clone https://github.com/wlelab/SpotifyPi-Control-Panel.git
$ cd SpotifyPi-Control-Panel
$ cargo build --release

編譯需要一段時間。編譯完成後,執行檔會放在 SpotifyPi-Control-Panel/target/release 目錄下。

在 Windows 上,直接執行 SpotifyPi-Control-Panel\target\release 目錄下的 spotifypi-control-panel.exe 即可,在 Linux / macOS 上,可以透過終端機執行:

$ ./target/release/spotifypi-control-panel

Windows 上的執行畫面:

Demo 影片:

用 Raspberry Pi 打造一台 Spotify 播放器 – Part 1

最近把 Raspberry Pi 4B 改成 Spotify 播放器,想把製作方式記錄下來。

以前要在 Raspberry Pi OS 的 Chromium 上執行 Spotify Web Player,會因為缺少 Widevine DRM 支持的關係而無法播放。約在今年三月時,Raspberry Pi OS 正式提供了 Widevine DRM 的支持,所以現在只要在 Raspberry Pi OS 上安裝 Widevine DRM 的套件包即可用 Chromium 播放 Spotify!

在 Raspberry Pi OS 上開啟終端機,執行:

$ sudo apt update
$ sudo apt upgrade
$ sudo apt install libwidevinecdm0
$ sudo reboot

然後用 Chromium 打開 https://open.spotify.com,就可以開始使用 Spotify 了。


不過我想把 Raspberry Pi 改成 Spotify 專屬播放機,所以接下來就來說明如何設定吧。

一開始要先做一些前置作業,先打開 Chromium 瀏覽器,然後在設定裡把「起始畫面」設成「開啟新分頁」:

打開 Raspberry Pi 設定,把 Display 裡的「Screen Blanking」設成「停用」:

接著切換到 Raspberry Pi 設定裡的「介面」,啟用「SSH」。

然後打開終端機,輸入 ifconfig,記下 Raspberry Pi 的 IP 位址。


我想利用 Chromium 的 Kiosk 模式來開啟 Spotify Web Player,並且在開機進入桌面時自動開啟 Chromium。這需要寫一些腳本程式,讓我們開始寫吧:

先在家目錄下建立一個 bin 資料夾,然後用文字編輯器 (nano) 開一個 start_kiosk.sh 檔案,並且存在 bin 資料夾裡:

$ cd ~
$ mkdir -p bin
$ cd bin
$ nano start_kiosk.sh

start_kiosk.sh 的內容如下,複製貼上後,Ctrl + O 存檔, Ctrl + X 離開:

#!/bin/bash

export DISPLAY=:0

while true;
do
  sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' /home/pi/.config/chromium/Default/Preferences
  sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' /home/pi/.config/chromium/Default/Preferences

  sleep 1

  /usr/bin/chromium-browser \
    --kiosk \
    --noerrdialogs \
    --disable-infobars \
    https://open.spotify.com
done

說明:

export DISPLAY=:0 這行是指定要輸出的顯示器。

sed -i 這兩行是用來清理 Chromium 的 crash flag:

sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' /home/pi/.config/chromium/Default/Preferences
sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' /home/pi/.config/chromium/Default/Preferences

用 Kiosk 模式啟動 Chromium,且不要顯示錯誤對話框與訊息條:

/usr/bin/chromium-browser \
  --kiosk \
  --noerrdialogs \
  --disable-infobars \
  https://open.spotify.com

在終端機裡,用 chmod 修改 start_kiosk.sh 的執行權限:

$ chmod 755 start_kiosk.sh

然後編輯 ~/.config/lxsession/LXDE-pi/autostart:

$ nano ~/.config/lxsession/LXDE-pi/autostart

在 autostart 加入這一行:

@/home/pi/bin/start_kiosk.sh

Ctrl + O 存檔, Ctrl + X 離開 nano。然後重新開機。

重新開機後,可以看到開機後會自動開啟一個全螢幕的 Spotify Web Player 了!


雖然用手機上的 Spotify app 就可以遠端控制 Spotify Web Player,不過我還想要控制 Raspberry Pi 的系統音量,或是遠端控制重開機或關機,所以我寫了一個 Python script,可以透過 WebSocket 控制 Raspberry Pi。

程式碼在此:https://github.com/wlelab/SpotifyPi/blob/main/spotifypi_service.py

這個 Python 程式還需要搭配「Spotify Web Player Hotkeys」這個 Chrome 擴充套件使用,先用 ssh 連到 Raspberry Pi,然後 cd 到 ~/.config/lxsession/LXDE-pi/ 目錄,把 autostart 改名,然後重新開機:

$ ssh pi@<Raspberry Pi IP address>
$ cd ~/.config/lxsession/LXDE-pi/
$ mv autostart autostart_copy
$ sudo reboot

重開機後,會進入 LXDE 桌面而不是開啟 Chromium Kiosk 模式。

然後打開 Chromium,前往 設定 => 擴充功能 => Chrome 線上應用程式商店,搜尋「Spotify Web Player Hotkeys」這個擴充套件,並安裝之。

然後在 Chromium => 設定 => 擴充功能 => 鍵盤快速鍵 裡設定 hot keys,如下圖所示:

spotifypi_service.pystart_spotifypi_service.sh 下載下來,複製到 ~/bin 資料夾,並且修改執行權限:

$ chmod 755 spotifypi_service.py
$ chmod 755 start_spotifypi_service.sh

安裝 Python script 所需的套件:

$ pip3 install websockets
$ pip3 install pyautogui

然後 cd 到 ~/.config/lxsession/LXDE-pi/ 資料夾,把 autostart 的名稱改回來:

$ cd ~/.config/lxsession/LXDE-pi/
$ mv autostart_cpoy autostart

接著用 nano 在 autostart 裡加上這行:@/home/pi/bin/start_spotifypi_service.sh,如下所示:

@/home/pi/bin/start_spotifypi_service.sh
@/home/pi/bin/start_kiosk.sh

重新開機,這次會用 Chromium Kiosk 模式開啟。


在其他的 PC 上,下載 test.py,然後開啟終端機,執行:

$ pip3 install websockets
$ python3 test.py

會有以下的資訊輸出:

Usage: test.py [URL] [command]

command list:
    get_volume - get current volume value
    set_volume [number] - set volume, number value range: 0 ~ 100
    toggle_play_pause - toggle play / pause
    next_track - next track
    prev_track - previous track
    toggle_shuffle - toggle shuffle
    toggle_repeat_state - toggle repeat off / single song / whole playlist
    shutdown - shutdown machine
    reboot - reboot machine

其中 URL 的格式為 ws://<Raspberry Pi IP address>:<Port>,我在 Python script 裡把 port 設為 9487。

假如 Raspberry Pi 的 IP 位址為 192.168.1.110,那麼 URL 就是「ws://192.168.1.110:9487」。

舉例,如果想取得當前的系統音量值:

$ python3 test.py ws://192.168.1.110:9487 get_volume

: [volume](40)

想設定系統音量值:

$ python3 test.py ws://192.168.1.110:9487 set_volume 55

: [volume](55)

想切換 播放/暫停 或是 下一首歌曲:

$ python3 test.py ws://192.168.1.110:9487 toggle_play_pause

: [toggle_play_pause](ok)

$ python3 test.py ws://192.168.1.110:9487 next_track

: [next_track](ok)

返回的內容格式為 [event](value),[ ] 內是事件名稱,( ) 內是事件的值。

既然可以透過 WebSocket 來控制 Raspberry Pi 了,所以我寫了一個 GUI app 來傳送控制命令,下一篇會講解如何編譯執行那個 GUI app。

筆記》Windows 安裝 gtk-rs 開發環境

步驟 1:安裝 rustup

步驟 2:安裝 MSYS2

步驟 3:開啟一個「MSYS2 MSYS」shell:

更新套件包與資料庫:

$ pacman -Syu

更新套件包 :

$ pacman -Su

安裝 GCC toolchain:

$ pacman -S --needed base-devel mingw-w64-x86_64-toolchain

安裝 GTK 3:

$ pacman -S mingw-w64-x86_64-gtk3

步驟 4:編輯 Windows 環境變數:

  • 在使用者變數的 Path 裡加入:C:\msys64\mingw64\bin
  • 在使用者變數新增一個 GTK_LIB_DIR 變數,變數值為:C:\msys64\mingw64\lib
  • 修改完環境變數後,將 Windows 重新啟動。

步驟 5:開啟一個 Windows Terminal 或是 PowerShell:

輸入以下指令添加 target:

PS C:\> rustup target add x86_64-pc-windows-gnu

然後輸入「rustup show」指令查看 target,應該可以看到這兩行:

stable-x86_64-pc-windows-gnu
stable-x86_64-pc-windows-msvc (default)

接著輸入下列指令,把預設的 toolchain 改成 GNU:

PS C:\> rustup default stable-x86_64-pc-windows-gnu

步驟 6:使用 cargo 指令建立一個新專案:

PS C:\> cargo new my-gtk-app
PS C:\> cd my-gtk-app

然後在 Cargo.toml 的 dependencies 裡加入 gtk:

[dependencies]
gtk = "0.14.3"

編譯執行,看看有沒有錯誤訊息:

PS C:\> cargo run

讀書》A Tour of C++ 中文版

《A Tour of C++, 2/e》繁體中文版與簡體中文版

最近把《A Tour of C++, 2/e》中文版重讀了一次,上次讀的時候因為台灣譯本裡的一些翻譯名詞看不太懂(有些名詞既不是台灣傳統上的譯法,也不是中國那邊的譯法),所以最後也沒讀完就丟在一旁了。

最近開始想認真學習一下 Modern C++(因為想用 JUCE 寫跨平台程式 🙂),所以把《A Tour of C++, 2/e》繁體中文版又拿出來讀,並且另外買了簡體中文版,當繁體版看不懂時就翻簡體版來對照著看 XD

《A Tour of C++, 2/e》內容涵蓋了部分 C++20 特性,而且很薄(才兩百多頁),很適合拿來快速理解 Modern C++…(大誤)。嗯,對有 C++98 經驗的人來說,這本應該是不錯的現代 C++ 入門書,對沒什麼程式經驗的人來說,這本書恐怕不易消化 😅(其實我覺得 Bjarne Stroustrup 的書對新手來說都不算友善 XD,個人感想,勿戰)。

談 C++ 語言的書通常都很厚,光是看到那比磚頭還厚的厚度,就沒什麼動力讀下去,這本《A Tour of C++, 2/e》夠薄,拿來入門剛剛好。不過,我認為最好的 C++ 入門書還是《Essential C++》,薄薄一本只有兩百多頁,講述了 C++ 最基本最核心的部分,只可惜《Essential C++》是 1999 年出版的,內容對現在而言已經過時了…

說到《Essential C++》,讓我想到了 Stanley B. Lippman 在《Essential C++》前言裡提到他在迪士尼電影動畫公司工作時,有次被要求用 Perl 重寫一個工具程式,但他不懂 Perl,所以他想找一本 Perl 書籍來抱佛腳,而且那本書不能太厚,因為當時的情況需要快點讓那個 Perl 程式動起來,最後他找了《Learning Perl》(台灣譯本名為:《Perl 學習手冊》)來讀。這次的經驗讓他明白對於想立刻學會並使用 C++ 的人來說,《C++ Primer》過於龐大複雜了,因此他寫了《Essential C++》這本小書。

說到《Learning Perl》,又讓我想起我的第一份正職程式設計工作時的一件事 😅,當時我剛進去工作不到兩個禮拜,第一個任務是要把一本英文字典做成 iOS app,當時的老闆已經有寫好一支程式把字典的純文字檔轉換成他想要的特定格式,他讓我接手修改那支程式,那支程式是用 Objective-C 寫的,我當時研究了程式的內容與特定格式的結構後,發現那支程式的效果不好,因為還需要半人工的方式去修改文字檔,而且無法正確找出所有的格式問題,最後我決定重寫一支程式來處理格式的問題。

一開始我是用 Objective-C 去寫,寫了一堆 NSRegularExpression 😵,過了三個禮拜後,覺得寫 NSRegularExpression 實在太痛苦,而且寫到卡關了,所以決定改用 Perl 來處理格式問題。雖然之前有用 Perl 寫過一些小程式的經驗,但是當時有一陣子沒寫 Perl 了,已經忘光 Perl 怎麼寫,因此趕快重讀了《Perl 學習手冊》(忘記是第五版還是第六版了),用了約四、五天的時間讀了一遍《Perl 學習手冊》,然後開始寫 Perl script 來處理格式問題,後來寫了十幾支 Perl script 才終於解決完格式的問題(一支 script 處理一種格式問題),而且才花了一個多禮拜的時間 😁

雖然格式的問題解決了,但是我後來發現文字檔裡有少數的地方有亂碼(原本的字典純文字檔就有亂碼),結果最後還是得找工讀生拿紙本字典對照著文字檔找出所有的亂碼,並手動修正,噗,這樣一開始就請工讀生直接手動修改格式並修正亂碼不就得了 🙃 …… #這就是人蔘啊

好像越扯越遠了,今天先這樣囉,Bye~