לדלג לתוכן

עבודה ברקע ציור ותצוגה

נוסיף לחלון חישוב של כמה עשרות מיליוני ערכים. אם נריץ אותו בתוך handler של לחיצה, החלון יפסיק להגיב. מערכת ההפעלה עדיין מתזמנת את התהליך, אבל הthread שאמור לטפל בקלט ובציור עסוק בחישוב. לכן המילה "התוכנית רצה" אינה אומרת שהמשתמש יכול להשתמש בה.

נוציא את החישוב לworker. עכשיו צריך להחליט איך תוצאה חוזרת, מי מחזיק אותה, ומה קורה אם המשתמש סוגר את החלון לפני הסיום. אחר כך נבדוק שהציור נשאר נכון גם במסכים עם DPI שונה ושאפשר להשתמש בממשק בלי לראות אותו.

מתחילים מחוזה קטן של worker

הקובץ הבא, ui_work.h, הוא רכיב שלם שאפשר לצרף לתוכנית מהשיעור הקודם. הוא מתחיל חישוב אחד, מפרסם התקדמות ומאפשר בקשת עצירה. אין לו HWND ואין לו גישה לcontrols. כך חיי החלון אינם משפיעים על יכולתו לסיים.

#pragma once
#include <atomic>
#include <cstdint>
#include <thread>

enum class WorkState { running, completed, canceled };
struct WorkSnapshot {
    WorkState state;
    unsigned progress;
    std::uint64_t result;
};

class UiWork {
    std::atomic<WorkState> state_{WorkState::running};
    std::atomic<unsigned> progress_{0};
    std::uint64_t result_ = 0;
    std::jthread thread_;
public:
    UiWork() : thread_([this](std::stop_token stop) {
        constexpr std::uint64_t limit = 50000000;
        std::uint64_t sum = 0;
        for (std::uint64_t i = 0; i < limit; ++i) {
            if ((i & 32767) == 0) {
                if (stop.stop_requested()) {
                    state_.store(WorkState::canceled, std::memory_order_release);
                    return;
                }
                progress_.store(static_cast<unsigned>(i * 100 / limit),
                                std::memory_order_relaxed);
            }
            sum += i;
        }
        result_ = sum;
        progress_.store(100, std::memory_order_relaxed);
        state_.store(WorkState::completed, std::memory_order_release);
    }) {}
    UiWork(const UiWork&) = delete;
    UiWork& operator=(const UiWork&) = delete;
    void request_stop() noexcept { thread_.request_stop(); }
    WorkSnapshot snapshot() const noexcept {
        const auto state = state_.load(std::memory_order_acquire);
        return {state, progress_.load(std::memory_order_relaxed),
                state == WorkState::completed ? result_ : 0};
    }
};

הפרסום של completed מתבצע אחרי כתיבת result_. הקריאה בacquire רואה את הכתיבה הזאת לפני שהיא קוראת את התוצאה. בזמן running לא נוגעים בשדה התוצאה. ההתקדמות היא מידע משוער להצגה, ולכן אין צורך שכל snapshot שלה יתאים בדיוק לאותה איטרציה שבה נקרא state.

המחלקה אינה מאפשרת restart. אם נוסיף restart בלי לשנות את החוזה, קורא יכול לראות completed ישן בזמן שworker חדש כבר כותב לresult_. כדי לתמוך בכמה עבודות ניצור אובייקט לכל generation, או נגן על snapshot שלם בסנכרון מתאים.

מחברים לחלון בלי לחסום אותו

נכלול את הheader בתוכנית הקודמת ונחליף את AppState בהגדרה הבאה. הקוד הזה מצטרף לwindow.cpp, ואינו תוכנית שנייה בפני עצמה.

struct AppState {
    UiWork work;
    bool closing = false;
    DWORD clicks = 0;
};

נוסיף טיפול בWM_CREATE ובWM_TIMER, ונחליף את הטיפול בWM_CLOSE. נשתמש בtimer רק כדי לדגום snapshot להצגה; הוא אינו שעון מדויק של החישוב.

case WM_CREATE:
    return SetTimer(window, 1, 100, nullptr) ? 0 : -1;
case WM_TIMER:
    if (wparam == 1 && state) {
        const auto snapshot = state->work.snapshot();
        InvalidateRect(window, nullptr, FALSE);
        if (state->closing && snapshot.state != WorkState::running)
            DestroyWindow(window);
    }
    return 0;
case WM_CLOSE:
    if (state) {
        state->closing = true;
        state->work.request_stop();
        if (state->work.snapshot().state != WorkState::running)
            DestroyWindow(window);
        else
            SetWindowTextW(window, L"Finishing background work...");
    }
    return 0;

בתוך WM_DESTROY נוסיף KillTimer(window, 1) לפני PostQuitMessage. בתוך paint נציג את state->work.snapshot() במקום את מספר הלחיצות. הבעלות על AppState נשארת בmain, ולכן הוא חי גם בזמן שהחלון ממתין לסיום.

