Delphi со вкусом Cocoa

в 9:02, , рубрики: Delphi, FireMonkey, fmx, mac os x, Разработка под OS X
Delphi со вкусом Cocoa

В жизни каждого мужчины наступает момент, когда, окинув взглядом свежую мировую статистику по использованию операционных систем, он понимает, что пришло время больших перемен. Дом, работу и жену при этом менять вовсе не обязательно, а вот попробовать охватить аудиторию, которая заметно выросла за последний десяток лет, все же стоит. Речь пойдет о разработке на Delphi для macOS (в девичестве OS X) и о том, как мы в компании TamoSoft выбирали инструменты, осваивали новое, учились, подрывались на минах и получали удовольствие от процесса.

Задача

Точка отправления: наш главный продукт TamoGraph Site Survey – инструмент для инспектирования Wi-Fi-сетей, который позволяет строить карты покрытия, оптимизировать размещение access points, создавать виртуальные модели распространения сигнала и делать еще много полезных вещей для инженеров, работающих в этой области. TamoGraph работает под Windows. Точка назначения: ну вы уже догадались. TamoGraph, который бы работал под macOS.

Продукт написан большей частью на Delphi, отдельные модули написаны на С++. Почему именно Delphi? (Варианты вопроса: Он еще не умер? Вы больные? Язык X на порядок лучше, а вы ретрограды, неспособные освоить новое!) Друзья, причин, почему мы используем не самый модный и популярный язык/среду (если верить Tiobe, сегодня Object Pascal 11-ый по популярности язык) много. Это и отличная продуктивность, и да, сила привычки, и быстрый компилятор, но самая главная причина лежит совершенно не в сфере технологий. Нам просто нравится писать на Delphi, мы получаем от этого кайф. А когда продукт написан с удовольствием и любовью, он, как правило, хорошо работает. Так что не будем заниматься религиозной полемикой, а перейдем непосредственно к делу.

Итак, из точки отправления (Windows, Delphi) мы должны кратчайшим путем попасть в точку назначения (macOS, пока неизвестный язык/среда). Были рассмотрены следующие основные варианты:

1. Переделать всё на Xcode, используя Swift или Objective-C.
2. Переделать бОльшую часть на Xcode с использованием части существующего Delphi-кода в виде динамических библиотек.
3. Переделать бОльшую часть на Delphi, используя фреймворк FMX (FireMonkey), а небольшую часть кода написать на Objective-C и использовать в виде динамических библиотек.
4. Переделать всё на RemObjects Elements, используя Oxygene, их разновидность Object Pascal.

У каждого варианта, естественно, нашлось много преимуществ и недостатков. Xcode – это полная нативность GUI, отсутствие каких-либо проблем при взаимодействии с операционной системой, масса sample code и библиотек. Но, и это очень большое «но», со всем этим «в комплекте» идет необходимость переписать очень много кода на другой язык. RemObjects Elements – также полная нативность GUI, при этом очень близкий к Object Pascal язык, что означает, что существующий код, не связанный с GUI, можно было бы использовать с относительно небольшими изменениями. Однако, этот инструмент никто из нас на тот момент еще не опробовал. И, наконец, Delphi FMX. Из плюсов – использование существующего отлаженного кода на полную катушку, знакомая среда, но при этом ненативные контролы (хотя, как оказалось, это не совсем так, подробнее ниже), возможные сложности при взаимодействии с macOS API, и много других сомнений.

Неспешно посовещавшись и проведя кое-какие тесты, мы, как вы догадались по заголовку этой статьи, остановились на варианте (3), т.е. Delphi FMX. Уж очень привлекательной была возможность не переписывать значительную часть кода. И, признаться, уж очень не понравился RemObjects Elements, к которому я изначально склонялся. Итак, выбор сделан, засучили рукава и поехали…

Арт-подготовка

Часть команды уже как минимум имела опыт тесного общения с macOS и хорошо представляла ее устройство. Часть же была совсем новичками, которым потребовалась теоретическая подготовка. Для этих целей неплохо подошла книга Mac OS X and iOS Internals: To the Apple's Core. Что касается практики, то всем нуждающимся были куплены MacBook'и, а на виртуальных машинах были развернуты разные версии macOS, от 10.9 до самой последней 10.12.

