Выпуск 21. Ноябрь 2014

Тестирование в Perl. Практика | Содержание | Еще немного об асинхронном программировании на Anyevent

GUI-приложения на Perl с помощью wxWidgets

Рассмотрены основы написания GUI-приложений с помощью wxWidgets

Написание GUI-приложений на Perl ничем не отличается от написания на других динамических языках. Все зависит от качества оберток над оригинальными библиотеками и их своевременного обновления.

Почему wxWidgets?

wxWidgets является по-настоящему кросс-платформенным GUI-инструментарием. В отличие от, например, Qt, который тоже вроде бы является кросс-платформенным, wxWidget на запускаемых платформах использует родные библиотеки, а не пытается их эмулировать. Таким образом приложения выглядят не cовсем одинаково, но совершенно привычно для своей графической оболочки.

Более того, поддержка у wxWidgets для Perl является полной, качественной и современной. Например, популярный редактор Padre тоже написан с помощью wxWidgets. Имея под рукой большой открытый и свободный проект, всегда можно подглядеть, как реализуются те или иные виджеты, можно на конкретном примере быстро научиться.

Настройка окружения

Для разработки я использую платформу GNU/Linux. Поэтому дальше приведена установка wxWidgets именно для моей ОС. Проще всего устанавливать библиотеки из дистрибутивных пакетов, на примере Debian:

# apt-get install libwx-perl

Конечно, всегда можно установить и с CPAN, но тогда придется много компилировать и можно наткнуться на несовместимость версий.

$ cpanm Wx

Первое приложение

Стандартным первым приложением будет окно на котором написано “Hello, world!”:

use strict;
use warnings;

use Wx;

my $app = Wx::SimpleApp->new;
my $frame = Wx::Frame->new(undef, -1, "Hello, world!");
$frame->Show;
$app->MainLoop;

Wx::Frame это окно, у которого может быть изменен размер, у него может быть статусная строка, а также меню. Т.е. это окно общего назначения. Кроме Wx::Frame существует также Wx::Dialog и другие. Для добавления визуальных элементов можно воспользоваться различными классами и методами wxWidgets. Например, чтобы добавить кнопку, которая закрывает приложение, напишем следующий код:

use strict;
use warnings;

use Wx;
use Wx::Event;

my $app    = Wx::SimpleApp->new;
my $frame  = Wx::Frame->new(undef, -1, "Hello, world!");

my $button = Wx::Button->new($frame, -1, 'Close');
Wx::Event::EVT_BUTTON($frame, $button, sub { $frame->Close(1) });

$frame->Show;
$app->MainLoop;

Все методы, события и т.д. хорошо описаны в документации wxWidgets. Не будем на этом подробно останавливаться. Рассмотрим общий подход в написании GUI-приложений.

Генераторы XML-интерфейса

Для небольшого приложения это вполне допустимо, также как и для простого Perl-скрипта можно все записать в один файл. Однако это очень неудобно для больших приложений с большим количеством окон, диалогов, сложной логикой.

Для графических приложений применяют тот же подход, что и для веб-приложений — MVC, или разделение бизнес-логики, отображения и управления. Так же можно поступить и с GUI-программой.

Для построения интерфейса лучше воспользоваться специальными программами, генерирующими XML-настройки (XRC-файлы), которые, будучи загруженными в приложении, преобразуются в графический интерфейс. Это гораздо легче поддерживать и проще изменять.

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

Например, XRC-файл для предыдущего приложения может выглядеть так:

<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<resource xmlns="http://www.wxwindows.org/wxxrc" version="2.3.0.1">
    <object class="wxFrame" name="MainFrame">
        <style>wxDEFAULT_FRAME_STYLE|wxTAB_TRAVERSAL</style>
        <size>500,300</size>
        <title></title>
        <centered>1</centered>
        <aui_managed>0</aui_managed>
        <object class="wxButton" name="CloseButton">
            <label>Close</label>
            <default>0</default>
        </object>
    </object>
</resource>

Теперь загрузим эту конфигурацию в приложении:

use strict;
use warnings;

use Wx;
use Wx::XRC;
use Wx::Event;

my $app = Wx::SimpleApp->new;

