23 июня 2012 г.

Разработка системы плагинов, часть 7: пакеты и оптимизация по размеру

В предыдущей части мы рассмотрели основы реализации UI в системе плагинов. В связи с этим возникает насущный вопрос: UI - это VCL или FMX. Если плагинов много и всем нужен UI - получается, что каждый плагин несёт в себе достаточно много дублирующегося кода. Это не имеет значения, если вы написали один или два плагина. Но что если вы такой плодовитый разработчик, что написали аж две дюжины плагинов? Особенно, если они устанавливаются скопом (в "сборке"). Тогда вы тратите достаточно много места на дублирование кода. Нельзя ли с этим что-нибудь сделать?

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

Оглавление

  1. Оптимизация одного плагина
  2. Оптимизация набора плагинов
  3. Заключение

Оптимизация по размеру одного плагина

Чистка uses

Как бы банально это не звучало, но начать стоит с простой чистки uses от не используемых вами модулей. Они могли оказаться там в результате народного метода Copy & Paste, либо они были нужны ранее, но стали не нужными после реорганизации кода.

Тут надо правильно понимать, в каком смысле употребляется "не используемые". С одной стороны, если модуль указан в uses, то он - используется. По определению. При запуске программы модуль будет выполнять свою секцию initialization, выполняя инициализацию кода поддержки - что приведёт к его включению в программу. Но, с другой стороны, если вы не используете возможности, которые обеспечиваются этим кодом поддержки, то модуль этот вами "не используется" - в том смысле, что вы не используете результаты его работы.

Итак, простейший метод чистки - закомментировать модуль и попробовать скомпилировать программу. Скомпилировалась? Удаляем. Нет? Раскомментариваем. Впрочем вы можете удалить и необходимый модуль. Например, без SysUtils в вашем плагине отвалится правильная обработка ошибок.

Плагины без COM

Для начала вспомним, что мы используем модуль ComObj. В этом модуле нас интересует вспомогательный код для обработки ошибок.

И если ваш плагин использует COM, то этот модуль у вас и так подключен и вы его используете, так что ничего страшного в этом нет. Я рекомендую вам оставить этот модуль как есть и использовать его. Ровно как мы делали это ранее. Этим вы обеспечите совпадение классов исключений (EOleSysError).

Но наша система плагинов вообще-то от COM не зависит. Её плагины работают на интерфейсах, но не используют COM. Поэтому Delphi-обёртки для COM нам не нужны. Поэтому, если ваш плагин не использует COM для своей работы, то модуль ComObj ему не очень-то нужен. Да, мы берём из него несколько определений и функций, но нам совершенно не нужны TComServerObject, TComClassManager, TComObject, TComObjectFactory, TAutoObject и другие объекты, описанные в этом модуле. Конечно, простое подключение модуля в uses ещё не означает подключение всего кода модуля - оптимизирующий компоновщик выбрасывает весь не используемый в программе код. Проблема в том, что модуль ComObj в своей части initialization содержит инициализацию кода поддержки COM. Но COM нами не используется, так что этот код нам бесполезен.

Практический пример

В качестве примера мы рассмотрим плагин, который вставляет в редактор программы-ядра текущую дату и время (DatePlugin). По умолчанию он занимает примерно 860 Кб (понятно, этот размер сильно зависит от ваших настроек проекта и версии Delphi).

Вот его код:
library DatePlugin;

uses
  Windows,
  SysUtils,
  Classes,
  ActiveX,
  AxCtrls,  
  PluginAPI in '..\PluginAPI\Headers\PluginAPI.pas',
  CRC16 in '..\PluginAPI\Headers\CRC16.pas',
  Helpers in '..\PluginAPI\Headers\Helpers.pas';

{$R *.res}
{$E .rep}

type
  TPlugin = class(TCheckedInterfacedObject, IUnknown, IPlugin, IDestroyNotify, INotifyEvent)
  private
    FCore: ICore;
  protected
    // IPlugin
    function GetID: TGUID; safecall;
    function GetName: WideString; safecall;
    function GetVersion: WideString; safecall;
    // IDestroyNotify
    procedure Delete; safecall;
    // INotifyEvent
    procedure Execute(Sender: IInterface); safecall;
  public
    constructor Create(const ACore: ICore);
    destructor Destroy; override;
  end;

{ TPlugin }

constructor TPlugin.Create(const ACore: ICore);
var
  Manager: IMenuManager;
  MenuItem: IMenuItem;
begin
  inherited Create;
  FCore := ACore;
  Assert(FCore.Version >= 1);

  if Supports(FCore, IMenuManager, Manager) then
  begin
    MenuItem := Manager.CreateMenuItem;
    MenuItem.Caption := 'Вставить дату';
    MenuItem.Hint := 'Вставляет в документ текущую дату и время в местном формате';
    MenuItem.Enabled := True;
    MenuItem.Checked := False;
    MenuItem.RegisterExecuteHandler(Self);
  end;
end;

destructor TPlugin.Destroy;
begin
  Delete;
  inherited;
end;

procedure TPlugin.Delete;
begin
  FCore := nil;
end;

procedure TPlugin.Execute(Sender: IInterface);
var
  Editor: IEditor;