Процесс отладки программы для macOS на Delphi отличается от привычного процесса для Windows, где, как правило, вы запускаете отлаживаемую программу на том же компьютере, где работает среда Delphi. C macOS все несколько сложнее: для начала вы устанавливаете на машине с macOS Platform Assistant, т.е. вспомогательное приложение (часть Delphi), которое обеспечивает deployment и отладку приложения, а со стороны Delphi, уже под Windows, вы указываете IP-адрес машины, на которой запущен Platform Assistant:

Delphi Platform Assistant

Дальше вы просто запускаете свою программу, которая тут же начинает работать на Маке. Естественно, ее можно отлаживать ровно так же, как все мы привыкли отлаживать Windows-программы.

Контролы FMX

Итак, все настроено, можно запустить свой первый «Hello World» на Маке. GUI делается в привычном визуальном редакторе Delphi с помощью визуальных компонентов FMX. Фреймворк FMX появился в Delphi еще в 2011 году, в версии Delphi XE2. Надо сказать, что вначале он был крайне глючен, но за эти шесть лет его основательно переписали, заметно снизив количество проблем. Сейчас это вполне пригодный к использованию набор компонентов, начиная от простейшей TButton и заканчивая grids, listview, и прочими привычными контролами. Поэтому делать на FMX интерфейсы на сегодня вполне реально и комфортно, однако здесь есть некоторые особенности.

Во-первых, FMX-контролы не нативны. Это не обертка вокруг системных контролов, как это сделано в VCL, где, к примеру, TButton – это системный контрол, который рисует Windows, а не Delphi. Тут контролы рисует Delphi, задействовав свой стилевой движок, который использует стиль, соответствующий стилю той версии macOS, на которой запущена программа.
Пример диалога на Yosemite (10.10):

Диалог в macOS 10.10

Ниже тот же диалог на Mavericks (10.9). Стили элементов GUI автоматически адаптировались под «родной» стиль Mavericks и выглядят уже иначе:

Диалог в macOS 10.9

В принципе это работает неплохо, хотя некоторое вещи в стилях приходится подправлять (или использовать нативные контролы, о чем ниже). Например, «графитовый» стиль macOS, который появился в Yosemite, в Delphi отсутствует, и его пришлось сделать самостоятельно. На это ушло два человеко-дня.

Вторая проблема – «детские болезни». Ребеночку (фреймворку FMX), как я уже говорил, шесть лет, и несмотря на усилия Embarcadero, он еще не до конца переболел всем, чем нужно. Например, в главном меню приложения событие OnClick срабатывает для всех айтемов, кроме айтемов верхнего уровня. Т.е. если у вас меню File → Open, File → Save и так далее, то ивент OnClick случится при клике на Open и Save, но не случится при клике на File, когда произойдет выпадение списка сабайтемов. Или возьмем стандартные диалоги Open и Save. Совершенно неожиданно показ диалога полностью «затыкает» event loop приложения, и у вас перестает что-либо происходить (включая тики таймеров), пока диалог открыт. Все это, на мой взгляд, результат слишком слабого тестирования in-house и слишком медленного реагирования Embarcadero на баг-репорты.

Эти болезни лечатся в run-time, без патчинга системных юнитов. Отсутствие OnClick мы вылечили перехватив вызов 'menuWillOpen:' класса TFMXMenuDelegate, показ системных диалогов мы вообще переписали целиком, но чтобы исправить баг, надо сначала на нем подорваться. Будьте бдительны, не пренебрегайте тестированием, и не забывайте сообщать о багах на quality.embarcadero.com.

Наконец, закрывая тему FMX-контролов, советую взглянуть на TMS FMX UI Pack, который включает в себя много очень неплохо написанных визуальных компонентов, в том числе отличный TreeView, умеющий работать в виртуальном режиме. Это как раз то, чего нет в стандартных компонентах FMX.

Run-Time Library

