Всем привет. Однажды мы узнали о том, что нам предстоит сделать True Image для Mac OS. Как это обычно бывает, сделать надо быстро и качественно, ага. Сразу возник резонный вопрос, почему бы просто не скомпилировать True Image для Windows под Мак, ведь большинство кода уже кроссплатформенно, в том числе интерфейс, написанный на Qt? Но нам тут же были обозначены рамки:
Интерфейс решено было сделать абсолютно новый, в разы проще чем у большого брата. Также в качестве GUI-фреймворка опытные в Маковых делах ребята из Parallels посоветовали использовать именно нативный Сocoa вместо Qt, а люди из еще одной известной компании подтвердили правильность этого решения. Решили не ставить под сомнение их опыт.
В итоге было решено попытаться написать фронтенд на Cocoa к существующему коду. Продукт мы таки выпустили и уже написали об этом на Хабре, а сегодня я хочу поделиться архитектурно-техническими деталями сего процесса.
Passive View
В основу новой архитектуры решили положить паттерн Passive View, исходное описание которого можно почитать у Фаулера.
Сам паттерн до безобразия прост. Как и в классической триаде MVC/MVP есть вид, модель и презентер (в другой терминологии контроллер). Разница с другими подобными паттернами заключается в том, что вид, как следует из названия, «пассивный» или, по-простому, «тупой» — он ничего не знает о модели, а всей координацией модели и вида занимается презентер.
Почему именно этот подход?
- Тестируемость — это самый большой плюс этого паттерна. Вид и модель изолированы, ничего не знают о внешнем мире, кроме как об обзерверах, подписанных на их изменения. Презентер в свою очередь практически все свои знания получает извне через инъекцию зависимости. Можно писать тесты на реализации вида, можно писать тесты на реализацию модели, можно писать тесты на корректное поведение логики в презентере;
- Понятность — вся логика конкретного куска сосредоточена в одном месте — в презентере, а не размазана по видам;
- Reusability и composability — презентер работает с видом и моделью через интерфейсы, поэтому можно одну и ту же логику, оформленную в презентер, использовать в разных местах программы.
Компоненты взаимодействуют так: презентер настраивает вид, подписывается на события вида и модели, показывает вид и обрабатывает события модели и вида:
Сей паттерн само собой не претендует на звание самого-самого, определенные вещи по-прежнему в разы удобней делать, используя, скажем, MVC-подход, когда данные вытягивает сам вид. Например таким путем был сделан файловый браузер в диалоге восстановления. Passive View хорош там, где нет большого потока данных в вид.
Код!
Виды и презентеры мы организовали в иерархии. Презентер главного окна порождает другие презентеры в обработчиках событий, и они занимаются своей частью работы. Концептуально все это выглядит так:
struct ModelObserver {
// various callbacks
virtual void OnModelChanged() = 0;
}
struct Model : Observable<ModelObserver> {
// virtual getters, setters, etc
}
struct ViewObserver {
// various callbacks
virtual void OnViewButtonClicked() = 0;
}
struct View : Observable<ViewObserver> {
// 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
Стоит упомянуть, как мы прикрутили в эту структуру нативный Cocoa, возможно кому-то пригодится. Использовали Objective-C++ и ARC, окна рисовали в Interface Builder. Процесс выглядит следующим образом:
- Делаем xib окна и его obj-c++ контроллер, для контролирования состояния окна в большинстве случаев используем биндинги
@interface ViewCocoa : NSWindowController { Observable<ViewObservable>* Callbacks; } @property NSNumber* Something; - (id)initWithObservable:(Observable<ViewObservable>*)callbacks; - (IBAction)OnButtonClicked:(id)sender; @end @implementation ViewCocoa { - (id)initWithObservable:(Observable<ViewObservable>*)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]; } }
Бонусный Command Line Interface
Абстрактность и пассивность вида дала возможность сделать альтернативный 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 подходит скорее для небольших или средних приложений — для больших приложений мне кажется преимущества гибкости не перевесят недостатка дороговизны/сложности расширения самого пользовательского интерфейса.
А какими подходами пользуетесь вы в своих кроссплатформенных проектах?
Автор: sergiorussia