my $xrc = Wx::XmlResource->new();
$xrc->InitAllHandlers();
$xrc->Load('example.xrc');

my $frame = Wx::Frame->new;
$xrc->LoadFrame($frame, undef, 'MainFrame');

Wx::Event::EVT_BUTTON(
    $frame,
    Wx::XmlResource::GetXRCID('CloseButton'),
    sub { $frame->Close(1) }
);

$frame->Show;
$app->MainLoop;

Для того, чтобы достать объект Wx::Frame, используем метод LoadFrame:

my $frame = Wx::Frame->new;
$xrc->LoadFrame($frame, undef, 'MainFrame');

А для того, чтобы достать объект кнопки, вначале получаем ее id по имени:

Wx::XmlResource::GetXRCID('CloseButton')

А затем привязываем событие.

Всегда стоит понятным образом называть все элементы, а не оставлять что-то вроде m_Button_1, это сделает управляющий код существенно понятнее.

Разделение на классы

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

Разделим наше приложение на несколько составляющих.

Основной класс приложения

package MyApp;

use strict;
use warnings;

use base 'Wx::SimpleApp';

use Wx;
use Wx::XRC;
use MyMainFrame;

sub OnInit {
    my $self = shift;

    my $xrc = Wx::XmlResource->new();
    $xrc->InitAllHandlers();
    $xrc->Load('app.xrc');

    my $frame = MyMainFrame->new;
    $xrc->LoadFrame($frame, undef, 'MainFrame');

    $frame->Show;
}

1;

Задача основого класса выполнить загрузку настроек, создать основное окно и показать его.

Класс основного окна

package MyMainFrame;

use strict;
use warnings;

use base 'Wx::Frame';

use Wx;
use Wx::Event;
use Wx::XRC;

sub new {
    my $self = shift->SUPER::new(@_);

    Wx::Event::EVT_BUTTON(
        $self,
        Wx::XmlResource::GetXRCID('CloseButton'),
        sub { $self->Close(1) }
    );

    return $self;
}

1;

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

Скрипт запуска

Скрипт запуска app.pl является входной точкой для запуска приложения.

#!/usr/bin/env perl

use strict;
use warnings;

use Wx;
use MyApp;

MyApp->new->MainLoop;

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

app.pl
app.xrc
lib/
    MyApp.pm
    MyMainFrame.pm

Можно у каждого класса сделать какой-то уникальный префикс, например GUI, чтобы их отличать от обычных Perl-классов, которые будут заниматься логикой приложения.

Генераторы Perl-кода

wxFormBuilder, как было уже указано, может генерировать XML-представление, однако он позволяет гененировать и код. К сожалению, из коробки нет поддержки генерации Perl-кода. Однако, разработчики Padre реализовали собственный генератор Perl-кода из файла проекта wxFormBuilder. Для этого потребуются два модуля: FBP и FBP::Perl. Написав простейший скрипт для создания классов, получим на выходе дерево файлов, где каждый — это отдельное окно:

#!/usr/bin/env perl

use strict;
use warnings;

my ($project_file, $root_dir) = @ARGV;
die "Usage: <project_file> <dir>\n" unless $project_file && $root_dir;
die "'$project_file' does not exist\n" unless -e $project_file;
die "'$root_dir' does not exist\n"     unless -d $root_dir;

use File::Slurp qw(write_file);
use File::Spec::Functions qw(catfile);
use File::Path qw(make_path);
use File::Basename qw(dirname);
use FBP;
use FBP::Perl;

my $fbp = FBP->new;
$fbp->parse_file($project_file);

my $project = $fbp->project;

my $generator = FBP::Perl->new(project => $project);

foreach my $form ($project->forms) {
    my $content = $generator->flatten($generator->frame_class($form));

    write_class($form->name, $content);
}

foreach my $dialog ($project->dialogs) {
    my $content = $generator->flatten($generator->dialog_class($dialog));

    write_class($dialog->name, $content);
}

sub write_class {
    my ($class_name, $content) = @_;

    my $path = join('/', split /::/, $class_name) . '.pm';
    $path = catfile($root_dir, $path);

    my $dir = dirname($path);
    make_path($dir);

    write_file $path, $content;
}