Использование Delphi RTL ожидаемо оказалось наиболее беспроблемной частью при портировании кода на macOS. RTL уже давно «заточена» под мультиплатформенность, поэтому вы совершенно смело можете использовать любые функции и невизуальные классы без изменений. Нужно лишь следить за такими мелочами как, например, использование платформонезависимой IncludeTrailingPathDelimiter вместо hard-coded разделителя "".

macOS API

Когда вы пишите что-либо чуть более сложное, чем калькулятор, вам рано или поздно придется использовать native API. Обойтись одними только RTL и фреймворком FMX совершенно нереально, равно как под Windows нереально обойтись лишь одними RTL и VCL. Нужно узнать системную локаль? Реализовать interprocess communications? Узнать размер virtual memory процесса? Шифрование? Синтез речи? Все это, естественно, native API. Но это совершенно не должно пугать, как нас не пугает вызов какой-нибудь FindWindow или GetLocaleInfo под Windows. А если что в Delphi не задекларировано, то можно задекларировать, добавить и переделать все что угодно.

Сам по себе API состоит из нескольких компонентов (BSD, Mach, Carbon, Cocoa и т.д.), но для наших целей главный интерес представляет собой Cocoa. Если говорить упрощенно, то Cocoa – это набор классов, что довольно непривычно для тех, кто привык использовать Windows API. Например, если вам нужно узнать смещение временной зоны компьютера относительно UTC, то в Windows это просто функция GetTimeZoneInformation. А вот в macOS это уже класс NSTimeZone. К этому со временем привыкаешь, покуривая на досуге Apple API Reference, ровно так же, как почти все мы когда-то в начале пути покуривали MSDN. Но вот от чего реально поначалу взрывается мозг, так это от синтаксиса «мостика» между Delphi и классами Cocoa. Это очень непривычно.

Class functions вызываются через волшебное слово OCClass:

TNSTimeZone.OCClass.localTimeZone

Возвращают они как правило указатели, но не все не так просто. Эти указатели на объекты нельзя использовать напрямую; указатели представляют из себя то, что называется id в Objective-C, и чтобы преобразовать такой указатель в объект, нужно сделать волшебный Wrap:

TNSTimeZone.Wrap(TNSTimeZone.OCClass.localTimeZone)

И вот теперь уже мы уже можем вызвать функцию экземпляра класса и получить наконец требуемое смещение:

TimeZoneShift:= TNSTimeZone.Wrap(TNSTimeZone.OCClass.localTimeZone).secondsFromGMT;

Еще примерчик? Пожалуйста! Проверяем доступность сервера:

function BlockingGetTestURL: boolean;
var
  URL: NSURL;
  URLRequest: NSURLRequest;
  aData: NSData;
  Response: Pointer;
  Policy: NSURLRequestCachePolicy;
  TimeOut: NSTimeInterval;
const
  URL_TO_CHECK = 'http://open.mapquestapi.com'; 
begin
  URL := TNSURL.Wrap(TNSURL.OCClass.URLWithString(StrToNSStr(URL_TO_CHECK)));
  Policy:=  NSURLRequestReloadIgnoringLocalCacheData;
  TimeOut:= 10;  
  URLRequest := TNSURLRequest.Wrap(TNSURLRequest.OCClass.requestWithURL(URL, Policy, TimeOut ));
  aData := TNSURLConnection.OCClass.sendSynchronousRequest(URLRequest, @Response,  nil);
  result:= (aData <> nil) and (aData.length > 0);
end;

Когда функция Objective-C-класса хочет от нас указатель на объект, мы опять же не можем просто взять и передать @MyDelphiObject, мы должны исполнить ритуальный танец по преобразованию этого указателя в id с помощью функции GetObjectID:

function GetUserDefaultMeasureUnit : TSSMeasureUnitType;
var
  p: pointer;
  ns: NSString;
const
  AppKitFwk: string = '/System/Library/Frameworks/AppKit.framework/AppKit';
