Как мы спроектировали и сделали True Image for Mac

1516d7731c4e4b969573344acde8ea2d.jpgВсем привет. Однажды мы узнали о том, что нам предстоит сделать True Image для Mac OS. Как это обычно бывает, сделать надо быстро и качественно, ага. Сразу возник резонный вопрос, почему бы просто не скомпилировать True Image для Windows под Мак, ведь большинство кода уже кроссплатформенно, в том числе интерфейс, написанный на Qt? Но нам тут же были обозначены рамки: Интерфейс решено было сделать абсолютно новый, в разы проще чем у большого брата. Также в качестве GUI-фреймворка опытные в Маковых делах ребята из Parallels посоветовали использовать именно нативный Сocoa вместо Qt, а люди из еще одной известной компании подтвердили правильность этого решения. Решили не ставить под сомнение их опыт.

В итоге было решено попытаться написать фронтенд на Cocoa к существующему коду. Продукт мы таки выпустили и уже написали об этом на Хабре, а сегодня я хочу поделиться архитектурно-техническими деталями сего процесса.

В основу новой архитектуры решили положить паттерн Passive View, исходное описание которого можно почитать у Фаулера.Сам паттерн до безобразия прост. Как и в классической триаде MVC/MVP есть вид, модель и презентер (в другой терминологии контроллер). Разница с другими подобными паттернами заключается в том, что вид, как следует из названия, «пассивный» или, по-простому, «тупой» — он ничего не знает о модели, а всей координацией модели и вида занимается презентер.0428c7ec8a5c47efb3a1881b03a39f7c.gif

Почему именно этот подход?

Тестируемость — это самый большой плюс этого паттерна. Вид и модель изолированы, ничего не знают о внешнем мире, кроме как об обзерверах, подписанных на их изменения. Презентер в свою очередь практически все свои знания получает извне через инъекцию зависимости. Можно писать тесты на реализации вида, можно писать тесты на реализацию модели, можно писать тесты на корректное поведение логики в презентере; Понятность — вся логика конкретного куска сосредоточена в одном месте — в презентере, а не размазана по видам; Reusability и composability — презентер работает с видом и моделью через интерфейсы, поэтому можно одну и ту же логику, оформленную в презентер, использовать в разных местах программы. Компоненты взаимодействуют так: презентер настраивает вид, подписывается на события вида и модели, показывает вид и обрабатывает события модели и вида: fceaa9abf9214c1aa55cf52a9eec2ebc.png

Сей паттерн само собой не претендует на звание самого-самого, определенные вещи по-прежнему в разы удобней делать, используя, скажем, MVC-подход, когда данные вытягивает сам вид. Например таким путем был сделан файловый браузер в диалоге восстановления. Passive View хорош там, где нет большого потока данных в вид.