Плюс этого подхода еще и в том, что нет необходимости вручную привязывать сигналы к элементам. Указав в wxFormBuilder название обработчика, PBP::Perl правильно сгенерирует соответствующий код. Вот как, например, выглядит класс основной формы:

package MyApp::FBP::MainFrame;

use 5.008005;
use utf8;
use strict;
use warnings;
use Wx 0.98 ':everything';

our $VERSION = '0.01';
our @ISA     = 'Wx::Frame';

sub new {
    my $class  = shift;
    my $parent = shift;

    my $self = $class->SUPER::new(
        $parent,
        -1,
        '',
        wxDefaultPosition,
        [ 500, 300 ],
        wxDEFAULT_FRAME_STYLE | wxTAB_TRAVERSAL,
    );

    $self->{CloseButton} = Wx::Button->new(
        $self,
        -1,
        "Close",
        wxDefaultPosition,
        wxDefaultSize,
    );

    Wx::Event::EVT_BUTTON(
        $self,
        $self->{CloseButton},
        sub {
            shift->OnClick(@_);
        },
    );

    my $bSizer1 = Wx::BoxSizer->new(wxVERTICAL);
    $bSizer1->Add( $self->{CloseButton}, 0, wxALL, 5 );

    $self->SetSizer($bSizer1);
    $self->Layout;

    return $self;
}

sub OnClick {
    warn 'Handler method OnClick for event CloseButton.OnButtonClick not implemented';
}

1;

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

Вот так выглядит реализация обработчика события OnClick в дочернем классе:

package MyApp::MainFrame;

use strict;
use warnings;

use base 'MyApp::FBP::MainFrame';

sub OnClick {
    my $self = shift;

    $self->Close(1);
}

1;

Проект с использованием сгенерированных классов выглядит следующим образом:

app.pl
lib/
    MyApp/
        FBP/
            MainFrame.pm
        MainFrame.pm
    MyApp.pm
generate.pl
simple.fbp

simple.fbp это файл проекта wxFormBuilder, а generate.pl — скрипт для генерации.

Модуль MyApp.pm придется тоже написать вручную, но его код предельно прост:

package MyApp;

use strict;
use warnings;

use base 'Wx::SimpleApp';

use MyApp::MainFrame;

sub OnInit {
    my $self = shift;

    my $frame = MyApp::MainFrame->new;

    $frame->Show;
}

1;

А app.pl остается тем же, что и в случае использования XML-представления.

Разделение логики

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

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

Впрочем, тестировать графические интерфейсы тоже можно и нужно, как например, с помощью Selenium тестируют и веб-приложения. Для X11 есть набор инструментов Xnee, который позволяет записывать сценарии, а затем выполнять их в автоматическом режиме.

Блокировки

Если писать серьезные приложения на wxWidgets, в скором времени вы столкнетесь с тем, что при выполнении требующих времени задач активное окно блокируется и как будто замирает. Все дело в блокировке. Во время выполнения вашего кода wxWidgets ждет, пока он закончится.

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

Кроме потоков возможно использование асинхронных фреймворков по типу POE. На Linux возможно использование wxGTK, а вместе с ним AnyEvent. В этом случае придется писать событийно-ориентированный код со всеми преимуществами и недостатками последнего.

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

Выполнение задач в отдельном потоке

Самый распространенный и простой способ выполнить задачу в фоновом режиме — использовать потоки. Чтобы использовать потоки с Wx, необходимо, во-первых, убедиться, что perl собран с поддержкой threads:

perl -V | grep threads

А во-вторых, подключить прагму threads ДО подключения Wx (это стоит сделать также в скрипте запуска приложения):

use threads;
use Wx;

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

package MyApp::MainFrame;

use strict;
use warnings;
use threads;
use threads::shared;

use base 'MyApp::FBP::MainFrame';

my $DONE_EVENT : shared = Wx::NewEventType;

sub OnClick {
    my $self = shift;

    Wx::Event::EVT_COMMAND($self, -1, $DONE_EVENT, \&done);

    threads->create(\&work, $self);
}