begin
  ns:= CocoaNSStringConst( AppKitFwk, 'NSLocaleUsesMetricSystem');
  p := TNSLocale.Wrap(TNSLocale.OCClass.currentLocale).objectForKey((NS as ILocalObject).GetObjectID);
  if TNSNumber.Wrap(p).boolValue
    then Result := msMeters else result:= msFeet;
end;

В общем, к синтаксису вполне можно привыкнуть, изучив примеры. Советую прочесть статью Using OS X APIs directly from Delphi, в которой эта тема хорошо раскрыта.

Если же говорить непосредственно про API (не ограничиваясь только Cocoa), то он оставляет довольно приятное ощущение. Каких-то вещей, имеющихся в Windows API, в macOS API попросту нет, и наоборот. Какие-то вещи в macOS делаются сложнее, чем в Windows, какие-то проще. Взять, к примеру, AES-шифрование. В Windows, чтобы зашифровать массив байт, нужно использовать пяток функций и пару дюжин строк кода, тогда как в macOS это можно сделать практически в одну строку функцией CCCrypt. И это уже не часть Cocoa.

Милый, милый POSIX

POSIX тоже не является частью Cocoa, но, черт возьми, большое ему спасибо, что он есть на macOS! Это делает жизнь намного проще. Многое, что можно сделать через классы, на высоком уровне, гораздо проще сделать на низком уровне через POSIX. Например, как реализовать interprocess communications? Distributed Objects и класс NSProxy? NSConnection? Забудьте, все решается в пару строк кода через memory-mapped files и функции POSIX. Нам нужны shm_open, shm_unlink и mmap. Первые две, кстати, в Delphi не задекларированы, но это не проблема. Внимательно читаем описание, декларируем:

function shm_open(__name: PAnsiChar; __oflag: integer; __mode: mode_t): integer;  cdecl; external libc name  _PU + 'shm_open';
function shm_unlink(__name: PAnsiChar): integer; cdecl; external libc name  _PU + 'shm_unlink';

А дальше все просто, вызываем:

fd := shm_open( PAnsiChar(UTF8Encode(ID)), O_RDWR or O_CREAT, S_IRUSR or S_IWUSR or S_IRGRP or S_IROTH ); 
ftruncate(fd, aSize);
mmap(nil, aSize, PROT_READ or PROT_WRITE, MAP_SHARED, fd, 0);

Все, мы создали маппинг, доступный по имени из других процессов.

Зачем нам еще может быть нужен POSIX? Да для многих вещей. Например, вот:

function GetPhysicalCoreCount: Cardinal;
var
  CoreCount: Cardinal;
  Size: Integer;
begin
  Size:= SizeOf(Cardinal);
  if sysctlbyname('hw.physicalcpu',@CoreCount, @Size, nil, 0) = 0
    then result:= CoreCount else result:= System.CPUCount;
end;

Работа с сокетами, с COM-портами и многое другое – для всего этого годится простой и привычный POSIX, почти с тем же синтаксисом, что и в Windows. Среди прочего, нам нужно было портировать Delphi-класс для работы с COM-портами, который мы использовали под Windows для работы с GPS-приемниками. Кода там примерно 1500 строк. Сложно? Нет, не очень. День работы и примерно 50 IFDEF'ов такого вида:

function TGPSReceiver.ClearInputBuffer: Boolean;
begin
  Result := False;
  if Assigned(ComThread) and ((ComThread as TComThread).ComDevice <> GPS_INVALID_HANDLE_VALUE) then
  begin
    try
    {$IFDEF MSWINDOWS}
    Result := PurgeComm((ComThread as TComThread).ComDevice, PURGE_RXCLEAR);
    {$ENDIF}
    {$IFDEF MACOS}
    result:= tcflush((ComThread as TComThread).ComDevice, TCIFLUSH) = 0;
    {$ENDIF}
    except
      Result := False;
    end;
  end;
end;

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

Нативные контролы

Если вас не устраивает стандартный набор FMX-контролов, то это не беда. Никто не запрещает использовать нативные визуальные классы и даже смешивать их с FMX-контролами, соблюдая определенные правила. Собственно говоря, никто не запрещает даже совсем не использовать фреймворк FMX в вашем приложении Delphi (хотя это уже слегка экстремально).