begin
  if not Supports(FCore, IEditor, Editor) then
    Exit;

  Editor.SelText := DateTimeToStr(Now);
end;

function TPlugin.GetID: TGUID;
const
  ID: TGUID = '{6DC24451-6C73-47E5-9397-BB7498F686BD}';
begin
  Result := ID;
end;

function TPlugin.GetName: WideString;
begin
  Result := 'Menu Demo plugin #1';
end;

function TPlugin.GetVersion: WideString;
begin
  Result := '1.0.0.0';
end;

// _________________________________________________________________

function Init(const ACore: ICore): IPlugin; safecall;
begin
  Result := TPlugin.Create(ACore);
end;

procedure Done; safecall;
begin

end;

exports
  Init name SPluginInitFuncName,
  Done name SPluginDoneFuncName;

begin
  ReportMemoryLeaksOnShutdown := True;
end.
Сам его код предельно прост и не использует COM. Модуля Windows, ActiveX, Classes и AxCtrls плагином не используются и могут быть удалены. Что сократит размер плагина с 860 Кб до 360 Кб. Впрочем модуля Windows и ActiveX являются заголовочными файлами и не содержат основного кода. Поэтому их подключение или отключение практически не влияет на программу.

SysUtils - минимально необходимый модуль в любом плагине. PluginAPI - это заголовочники (только объявления, не содержат кода). CRC16 (две функции и одна таблица) мы подключаем для модуля Helpers (можно было бы не подключать, а просто указать папку в путях поиска проекта), ну а Helpers содержит вспомогательный код по обработке ошибок (TCheckedInterfacedObject) - и вот он содержит ссылки ещё на Classes и ComObj.

Модуль Classes нам нужен исключительно для импорта объявлений исключений, с которыми мы будем сопоставлять коды ошибок. Больше он нам ни для чего не нужен (имеется в виду в рамках модуля Helpers). Как правило, модуль Classes используется в любой Delphi программе (равно как и SysUtils), поэтому убирать его большого смысла нет. Но если вы делаете какой-то уж очень примитивный плагин - то можете его убрать и вырезать части, которые ему соответствуют из CreateExceptionFromCode. А чтобы один и тот же исходный код можно было бы использовать для любых плагинов - заверните этот код в условную компиляцию, например:
unit Helpers;

interface

uses
  Windows,
  SysUtils,
  {$IFNDEF EXCLUDE_CLASSES}
  Classes,
  {$ENDIF}
  ComObj,
  ActiveX;

...

implementation

...

procedure CustomSafeCallError(ErrorCode: HResult; ErrorAddr: Pointer);

  function CreateExceptionFromCode(ACode: HRESULT): Exception;
  ...
  begin
    ...
    case HRESULT(ErrorCode) of
      ...
      E_ProgrammerNotFound:                     ExceptionClass := EProgrammerNotFound;
      {$IFNDEF EXCLUDE_CLASSES}
      E_StreamError:                            ExceptionClass := EStreamError;
      E_FileStreamError:                        ExceptionClass := EFileStreamError;
      E_FCreateError:                           ExceptionClass := EFCreateError;
      E_FOpenError:                             ExceptionClass := EFOpenError;
      E_FilerError:                             ExceptionClass := EFilerError;
      E_ReadError:                              ExceptionClass := EReadError;
      E_WriteError:                             ExceptionClass := EWriteError;
      E_ClassNotFound:                          ExceptionClass := EClassNotFound;
      E_MethodNotFound:                         ExceptionClass := EMethodNotFound;
      E_InvalidImage:                           ExceptionClass := EInvalidImage;
      E_ResNotFound:                            ExceptionClass := EResNotFound;
      E_ListError:                              ExceptionClass := EListError;
      E_BitsError:                              ExceptionClass := EBitsError;
      E_StringListError:                        ExceptionClass := EStringListError;
      E_ComponentError:                         ExceptionClass := EComponentError;
      E_ParserError:                            ExceptionClass := EParserError;
      E_OutOfResources:                         ExceptionClass := EOutOfResources;
      E_InvalidOperation:                       ExceptionClass := EInvalidOperation;
      {$ENDIF}
      E_CheckedInterfacedObjectError:           ExceptionClass := ECheckedInterfacedObjectError;
      ...
    end;
    ...
  end; 
  
...

begin
  ...
end;

...

end.
Как работать с этим - я покажу чуть ниже.

Далее - модуль ComObj. Надо сказать, что если в вашей программе используется модуль Classes, то подключение модуля ComObj даёт совсем уж небольшую прибавку в весе, так что на него можно было бы и не обращать внимание. Но если вы пишете примитивный плагин без модуля Classes, то было бы неплохо избавиться и от ComObj, возможности которого, как я уже сказал, нами не используются.

Поскольку вы можете писать плагины, которые для своей работы используют COM (не как интерфейс для связи с ядром, а как часть своей работы), то вообще-то модуль ComObj может быть вам необходим. Поэтому вам снова нужно использовать директивы условной компиляции:
unit Helpers;

interface

uses
  Windows,
  SysUtils,
  {$IFNDEF EXCLUDE_CLASSES}
  Classes,
  {$ENDIF}
  {$IFNDEF EXCLUDE_COMOBJ}
  ComObj,
  {$ENDIF}
  ActiveX;

