Delphi зі смаком Cocoa

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 і iOS Internals: To the apple's Core. Що стосується практики, то всім нужденним були куплені Масвоок'и, а на віртуальних машинах були розгорнуті різні версії macOS, від 10.9 до самої останньої 10.12.

Процес налагодження програми для macOS на Delphi відрізняється від звичного процесу для Windows, де, як правило, ви запускаєте відлагоджувати програми на тому ж комп'ютері, де працює середовище Delphi. C macOS все трохи складніше: для початку ви встановлюєте на машині з macOS Platform Assistant, тобто допоміжний додаток (частина Delphi), яке забезпечує розгортання і налагодження програми, а з боку 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, які очікує від нас 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 чи ДОБРЕ ми потрапляємо в хукнутую функцію і, відповідно, дізнаємося, що діалог закритий, а також і сам результат закриття.

Міни
Напевно, нікому не вдавалося створити продукт, не підірвавшись на мінах, але, підкреслю це спеціально ще раз, кількість підривів можна мінімізувати, якщо ви будете побільше дивитися на чужий код, читати книги і API reference. Ні, правда, краще прочитати про який-небудь App Nap на developer.apple.com, ніж не прочитати і потім довго гадати, чому всі таймери у вашому додатку стали раптом тікати в 10 разів рідше. І краще дізнатися заздалегідь, що рядкові параметри РОЅІХ-функції повинні передаватися в кодуванні 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.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.