Подробный гайд: Создание графической C++-библиотеки для macOS с Metal и Objective-C++ - Часть 1

Гайд по созданию графической C++-библиотеки для macOS с Metal и Objective-C++: решение проблемы блокировки потока NSApplication через ручную обработку событий.

2026.10.05                  


Подробный гайд: Создание графической C++-библиотеки для macOS с Metal и Objective-C++ - Часть 1Подробный гайд: Создание графической C++-библиотеки для macOS с Metal и Objective-C++ - Часть 1 Проблема классическая при разработке кроссплатформенных графических библиотек под macOS. Давайте разберём её по косточкам и построим правильное архитектурное решение.


1. Почему [NSApplication run] вас не устраивает

Три фундаментальных ограничения macOS:

  1. [NSApplication run] — это бесконечный цикл обработки событий (main event loop). Он не возвращает управление до тех пор, пока приложение не завершится.
  2. AppKit требует, чтобы весь UI-код выполнялся в главном потоке (main thread). Перенос [NSApp run] в фоновый поток приведёт к падениям, зависаниям и непредсказуемому поведению.
  3. Библиотека не должна захватывать контроль над main loop'ом. Это плохой API-дизайн: пользователь библиотеки сам хочет решать, как организовать свой цикл (игровой цикл, UI-цикл, headless-рендеринг и т.д.).



Что делают реальные библиотеки (GLFW, SDL, sokol_app): они не вызывают [NSApplication run].

Вместо этого они:

  • Инициализируют NSApplication один раз
  • Предоставляют функции pollEvents() / waitEvents(), которые пользователь вызывает в своём цикле
  • Вручную "прокачивают" очередь событий macOS

2. Архитектура библиотеки

mylib/
├── include/
│   └── mylib.h              ← Публичный C++ API (без Objective-C)
├── src/
│   ├── mylib.cpp            ← Реализация API
│   ├── platform.h           ← Абстракция платформы (internal)
│   ├── platform_mac.mm      ← Objective-C++ бэкенд
│   └── metal_renderer.mm    ← Metal-рендеринг
├── examples/
│   └── triangle.cpp         ← Пример использования
└── CMakeLists.txt

Принцип разделения:

  • Публичный заголовок — чистый C++ (никакого <Cocoa/Cocoa.h>)
  • Objective-C++ живёт только в .mm файлах
  • Связь между C++ и ObjC++ через opaque-указатели (void*) или PIMPL

3. Публичный API (include/mylib.h)

#pragma once
#include <cstdint>

namespace mylib {

// ---- Инициализация / завершение ----
bool init();                    // Инициализирует NSApplication (без run!)
void terminate();               // Освобождает ресурсы

// ---- Окно ----
struct WindowConfig {
    int width  = 1280;
    int height = 720;
    const char* title = "MyLib Window";
    bool resizable = true;
};

class Window {
public:
    static Window* create(const WindowConfig& cfg);
    void destroy();

    bool shouldClose() const;
    void setShouldClose(bool v);

    void getSize(int& w, int& h) const;
    void* nativeHandle() const;   // NSWindow* (для продвинутых пользователей)
    void* metalLayer() const;     // CAMetalLayer*

private:
    Window() = default;
    struct Impl;
    Impl* impl_ = nullptr;
};

// ---- События ----
// Неблокирующая прокачка — возвращает управление немедленно
void pollEvents();

// Блокирует до следующего события
void waitEvents();

// Блокирует максимум на timeout секунд
void waitEventsTimeout(double timeout);

// ---- Рендерер Metal ----
class MetalRenderer {
public:
    static MetalRenderer* create(Window* window);
    void destroy();

    struct Frame {
        void* drawable;         // id<CAMetalDrawable>
        void* commandBuffer;    // id<MTLCommandBuffer>
        void* renderPassDesc;   // MTLRenderPassDescriptor*
    };

    Frame beginFrame();
    void endFrame(Frame frame);

private:
    MetalRenderer() = default;
    struct Impl;
    Impl* impl_ = nullptr;
};

} // namespace mylib

Пользователь владеет main loop'ом:

int main() {
    mylib::init();
    auto* win = mylib::Window::create({1280, 720, "Hello"});
    auto* rnd = mylib::MetalRenderer::create(win);

    while (!win->shouldClose()) {
        mylib::pollEvents();          // ← библиотека НЕ блокирует
        auto frame = rnd->beginFrame();
        // ... рендеринг ...
        rnd->endFrame(frame);
    }

    rnd->destroy();
    win->destroy();
    mylib::terminate();
}

4. Инициализация NSApplication без [NSApp run] (src/platform_mac.mm)

Это самая важная часть — как запустить AppKit, не блокируя поток.



