Добрый день. Меня зовут Константин Дудник, я Team Lead, Consultant проекта Nik Collection компании Infopulse. Nik Collection — это набор плагинов для Adobe Photoshop и Lightroom. Если вы занимаетесь профессиональной фотографией, то могли о нем слышать. Если нет, просто поверьте, что это весьма популярная вещь среди фотографов. Ну или не верьте нам, а погуглите Nik Collection — и вы найдете кучу примеров того, какую красоту люди способны создавать с помощью подходящих инструментов.
Как разрабатываются плагины для Photoshop и Lightroom, какие технологии для этого актуальны, с какими проблемами можно столкнуться и как их решать. В статье также найдете информацию о Qt, кроссплатформенной разработке, проблемах legacy и современном IPC и о том, как правильная архитектура проекта может помочь его удобному тестированию.
В 2012 году всю компанию Nik Software купил Google, после чего использовал часть ее наработок в других своих продуктах. Что же касается Nik Collection, то Google не увидел для себя возможности зарабатывать на ней деньги и просто сделал бесплатной. Да, был период, когда такой большой профессиональный продукт можно было бесплатно скачать с google.com. Из плохих новостей — Google прекратил активную разработку Nik Collection, чем вызвал волну недовольства на фотофорумах.
Потом Google еще немного поразмышлял, что делать с Nik Collection и, так и не придумав ей место в своей экосистеме, в 2017 году продал продукт компании DxO, которая и продолжила разработку, уже продавая Nik Collection за деньги. DxO была уже хорошо известна на фоторынке благодаря своим обзорам камеры и линз DXOMARK, продукту для обработки фотографий PhotoLab. Так что Nik Collection попал в хорошие руки. Через некоторое время DxO наняла подрядчиком нас и работа продолжилась уже вместе.
Плагин Perspective Efex умеет работать с оптическими искажениями изображения и перспективой. Например, может из вот такой картинки:
Сделать такую:
Здесь и далее использованы иллюстрации с официального сайта продукта
Или добавить к достаточно тривиальной фотографии:
Такой эффект миниатюры:
Плагин Dfine позволяет бороться с шумами:
Как вы понимаете, это совершенно не «Reduce Noise...» из Photoshop.
Благодаря Silver Efex Pro можно сделать из цветной картинки черно-белую. Но погодите скептически поднимать бровь! Не просто сделать из цветной картинки черно-белую, а как бы «переснять» ее в черно-белом режиме, причем даже с указанием нужной вам фотопленки:
Можно подобрать зернистость или контрастность. А еще разузнать, на какой пленке была сделана классическая черно-белая фотография, и воспроизвести этот эффект на своем фото в один клик.
Тут же скажем о похожем плагине Analog Efex Pro — для воспроизведения эффекта съемки на классическую аналоговую камеру, да еще и с возможностями ее настройки и эмуляции определенных типов объективов:
Хорошее видео с примером использования этого плагина: Analog Efex Pro 2: Exploring Creativity: The Super Cell.
Еще есть такие плагины:
Еще была версия продукта, собранная из исходников от Google уже нашим заказчиком (DxO), с гарантией совместимости с последним Photoshop/Lightroom и актуальными ОС. Из нововведений там были только некоторое количество преднастроенных фильтров («рецептов»), что, конечно, хорошо, но мало. Нужно было быстро показать профессиональному фотосообществу, что продукт жив и развивается. Для этого выбрали несколько направлений.
«Невероятно красиво», не правда ли?
Мы полностью переделали панель (об использованных технологиях будет ниже), и теперь она выглядит так:
Помимо современного вида, добавился функционал — теперь, редактируя набор схожих картинок для одной цели, можно настроить фильтры плагина лишь один раз, а для следующих изображений применять их в один клик.
А что, если на следующий день мы откроем обработанное фото и решим, что какую-то одну настройку фильтра надо бы немного подкорректировать? Выхода особо не было: либо начинать редактирование с нуля на оригинальном изображении (если оно еще сохранилось), либо смириться с тем, что есть.
В Nik Collection 3 мы развязали пользователю руки. Теперь можно из Lightroom передать картинку в плагин, применить фильтры и сохранить результат в специальный формат — многостраничный TIFF:
При этом в него будут сохранены первоначальное изображение (в одну страницу TIFF-контейнера), конечное изображение (в другую страницу TIFF-контейнера) и все настройки всех использованных фильтров (в метаданные TIFF):
Таким образом полученный файл будет содержать всю необходимую информацию для реализации любых сценариев:
Кроме того, и самописный UI-фреймворк, и использованная версия wxWidgets были уже старыми, в них возникала куча проблем. Например, несовместимость с современными high-DPI дисплеями и мacOS Catalina. Какого-то простого способа решения этого не было, поскольку разработка самописного UI-фреймворка была давно заброшена, а wxWidgets в приложении был форкнут и существенно переработан, что сделало невозможным апгрейд на новую версию.
Мы решили переходить на Qt/QML — современный кроссплатформенный фреймворк с кучей полезного функционала. Начали с той самой некрасивой панели запуска плагинов — это был относительно отдельный и небольшой компонент продукта, на котором можно было протестировать подходы и решения.
Заказчик сразу заявил о своем желании использовать именно бесплатную опенсорсную версию Qt. Лицензия фреймворка позволяет его бесплатное использование даже в закрытых коммерческих продуктах, однако в этом случае доступна лишь динамическая линковка библиотек Qt (собрать весь Qt к себе в один бинарник нельзя). В этом, на первый взгляд, нет никаких проблем: мы делаем плагин (это библиотека, которая будет загружаться в процесс Photoshop), он подменяет library search path, загружает библиотеки Qt, и дальше все работает:
Но это только на первый взгляд. При тестировании оказалось, что не одни мы такие умные, в мире существует много других плагинов для Photoshop, которые тоже загружают библиотеки Qt и тоже динамически:
И вот когда в процесс Photoshop вдруг загружается несколько комплектов библиотек Qt (возможно, одной версии, а возможно, разных), это порой приводит к непредвиденным последствиям. Когда создается инстанс QtApplication, он запускает singleton-instance QCoreApplication и инициализирует несколько статических переменных, которые (если они разных версий) могут повлиять друг на друга и привести к неправильной работе одного из модулей.
Иногда Photoshop попросту падал. Другой раз ивенты начинали приходить не тем получателям. Можно было попробовать это исправить, но в общем виде задача «быть совместимыми со всеми остальными плагинами Photoshop, которые используют Qt» является плохо разрешимой ввиду неизвестного количества этих плагинов, используемых ими версий Qt и способов применения. Нельзя гарантировать совместимость с тем, чего не знаешь. И мы решили пойти другим путем.
Наш плагин был разделен на две части: в процесс Photoshop мы загружали относительно небольшой модуль, который вообще не использовал Qt и лишь интегрировался с Photoshop через его SDK. Сразу при загрузке он запускает отдельный процесс, в который уже загружается Qt без риска несовместимости с другими плагинами. Для взаимодействия модуля, загружаемого в Photoshop и standalone-процесса с UI, мы построили IPC на базе gRPC.
Дополнительно пришлось написать сериализацию всех внутренних параметров для всех плагинов. Теперь их можно завернуть в XML и передавать между процессами.
Отдельно стоит сказать, что в связи с последними веяниями в мире Qt прослеживаются некоторые риски относительно возможностей, ограничений и цены (явной или косвенной) фреймворка Qt в будущем. Поэтому мы сразу решили отделять и изолировать UI-слой от всего остального. Да, мы используем QML и все его возможности, но как только данные доходят до слоя бизнес-логики, никакого Qt там больше нет. Таким образом, если в будущем придется заменить Qt на что-то другое, это хоть и займет некоторое время, но будет возможно и не затронет никакие другие слои приложения, кроме UI.
Итак, что же умеет gRPC? Опустим нюансы его установки и подключения к проекту. Вы описываете функционал своего межкомпонентного взаимодействия в терминах сервисов и действий, которые эти сервисы умеют делать. Давайте, например, объявим сервис PingPong:
service PingPongService {
rpc SendPing (PingRequest) returns (PongReply) {}
}
message PingRequest {
string text = 1;
}
message PongReply {
string text = 1;
}
Далее с помощью функционала gRPC вы скармливаете этот proto-файл его компилятору и получаете на выходе набор классов для нужного вам языка программирования, которые можно подключить себе в проект. Для С++ это выглядит как пара заголовочного файла и файла с кодом. Включаем заголовочный файл в код компонента-сервера и реализуем методы:
class PingPongServiceImpl final : public PingPongService::Service
{
Status SendPing(ServerContext* context, const PingRequest* request, PongReply* reply) override
{
...
}
};
Затем включаем его же в код компонента-клиента и реализуем отправку сообщения:
class PingPongClient
{
public:
PingPongClient(std::shared_ptr<Channel> channel)
: stub_(PingPongService::NewStub(channel)) {}
std::string SendPing(const std::string& text)
{
PingRequest request;
request.set_text(text);
PongReply reply;
ClientContext context;
Status status = stub_->SendPing(&context, request, &reply);
...
}
private:
std::unique_ptr<PingPongService::Stub> stub_;
};
Особенная прелесть в том, что на основе того же описанного в начале proto-файла можно сгенерировать и Python-обертку, которую используют, например, в автотестах:
def runPingPongTest():
with grpc.insecure_channel('localhost:12345') as channel:
stub = pingpong_pb2_grpc.PingPongServiceStub(channel)
reply = stub.SendPing(pingpong_pb2.PingRequest(text='ping'))
print("Response: " + reply.text)
Например, мы столкнулись с проблемой совместимости с Adobe Lightroom для Mac. На этой платформе есть два способа установки Lightroom — инсталлятором от Adobe или через Apple App Store. При установке вторым способом есть требование работы в sandbox-режиме — приложение изолируется, не мешает другим (и ему никто не мешает). Нельзя запускать дочерние приложения, для доступа в интернет нужна соответствующая подпись. Это наложило некоторый отпечаток на то, как мы инициализируем IPC.
Если под Windows можем просто создать сокет и передавать данные по нему, то под Mac (в версии для App Store) пришлось использовать Unix domain socket, который, в отличие от обычного, работает только локально, зато без ограничений песочницы Apple. Вот примерный код запуска gRPC-сервера, пытающегося использовать обычные сокеты, но при неудаче переключающегося на Unix domain socket (код основан на «Hello world!» для gRPC от Google):
void RunServer() {
std::string server_address("0.0.0.0:50051");
GreeterServiceImpl service;
grpc::EnableDefaultHealthCheckService(true);
grpc::reflection::InitProtoReflectionServerBuilderPlugin();
ServerBuilder builder;
int selected_port = 0; // used to fetch bound port of the server, if successfully bound returns positive port number, 0 otherwise
// Listen on the given address without any authentication mechanism.
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials(),&selected_port);
// Register "service" as the instance through which we'll communicate with
// clients. In this case it corresponds to an *synchronous* service.
builder.RegisterService(&service);
// Finally assemble the server.
std::unique_ptr<Server> server(builder.BuildAndStart());
// in case we were not successfully at registering on localhost tcp, try to fallback to UDS
if( 0 == selected_port )
{
#if __APPLE__
// fallback case for Apple Sandboxed applications that have no
// permission to create network sockets, try to use unix domain sockets
{
std::cout << "Failed to create network GRPC, fallback to UDS" << std::endl;
server.reset(nullptr);
ServerBuilder sandboxBuilder;
// create named socket at homeDir, in sandboxed app this will direct us to the container home dir
const auto homePath = eos::getHomeDir(); // NSHomeDirectory() equivalent;
const std::stringstream udsPath = "unix://" << homePath << "/FallbackSocket.uds";
sandboxBuilder.AddListeningPort(udsPath.str(), grpc::InsecureServerCredentials(), &selected_port);
sandboxBuilder.RegisterService(this);
server = std::unique_ptr<Server>(sandboxBuilder.BuildAndStart());
// creating named socket returns 1 as a result, if it remains 0 - we failed creating one
if (m_port == 0)
{
std::cout << "Failed to create fallback GRPC Server" << std::endl;
return;
}
}
#endif
}
std::cout << "Server listening on " << server_address << std::endl;
// Wait for the server to shutdown. Note that some other thread must be
// responsible for shutting down the server for this call to ever return.
server->Wait();
}
У нас было несколько вариантов реализации данного режима. Самый простой — это просто сохранить рядом эти три файла (оригинал, результат, параметры). С одной стороны, это удобно — можно отдельно использовать каждый из них. А с другой — это накладывало на пользователя обязанность хранить и перемещать эти три файла (для каждой фотографии) вместе. Потерял один, и неразрушающее редактирование на этом для тебя закончилось. Посовещавшись с заказчиком и бета-пользователями, было принято отказаться от такой схемы.
Второй вариант — организовать какую-то базу данных и хранить настройки в ней. Но это также создавало проблемы: как передать файл и набор настроек на другой компьютер, как отслеживать перемещение оригинала изображения из одной папки в другую? Отказались и от этого.
Мы решили хранить все (оригинал, результат и настройки) в TIFF. Это контейнерный формат, специально предназначенный для хранения нескольких изображений и метаданных. Мы предоставили пользователю возможность решить, хочет ли он в будущем вернуться к редактированию данной фотографии. Если да — мы создаем соответствующий TIFF-файл и даем ему такую возможность. Ценой этому является увеличение (примерно вдвое) размера фотографии, но игра стоит свеч.
Мы рассматривали возможность применения пакетных менеджеров Conan и vcpkg. Второй лучше интегрируется с Microsoft Visual Studio, которую мы активно используем. Поэтому мы остановились на нем.
Для генерации проектов выбрали CMake. Инсталяхи под Win и Mac собираем самописными скриптами на Python. Используем Boost и еще горстку небольших библиотек.
Например, у нас есть какой-то класс, который генерирует события, и есть один или несколько других классов, которые заинтересованы в этих событиях. Да, можно построить классическую схему на коллбэках, но у этого решения есть недостатки. Нужно ли первому классу знать о всех остальных? Нет. Более того, и слушателям не нужно знать, чьи события они слушают — они заинтересованы в самих событиях, а не в их источнике. Жесткая связь источника событий и слушателей всегда чревата неприятностями: сложно рефакторить и тестировать.
Сигналы и слоты Boost позволяют реализовать слабую связь компонентов:
class Producer
{
...
boost::signals2::signal<void ()> ourCoolSignal;
};
class Listener
{
...
void ourCoolEventHandler()
{
std::cout << "Event!";
}
};
...
Producer producer;
Listener listener;
producer.ourCoolSignal.connect(std::bind(&Listener:urCoolEventHandler, &listener));
using namespace boost:rogram_options;
...
options_description options{"Options"};
options.add_options()
("param1", value<int>()->default_value(1), "param 1")
("param2", value<std::string>(), "param 2");
variables_map variables;
store(parse_command_line(argc, argv, options), variables);
if (variables.count("param1"))
std::cout << "param1: " << variables["param1"].as<int>();
using namespace boost::locale;
using namespace std;
generator ourGenerator;
locale ourLocale = ourGenerator("");
locale::global(ourLocale); // use this locale globally
cout.imbue(ourLocale); // use this locale for cout
cout<<"Numbers in this locale "<<as::number << 58.17 <<endl;
cout<<"Currency in this locale "<<as::currency << 58.17<<endl;
cout<<"Date in this locale "<<as::date << std::time(0) <<endl;
cout<<"Time in this locale "<<as::time << std::time(0) <<endl;
cout<<"Upper case "<<to_upper("Upper Case!")<<endl;
cout<<"Lower case "<<to_lower("Lower Case!")<<endl;
Все это выглядит простенько или даже не нужно, пока ты не задумываешься, как хотят видеть дату американцы, сумму китайцы или слово «grüßen» в верхнем регистре немцы. А с библиотекой Boost.Locale разработчику и не надо об этом задумываться.
Мы можем выполнить какое-то действие на UI, а затем дернуть property какого-то QML-объекта, посмотреть, что получилось. Или работать в стиле «черного ящика», писать тесты, опирающиеся на внутреннюю структуру UI. С Squish все хорошо, кроме одного — его цены. Вот, например, скромная конфигурация «5 пользователей, две платформы (Win, Mac)» обойдётся в €10 695 в год.
Мини-альтернативой для Squish может быть, например, Spix — умеет меньше, но базовые вещи (контроль приложения, понимание QML) здесь реализовать тоже можно. Ну и все преимущества open-source: если чего-то не хватает, берешь и дописываешь себе. Из минусов — поддержка QML несколько ограничена. Например, можно инспектировать только property типа string. Это иногда накладывает дополнительные ограничения: если есть целочисленная property, которую тестировщик хочет использовать в автотесте, приходится делать еще одну property типа string, «оборачивающую» первую.
Отдельно стоит упомянуть утилиту Gamma Ray — это инструмент интроспекции для Qt/QML. Он позволяет в реальном времени просматривать любые свойства любых объектов внутри QML-приложения. Это достигается подменой стандартных библиотек Qt на специально модифицированные версии, благодаря которым можно не только видеть извне приложения всю его UI-структуру, но и менять QML-свойства на лету.
Изображение взято с сайта Gamma Ray
Заказчик сразу попросил писать весь новый код с покрытием тестами. Под «весь» и вправду подразумевалось почти весь: «90-95% нового кода должно быть покрыто тестами». Это требование было значительно строже того, согласно которому писался код. Но никогда не поздно попробовать начать делать хорошо, и мы начали. Test-driven development, unit-тесты, функциональные тесты — теперь все это у нас есть. Используем googletest (для написания и запуска самих тестов) и gcovr для анализа тестового покрытия. Работает хорошо, рекомендуем.
Спасибо за внимание!
Как разрабатываются плагины для Photoshop и Lightroom, какие технологии для этого актуальны, с какими проблемами можно столкнуться и как их решать. В статье также найдете информацию о Qt, кроссплатформенной разработке, проблемах legacy и современном IPC и о том, как правильная архитектура проекта может помочь его удобному тестированию.
История проекта
В 1995 году была создана компания Nik Software, которая начала разрабатывать инструменты обработки изображений с прицелом на профессиональную аудиторию фотографов. На тех, кому «обычного фотошопа» не хватает. Так появились первые продукты, которые со временем были объединены в Nik Collection. Компанией заинтересовался Nikon, который в 2005-м приобрел ее часть (Википедия говорит, что 35%). Далее разработка продолжилась, выпускались определённые продукты специально для Nikon, но Nik Collection продолжала существовать и развиваться отдельно.В 2012 году всю компанию Nik Software купил Google, после чего использовал часть ее наработок в других своих продуктах. Что же касается Nik Collection, то Google не увидел для себя возможности зарабатывать на ней деньги и просто сделал бесплатной. Да, был период, когда такой большой профессиональный продукт можно было бесплатно скачать с google.com. Из плохих новостей — Google прекратил активную разработку Nik Collection, чем вызвал волну недовольства на фотофорумах.
Потом Google еще немного поразмышлял, что делать с Nik Collection и, так и не придумав ей место в своей экосистеме, в 2017 году продал продукт компании DxO, которая и продолжила разработку, уже продавая Nik Collection за деньги. DxO была уже хорошо известна на фоторынке благодаря своим обзорам камеры и линз DXOMARK, продукту для обработки фотографий PhotoLab. Так что Nik Collection попал в хорошие руки. Через некоторое время DxO наняла подрядчиком нас и работа продолжилась уже вместе.
Пара примеров того, что умеет Nik Collection
Nik Collection — это набор из 8 отдельных плагинов. Они могут интегрироваться в Adobe Photoshop, Lightroom, Affinity Photo или даже использоваться как standalone-приложения. Работают под Windows и Mac.Плагин Perspective Efex умеет работать с оптическими искажениями изображения и перспективой. Например, может из вот такой картинки:
Сделать такую:
Или добавить к достаточно тривиальной фотографии:
Такой эффект миниатюры:
Плагин Dfine позволяет бороться с шумами:
Как вы понимаете, это совершенно не «Reduce Noise...» из Photoshop.
Благодаря Silver Efex Pro можно сделать из цветной картинки черно-белую. Но погодите скептически поднимать бровь! Не просто сделать из цветной картинки черно-белую, а как бы «переснять» ее в черно-белом режиме, причем даже с указанием нужной вам фотопленки:
Можно подобрать зернистость или контрастность. А еще разузнать, на какой пленке была сделана классическая черно-белая фотография, и воспроизвести этот эффект на своем фото в один клик.
Тут же скажем о похожем плагине Analog Efex Pro — для воспроизведения эффекта съемки на классическую аналоговую камеру, да еще и с возможностями ее настройки и эмуляции определенных типов объективов:
Хорошее видео с примером использования этого плагина: Analog Efex Pro 2: Exploring Creativity: The Super Cell.
Еще есть такие плагины:
- HDR Efex Pro — для объединения нескольких изображений с разными экспозициями в одно и постэффектов для создания HDR.
- Color Efex Pro — для быстрого применения фильтров типа «дым», «туман», «сияние» (55 штук).
- Viveza — цветокоррекция с упором на точечное применение к определенным областям/цветам.
- Sharpener Pro — работа с чёткостью изображения. Возможность, например, поработать над реалистичностью текстуры кожи на портрете.
Nik Collection 3
Наша команда начала предоставлять свои услуги с третьей версии продукта. На тот момент существовала предыдущая бесплатная версия от Google. Она уже не поддерживалась, но ее все еще можно было найти на фотофорумах и с какой-то долей вероятности установить на Photoshop (не факт, что на последний).Еще была версия продукта, собранная из исходников от Google уже нашим заказчиком (DxO), с гарантией совместимости с последним Photoshop/Lightroom и актуальными ОС. Из нововведений там были только некоторое количество преднастроенных фильтров («рецептов»), что, конечно, хорошо, но мало. Нужно было быстро показать профессиональному фотосообществу, что продукт жив и развивается. Для этого выбрали несколько направлений.
Визуальная часть
То, что «встречают по одежке» — вообще не секрет, а уж в сфере нашего продукта, где каждый первый — художник или фотограф, выглядеть приятно особо важно. И вот покажем панель запуска плагинов в той версии Nik Collection, которая была получена от Google:«Невероятно красиво», не правда ли?
Мы полностью переделали панель (об использованных технологиях будет ниже), и теперь она выглядит так:
Помимо современного вида, добавился функционал — теперь, редактируя набор схожих картинок для одной цели, можно настроить фильтры плагина лишь один раз, а для следующих изображений применять их в один клик.
Новый функционал — режим неразрушающего редактирования
При использовании плагинов Nik Collection (да и любых других) совместно с Adobe Lightroom всегда существовала проблема «разрывности» или «деструктивности» редактирования. Например, есть изображение в Lightroom, мы применили к нему плагин, и теперь у нас обработанное плагином изображение. И между этими двумя «контрольными точками» нет никаких промежуточных.А что, если на следующий день мы откроем обработанное фото и решим, что какую-то одну настройку фильтра надо бы немного подкорректировать? Выхода особо не было: либо начинать редактирование с нуля на оригинальном изображении (если оно еще сохранилось), либо смириться с тем, что есть.
В Nik Collection 3 мы развязали пользователю руки. Теперь можно из Lightroom передать картинку в плагин, применить фильтры и сохранить результат в специальный формат — многостраничный TIFF:
При этом в него будут сохранены первоначальное изображение (в одну страницу TIFF-контейнера), конечное изображение (в другую страницу TIFF-контейнера) и все настройки всех использованных фильтров (в метаданные TIFF):
Таким образом полученный файл будет содержать всю необходимую информацию для реализации любых сценариев:
- можно использовать уже обработанное изображение;
- можно вернуться к оригинальному изображению;
- можно открыть файл на следующий день и продолжить работу с фильтрами с того места, где закончили в прошлый раз. Ведь у нас есть оригинальное изображение и весь набор настроек примененных фильтров, можем изменять их дальше — и они будут правильно применяться к оригинальному изображению.
Техническая часть
Я хотел бы сразу извиниться перед теми, кто пришел сюда почитать о крутой «математике» обработки изображений. Она, конечно, существует (ради нее Nik Collection и покупают), но является ценной интеллектуальной собственностью, раскрывать которую нам нельзя. Но зато расскажем о всяких технологиях, применяемых в работе.О UI-фреймворках
Продукт достался нам от Google в весьма странном с точки зрения UI состоянии. Часть плагинов была написана c использованием библиотеки wxWidgets, еще часть — на самописном UI-фреймворке, который разрабатывала компания Nik Software еще лет 20 назад. Как вы понимаете, внешний вид плагинов из-за этого несколько отличался, что вызывало вопросы у пользователей.Кроме того, и самописный UI-фреймворк, и использованная версия wxWidgets были уже старыми, в них возникала куча проблем. Например, несовместимость с современными high-DPI дисплеями и мacOS Catalina. Какого-то простого способа решения этого не было, поскольку разработка самописного UI-фреймворка была давно заброшена, а wxWidgets в приложении был форкнут и существенно переработан, что сделало невозможным апгрейд на новую версию.
Мы решили переходить на Qt/QML — современный кроссплатформенный фреймворк с кучей полезного функционала. Начали с той самой некрасивой панели запуска плагинов — это был относительно отдельный и небольшой компонент продукта, на котором можно было протестировать подходы и решения.
Заказчик сразу заявил о своем желании использовать именно бесплатную опенсорсную версию Qt. Лицензия фреймворка позволяет его бесплатное использование даже в закрытых коммерческих продуктах, однако в этом случае доступна лишь динамическая линковка библиотек Qt (собрать весь Qt к себе в один бинарник нельзя). В этом, на первый взгляд, нет никаких проблем: мы делаем плагин (это библиотека, которая будет загружаться в процесс Photoshop), он подменяет library search path, загружает библиотеки Qt, и дальше все работает:
Но это только на первый взгляд. При тестировании оказалось, что не одни мы такие умные, в мире существует много других плагинов для Photoshop, которые тоже загружают библиотеки Qt и тоже динамически:
И вот когда в процесс Photoshop вдруг загружается несколько комплектов библиотек Qt (возможно, одной версии, а возможно, разных), это порой приводит к непредвиденным последствиям. Когда создается инстанс QtApplication, он запускает singleton-instance QCoreApplication и инициализирует несколько статических переменных, которые (если они разных версий) могут повлиять друг на друга и привести к неправильной работе одного из модулей.
Иногда Photoshop попросту падал. Другой раз ивенты начинали приходить не тем получателям. Можно было попробовать это исправить, но в общем виде задача «быть совместимыми со всеми остальными плагинами Photoshop, которые используют Qt» является плохо разрешимой ввиду неизвестного количества этих плагинов, используемых ими версий Qt и способов применения. Нельзя гарантировать совместимость с тем, чего не знаешь. И мы решили пойти другим путем.
Наш плагин был разделен на две части: в процесс Photoshop мы загружали относительно небольшой модуль, который вообще не использовал Qt и лишь интегрировался с Photoshop через его SDK. Сразу при загрузке он запускает отдельный процесс, в который уже загружается Qt без риска несовместимости с другими плагинами. Для взаимодействия модуля, загружаемого в Photoshop и standalone-процесса с UI, мы построили IPC на базе gRPC.
Дополнительно пришлось написать сериализацию всех внутренних параметров для всех плагинов. Теперь их можно завернуть в XML и передавать между процессами.
Отдельно стоит сказать, что в связи с последними веяниями в мире Qt прослеживаются некоторые риски относительно возможностей, ограничений и цены (явной или косвенной) фреймворка Qt в будущем. Поэтому мы сразу решили отделять и изолировать UI-слой от всего остального. Да, мы используем QML и все его возможности, но как только данные доходят до слоя бизнес-логики, никакого Qt там больше нет. Таким образом, если в будущем придется заменить Qt на что-то другое, это хоть и займет некоторое время, но будет возможно и не затронет никакие другие слои приложения, кроме UI.
О gRPC
gRPC расшифровывается как gRPC Remote Procedure Calls. Это отличный современный стандарт взаимодействия компонентов в том случае, когда нужно «быстро, сжато, между своими внутренними компонентами». Если надо публичный API — тут мир завоевал REST. Но вот если между своими процессами или в своем дата-центре — здесь вотчина gRPC. Разумеется, все надежно, протестировано, кроссплатформенно, открыто и бесплатно.Итак, что же умеет gRPC? Опустим нюансы его установки и подключения к проекту. Вы описываете функционал своего межкомпонентного взаимодействия в терминах сервисов и действий, которые эти сервисы умеют делать. Давайте, например, объявим сервис PingPong:
service PingPongService {
rpc SendPing (PingRequest) returns (PongReply) {}
}
message PingRequest {
string text = 1;
}
message PongReply {
string text = 1;
}
Далее с помощью функционала gRPC вы скармливаете этот proto-файл его компилятору и получаете на выходе набор классов для нужного вам языка программирования, которые можно подключить себе в проект. Для С++ это выглядит как пара заголовочного файла и файла с кодом. Включаем заголовочный файл в код компонента-сервера и реализуем методы:
class PingPongServiceImpl final : public PingPongService::Service
{
Status SendPing(ServerContext* context, const PingRequest* request, PongReply* reply) override
{
...
}
};
Затем включаем его же в код компонента-клиента и реализуем отправку сообщения:
class PingPongClient
{
public:
PingPongClient(std::shared_ptr<Channel> channel)
: stub_(PingPongService::NewStub(channel)) {}
std::string SendPing(const std::string& text)
{
PingRequest request;
request.set_text(text);
PongReply reply;
ClientContext context;
Status status = stub_->SendPing(&context, request, &reply);
...
}
private:
std::unique_ptr<PingPongService::Stub> stub_;
};
Особенная прелесть в том, что на основе того же описанного в начале proto-файла можно сгенерировать и Python-обертку, которую используют, например, в автотестах:
def runPingPongTest():
with grpc.insecure_channel('localhost:12345') as channel:
stub = pingpong_pb2_grpc.PingPongServiceStub(channel)
reply = stub.SendPing(pingpong_pb2.PingRequest(text='ping'))
print("Response: " + reply.text)
О кроссплатформенной разработке
Adobe Photoshop и Lightroom работают как под Windows, так и под Mac. Соответственно, нужно было гарантировать, что наш продукт работает и там, и там. C++ и Qt дают неплохой шанс этого добиться, но все же есть определенные нюансы, которые следует учитывать.Например, мы столкнулись с проблемой совместимости с Adobe Lightroom для Mac. На этой платформе есть два способа установки Lightroom — инсталлятором от Adobe или через Apple App Store. При установке вторым способом есть требование работы в sandbox-режиме — приложение изолируется, не мешает другим (и ему никто не мешает). Нельзя запускать дочерние приложения, для доступа в интернет нужна соответствующая подпись. Это наложило некоторый отпечаток на то, как мы инициализируем IPC.
Если под Windows можем просто создать сокет и передавать данные по нему, то под Mac (в версии для App Store) пришлось использовать Unix domain socket, который, в отличие от обычного, работает только локально, зато без ограничений песочницы Apple. Вот примерный код запуска gRPC-сервера, пытающегося использовать обычные сокеты, но при неудаче переключающегося на Unix domain socket (код основан на «Hello world!» для gRPC от Google):
void RunServer() {
std::string server_address("0.0.0.0:50051");
GreeterServiceImpl service;
grpc::EnableDefaultHealthCheckService(true);
grpc::reflection::InitProtoReflectionServerBuilderPlugin();
ServerBuilder builder;
int selected_port = 0; // used to fetch bound port of the server, if successfully bound returns positive port number, 0 otherwise
// Listen on the given address without any authentication mechanism.
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials(),&selected_port);
// Register "service" as the instance through which we'll communicate with
// clients. In this case it corresponds to an *synchronous* service.
builder.RegisterService(&service);
// Finally assemble the server.
std::unique_ptr<Server> server(builder.BuildAndStart());
// in case we were not successfully at registering on localhost tcp, try to fallback to UDS
if( 0 == selected_port )
{
#if __APPLE__
// fallback case for Apple Sandboxed applications that have no
// permission to create network sockets, try to use unix domain sockets
{
std::cout << "Failed to create network GRPC, fallback to UDS" << std::endl;
server.reset(nullptr);
ServerBuilder sandboxBuilder;
// create named socket at homeDir, in sandboxed app this will direct us to the container home dir
const auto homePath = eos::getHomeDir(); // NSHomeDirectory() equivalent;
const std::stringstream udsPath = "unix://" << homePath << "/FallbackSocket.uds";
sandboxBuilder.AddListeningPort(udsPath.str(), grpc::InsecureServerCredentials(), &selected_port);
sandboxBuilder.RegisterService(this);
server = std::unique_ptr<Server>(sandboxBuilder.BuildAndStart());
// creating named socket returns 1 as a result, if it remains 0 - we failed creating one
if (m_port == 0)
{
std::cout << "Failed to create fallback GRPC Server" << std::endl;
return;
}
}
#endif
}
std::cout << "Server listening on " << server_address << std::endl;
// Wait for the server to shutdown. Note that some other thread must be
// responsible for shutting down the server for this call to ever return.
server->Wait();
}
О режиме неразрушающего редактирования
Еще раз кратко о том, что такое режим неразрушающего редактирования: это когда после обработки изображения остается оригинал, результат обработки и набор параметров, которые позволяют из первого получить второе. Это дает возможность вернуться к обработке и продолжить ее с того места, где в прошлый раз закончили, а не с самого начала.У нас было несколько вариантов реализации данного режима. Самый простой — это просто сохранить рядом эти три файла (оригинал, результат, параметры). С одной стороны, это удобно — можно отдельно использовать каждый из них. А с другой — это накладывало на пользователя обязанность хранить и перемещать эти три файла (для каждой фотографии) вместе. Потерял один, и неразрушающее редактирование на этом для тебя закончилось. Посовещавшись с заказчиком и бета-пользователями, было принято отказаться от такой схемы.
Второй вариант — организовать какую-то базу данных и хранить настройки в ней. Но это также создавало проблемы: как передать файл и набор настроек на другой компьютер, как отслеживать перемещение оригинала изображения из одной папки в другую? Отказались и от этого.
Мы решили хранить все (оригинал, результат и настройки) в TIFF. Это контейнерный формат, специально предназначенный для хранения нескольких изображений и метаданных. Мы предоставили пользователю возможность решить, хочет ли он в будущем вернуться к редактированию данной фотографии. Если да — мы создаем соответствующий TIFF-файл и даем ему такую возможность. Ценой этому является увеличение (примерно вдвое) размера фотографии, но игра стоит свеч.
Об инструментах
У нас много разных средств разработки. Одних только IDE в регулярном использовании целых три: Qt Creator для работы с QML (есть плагины для поддержки QML в других IDE, но их качество хуже, и мы от них отказались), Visual Studio (сейчас 2019) для разработки под Windows и Xcode (сейчас 11) для Mac. Также используем практически весь набор инструментов Atlassian: Confluence для документации, Crucible для code review, Bitbucket для хранения Git-репозитория, Bamboo для CI/CD.Мы рассматривали возможность применения пакетных менеджеров Conan и vcpkg. Второй лучше интегрируется с Microsoft Visual Studio, которую мы активно используем. Поэтому мы остановились на нем.
О C++
Мы пишем на С++. Конкретно сейчас — на стандарте С++17, из которого используем такие фичи, как:- атрибуты [[fallthrough]], [[nodiscard]], [[maybe_unused]]:
- structural bindings;
- многие новые контейнеры: variant, optional, string_view.
Для генерации проектов выбрали CMake. Инсталяхи под Win и Mac собираем самописными скриптами на Python. Используем Boost и еще горстку небольших библиотек.
Об использовании Boost
Boost — это отличный, проверенный и свободный набор библиотек. Лицензия позволяет использовать его где угодно.Сигналы
Одна из мощных штук, которая есть как в Boost, так и в Qt — это система сигналов и слотов. Поскольку в некоторых модулях мы не можем использовать Qt, то применяем сигналы Boost.Например, у нас есть какой-то класс, который генерирует события, и есть один или несколько других классов, которые заинтересованы в этих событиях. Да, можно построить классическую схему на коллбэках, но у этого решения есть недостатки. Нужно ли первому классу знать о всех остальных? Нет. Более того, и слушателям не нужно знать, чьи события они слушают — они заинтересованы в самих событиях, а не в их источнике. Жесткая связь источника событий и слушателей всегда чревата неприятностями: сложно рефакторить и тестировать.
Сигналы и слоты Boost позволяют реализовать слабую связь компонентов:
class Producer
{
...
boost::signals2::signal<void ()> ourCoolSignal;
};
class Listener
{
...
void ourCoolEventHandler()
{
std::cout << "Event!";
}
};
...
Producer producer;
Listener listener;
producer.ourCoolSignal.connect(std::bind(&Listener:urCoolEventHandler, &listener));
Program options
Мы используем Boost.ProgramOptions (в тестовых приложениях). Особой магии в библиотеке нет, но у нее удобный интерфейс, позволяющий в пару строк разобрать опции, с которыми было запущено приложение:using namespace boost:rogram_options;
...
options_description options{"Options"};
options.add_options()
("param1", value<int>()->default_value(1), "param 1")
("param2", value<std::string>(), "param 2");
variables_map variables;
store(parse_command_line(argc, argv, options), variables);
if (variables.count("param1"))
std::cout << "param1: " << variables["param1"].as<int>();
Локали
Разрабатывая программы для глобального рынка, важно помнить, что в разных странах есть свои правила форматирования дат, чисел, преобразования регистра текста и так далее. В мире C++ с подобными задачами хорошо справляется библиотека Boost.Locale:using namespace boost::locale;
using namespace std;
generator ourGenerator;
locale ourLocale = ourGenerator("");
locale::global(ourLocale); // use this locale globally
cout.imbue(ourLocale); // use this locale for cout
cout<<"Numbers in this locale "<<as::number << 58.17 <<endl;
cout<<"Currency in this locale "<<as::currency << 58.17<<endl;
cout<<"Date in this locale "<<as::date << std::time(0) <<endl;
cout<<"Time in this locale "<<as::time << std::time(0) <<endl;
cout<<"Upper case "<<to_upper("Upper Case!")<<endl;
cout<<"Lower case "<<to_lower("Lower Case!")<<endl;
Все это выглядит простенько или даже не нужно, пока ты не задумываешься, как хотят видеть дату американцы, сумму китайцы или слово «grüßen» в верхнем регистре немцы. А с библиотекой Boost.Locale разработчику и не надо об этом задумываться.
О тестировании
Мы рассматривали несколько способов тестирования UI нашего приложения. Во-первых, есть мощный инструмент Squish. Умеет все: тестирует UI любых приложений и под любые платформы, позволяет «записать и воспроизвести» тест, писать тесты на Python/Ruby/JS (и еще на куче языков), имеет свою IDE, хорошо интегрируется с CI/CD-системами. И самое важное — хорошо понимает Qt и QML.Мы можем выполнить какое-то действие на UI, а затем дернуть property какого-то QML-объекта, посмотреть, что получилось. Или работать в стиле «черного ящика», писать тесты, опирающиеся на внутреннюю структуру UI. С Squish все хорошо, кроме одного — его цены. Вот, например, скромная конфигурация «5 пользователей, две платформы (Win, Mac)» обойдётся в €10 695 в год.
Мини-альтернативой для Squish может быть, например, Spix — умеет меньше, но базовые вещи (контроль приложения, понимание QML) здесь реализовать тоже можно. Ну и все преимущества open-source: если чего-то не хватает, берешь и дописываешь себе. Из минусов — поддержка QML несколько ограничена. Например, можно инспектировать только property типа string. Это иногда накладывает дополнительные ограничения: если есть целочисленная property, которую тестировщик хочет использовать в автотесте, приходится делать еще одну property типа string, «оборачивающую» первую.
Отдельно стоит упомянуть утилиту Gamma Ray — это инструмент интроспекции для Qt/QML. Он позволяет в реальном времени просматривать любые свойства любых объектов внутри QML-приложения. Это достигается подменой стандартных библиотек Qt на специально модифицированные версии, благодаря которым можно не только видеть извне приложения всю его UI-структуру, но и менять QML-свойства на лету.
Заказчик сразу попросил писать весь новый код с покрытием тестами. Под «весь» и вправду подразумевалось почти весь: «90-95% нового кода должно быть покрыто тестами». Это требование было значительно строже того, согласно которому писался код. Но никогда не поздно попробовать начать делать хорошо, и мы начали. Test-driven development, unit-тесты, функциональные тесты — теперь все это у нас есть. Используем googletest (для написания и запуска самих тестов) и gcovr для анализа тестового покрытия. Работает хорошо, рекомендуем.
О планах
Nik Collection 3 уже выпущен. Мы продолжаем делать следующую версию продукта. Пока не можем рассказывать, что туда войдет. Наверняка будем использовать все то, о чем писалось выше, и, возможно, что-то новое. Скорее всего, перейдем на С++20. Возможно, перейдем на Qt 6 (а возможно, останемся на пятом). Может быть, начнем использовать Vulkan и Metal (это зависит от состояния их поддержки в Qt). Улучшим UI наших плагинов, будем добиваться его унификации. Задач в бэклоге у нас много, хватило бы рук.Спасибо за внимание!
DOU
DOU – Найбільша спільнота розробників України. Все про IT: цікаві статті, інтервʼю, розслідування, дослідження ринку, свіжі новини та події. Спілкування на форумі з айтівцями на найгарячіші теми та технічні матеріали від експертів. Вакансії, рейтинг IT-компаній, відгуки співробітників, аналітика...
dou.ua