Подробный гайд: Создание графической C++-библиотеки для macOS с Metal и Objective-C++ - Часть 1
Проблема классическая при разработке кроссплатформенных графических библиотек под macOS. Давайте разберём её по косточкам и построим правильное архитектурное решение.
1. Почему [NSApplication run] вас не устраивает
Три фундаментальных ограничения macOS:
[NSApplication run]— это бесконечный цикл обработки событий (main event loop). Он не возвращает управление до тех пор, пока приложение не завершится.- AppKit требует, чтобы весь UI-код выполнялся в главном потоке (main thread). Перенос
[NSApp run]в фоновый поток приведёт к падениям, зависаниям и непредсказуемому поведению. - Библиотека не должна захватывать контроль над 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:передаёт событие в окно, которое само разбирается с ним черезNSResponderchain
Мы делимся этой технической информацией, чтобы помочь вам в решении задач — используйте её с пониманием. Статья носит рекомендательный характер, поэтому, пожалуйста, применяйте описанные методы осмотрительно.