Нативные классы стоит использовать ради производительности. Мы, например, столкнулись с тем, что viewport, выполненный на FMX-компонентах заметно тормозил при zoom'е и scroll'е больших битмапов, мы заменили его на нативный NSScrollView c NSImageView внутри. Чтобы получить доступ к событиям нативных классов, их надо сабклассить и/или использовать delegates. Это довольно тривиально кодируется в Delphi, и в результате вы получаете доступ к любым событиям. Нужно событие magnifyWithEvent класса NSImageView? Не проблема. Наследуем интерфейс:

NSImageViewEx = interface(NSImageView)
    ['{3E4F87DA-0577-4F21-A1CF-8BCA774FA903}']
    procedure magnifyWithEvent(event: NSEvent); cdecl;
end;

Делаем класс-имплементатор:

TExtendedNSImageView = class(TOCLocal)
…
public
  procedure magnifyWithEvent(event: NSEvent); cdecl;
…
end;

И делаем все что хотим, когда вызывается метод класса-имплементатора:

procedure TExtendedNSImageView.magnifyWithEvent(event: NSEvent);
begin
// Do whatever you want
end;

Чтобы это работало, нужно еще некоторое количество кода при создании класса; примеры можно легко найти в интернете. Сабклассинг – не единственный способ перехвата событий, можно также использовать method swizzling, и я даже приведу пример ниже.

Вот так примерно мы и живем, смешивая нативные и FMX-контролы.

TamoGraph on macOS

Что (пока) не может Delphi на macOS

С большой бочкой меда зачастую идет некоторое количество не столь прекрасной субстанции. Поговорим для разнообразия о недостатках.

Из нерешаемых проблем пока есть одна, но довольно важная. Это 64-битный компилятор для macOS, который есть в roadmap, но пока не сделан. Это, конечно, позор для Idera/Embarcadero, которые увлечены, на наш взгляд, гораздо менее важными вещами, пренебрегая Mac-веткой продукта. Так что, ждем с нетерпением.

Из решаемых – code blocks, языковая фича С++ и Objective-C, которая не поддерживается в Delphi. Точнее, Delphi имеет свой аналог code blocks, но он несовместим с теми code blocks, которые ожидает от наc macOS API. Дело в том, что многие классы имеют функции, в которых используются code blocks в качестве handler'ов завершения. Самый простой пример — beginWithCompletionHandler классов NSSavePanel и NSOpenPanel. Передаваемый сode block выполняется в момент закрытия диалога:

- (IBAction)openExistingDocument:(id)sender {
   NSOpenPanel* panel = [NSOpenPanel openPanel];
 
   // This method displays the panel and returns immediately.
   // The completion handler is called when the user selects an
   // item or cancels the panel.
   [panel beginWithCompletionHandler:^(NSInteger result){
      if (result == NSFileHandlingPanelOKButton) {
         NSURL*  theDoc = [[panel URLs] objectAtIndex:0];
 
         // Open  the document.
      }
 
   }];
}

На Delphi такой «трюк ушами» исполнить, видимо, пока крайне проблематично (по крайней мере, нам это не удалось). Иными словами, нормальным путем мы не можем узнать о закрытии диалога. Но нормальный путь – это даже скучно! Кто нам мешает пойти ненормальным путем? Извращенных подходов к решению таких проблем несколько, но в данном случае, например, хорошо сработает следующий. Для начала мы можем получить список всех, как документированных, так и недокументированных, функций класса NSSavePanel. Делается это примерно так:


function ListMethodsForClass(const aClassName: string): string;
var
  aClass: Pointer;
  OutCount, i: integer;
  Arr: PPointerArray;
  p: PAnsiChar;
