以 PHP-GTK + Glade 設計桌面應用程式 - 混合 Web 應用程式的 MVC 架構敏捷途徑
我們一般對 PHP 的印象是:寫 Web 應用程式的工具。其實它也可以作為單純的解譯器運行一般的本地程式, PHP 稱此運行模式為 CLI mode。若進一步結合 PHP-GTK 擴充模組 (關於 PHP-GTK 的安裝,請參考《Glade/GTK2 for Windows with PHP5 and Ruby 快速安裝指南》) ,我們仍然可以使用 PHP 設計具有圖形使用者介面的桌面應用程式。
本文不只單純地說明如何利用 PHP-GTK + Glade 設計桌面應用程式,更要混合現成的 Web 應用程式,一併為各位展示 MVC 架構所帶來的高度彈性與可用性。
PHP 是現今最流行的 Web 應用程式開發工具之一。與基於 MVC 架構的各種 framework 結合之後,開發速度與軟體品質也逐漸提昇。同時這也意味著,有愈來愈多現成的 Model 與 Control 組件可用。 MVC 架構在概念上就已經將使用者輸出入、資料來源、運算處理等等不同工作隔離了,其體質原本就可適應分散式架構。因此對 MVC 架構而言, Web 應用程式與桌面應用程式唯一的差異是使用者介面不同,也就是 View 不同。一但我們嘗試利用 PHP-GTK + Glade 設計桌面應用程式時,我們理論上將可直接使用那些原本是為 Web 應用程式所開發的 Model 與 Control 組件,僅需專注於基於 GTK 函數庫的圖形使用者介面之設計工作。
基於 MVC 架構的 Web 應用程式
在設計桌面應用程式之前,我們先來看一個基於 MVC 架構所設計的 Web 應用程式。這是一個現成的程式開發資源,我們稍候將直接利用此一現成的資源開發桌面應用程式,而且提供相同的操作功能。