#import "platform.h"
#import <Cocoa/Cocoa.h>
#import <QuartzCore/CAMetalLayer.h>
#import <Metal/Metal.h>

// Глобальное состояние платформы
namespace {
    bool g_initialized = false;
}

bool platform_init() {
    if (g_initialized) return true;

    @autoreleasepool {
        // 1. Создаём shared application (это singleton, вызов идемпотентный)
        [NSApplication sharedApplication];

        // 2. Делаем приложение "регулярным" — появляется в Dock, получает фокус
        [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];

        // 3. ВАЖНО: НЕ вызываем [NSApp run]!
        // Вместо этого завершаем "запуск" вручную.
        // Это нужно, чтобы AppKit считал приложение запущенным,
        // но цикл событий мы будем качать сами.
        [NSApp finishLaunching];

        // 4. Активируем приложение (чтобы окно было на переднем плане)
        [NSApp activateIgnoringOtherApps:YES];

        // 5. Устанавливаем минимальное меню (иначе Cmd+Q не работает)
        NSMenu* mainMenu = [[NSMenu alloc] init];
        NSMenuItem* appMenuItem = [[NSMenuItem alloc] init];
        NSMenu* appMenu = [[NSMenu alloc] init];
        NSMenuItem* quitItem = [[NSMenuItem alloc]
            initWithTitle:@"Quit"
            action:@selector(terminate:)
            keyEquivalent:@"q"];
        [appMenu addItem:quitItem];
        [appMenuItem setSubmenu:appMenu];
        [mainMenu addItem:appMenuItem];
        [NSApp setMainMenu:mainMenu];

        g_initialized = true;
    }
    return true;
}

void platform_terminate() {
    if (!g_initialized) return;
    // При желании можно вызвать [NSApp terminate:nil],
    // но обычно достаточно просто выйти из main().
    g_initialized = false;
}

Ключевая идея:

Что Зачем
[NSApplication sharedApplication] Создаёт синглтон приложения
setActivationPolicy: Делает приложение видимым в системе
finishLaunching Говорит AppKit'у "мы запустились" без входа в run loop
activateIgnoringOtherApps: Окно появится поверх других
НЕ вызываем [NSApp run] Поток остаётся свободным

5. Ручная прокачка событий

Вот сердце решения — мы сами вытаскиваем события из очереди macOS и отправляем их в AppKit.

// src/platform_mac.mm (продолжение)

void platform_pollEvents() {
    @autoreleasepool {
        NSEvent* event;
        // distantPast = "не ждать, вернуть управление немедленно"
        NSDate* distantPast = [NSDate distantPast];

        while ((event = [NSApp nextEventMatchingMask:NSEventMaskAny
                                           untilDate:distantPast
                                              inMode:NSDefaultRunLoopMode
                                             dequeue:YES])) {
            [NSApp sendEvent:event];
        }
        // Заставляем окна обновить своё состояние (перерисовка заголовков,
        // обработка invalidate-запросов и т.д.)
        [NSApp updateWindows];
    }
}

void platform_waitEvents() {
    @autoreleasepool {
        // distantFuture = блокировать до прихода события
        NSEvent* event = [NSApp nextEventMatchingMask:NSEventMaskAny
                                            untilDate:[NSDate distantFuture]
                                               inMode:NSDefaultRunLoopMode
                                              dequeue:YES];
        if (event) {
            [NSApp sendEvent:event];
        }
        // Добираем остальные накопившиеся события
        platform_pollEvents();
    }
}

void platform_waitEventsTimeout(double timeout) {
    @autoreleasepool {
        NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow:timeout];
        NSEvent* event = [NSApp nextEventMatchingMask:NSEventMaskAny
                                            untilDate:deadline
                                               inMode:NSDefaultRunLoopMode
                                              dequeue:YES];
        if (event) {
            [NSApp sendEvent:event];
        }
        platform_pollEvents();
    }
}



Почему это работает:

  • [NSApp nextEventMatchingMask:...] — единственный sanctioned way получить событие из очереди AppKit
  • Параметр untilDate контролирует блокировку: distantPast = неблокирующий, distantFuture = блокирующий
  • sendEvent: передаёт событие в окно, которое само разбирается с ним через NSResponder chain

Мы делимся этой технической информацией, чтобы помочь вам в решении задач — используйте её с пониманием. Статья носит рекомендательный характер, поэтому, пожалуйста, применяйте описанные методы осмотрительно.


Статью подготовил: Аверко Денис Сергеевич @Nymexis г. Омск (специалист по ЗИ)

Комментарии

Загрузка...
Если комментарии не загружаются, можете попробовать отключить блокировщик рекламы для этого сайта