最近更新: 2007-06-04

以 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應用程式操作圖例
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

命名方式跟隨 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 架構的設計彈性與可用性。

樂多舊網址: http://blog.roodo.com/rocksaying/archives/3400303.html