type
  {$IFDEF EXCLUDE_COMOBJ}
  EOleError = class(Exception);

  EOleSysError = class(EOleError)
  private
    FErrorCode: HRESULT;
  public
    constructor Create(const Message: UnicodeString; ErrorCode: HRESULT;
      HelpContext: Integer);
    property ErrorCode: HRESULT read FErrorCode write FErrorCode;
  end;

  EOleException = class(EOleSysError)
  private
    FSource: string;
    FHelpFile: string;
  public
    constructor Create(const Message: string; ErrorCode: HRESULT;
      const Source, HelpFile: string; HelpContext: Integer);
    property HelpFile: string read FHelpFile write FHelpFile;
    property Source: string read FSource write FSource;
  end;

  EOleRegistrationError = class(EOleSysError);
  {$ENDIF}

  EBaseException = class(EOleSysError)
  ...
  end;

...

implementation

uses
  PluginAPI,
  {$IFDEF EXCLUDE_COMOBJ}
  ComConst,
  {$ENDIF}
  CRC16;

{$IFDEF EXCLUDE_COMOBJ}

{ EOleSysError }

constructor EOleSysError.Create(const Message: UnicodeString; ErrorCode: HRESULT; HelpContext: Integer);
var
  S: string;
begin
  S := Message;
  if S = '' then
  begin
    S := SysErrorMessage(Cardinal(ErrorCode));
    if S = '' then
      FmtStr(S, SOleError, [ErrorCode]);
  end;
  inherited CreateHelp(S, HelpContext);
  FErrorCode := ErrorCode;
end;

{ EOleException }