הdestructor של jthread מבקש עצירה ומצטרף לthread. בניסוי הזה העבודה בודקת עצירה לאורך חישוב סופי ואינה תלויה בUI. עדיין אין הבטחת מספר milliseconds קשיחה, משום שהתזמון נתון למערכת. אם נחליף את החישוב בI/O בלתי מוגבל, destructor כזה יכול לחסום; לא נייבא את המסקנה מהניסוי בלי לבדוק את החוזה החדש.

מה עושים עם אלפי תוצאות

דגימת progress יחיד אינה תור של תוצאות. עבור stream של תוצאות נשתמש בתור מוגבל ובמדיניות עומס. אפשר להוסיף הודעת WM_APP אחת שאומרת "יש מידע בתור", ולצרף באותה הודעה כמה תוצאות. הproducer לא צריך להציף את תור החלון בהודעה לכל byte.

התור יישאר בבעלות אובייקט שחי עד סוף כל הproducerים. אם החלון נסגר, מפסיקים פרסום אליו, מנקזים או משחררים את התור לפי החוזה ומחכים לסיום פעולות שעדיין מחזיקות אותו. בדיקת IsWindow לפני כל post אינה תחליף לסדר הסגירה הזה.

יחידות על המסך

המרחק 20 pixels נראה שונה במסכים עם צפיפות שונה. נגדיר layout ביחידות לוגיות, למשל DIP לפי בסיס 96 DPI, ונמיר לגודל המתאים לחלון. עבור margin של 12 DIP נחשב MulDiv(12, dpi, 96). עבור גובה font בנקודות נשתמש בבסיס 72, ולכן אין להעתיק אותה נוסחה לכל יחידה.

ביישום חדש נגדיר מודל Per-Monitor V2 בmanifest לפני יצירת חלונות. במעבדה בלי manifest אפשר לקרוא לSetProcessDpiAwarenessContext בתחילת main, לבדוק הצלחה ולהפסיק אם הסביבה אינה עומדת בדרישה. לא נקרא לפונקציה שוב אחרי שframework כבר יצר חלון.

במעבר בין מסכים מתקבלת WM_DPICHANGED. נשמור את הDPI החדש, נבנה מחדש משאבי ציור שתלויים בו ונשתמש במלבן המוצע כאשר הוא מתאים למדיניות החלון. הדוגמה הבאה היא handler נוסף לאותו חלון.

case WM_DPICHANGED: {
    const RECT suggested = *reinterpret_cast<const RECT*>(lparam);
    SetWindowPos(window, nullptr, suggested.left, suggested.top,
                 suggested.right - suggested.left, suggested.bottom - suggested.top,
                 SWP_NOZORDER | SWP_NOACTIVATE);
    InvalidateRect(window, nullptr, TRUE);
    return 0;
}

הhandler אינו מספיק אם שמרנו font או bitmap בגודל הישן; צריך לעדכן גם אותם. נקרא את הDPI של החלון באמצעות GetDpiForWindow, ולא נניח שהמסך הראשי מייצג את כל התצוגות. נסמן בכל מבנה האם הקואורדינטות הן של client או של screen, והאם הן לוגיות או פיזיות. הסקירה של פיתוח לתצוגות DPI שונות עוזרת להבין מה המערכת מגדילה אוטומטית ומה נשאר באחריותנו.

משאבי ציור הם גם משאבים

אובייקט GDI כמו font או brush אינו handle שסוגרים בCloseHandle. אובייקט שיצרנו נשחרר בדרך המתאימה, למשל DeleteObject. לפני מחיקת font שנבחר לתוך DC נחזיר את האובייקט הקודם. לא נמחק stock object או brush שהמערכת החזירה בהשאלה.

הקשר שמתקבל מBeginPaint נסגר עם EndPaint; הקשר שמתקבל מGetDC מוחזר עם ReleaseDC. ערבוב הזוגות יכול לדלוף גם כשהחלון עדיין מצויר. אם מוסיפים double buffering, צריך לשמור גם על חיי הbitmap הנבחר בmemory DC ולשנות את גודלו כשהclient משתנה.

עברית ונגישות

טקסט בעברית עם מספרים ונתיב באנגלית דורש טיפול בכיווניות, לא היפוך ידני של תווים. יש הבדל בין כיוון הקריאה של טקסט, יישור לימין ומירור של layout. נבדוק כל אחד עם דוגמאות שמכילות מספר, סוגריים ושם קובץ. גם מיקום מסך יכול להיות שלילי במערך מסכים; לא נפרש כל קואורדינטה כunsigned.

טקסט שציירנו בGDI אינו בהכרח נגיש לקורא מסך. פקדים תקניים מקבלים תמיכת UI Automation מהמערכת, בעוד רכיב מצויר מותאם זקוק לprovider שמציג שם, תפקיד, מצב וpatterns מתאימים. נשתמש במפת הproviders ובחוזי הpatterns כדי לבחור יכולות לפי משמעות הרכיב.

כפתור ביטול צריך להיות ניתן להפעלה מהמקלדת ומכלי נגישות, לא רק מלכידת לחיצה בקואורדינטה. ערך progress צריך להיחשף כערך עם תחום, ולא רק כפס בצבע. נבדוק את עץ האוטומציה ואת focus בפועל; צילום מסך יפה אינו בדיקת נגישות.