begin
  result:= 'Instance methods for class ' +  aClassName + ':' + #13#10;
  aClass := objc_getClass(PAnsiChar(ansistring(aClassName)));
  if aClass <> nil  then
  begin
     Arr:= class_copyMethodList(aClass, OutCount);
     if Arr <> nil then
     begin
       for i := 0 to OutCount - 1 do
       begin
         p:= sel_getName(method_getName(Arr^[i]));
         result:= result + string(p) + #13#10;
       end;
       Posix.Stdlib.free(Arr);
     end;

     result:= result + 'Class methods:' + #13#10;
     Arr:= class_copyMethodList(object_getClass(aClass), OutCount);
     if Arr <> nil then
     begin
       for i := 0 to OutCount - 1 do
       begin
         p:= sel_getName(method_getName(Arr^[i]));
         result:= result + string(p) + #13#10;
       end;
       Posix.Stdlib.free(Arr);
     end;
  end;
end; 

Получили список и ищем что-нибудь вкусненькое… Ага, нашли: "_didEndSheet:returnCode:contextInfo:". Очень похоже на то, что нам нужно. Надо проверить теорию, вызывается ли этот селектор при закрытии диалога. Можно сделать сабкласс NSSavePanel, а можно грубо и беспардонно поставить хук на этот селектор, подменив имплементацию метода (method sizzling):

const
  END_SHEET_SELECTOR  : ansistring  = '_didEndSheet:returnCode:contextInfo:';
  SAVE_PANEL_CLASS    : ansistring  = 'NSSavePanel';
var
  endSheetOld: procedure (self: pointer; _cmd: pointer; sheet: pointer; returncode: NSinteger; contextinfo: pointer); cdecl;

procedure endSheetNew (self: pointer; _cmd: pointer; sheet: pointer; returncode: NSinteger; contextinfo: pointer); cdecl;
begin
  endSheetOld(self, _cmd, sheet, returncode,  contextinfo);
  FDialogClosed:= ReturnCode;
end;


procedure DoDialogHooks();
var
  FM1, aClass: pointer;
begin
  aClass := objc_getClass(PAnsiChar(SAVE_PANEL_CLASS));
  if aClass <> nil then
  begin
    FM1 := class_getInstanceMethod(aClass, sel_getUid(PAnsiChar(END_SHEET_SELECTOR)));
    if FM1 <> nil then
    begin
      @endSheetOld := method_getImplementation(FM1);
      method_setImplementation(FM1, @endSheetNew);
    end
     else raise Exception.Create('Failed to hook NSSavePanel');
  end;
end;

Проверяем – и о чудо, в момент закрытия диалога по Cancel или OK мы попадаем в хукнутую функцию и, соответственно, узнаем, что диалог закрыт, а также и сам результат закрытия.

Мины

Наверное, никому не удавалось создать продукт, не подорвавшись на минах, но, подчеркну это специально еще раз, количество подрывов можно минимизировать, если вы будете побольше смотреть на чужой код, читать книги и API reference. Нет, правда, лучше прочесть о каком-нибудь App Nap на developer.apple.com, чем не прочесть и потом долго гадать, почему все таймеры в вашем приложении стали вдруг тикать в 10 раз реже. И лучше узнать заранее, что строковые параметры в POSIХ-функциях должны передаваться в кодировке UTF-8, а не ANSI или UTF-16. И тестируйте, тестируйте, тестируйте… Причем «и за себя, и за того парня». Да, мины будут и в Delphi тоже, Idera/Embarcadero не очень любит тестировать Mac-часть продукта. Ну не падала бы у них Macapi.Foundation.NSMakeRect, если бы нормально было организовано тестирование.

Итоги

Надеюсь, для тех, кто раздумывает о том, как сделать продукт для macOS, первое знакомство с Delphi + Cocoa оказалось познавательным. Связка вполне рабочая, позволяющая делать серьезный софт. А мои пожелания Idera/Embarcadero – не забывайте про macOS. Я понимаю, что мобильная разработка – это очень модно, но разработка десктопного софта – весьма приличный рынок, в чем вы могли убедиться на примере Windows за последние лет 20. У вас есть почти все для отличного продукта для macOS, нужно только еще немного потрудиться. Выкатывайте скорее 64-битный компилятор и исправляйте то, о чем вам поведали на quality.embarcadero.com.

Автор: NightFlight

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js