constructor EOleException.Create(const Message: string; ErrorCode: HRESULT; const Source, HelpFile: string; HelpContext: Integer);

  function TrimPunctuation(const S: string): string;
  var
    P: PChar;
  begin
    Result := S;
    P := AnsiLastChar(Result);
    while (Length(Result) > 0) and CharInSet(P^, [#0..#32, '.']) do
    begin
      SetLength(Result, P - PChar(Result));
      P := AnsiLastChar(Result);
    end;
  end;

begin
  inherited Create(TrimPunctuation(Message), ErrorCode, HelpContext);
  FSource := Source;
  FHelpFile := HelpFile;
end;

{$ENDIF}

...

end.
Как видите - мы эмулируем исходный код оригинального ComObj, чтобы код ниже не изменялся бы.

Здесь нужно также отметить два крайне важных момента. Обратите внимание, что в наших предыдущих статьях мы уже перенесли часть кода из модуля ComObj: это наш "кастомизированный" вариант HandleSafeCallException и свой обработчик SafeCallErrorProc. Именно поэтому сейчас нам осталось так мало действий по изоляции модуля - потому что самую важную часть мы уже вынесли, осталась только синхронизация типов исключений. Вот почему часть про оптимизацию размера расположена после части про обработку ошибок и части про UI.

Условная компиляция

Осталось договорить про условную компиляцию (conditional compilation). Вы можете указать компилятору не компилировать определённый код при выполнении каких-либо условий, чаще всего - наличия или отсутствия специального маркера (т.н. символа условной компиляции - conditional symbol). Делается это с помощью директив {$IFDEF}/{$IFNDEF}/{$IF}. Символ условной компиляции может быть либо задан (определён), либо не задан (не определён). Т.е. это двоичный признак - да/нет. Каждый символ можно проверить на заданность (существование) и компилировать (или нет) выделенный код в зависимости от результата проверки, например:
procedure DoSomething;
begin
  {$IFDEF DEBUG}
  OutputDebugString('DoSomething called');
  {$ENDIF}

  // ...
  // Код DoSomething
  // ...
end;
Заметьте, что это действие выполняется на этапе компиляции программы. Т.е. если символ DEBUG определён, то итоговый код DoSomething будет выглядеть так:
procedure DoSomething;
begin
  OutputDebugString('DoSomething called');
  // ...
  // Код DoSomething
  // ...
end;
А если DEBUG не определён, то - так:
procedure DoSomething;
begin
  // ...
  // Код DoSomething
  // ...
end;
Иными словами, "отфильтрованный код" в итоговую программу просто не попадает.

Кстати, регистр имени символа условной компиляции значения не имеет.

Сами символы условной компиляции могут задаваться несколькими способами:
  • Предопределённые. Они задаются компилятором. Например, WINDOWS, WIN32, DCC, CONSOLE и т.п.
  • Определённые вами в опциях проекта. Например, DEBUG.
  • Определённые вами в исходном коде с использованием директивы {$DEFINE Symbol}.
  • Определённые вами во включаемом файла (.inc файл, подключаемый через {$I ИмяФайла.inc}). Формально это тождественно предыдущему пункту, но используется намного чаще.
  • Определённые во время компиляции проекта, передачей компилятору специального параметра командной строки -DSymbol.

Итак, в нашем случае мы сделали код модуля Helpers зависящим от двух символов условной компиляции - EXCLUDE_CLASSES и EXCLUDE_COMOBJ. Обратите внимание на семантику, которую я выбрал. Понятно, что любой произвольный символ не определён, пока вы его явно не зададите. Поэтому, в любом новом плагине символы EXCLUDE_CLASSES и EXCLUDE_COMOBJ отсутствуют. Это важно. Это означает, что по умолчанию в нашей программе будет использоваться интеграция с модулями Classes и ComObj, как стандартная ситуация в типичных плагинах. Нетипичное же поведение (отключение модулей) нужно задавать явно, руками. Этот же принцип (типичное - по умолчанию) мы увидим в дальнейшем (следующей части статей, посвящённой расширению и обратной совместимости).

Завершая наш практический пример для плагина DatePlugin: поскольку плагин не использует модуля Classes и ComObj для своей работы, то синхронизация с классами исключений в них нам не нужна, поэтому их (модуля) можно отключить. Для этого заходим в опции проекта и включаем директивы (для базовой конфигурации):

Указание символов условной компиляции (картинка кликабельна)
Делаем сборку проекта (обязательно Build, а не Compile) и получаем итоговый размер плагина в 140 Кб. Это и есть итоговый размер после оптимизации. Напомню, начинали мы с 860 Кб.

Три замечания:
  • Мы делали оптимизацию без ущерба скорости разработки и функциональности. При сильном желании вы можете попробовать произвести дальнейшее уменьшенеие размеров - за счёт отказа от SysUtils, но тогда крайне затруднится разработка. Лично я не рекомендую это делать. Также вы можете попробовать побороться за копейки - например, отключением RTTI, ресурсов (иконки, информации Borland) и т.п. Но лично я считаю это напрасной тратой времени.
  • Важно понимать, что включение символов EXCLUDE_CLASSES и EXCLUDE_COMOBJ нужно производить как "последняя мера". Не следует бездумно включать их в каждый проект. Если ваш плагин использует какой-либо из этих модулей для своей работы, а вы включите эти символы, то модуль в плагин всё равно попадёт, но вот синхронизация классов исключений с ним будет нарушена, что приведёт к странному поведению плагина во время обработки ошибок. Я рекомендую следующий алгоритм:
    • Вести разработку с выключенными символами EXCLUDE_CLASSES и EXCLUDE_COMOBJ.
    • Непосредственно перед поставкой (релизом) плагинов, проверсти эксперимент.
    • Включить EXCLUDE_COMOBJ. Если размер уменьшился - оставить, если нет - убрать.
    • Включить EXCLUDE_CLASSES. Если размер уменьшился - оставить, если нет - убрать.
  • Не забудьте про эту статью. На размер исполняемого модуля может влиять отладочная информация, внедряемая в него. Это может быть как полезно вам, так и случиться по недосмотру и не быть запланированным.

Код к этому моменту можно скачать тут.

Оптимизация по размеру набора плагинов

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

Вынос общего кода

Исторически, способ разделения общего кода - это DLL. Проблема с DLL лишь в том, что их крайне неудобно использовать. Вам нужно приложить много усилий для этого. Посмотрите, вот сейчас мы пишем систему плагинов: я пишу уже 7 часть в серии (каждая из которых - это просто прорва текста) - и всё это только для того, чтобы рассказать, как правильно работать с DLL.

К счатью, всё не так плохо. В Delphi есть понятие пакета времени выполнения (run-time package, .bpl). BPL - это обычная DLL, которая трактуется специальным образом. На неё накладываются дополнительные ограничения, что, в свою очедерь, даёт и новые возможности. Не вдаваясь в детали, с точки зрения пользователя ограничения BPL - в привязке к компилятору. Т.е. к Delphi и, более того, к конкретной его версии. А в плюсах сидит прозрачная интеграция кода exe и BPL: код не нужно экспортировать, импортировать - достаточно включить модуль в пакет; нет разницы в вызове функции из текущего модуля и из BPL; общий менеджер памяти.

Да, мы не можем использовать пакеты (именно как пакеты - со всеми плюсами) при организации системы плагинов - потому что это введёт жёсткие ограничения на компилятор и его версию. В некоторых случаях (корпоративный софт) это может быть приемлемым, но зачем тогда вы читаете эту серию статей? В любом случае, нет ничего плохого в том, чтобы использовать пакеты для выноса общего кода из плагинов. И, быть может, выноса общего кода из плагинов и ядра. Главное при этом понимать, что пакеты тут используются только как средство разделения кода. Никакие другие возможности не используются. Протокол API плагинов по прежнему соблюдает изоляцию и ровно так же следует всем старым правилам.

Вот как примерно это может выглядеть на практике:



Один или несколько плагинов (и ядро) могут использовать общий пакет времени выполнения, разделяя через него общий код. Другой набор плагинов могут использовать другой пакет (например - от другой версии компилятора или свой собственный). При этом между собой все они будут общаться по существующему протоколу, через интерфейсы. Также нужно обратить внимание, что сами плагины при этом будут являться DLL, а не пакетами. Они лишь будут использовать стандартные пакеты.

Основными стандартными пакетами, которые нас будут интересовать в большинстве плагинов являются RTL и (для плагинов с UI) VCL/FMX. Файл пакета имеет такое же имя, только с добавлением версии компилятора и расширения .bpl, например: rtl70.bpl - пакет RTL от Delphi 7, vcl160.bpl - пакет VCL от Delphi XE2. Здесь 70 - это 7.0, а 160 - это 16.0 - версия среды. При этом, имя пакета всюду указывается без суффикса и расширения. Например, rtl или vcl. Таким образом, указав пакет rtl для использования в плагине, плагин будет использовать rtl70.bpl, будучи скомпилированным в Delphi 7 и rtl150.bpl, будучи скомпилированным в Delphi XE.

Практический пример

Чтобы собрать плагин с пакетами времени выполнения, нужно зайти в Project / Options и найти раздел "Packages" - в разных версиях Delphi он называется по разному и располагается в разных местах. Там вам нужно включить/установить галочку "Build with run-time packages"/"Link with run-time packages". Надо сказать, что название этой опции крайне запутывает, особенно в последнем варианте. Можно подумать, что имеется в виду, что код из пакетов будет влинкован в исполняемый модуль - т.е. противоположный эффект: автономный модуль без ссылки на пакет. На самом деле, тут имеется в виду, что модуль будет содержать ссылку (link) на пакет, т.е. код как раз будет вынесен из самого модуля.

Кроме того, вам (возможно) необходимо будет подправить список пакетов. Достаточно часто при интенсивном использовании среды в списке по умолчанию много "левых" пакетов, которых давно у вас нет, но без которых проект не соберётся (т.к. ссылка на них указана). Я рекомендую попробовать начать с указания минимально возможного варианта:
rtl;vcl (для Delphi XE и ниже)
rtl;vcl;fmx (для Delphi XE2 и выше)
Заметьте, что порядок указания пакетов роли не играет. Также не имеет значения, если вы укажете пакет vcl, но не будете использовать формы в плагине - если в исполняемом модуле (module) нет ни одного модуля (unit) из пакета - то пакет к вашему модулю присоединяться не будет. Нет никакой необходимости агрессивно чистить список используемых пакетов.

После включения пакетов в плагины и их пересборки - все файлы плагинов уменьшаются до размеров от 28 до 100 Кб. В среднем - около 40 Кб на один плагин.

Ключевой недостаток плагинов с пакетами - невозможность корректной диагностики утечек памяти, поскольку мы разделяем код между несколькими плагинами. Поэтому я рекомендую разработку и тестирование проводить с плагинами, собранными без пакетов, а разбивку на пакеты включать лишь для сборки финальной версии (которую, разумеется, необходимо будет проверить отдельно).

Развёртывание проекта

Разумеется, после включения пакетов в плагинах, каждый плагин теперь зависит ещё и от файла пакета. На вашей машине с Delphi это не имеет значения, потому что пакеты от Delphi у вас уже стоят - вы можете просто запустить приложение и оно будет работать. Все пакеты будут найдены и загружены автоматически. Но на других машинах необходимых пакетов может не быть, поэтому их нужно распространять вместе с плагинами.

Тут получается хитрая вещь. Размер пакета много больше размера, который экономится в каждом плагине. Но чтобы плагин с пакетом работал бы вообще - вместе с плагином нужно распространять и пакет. Т.е. размер дистрибутива из одного плагина значительно увеличивается. Увеличивается и требуемое место на конечной машине. Лишь при установке набора из плагинов, либо нескольких таких плагинов (с пакетами) подряд вы получите выигрыш в месте (суммарно), но размер дистрибутивов для отдельных плагинов будет всё ещё велик.

К примеру, размер rtl150.bpl - 2 Мб, vcl150.bpl - 2.3 Мб, vcl160.bpl - 3.4 Мб, fmx162.bpl - 3.1 Мб. К счастью, других пакетов эти пакеты не используют, но vcl/fmx использует rtl. Т.е. если вы включаете в плагин пакет vcl или fmx, то он будет зависеть и от vcl (fmx) и от rtl.

Итак, если наш плагин DatePlugin занимал 140 Кб до и 28 Кб после, то мы выиграли 150 - 28 = 112 Кб. Значит, чтобы включение пакета было бы оправданным, нам нужно написать 2048 / 112 = 19 плагинов уровня DatePlugin! Конечно, это число будет несколько меньше, если мы будем писать более сложные плагины, экономия места для которых будет выше для каждого отдельного плагина. К примеру, вот плагин UIDemo занимал 830 Кб до и 44 Кб после, т.е. экономия составила 786 Кб, но только за счёт 2 + 2.3 = 4.3 Мб пакетов. Т.е. для реального суммарного выигрыша, у вас должно быть написано более 4404 / 786 = 6 плагинов класса UIDemo.

Подводя черту: экономия на пакетах имеет смысл либо для толстых плагинов, а не простых, либо для действительно большого количества мелких плагинов.

Возвращаясь к теме развёртывания (deploy) пакетов на конечной машине: вам, конечно же, во-первых нужно будет включить требуемые пакеты в дистрибутив плагина. Требуемые пакеты проще всего подсмотреть любой утилитой анализа DLL файлов: какие DLL перечислены в импорте для вашего плагина? Там будут системые DLL и пакеты Delphi. Вот их как раз (пакеты, а не системные DLL) и нужно будет добавить в установщик плагина.

Далее, возникает вопрос, куда устанавливать пакеты. Вариант, как это было бы лет десять назад - устанавливать в системную папку (System32). Хотя при этом нет проблемы перезаписать более новую версию пакета от другой среды (поскольку каждый пакет имеет суффикс версии), но при этом всё ещё есть шанс перезаписать более новый пакет старым - от другой версии update-а. Это - проблема, поскольку стандартные пакеты не имеют в себе версионной информации. Короче говоря, имеем DLL Hell во всей красе.

Именно по этой причине устанавливать пакеты следует исключительно в папку плагинов - чтобы исключить их влияние на другие программы в системе, равно как и наоборот. Но при этом снова возникает проблема (ну почему у нас постоянно возникают проблемы?!): дело в том, что плагин связан с пакетом статически. Т.е. пакет грузится не плагином, а системой. Соответственно, плагин не может указать, откуда грузить пакет. А пути поиска DLL системы устроены так, что поиск помимо системной папки ведётся ещё и в папке программы, но не загружаемого модуля. Если бы плагины у нас размещались бы в папке с .exe файлом ядра, то проблемы бы не было. Но поскольку они размещаются в подпапке Plugins, то нам дополнительно нужно указать системе на поиск DLL в этой папке. Надо особо уточнить, что речь идёт о загрузке DLL-пакетов по статическим зависимостям в DLL-плагинах, а не загрузке самих DLL-плагинов, которые всегда грузятся ядром явно по указанным полным путям. Зависимости же не имеют полных путей, а имеют лишь имя DLL.

Для изменения порядка поиска DLL есть несколько способов, из которых мне наиболее нравится способ с вызовом LoadLibraryEx с флагом LOAD_WITH_ALTERED_SEARCH_PATH, который специально предназначен для нашего случая.

Собственно, всё изменение в коде ядра:
unit PluginManager;

...

constructor TPlugin.Create(const APluginManger: TPluginManager;
  const AFileName: String);

  function SafeLoadLibrary(const Filename: string; ErrorMode: UINT): HMODULE;
  const
    LOAD_WITH_ALTERED_SEARCH_PATH    = $008;
  var
    OldMode: UINT;
    FPUControlWord: Word;
  begin
    OldMode := SetErrorMode(ErrorMode);
    try
      {$IFNDEF CPUX64}
      asm
        FNSTCW  FPUControlWord
      end;
      try
      {$ENDIF}
        Result := LoadLibraryEx(PChar(Filename), 0, LOAD_WITH_ALTERED_SEARCH_PATH);
      {$IFNDEF CPUX64}
      finally
        asm
          FNCLEX
          FLDCW FPUControlWord
        end;
      end;
      {$ENDIF}
    finally
      SetErrorMode(OldMode);
    end;
  end;

var
  Ind: Integer;
begin
  ...
Мы добавили код эмуляции SafeLoadLibrary, заменив в ней вызов LoadLibrary на вызов LoadLibraryEx.

Код к этому моменту можно скачать здесь.

Заключение

В следующих статьях серии: основы расширения и обратной совместимости, демонстрация универсальности полученной системы плагинов, внедрение трейсера исключений и финальная шлифовка (баг-фиксинг).

28 комментариев :

  1. В надстройке к среде Delphi CnWizards есть функция автоматической проверки на не используемые модули, можно сразу весь проект проверить.

    ОтветитьУдалить
  2. Да, но не у всех есть CnWizards и не все считают его удобным, да и готовое решение стороннего разработчика - не лучший способ (само)образования в данном частном случае

    ОтветитьУдалить
  3. Если вам не нравится распространять с программой множество файлов rtl160.bpl, vcl160.bpl, VirtualTreesD16.bpl и т.п., их все можно объединить в один, например Core.bpl.
    Для этого нужно создать пакет, вписать в секцию contains все необходимые модули, и в "Build with run-time packages" указывать уже свой пакет.

    ОтветитьУдалить
  4. > Если вам не нравится распространять с программой множество файлов rtl160.bpl, vcl160.bpl, VirtualTreesD16.bpl и т.п., их все можно объединить в один, например Core.bpl.

    Да, но этим теряется возможность экономии места с другими плагинами, которые чаще всего будут собраны именно со стандартными пакетами.

    Зато и плюс есть - в свой пакет можно включить только необходимые для плагинов модуля, так что он будет весить не 4.3 Мб, а только (от балды говорю) 1.2 Мб.

    ОтветитьУдалить
  5. Вообще говоря, смешивать BPL и DLL не самое веселое занитие. Особенно если используются "жирные" BPL и типа DevExpress и при каждой загрузхке они будут инициализироваться. Это бывает долго.

    Но хуже всего то, что BPL просто не приспособленны у такому режиму. Либюо человек их использует, либо игнорирует и пишет на DLL.

    QC#101022 QC#103917

    ОтветитьУдалить
  6. Если честно, то я в недоумении от этого комментария.

    Во-первых, не очень понятно, в чём тут фундаментальная проблема.

    Плагины грузятся один раз при запуске программы или, в крайнем случае, по требованию и остаются до конца работы программы. Это самый типичный случай. Поэтому проблем типа load-free-load-problem тут не будет в 99.9% случаев.

    Далее, ссылки на QC - это одна конкретная проблема в стилях VCL (появились в XE2). Вроде понятно, что конкретный баг в конкретной библиотеке - это же означает, что сам принцип работать не будет.

    Ну и "Особенно если используются "жирные" BPL и типа DevExpress и при каждой загрузхке они будут инициализироваться. Это бывает долго." вообще вызывает удивление. Можно подумать, что если код из модуля A перенести в модуль B, то от этого он волшебным быстрее выполняться?! С чего бы?

    Пакет (равно как и exe и DLL) не грузится целиком в память. Страницы кода и данных подгружаются только по мере обращения к ним. Более того, модули из пакета вовсе не инициализируются при его загрузке. Модули инициализируются по запросу вызывающего. К примеру, если exe использует модуль SysUtils, но не Classes и собран с пакетом RTL, то при загрузке пакета rtlXYZ.bpl секция initialization модуля Classes вызвана не будет.

    ОтветитьУдалить
  7. Достаточно серьёзной оптимизации по размеру можно добиться, используя упаковщики исполняемых файлов (EXE и DLL). UPX, например, способен уменьшить размер в 2-3 раза спокойно. Исполняемый файл распаковывается при загрузке достаточно быстро.

    ОтветитьУдалить
  8. Использование упаковщика означает, что файл вынужден грузиться в память целиком. Неупакованный файл может грузиться по частям - по мере выполнения. Но упакованный должен быть полностью загружен при начальной загрузке. Это особенно весело, если мы загружаем 30-мегабайтный файл только для того, чтобы извлечь из него иконку.

    И чем больше файл по размеру, тем больше будет разница. Т.е. чем больше размер файла - тем больше выгоды (по размеру) можно получить от использования упаковщика - но тем больше накладные расходы. За что боролись, на то и напоролись.

    Кроме того, в случае нехватки памяти система более не сможет выбросить память от упакованного плагина, как она сделала бы это для обычной DLL - потому что эта область памяти была модифицирована и, следовательно, отличается от мастер-копии в DLL файле. Это означает, что файл плагина вынужден не только грузиться в память, но и сбрасываться из неё в файл подкачки.

    Итого, принимая во внимание стоимость дискового пространства, в чём всё же смысл использовать упаковщики? Вы теряете ценный ресурс - скорость, чтобы сэкономить то, что и так практически бесплатно (дисковое пространство). Лично мне это не кажется хорошим балансом.

    А если речь про экономию трафика - так ведь файлы и так будут сжаты в установщике. А если установщик не используется, то можно упаковать их в архив для пересылки. Смысла будет больше.

    ОтветитьУдалить
  9. Согласен с Вами, Александр. С побочными эффектами от применения упаковщика знаком. Однако, на объёмах моих проектов (несколько мегабайт в несжатом виде) потери в скорости из-за распаковки почти незаметны. На таких объёмах экономить память (которую мы теряем из-за того, что файл должен полностью грузиться в память) смысла нет.
    Вы просто затронули тему оптимизации по размеру, использование упаковщика - это тоже способ, позволяющий на 50-60% сократить размер исполняемых файлов.

    ОтветитьУдалить
  10. Да, но оптимизация по размеру всегда будет вторичной по отношению к оптимизации по производительности. Особенно это актуально на малобюджетных ПК, а в организациях таких большинство. И здесь момент экономии оперативки бывает весьма критичен: у меня уже были моменты, когда неупакованный 32мб exe'шник нормально запускался, а он же упакованный до 14,5мб просто вешал систему ;-(

    ОтветитьУдалить
  11. Здесь никогда не следует забывать, что мы, программисты, работаем не на себя, а на пользователя, чей компьютер может оказаться далеко не таким "крутым и навороченным", нежели тот, на котором ведется разработка ПО.

    ОтветитьУдалить
  12. Dmitro25
    Применение UPX (по сути, это архиватор) не является оптимизацией. Это скорее зарывание головы в песок, которое, к тому же, требует ещё и дополнительных расходов оперативки.

    ОтветитьУдалить
  13. В статье не упомянут еще один важный плюс компиляции с использованием отдельного пакета vcl - отсутствие в плагинах с формами багов типа "Cannot assign a TFont to a TFont".
    Лично я бы в свою систему плагинов даже скриптовый движек добавил для избежания этой проблемы. И заставил бы всех плагино-писателей интерфейс на скриптах делать, а в нативном коде плагинов только "бизнес логику" реализовывать.

    ОтветитьУдалить
  14. Не оченб понял.

    Проблема типа "Cannot assign a TFont to a TFont" в реализуемой этой серией статей схеме отсутствует как класс - из-за правила "не обмениваться объектами".

    ОтветитьУдалить
  15. А как запретить VCL обмениватся обьектами? Можно конечно понапридумывать кучу хаков, но даже в этом случае любой шаг в сторону - расстрел. Скрипты тем и хороши, что несмотря на то, что текст скрипта загружается из плагина, выполняется он на стороне приложения. Соответственно плагин может быть написан на любой версии делфи, но его интерфейс будет работать без каких либо проблем.

    ОтветитьУдалить
  16. Гм, а как это она сама обменивается объектами?

    ОтветитьУдалить
  17. Есть FindControl, есть глобальные объекты, есть хуки которые кто то куда то вешает. Для стандартных компонентов баги с участием этих штук более менее пофиксили (кроме MDI, в котором ничего особо не исправишь). Но ведь почти ни одна программа не обходится без сторонних компонент. И как в них реализованы подобные механизмы заранее никогда не угадаешь.
    Или к примеру ситуация, когда плагин написан на другой версии делфи. На работе у меня простейшая демка падала с AV при закрытии, дома все ОК.
    Ну и наконец ситуация когда требуется единый стиль/скин плагинов и приложения.
    Имхо некое подобие скриптового движка с возможностью строить сложные интерфейсы обязано присутствовать в нормальной системе плагинов. Если не как обязательный пункт, то хотя бы как опция.

    ОтветитьУдалить
  18. Ну, если нужны однородные скины - то, да, плагин должен просить ядро обслуживать UI, как это описывалось в предыдущей части. Да, скрипты - один из способов такой реализации.

    Но если интерфейс делается изолировано (как это и рекомендовалось в предыдущей части), то подобных проблем не будет.

    А перекладывать решение этой проблемы на пакеты и записывать это в плюс - неправильно. Если только мы не делаем систему, привязанную к компилятору, но зачем нам тогда DLL и интерфейсы?

    ОтветитьУдалить
  19. Torbins, а вы возьметесь за реализацию подобного скриптового движка, с поддержкой vcl/fm + ряда наиболее популярных сторонних пакетов (eurekalog, fastreport, fib+, devexpress и etc из списка разрабатываемых наиболее серьезными конторами)?

    ОтветитьУдалить
  20. Александр Алексеев: Предлагаемая вами система уже привязана к компилятору. Демка с dll собраной в семерке и exe - в XE2: DllTest.rar, после отображения хинта над Button2 получаем AV при закрытии.

    AlekVolsk: А при чем тут наборы компонент? Погоняйте демку PascalScript, там есть пример создания интерфейса. От набора компонент там ничего не зависит.
    В новых делфях можно реализовать более продвинутый вариант с парсингом DFM и привязкой ее к скрипту через TVirtualInterface. Вот тут уже понадобится немного поработать напильником, была бы надобность.

    ОтветитьУдалить
  21. > После отображения хинта над Button2 получаем AV при закрытии

    Хинты используют TApplication, в котором происходит управление специальным потоком HintMouseThread, который иногда не завершается. Хинты в DLL могут вызывать AV при выгрузке библиотеки даже при собранных одной версией Delphi плагине и приложении.
    Например QC85705.

    ОтветитьУдалить
  22. Александр, есть ли возможность все статьи этого цикла пометить общей меткой, чтобы можно было как оглавление книги смотреть?

    ОтветитьУдалить
  23. Да, это было бы весьма неплохо и кстати

    ОтветитьУдалить
  24. > Демка с dll собраной в семерке и exe - в XE2: DllTest.rar, после отображения хинта над Button2 получаем AV при закрытии.

    Воспроизвести не смог :(

    > Хинты используют TApplication, в котором происходит управление специальным потоком HintMouseThread, который иногда не завершается.

    Не совсем верно. Поток HintMouseThread завершается при скрытии хинта (Application.CancelHint -> SetEvent(HintDoneEvent) -> выход из HintMouseThread).

    ОтветитьУдалить
  25. > Александр, есть ли возможность все статьи этого цикла пометить общей меткой, чтобы можно было как оглавление книги смотреть?
    > Да, это было бы весьма неплохо и кстати

    Такая метка уже есть. Отдельных меток на серии я не делаю и не буду делать.

    ОтветитьУдалить
  26. > Не совсем верно. Поток HintMouseThread завершается при скрытии хинта

    Так должно быть, но на практике, о чем говорит QC85705, это иногда не так.
    Баг стабильно проявляется при использовании хинтов в TVirtualStringTree.

    Помогает такой хак перед выгрузкой DLL:

    TApplicationHelper = class helper for TApplication
    procedure _CancelHint;
    end;

    procedure TApplicationHelper._CancelHint;
    begin
    Self.FHintControl := TControl(1);
    Self.CancelHint();
    end;

    ОтветитьУдалить
  27. Здравствуйте!
    Подскажите пожалуйста, как можно передать значение из edText формы плагина при нажатии кнопки на последней?
    Что то я неувидел данного примера

    ОтветитьУдалить
  28. Вообще не понял в чём проблема. Назначьте обработчик OnClick, в нём передавайте текст куда угодно.

    Например, если форма была показана вызовом функции, модально (т.е. как некоторое действие плагина), то вы можете закрыть форму и вернуть содержимое edit в качестве результата. Если же форма постоянно висит на экране (т.е. не модальная), то вызывайте нужную функцию, которая требует данных из edit, в обработчике OnClick. Если плагин знает, кто хочет данные от формы, то он знает и функцию, а, значит, может вызвать её напрямую. Если плагин этого не знает, то кто-то (= тот, кому эти данные нужны) должен ему об этом сказать, причём ещё до показа формы. "Сказать" = передать плагину (т.е. функции показа формы) callback-функцию, которую плагин должен будет вызвать в своём обработчике OnClick.

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку.

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.

Примечание. Отправлять комментарии могут только участники этого блога.