Виды и презентеры мы организовали в иерархии. Презентер главного окна порождает другие презентеры в обработчиках событий, и они занимаются своей частью работы. Концептуально все это выглядит так: struct ModelObserver { // various callbacks virtual void OnModelChanged () = 0; }

struct Model: Observable { // virtual getters, setters, etc }

struct ViewObserver { // various callbacks virtual void OnViewButtonClicked () = 0; }

struct View: Observable { // virtual setters, etc virtual void Show () = 0; // запускает вид в своем event loop, блокирующий вызов как QDialog: exec () }

struct PresenterParent: ModelObserver, ViewObserver { Model M; // injected in ctor View V; // injected in ctor

void Run () { M.AttachObserver (this); V.AttachObserver (this); V.Show (); }

void OnModelChanged () { // например обновляем вид V.SetSomething (M.GetSomething ()); }

void OnViewButtonClicked () { // например показываем другое окошко // в нашем примере вид V одновременно является фабрикой дочерних видов // в качестве альтернативы можно сделать отдельную ViewFactory и таскать ее за собой PresenterChild p (M, V.CreateChildView ()); p.Run (); } }

void main (argc, argv) { Model m (CreateModel ()); // какая-то реализация модели, например фейк View v (CreateParentView ()); // какая-то реализация вида, например Qt или Cocoa PresenterParent p (m, v); p.Run (); } Так как сроки у нас были достаточно жесткие, все эти инжектирования нам очень пригодились с самого начала и позволили распараллелить работу: подменили модели на заглушки и вовсю тестировали поведение интерфейса, пока параллельно реализовывались полноценные модели. Сами заглушки можно переиспользовать для юнит-тестов.В один момент абстрактность моделей можно сказать спасла сроки окончания проекта, когда было принято (правильное) решение не изобретать велосипед для нескольких подсистем, а использовать всю low- и middle-level логику из True Image для Windows. В результате модели были реализованы разной степени толщины фасадами или адаптерами к существующему слою логики, и обе версии True Image получили все полагающиеся к этому бонусы, в том числе в виде исправления древних багов, которые вылезли только на Маке (например некорректная или недостаточная синхронизация лучше проявляется именно на GCC, чем на MSVC).

Стоит упомянуть, как мы прикрутили в эту структуру нативный Cocoa, возможно кому-то пригодится. Использовали Objective-C++ и ARC, окна рисовали в Interface Builder. Процесс выглядит следующим образом: Делаем xib окна и его obj-c++ контроллер, для контролирования состояния окна в большинстве случаев используем биндинги @interface ViewCocoa: NSWindowController { Observable* Callbacks; } @property NSNumber* Something;  — (id)initWithObservable:(Observable*)callbacks;  — (IBAction)OnButtonClicked:(id)sender; @end

@implementation ViewCocoa {  — (id)initWithObservable:(Observable*)callbacks { if (self = [super initWithWindowNibName:@«ViewCocoa»]) { Callbacks = callbacks; } return self; } }

— (IBAction)OnButtonClicked:(id)sender { // используем наш механизм подписки для оповещения Callbacks→NotifyObservers (bind (&ViewObserver: OnViewButtonClicked, _1)); } @end Делаем obj-c++ адаптер, который уже можно инжектить в презентер struct ViewCocoaAdapter: View { ViewCocoa* Adaptee = [[ViewCocoa alloc] initWithObservable: this];

virtufal void Show () { // в реальности тут разные типы показа окна и все немного сложнее [NSApp runModalForWindow: Adaptee.window]; }

// сеттеры могут например просто устанавливать различные свойства для нашего Adaptee virtual void SetContent (int something) { // подробней о том, что здесь забыл performSelectorOnMainThread, рассказано ниже в разделе Многопоточность [Adaptee performSelectorOnMainThread:@selector (setSomething:) withObject:[NSNumber numberWithInt: something] waitUntilDone: NO]; } } Абстрактность и пассивность вида дала возможность сделать альтернативный CLI-интерфейс, который активно используются для наших автотестов для Мака. Поддерживать его очень легко, ведь для каждого вида достаточно реализовать всего один класс без всякой бизнес-логики! struct ViewCli: View { virtual void Show () { for (;;) { // парсим команду, вызываем какой-нибудь калбек std: string cmd; std: cin >> cmd; if (cmd == «ls») { std: cout << "Печатаем содержимое окна, которое мы получили через сеттеры от презентера..." << std::endl; } else if (cmd == "x") { break; } else if (cmd == "click") { NotifyObservers(bind(&ViewObserver::OnViewButtonClicked, _1)); } } } // реализуем все остальное, в основном сеттеры, которые обычно просто наполняют вид данными для последующего вывода командой "ls" }

С самого начала мы сделали одно существенное допущение — считать все виды потокобезопасными. Это позволило существенно упростить код презентеров. Фишка в том что практически все GUI-фреймворки имеют возможность выполнить асинхронно операцию в главном гуевом потоке, этим и воспользовались: у qt это QMetaObject: invokeMethod с Qt: QueuedConnection, либо QCoreApplication: postEvent c событием-операцией у cocoa это dispatch_async + dispatch_get_main_queue, либо performSelectorOnMainThread у cli достаточно просто мутекса Повторюсь, тестируемость: юнит-тесты, авто-тесты… да какие угодно тесты! Концентрация логики в четко определенных местах: на практике действительно очень просто находить и дополнять нужный код; Переиспользуемость логики: один и тот же презентер можно сделать настраиваемым, в результате виды ведут себя по-разному и остаются при этом по-прежнему «тупыми», а мы получили практически нулевую дупликацию кода; Возможность писать логику один раз  и на века  под разные GUI-фреймворки, благо в основном подходы у них примерно одни и те же — event-loop, модальные и немодальные окна и тд. Callback hell — либо куча методов в одном классе, либо куча мелких интерфейсов и презентеров, но в любом случае со временем получается он самый; Сложность реализации паттерна вместе с Cocoa. Особенно на себе это чуствовали люди, которые видели код в первый раз. Действительно, чтобы создать новое окно, требуется создать C++ класс вида, C++ класс обзервера вида, xib, Objective-C++ interface и implementation, Objective-C++ адаптер — куча сущностей! Сравните с привычным, например по Qt, паттерном Forms and Controls, где достаточно лишь ui и полноценного класса окна с логикой. Тут просто стоит понимать, чем ради чего жертвовать. Тестируемость и халявный CLI для нас являются весомыми плюсами, поэтому такую сложность пришлось потерпеть. Впрочем, как правило со временем количество новых окон перестает расти, уступая место фиксу багов и дополнению существующего кода. Халявный CLI это круто! Если у вас налажен запуск автотестов. Но уж если налажен, то это действительно круто.Несколько раз выбранный подход спасал от переписывания кучи кода, например когда дизайнеры решали основательно перерисовать большую часть интерфейса. Изменения в коде ограничились по большей части лишь реализацией класса вида, а практически вся бизнес-логика осталась нетронутой. При всем при этом по моим ощущениям Passive View подходит скорее для небольших или средних приложений — для больших приложений мне кажется преимущества гибкости не перевесят недостатка дороговизны/сложности расширения самого пользовательского интерфейса.

А какими подходами пользуетесь вы в своих кроссплатформенных проектах?

© Habrahabr.ru