sub work {
    my $handler = shift;

    # do something

    my @result : shared = ...;

    my $threvent = Wx::PlThreadEvent->new(-1, $DONE_EVENT, \@result);
    Wx::PostEvent($handler, $threvent);

    threads->detach;
    threads->exit;
}

sub done {
    my $self = shift;
    my ($event) = @_;

    my $result = $event->GetData;

    # do something
    ...
}

1;

Переменные с тегом shared необходимы для обмена данными между потоками, так как потоки выполняются одновременно, и использование обычных переменных небезопасно. Wx сам проследит, что переменные объявлены как shared, обезопасив таким образом программиста.

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

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

Пример приложения

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

Модель

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

package MyApp::ModulesFetcher;

use strict;
use warnings;

use JSON ();
use LWP::UserAgent;

my $METACPAN = 'http://api.metacpan.org/v0';

sub new {
    my $class = shift;

    my $self = {};
    bless $self, $class;

    return $self;
}

sub fetch {
    my $self = shift;

    my $ua = LWP::UserAgent->new;

    my $response = $ua->post(
        "$METACPAN/release/_search",
        Content => JSON::encode_json(
            {
                query => {
                    filtered => {
                        query  => {"match_all" => {}},
                        filter => {
                            term => {'release.authorized' => \1},
                        }
                    }
                },
                fields => ['distribution'],
                size => 10,
                from => 0,
                sort => [{date => 'desc'}],
            }
        )
    );

    return () unless $response->is_success;

    my $res = JSON::decode_json($response->decoded_content);

    my @distributions = @{$res->{hits}->{hits}};

    return map { $_->{fields}->{distribution} } @distributions;
}

1;

Более подробно о том, как работать с API MetaCPAN, можно почитать в документации. Там же есть множество примеров.

Отображение

Графическое приложение будет представлять собой одно единственное окно с кнопкой Fetch и списком. При нажатии на кнопку в отдельном потоке создаем объект класса ModulesFetcher и возвращаем результат. Главное графическое приложение обрабатывает результат и заполняет список.

Скелет всего приложения уже описывался нами выше, поэтому здесь покажем только пример использования стороннего модуля:

package MyApp::MainFrame;

use strict;
use warnings;
use threads;
use threads::shared;

use base 'MyApp::FBP::MainFrame';

use MyApp::ModulesFetcher;

my $DONE_EVENT : shared = Wx::NewEventType;

sub OnClick {
    my $self = shift;

    Wx::Event::EVT_COMMAND($self, -1, $DONE_EVENT, \&done);

    threads->create(\&work, $self);
}

sub work {
    my $handler = shift;

    my $fetcher = MyApp::ModulesFetcher->new;
    my @modules = $fetcher->fetch;

    my @result : shared = @modules;

    my $threvent = Wx::PlThreadEvent->new(-1, $DONE_EVENT, \@result);
    Wx::PostEvent($handler, $threvent);

    threads->detach;
    threads->exit;
}

sub done {
    my $self = shift;
    my ($event) = @_;

    my $modules = $event->GetData;

    my $list_ctrl = $self->{ModuleListCtrl};
    $list_ctrl->ClearAll;

    foreach my $module (reverse @$modules) {
        $list_ctrl->InsertStringItem(0, $module);
    }

}

1;

В методе work вызывается метод fetch на объекте модели, затем результат преобразуется в shared-переменную, и создается сигнал главному окну с передачей результата.

В методе done запрашиваем полученные данные у события с помощью метода GetData и затем в цикле добавляем их в объект списка. Перед этим не забываем очистить старые данные.

Выводы

Писать графические приложения на Perl можно вполне успешно. С помощью библиотеки wxWidgets это еще и просто. Правильно разделяя модель и отображение, можно добиться переносимости между различными графическими библиотеками, а также упростить реализацию.

Если читателям интересны подобные статьи, в планах есть написание аналогичного примера с помощью библиотеки Gtk. Жду ваших комментариев.

Олеся Кузьмина


Тестирование в Perl. Практика | Содержание | Еще немного об асинхронном программировании на Anyevent
Нас уже 1393. Больше подписчиков — лучше выпуски!

Комментарии к статье