Web 程式相關程式碼
本例中的 URL root 為 /test/FormExample
。
- FormExample.php - Web 應用程式的進入點 (master/loader)。它可呈現使用者的表單輸入網頁,另一方面也是相關 Control 的調用者。它透過 FormExample.php/$controlName/$methodName/$arguments 的格式調用指定的 Control 組件方法,並將回傳值以 JSON 格式輸出。
- UserControl.php - UserControl 類,提供使用者資料操作的服務。在本例中,它只有一個
user_profile($userName)
方法。此方法將根據傳入的使用者名稱查詢使用者的相關資料,若無使用者則回傳false
。本例同時將使用者資料直接建立在 UserControl 類之中,故沒有其他的 Model 。 - view/web/FormExampleView.phtml - View 的網頁樣版。
- view/web/FormExampleView.js - 瀏覽器端的 JavaScript 程式,利用 Ajax 技術。
- view/web/FormExampleView.php - Web 應用程式的 View 。
- view/web/jquery.js - jQuery library。瀏覽器端的 JavaScript 程式所用到的 JavaScript library。
FormExample.php (web ver.)
<?php
require 'view/web/FormExampleView.php';
if (isset($_SERVER['PATH_INFO'])) {
$pathInfoSet = explode('/', $_SERVER['PATH_INFO']);
if (count($pathInfoSet) < 3) {
echo "error...<br/>\n";
}
else {
list(, $controlName, $methodName) = $pathInfoSet;
$methodArgs = array_slice($pathInfoSet, 3);
$controlName = ucfirst($controlName) . 'Control';
require $controlName . '.php';
if (class_exists($controlName)
and ($control = new $controlName)
and method_exists($control, $methodName))
{
$control->results = call_user_func_array(
array($control, $methodName),
$methodArgs
);
echo json_encode($control->results);
}
exit(0);
}
}
$view = new FormExampleView;
$view->show($control);
?>
UserControl.php
<?php
class UserControl {
protected $userTable = array(
'rock' => array('userEmail' => 'rock@example.com', 'userNote' => 'nothing'),
'xman' => array('userEmail' => 'xman@example.com', 'userNote' => 'X')
);
public function user_profile($userName) {
return (array_key_exists($userName, $this->userTable)
? $this->userTable[$userName]
: false
);
}
}
?>
view/web/FormExampleView.phtml
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>Form Example</title>
<?php $this->loadScript($this->urlRoot . '/view/web/jquery.js'); ?>
<!--script type="text/javascript" src="/test/view/web/jquery.js"></script-->
</head>
<body>
<?php $this->loadScript($this->urlRoot . '/view/web/FormExampleView.js'); ?>
<style type="text/css">
#label {
border: none;
width: 300px;
height:100px;
}
</style>
<div id="label">
</div>
<form id="form1" method="post">
<label for="userName">姓名: </label>
<input id="userName" name="uesrName" type="text" size="40"/>
<br/>
<label for="userEmail">E-Mail: </label>
<input id="userEmail" name="userEmail" type="text" size="40"/>
<br/>
<label for="userNote">Note: </label>
<input id="userNote" name="userNote" type="text" size="40"/>
<p>
<button type="button" onclick="on_submit_clicked();">送出</button>
<button type="button" onclick="on_close_clicked();">關閉</button>
</p>
</form>
</body>
</html>
view/web/FormExampleView.php (web ver.)
<?php
class FormExampleView {
public $urlRoot = '/test/FormExample';
public function loadScript($filePath) {
echo '<script type="text/javascript" src="',
$filePath, '"></script>', "\n";
}
public function show() {
require 'view/web/' . __CLASS__ . '.phtml';
}
}
?>
view/web/FormExampleView.js
function on_submit_clicked() {
var userName = jQuery('input#userName').val();
var serviceUri = document.URL + '/user/user_profile/' + userName;
//alert(serviceUri);
$.getJSON(serviceUri, function(json){
//alert("JSON Data: " + json.toString());
var s = '';
if (json) {
alert('exist');
s = 'userName: ' + userName + '<br/>';
for (var p in json) {
s += p + ': ' + json[p] + '<br/>';
}
}
else {
$('#form1 :text').each(function(i){
s += this.id + ': ' + this.value + '<br/>';
});
}
$('#label').html(s);
});
}
function on_close_clicked() {
window.close();
}
設計桌面應用程式
首先,我們需要改造一下作為主控程式的 FormExample.php,使其依 PHP 的運行模式判斷其所需的 View 。
以 PHP 內建的系統常數 PHP_SAPI 便可判斷運行模式。當 PHP_SAPI 之值為 'cli' 時,即為本地程式之運行狀態。
我於原本的 view 目錄下建立了 2 個子目錄,分別為 web 與 desktop ,各自放置 Web 應用程式的 View 源碼檔與桌面應用程式的 View 源碼檔。
FormExample.php (兩用版)
<?php
$viewType = (PHP_SAPI == 'cli' ? 'desktop' : 'web');
require 'view/'.$viewType.'/FormExampleView.php';
if (isset($_SERVER['PATH_INFO'])) {
$pathInfoSet = explode('/', $_SERVER['PATH_INFO']);
if (count($pathInfoSet) < 3) {
echo "error...<br/>\n";
}
else {
list(, $controlName, $methodName) = $pathInfoSet;
$methodArgs = array_slice($pathInfoSet, 3);
$controlName = ucfirst($controlName) . 'Control';
require $controlName . '.php';
if (class_exists($controlName)
and ($control = new $controlName)
and method_exists($control, $methodName))
{
$control->results = call_user_func_array(
array($control, $methodName),
$methodArgs
);
echo json_encode($control->results);
}
exit(0);
}
}
else {
require 'UserControl.php';
$control = new UserControl;
}
$view = new FormExampleView;
$view->show($control);
?>

桌面程式相關程式碼
- view/desktop/FormExampleView.glade - 以 Glade 設計的桌面程式使用者介面。
- view/desktop/View.php - 衍生自 GladeXML 的 View 基礎類。並按 JavaScript 的習慣,提供
alert()
方法。 - view/desktop/FormExampleView.php - 桌面程式的 View 。主要實作各種操作介面的事件處理內容。
view/desktop/FormExampleView.glade
命名方式跟隨 Web 應用程式的慣例。並設主控視窗元件的名稱為 window ,以表示其意義如同 JavaScript 的 window 個體。
元件 | 事件 | 處理方法 |
---|---|---|
window | destroy | Gtk::main_quit |
submit | clicked | on_submit_clicked |
close | clicked | on_close_clicked |
內容過長,不線上顯示。
<?xml version="1.0" standalone="no"?> <!--*- mode: xml -*-->
<!DOCTYPE glade-interface SYSTEM "http://glade.gnome.org/glade-2.0.dtd">
<glade-interface>
<widget class="GtkWindow" id="window">
<property name="width_request">400</property>
<property name="height_request">300</property>
<property name="visible">True</property>
<property name="title" translatable="yes">form example</property>
<property name="type">GTK_WINDOW_TOPLEVEL</property>
<property name="window_position">GTK_WIN_POS_NONE</property>
<property name="modal">False</property>
<property name="resizable">True</property>
<property name="destroy_with_parent">False</property>
<property name="decorated">True</property>
<property name="skip_taskbar_hint">False</property>
<property name="skip_pager_hint">False</property>
<property name="type_hint">GDK_WINDOW_TYPE_HINT_NORMAL</property>
<property name="gravity">GDK_GRAVITY_NORTH_WEST</property>
<property name="focus_on_map">True</property>
<property name="urgency_hint">False</property>
<signal name="destroy" handler="Gtk::main_quit"/>
<child>
<widget class="GtkVBox" id="vbox1">
<property name="visible">True</property>
<property name="homogeneous">False</property>
<property name="spacing">0</property>
<child>
<widget class="GtkLabel" id="label">
<property name="visible">True</property>
<property name="label" translatable="yes">Hello</property>
<property name="use_underline">False</property>
<property name="use_markup">False</property>
<property name="justify">GTK_JUSTIFY_LEFT</property>
<property name="wrap">False</property>
<property name="selectable">False</property>
<property name="xalign">0.5</property>
<property name="yalign">0.5</property>
<property name="xpad">0</property>
<property name="ypad">0</property>
<property name="ellipsize">PANGO_ELLIPSIZE_NONE</property>
<property name="width_chars">-1</property>
<property name="single_line_mode">False</property>
<property name="angle">0</property>
</widget>
<packing>
<property name="padding">0</property>
<property name="expand">True</property>
<property name="fill">True</property>
</packing>
</child>
<child>
<widget class="GtkTable" id="form1">
<property name="border_width">5</property>
<property name="visible">True</property>
<property name="n_rows">3</property>
<property name="n_columns">2</property>
<property name="homogeneous">False</property>
<property name="row_spacing">4</property>
<property name="column_spacing">0</property>
<child>
<widget class="GtkLabel" id="label2">
<property name="visible">True</property>
<property name="label" translatable="yes">姓名: </property>
<property name="use_underline">False</property>
<property name="use_markup">False</property>
<property name="justify">GTK_JUSTIFY_LEFT</property>
<property name="wrap">False</property>
<property name="selectable">False</property>
<property name="xalign">0</property>
<property name="yalign">0.5</property>
<property name="xpad">0</property>
<property name="ypad">0</property>
<property name="ellipsize">PANGO_ELLIPSIZE_NONE</property>
<property name="width_chars">-1</property>
<property name="single_line_mode">False</property>
<property name="angle">0</property>
</widget>
<packing>
<property name="left_attach">0</property>
<property name="right_attach">1</property>
<property name="top_attach">0</property>
<property name="bottom_attach">1</property>
<property name="x_options">fill</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<widget class="GtkLabel" id="label3">
<property name="visible">True</property>
<property name="label" translatable="yes">E-Mail: </property>
<property name="use_underline">False</property>
<property name="use_markup">False</property>
<property name="justify">GTK_JUSTIFY_LEFT</property>
<property name="wrap">False</property>
<property name="selectable">False</property>
<property name="xalign">0</property>
<property name="yalign">0.5</property>
<property name="xpad">0</property>
<property name="ypad">0</property>
<property name="ellipsize">PANGO_ELLIPSIZE_NONE</property>
<property name="width_chars">-1</property>
<property name="single_line_mode">False</property>
<property name="angle">0</property>
</widget>
<packing>
<property name="left_attach">0</property>
<property name="right_attach">1</property>
<property name="top_attach">1</property>
<property name="bottom_attach">2</property>
<property name="x_options">fill</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<widget class="GtkLabel" id="label4">
<property name="visible">True</property>
<property name="label" translatable="yes">Note: </property>
<property name="use_underline">False</property>
<property name="use_markup">False</property>
<property name="justify">GTK_JUSTIFY_LEFT</property>
<property name="wrap">False</property>
<property name="selectable">False</property>
<property name="xalign">0</property>
<property name="yalign">0.5</property>
<property name="xpad">0</property>
<property name="ypad">0</property>
<property name="ellipsize">PANGO_ELLIPSIZE_NONE</property>
<property name="width_chars">-1</property>
<property name="single_line_mode">False</property>
<property name="angle">0</property>
</widget>
<packing>
<property name="left_attach">0</property>
<property name="right_attach">1</property>
<property name="top_attach">2</property>
<property name="bottom_attach">3</property>
<property name="x_options">fill</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<widget class="GtkEntry" id="userName">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="editable">True</property>
<property name="visibility">True</property>
<property name="max_length">0</property>
<property name="text" translatable="yes"></property>
<property name="has_frame">True</property>
<property name="invisible_char">*</property>
<property name="activates_default">False</property>
</widget>
<packing>
<property name="left_attach">1</property>
<property name="right_attach">2</property>
<property name="top_attach">0</property>
<property name="bottom_attach">1</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<widget class="GtkEntry" id="userEmail">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="editable">True</property>
<property name="visibility">True</property>
<property name="max_length">0</property>
<property name="text" translatable="yes"></property>
<property name="has_frame">True</property>
<property name="invisible_char">*</property>
<property name="activates_default">False</property>
</widget>
<packing>
<property name="left_attach">1</property>
<property name="right_attach">2</property>
<property name="top_attach">1</property>
<property name="bottom_attach">2</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<widget class="GtkEntry" id="userNote">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="editable">True</property>
<property name="visibility">True</property>
<property name="max_length">0</property>
<property name="text" translatable="yes"></property>
<property name="has_frame">True</property>
<property name="invisible_char">*</property>
<property name="activates_default">False</property>
</widget>
<packing>
<property name="left_attach">1</property>
<property name="right_attach">2</property>
<property name="top_attach">2</property>
<property name="bottom_attach">3</property>
<property name="y_options"></property>
</packing>
</child>
</widget>
<packing>
<property name="padding">0</property>
<property name="expand">False</property>
<property name="fill">False</property>
</packing>
</child>
<child>
<widget class="GtkHButtonBox" id="hbuttonbox1">
<property name="border_width">5</property>
<property name="visible">True</property>
<property name="layout_style">GTK_BUTTONBOX_SPREAD</property>
<property name="spacing">0</property>
<child>
<widget class="GtkButton" id="submit">
<property name="visible">True</property>
<property name="can_default">True</property>
<property name="can_focus">True</property>
<property name="label" translatable="yes">送出</property>
<property name="use_underline">True</property>
<property name="relief">GTK_RELIEF_NORMAL</property>
<property name="focus_on_click">True</property>
<signal name="clicked" handler="on_submit_clicked"/>
</widget>
</child>
<child>
<widget class="GtkButton" id="close">
<property name="visible">True</property>
<property name="can_default">True</property>
<property name="can_focus">True</property>
<property name="label" translatable="yes">關閉</property>
<property name="use_underline">True</property>
<property name="relief">GTK_RELIEF_NORMAL</property>
<property name="focus_on_click">True</property>
<signal name="clicked" handler="on_close_clicked"/>
</widget>
</child>
</widget>
<packing>
<property name="padding">0</property>
<property name="expand">False</property>
<property name="fill">False</property>
</packing>
</child>
</widget>
</child>
</widget>
</glade-interface>
view/desktop/View.php
設計 View 時,桌面應用程式有許多與 Web 應用程式之設計差異。在 Web 應用程式中,程序員並不需要處理使用者介面的諸多細節。但桌面應用程式就需要決定何時顯現視窗及視覺元件,何時進入等待使用者操作動作的迴圈,以及如何自視覺元件之中取得使用者輸入的資料。將這些細節封裝在此 View 基礎類。
<?php
class View extends GladeXML {
public function __construct($viewFilePath) {
// $viewFilePath is requiried. Let PHP check it.
//echo $viewFilePath, "\n";
$args = func_get_args();
call_user_func_array(array('GladeXML','__construct'), $args);
//parent::__construct(...)
//name of default GtkWindow's instance
$this->window = $this->get_widget('window');
}
public function __get($widgetName) {
return $this->get_widget($widgetName);
}
public function show(&$callBackControl) {
$this->control = $callBackControl;
$this->signal_autoconnect_instance($this);
Gtk::main();
}
public function alert($msg) {
$msgDialog = new GtkMessageDialog($this->window,
Gtk::DIALOG_MODAL, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, $msg);
$rc = $msgDialog->run();
$msgDialog->destroy();
return $rc;
}
protected $inputGetMethodMap = array(
'GtkEntry' => 'get_text',
'GtkFileChooserButton' => 'get_filename'
);
public function &fetchFormValues($formName) {
try {
$widgets = $this->$formName->get_children();
$inputGetMethodMap = &$this->inputGetMethodMap;
foreach ($widgets as $widget) {
$gobj = $this->get_widget($widget->name);
$className = get_class($gobj);
if (array_key_exists($className, $inputGetMethodMap)) {
//echo $widget->name, ': ', get_class($gobj), "\n";
$formValues[$widget->name]
= $gobj->$inputGetMethodMap[$className]();
}
}
}
catch (Exception $e) {
$formValues = false;
}
return $formValues;
}
}
?>
view/desktop/FormExampleView.php
這是桌面程式的 FormExampleView.php 。 Web 版載入 FormExampleView.phtml ,而桌面版則載入 FormExampleView.glade 。至於事件處理方法,在此處的 PHP 程式碼工作,與 Web 版中的 FormExampleView.js 中的 JavaScript 程式所負責的工作相同。
<?php
require 'View.php';
class FormExampleView extends View {
public function __construct() {
//load $path/$className.glade
parent::__construct(
substr(__FILE__, 0, strrpos(__FILE__, DIRECTORY_SEPARATOR))
. DIRECTORY_SEPARATOR . __CLASS__ . '.glade'
);
}
public function on_close_clicked() {
Gtk::main_quit();
}
public function on_submit_clicked() {
$form =& $this->fetchFormValues('form1');
print_r($form);
if (($profile = $this->control->user_profile($form['userName']))) {
foreach ($profile as $k => $v) {
$form[$k] = $v;
}
$this->alert('exist');
}
$s = '';
foreach ($form as $k => $v) {
$s .= "{$k}: {$v}\n";
}
$this->refresh_label($s);
}
public function refresh_label($text) {
$this->label->set_text($text);
}
}
?>
程式碼即文件。本文儘可能地精簡程式碼內容,只使用恰好能展示本文目的之內容。請各位自行閱讀源碼,細細體會 (好吧,我承認我是懶得打字了)。
本文於設計桌面應用程式時,完全使用了原有的 Control 與 Model 組件的程式碼。儘管那些 Control 與 Model 原本是為了 Web 應用程式所設計,但本文仍然未做任何修改便可運用如常。在設計桌面應用程式時所需要做的,就是專注於 View 的內容。本文除了示範如何實作一個封裝 GladeXML 的 View 基礎類,亦適切地展示了 MVC 架構的設計彈性與可用性。