再論Java Swing線程

不正確的Swing線程是運行緩慢、無響應和不穩定的Swing應用的主要原因之一。這是許多原因造成的,從開發人員對Swing單線程模型的誤解,到保證正確的線程執行的困難。即使對Swing線程進行了很多努力,應用線程邏輯也是很難理解和維護的。本文闡述瞭如何在開發Swing應用中使用事件驅動編程,以大大簡化開發、維護,並提供高靈活性。

  背景

  既然我們是要簡化Swing應用的線程,首先讓我們來看看 Swing線程是怎麼工作的,爲什麼它是必須的。Swing API是圍繞單線程模型設計的。這意味着Swing組件必須總是通過同一個線程來修改和操縱。爲什麼採用單線程模型,這有很多原因,包括開發成本和同步 Swing的複雜性--這都會造成一個遲鈍的API。爲了達到單線程模型,有一個專門的線程用於和Swing組件交互。這個線程就是大家熟知的Swing 線程,AWT(有時也發音爲“ought”)線程,或者事件分派線程。在本文的下面的部分,我選用Swing線程的叫法。
既然Swing線程是和 Swing組件進行交互的唯一的線程,它就被賦予了很多責任。所有的繪製和圖形,鼠標事件,組件事件,按鈕事件,和所有其它事件都發生在Swing線程。因爲Swing線程的工作已經非常沉重了,當太多其它工作在Swing線程中進行處理時就會發生問題。會引起這個問題的最常見的位置是在非Swing處理的地方,像發生在一個事件監聽器方法中,比如JButton的ActionListener,的數據庫查找。既然ActionListener的 actionPerformed()方法自動在Swing線程中執行,那麼,數據庫查找也將在Swing線程中執行。這將佔用了Swing的工作,阻止它處理它的其它任務--像繪製,響應鼠標移動,處理按鈕事件,和應用的縮放。用戶以爲應用死掉了,但實際上並不是這樣。在適當的線程中執行代碼對確保系統正常地執行非常重要。

  既然我們已經看到了在適當的線程中執行Swing應用的代碼是多麼重要,現在讓我們如何實現這些線程。我們看看將代碼放入和移出Swing線程的標準機制。在講述過程中,我將突出幾個和標準機制有關的問題和難點。正如我們看到的,大部分的問題都來自於企圖在異步的Swing線程模型上實現同步的代碼模型。從那兒,我們將看到如何修改我們的例子到事件驅動--移植整個方式到異步模型。
  通用Swing線程解決方案

  讓我們以一個最常用的Swing線程錯誤開始。我們將企圖使用標準的技術來修正這個問題。在這個過程中,我們將看到實現正確的Swing線程的複雜性和常見困難。並且,注意在修正這個Swing線程問題中,許多中間的例子也是不能工作的。在例子中,我在代碼失敗的地方以//broken開頭標出。好了,現在,讓我們進入我們的例子吧。

  假設我們在執行圖書查找。我們有一個簡單的用戶界面,包括一個查找文本域,一個查找按鈕,和一個輸出的文本區域。這個接口如圖1所示。不要批評我的UI設計,這個確實很醜陋,我承認。


  用戶輸入書的標題,作者或者其它條件,然後顯示一個結果的列表。下面的代碼例子演示了按鈕的ActionListener在同一個線程中調用 lookup()方法。在這些例子中,我使用了thread.sleep()休眠5秒來作爲一個佔位的外部查找。線程休眠的結果等同於一個耗時5秒的同步的服務器調用。
private void searchButton_actionPerformed()
{
 outputTA.setText("Searching for: " + searchTF.getText());
 //Broken!! Too much work in the Swing
 thread String[] results = lookup(searchTF.getText());
 outputTA.setText("");
 for (int i = 0; i < results.length; i++)
 {
  String result = results[i];
  outputTA.setText(outputTA.getText() + ´´ ´´ + result);
  }
}
  如果你運行這段代碼(完整的代碼可以在這兒下載),你會立即發現存在一些問題。圖2顯示了查找運行中的一個屏幕截圖。


  注意Go按鈕看起來是被按下了。這是因爲actionPerformed方法通知了按鈕繪製爲非按下外觀,但是還沒有返回。你也會發現要查找的字串 “abcde”並沒有出現在文本區域中。searchButton_actionPerformed的第1行代碼將文本區域設置爲要查找的字串。但是,注意Swing重畫並不是立即執行的。而是把重畫請求放置到Swing事件隊列中等待Swing線程處理。但是這兒,我們因查找處理佔用了Swing線程,所以,它還不能馬上進行重畫。

  要修正這些問題,讓我們把查找操作移入非Swing線程中。我們第一個想到的就是讓整個方法在一個新的線程中執行。這樣作的問題是Swing組件,本例中的文本區域,只能從Swing線程中進行編輯。下面是修改後的 searchButton_actionPerformed方法:
private void searchButton_actionPerformed()
{
 outputTA.setText("Searching for: " + searchTF.getText());
 //the String[][] is used to allow access to
 // setting the results from an inner class
 final String[][] results = new String[1][1];
 new Thread()
 {
  public void run()
  {
   results[0] = lookup(searchTF.getText());
   }
  }.start();
 outputTA.setText("");
 for (int i = 0; i < results[0].length; i++)
  {
  String result = results[0][i];
  outputTA.setText(outputTA.getText() + ´´ ´´ + result);
  }
}
  這種方法有很多問題。注意final String[][] 。這是一個處理匿名內部類和作用域的不得已的替代。基本上,在匿名內部類中使用的,但在外部環繞類作用域中定義的任何變量都需要定義爲final。你可以通過創建一個數組來持有變量解決這個問題。這樣的話,你可以創建數組爲final的,修改數組中的元素,而不是數組的引用自身。既然我們已經解決這個問題,讓我們進入真正的問題所在吧。圖3顯示了這段代碼運行時發生的情況:


  界面顯示了一個null,因爲顯示代碼在查找代碼完成前被處理了。這是因爲一旦新的線程啓動了,代碼塊繼續執行,而不是等待線程執行完畢。這是那些奇怪的併發代碼塊中的一個,下面將把它編寫到一個方法中使其能夠真正執行。

  在SwingUtilities類中有兩個方法可以幫助我們解決這些問題:invokerLater()和invokeAndWait()。每一個方法都以一個Runnable作爲參數,並在Swing線程中執行它。invokeAndWait()方法阻塞直到Runnnable執行完畢;invokeLater()異步地執行Runnable。invokeAndWait()一般不贊成使用,因爲它可能導致嚴重的線程死鎖,對你的應用造成嚴重的破壞。所以,讓我們把它放置一邊,使用invokeLater()方法。
要修正最後一個變量變量scooping和執行順序的問題,我們必須將文本區域的getText()和setText()方法調用移入一個Runnable,只有在查詢結果返回後再執行它,並且在Swing線程中執行。我們可以這樣作,創建一個匿名Runnable傳遞給invokeLater(),包括在新線程的Runnable後的文本區域操作。這保證了 Swing代碼不會在查找結束之前執行。下面是修正後的代碼:

private void searchButton_actionPerformed()
{
 outputTA.setText("Searching for: " + searchTF.getText());
 final String[][] results = new String[1][1];
 new Thread()
 {
  public void run()
  { //get results.
   results[0] = lookup(searchTF.getText())
   // send runnable to the Swing thread
   // the runnable is queued after the
   // results are returned
   SwingUtilities.invokeLater(
    new Runnable()
    {
     public void run()
     {
      // Now we´´re in the Swing thread
      outputTA.setText("");
      for (int i = 0; i < results[0].length; i++)
      {
       String result = results[0][i];
       outputTA.setText( outputTA.getText() + ´´ ´´ + result);
       }
      }
    }
   );
  }
 }.start();}
 這可以工作,但是這樣做令人非常頭痛。我們不得不對通過匿名線程執行的順序,我們還不得不處理困難的scooping問題。問題並不少見,並且,這只是一個非常簡單的例子,我們已經遇到了作用域,變量傳遞,和執行順序等一系列問題。相像一個更復雜的問題,包含了幾層嵌套,共享的引用和指定的執行順序。這種方法很快就失控了。
  問題

  我們在企圖強制通過異步模型進行同步執行--企圖將一個方形的螺栓放到一個圓形的空中。只有我們嘗試這樣做,我們就會不斷地遭遇這些問題。從我的經驗,可以告訴你這些代碼很難閱讀,很難維護,並且易於出錯。

  這看起來是一個常見的問題,所以一定有標準的方式來解決,對嗎?出現了一些框架用於管理Swing的複雜性,所以讓我們來快速預覽一下它們可以做什麼。

  一個可以得到的解決方案是Foxtrot,一個由Biorn Steedom寫的框架,可以在SourceForge上獲取。它使用一個叫做Worker的對象來控制非Swing任務在非Swing線程中的執行,阻塞直到非Swing任務執行完畢。它簡化了Swing線程,允許你編寫同步代碼,並在Swing線程和非Swing線程直接切換。下面是來自它的站點的一個例子:
public void actionPerformed(ActionEvent e)
{
 button.setText("Sleeping...");
 String text = null;
 try
 {
  text = (String)Worker.post(new Task() {
   public Object run() throws Exception {
    Thread.sleep(10000); return "Slept !";
    }
   }
  );
 }
 catch (Exception x) ... button.setText(text); somethingElse();}
  注意它是如何解決上面的那些問題的。我們能夠非常容易地在Swing線程中傳入傳出變量。並且,代碼塊看起來也很正確--先編寫的先執行。但是仍然有一些問題障礙阻止使用從準同步異步解決方案。Foxtrot中的一個問題是異常管理。使用Foxtrot,每次調用Worker必須捕獲 Exception。這是將執行代理給Worker來解決同步對異步問題的一個產物。

  同樣以非常相似的方式,我此前也創建了一個框架,我稱它爲鏈接運行引擎(Chained Runnable Engine),同樣也遭受來自類似同步對異步問題的困擾。使用這個框架,你將創建一個將被引擎執行的Runnable的集合。每一個Runnable都有一個指示器告訴引擎是否應該在Swing線程或者另外的線程中執行。引擎也保證Runnable以正確的順序執行。所以Runnable #2將不會放入隊列直到Runnable #1執行完畢。並且,它支持變量以HashMap的形式從Runnable到Runnable傳遞。

  表面上,它看起來解決了我們的主要問題。但是當你深入進去後,同樣的問題又冒出來了。本質上,我們並沒有改變上面描述的任何東西--我們只是將複雜性隱藏在引擎的後面。因爲指數級增長的Runnable而使代碼編寫將變得非常枯燥,也很複雜,並且這些Runnable常常相互耦合。Runnable之間的非類型的HashMap變量傳遞變得難於管理。問題的列表還有很多。

  在編寫這個框架之後,我意識到這需要一個完全不同的解決方案。這讓我重新審視了問題,看別人是怎麼解決類似的問題的,並深入的研究了Swing的源代碼。
  解決方案:事件驅動編程

  所有前面的這些解決方案都存在一個共同的致命缺陷--企圖在持續地改變線程的同時表示一個任務的功能集。但是改變線程需要異步的模型,而線程異步地處理 Runnable。問題的部分原因是我們在企圖在一個異步的線程模型之上實現一個同步的模型。這是所有Runnable之間的鏈和依賴,執行順序和內部類 scooping問題的根源。如果我們可以構建真正的異步,我們就可以解決我們的問題並極大地簡化Swing線程。
在這之前,讓我們先列舉一下我們要解決的問題:

  1. 在適當的線程中執行代碼

  2. 使用SwingUtilities.invokeLater()異步地執行.

  異步地執行導致了下面的問題:

  1. 互相耦合的組件

  2. 變量傳遞的困難

  3. 執行的順序

  讓我們考慮一下像Java消息服務(JMS)這樣的基於消息的系統,因爲它們提供了在異步環境中功能組件之間的鬆散耦合。消息系統觸發異步事件,正如在 Enterprise Integration Patterns 中描述的。感興趣的參與者監聽該事件,並對事件做成響應--通常通過執行它們自己的一些代碼。結果是一組模塊化的,鬆散耦合的組件,組件可以添加到或者從系統中去除而不影響到其它組件。更重要的,組件之間的依賴被最小化了,而每一個組件都是良好定義的和封裝的--每一個都僅對自己的工作負責。它們簡單地觸發消息,其它一些組件將響應這個消息,並對其它組件觸發的消息進行響應。

  現在,我們先忽略線程問題,將組件解耦並移植到異步環境中。在我們解決了異步問題後,我們將回過頭來看看線程問題。正如我們所將要看到的,那時解決這個問題將非常容易。

  讓我們還拿前面引入的例子,並把它移植到基於事件的模型。首先,我們把lookup調用抽象到一個叫LookupManager的類中。這將允許我們將所有UI類中的數據庫邏輯移出,並最終允許我們完全將這兩者脫耦。下面是LookupManager類的代碼:
class LookupManager {
 private String[] lookup(String text) {
  String[] results = ... // database lookup code return results
 }
}

  現在我們開始向異步模型轉換。爲了使這個調用異步化,我們需要抽象調用的返回。換句話,方法不能返回任何值。我們將以分辨什麼相關的動作是其它類所希望知道的開始。在我們這個例子中最明顯的事件是搜索結束事件。所以讓我們創建一個監聽器接口來響應這些事件。該接口含有單個方法 lookupCompleted()。下面是接口的定義:
interface LookupListener { public void lookupCompleted(Iterator results);}

  遵守Java的標準,我們創建另外一個稱作LookupEvent的類包含結果字串數組,而不是到處直接傳遞字串數組。這將允許我們在不改變 LookupListener接口的情況下傳遞其它信息。例如,我們可以在LookupEvent中同時包括查找的字串和結果。下面是 LookupEvent類:
public class LookupEvent {
 String searchText;
 String[] results;
 public LookupEvent(String searchText) {
  this.searchText = searchText;
 }
 public LookupEvent(String searchText, String[] results) {
  this.searchText = searchText;
  this.results = results;
 }
 public String getSearchText() {
  return searchText;
 }
 public String[] getResults() {
  return results;
 }
}
  注意LookupEvent類是不可變的。這是很重要的,因爲我們並不知道在傳遞過程中誰將處理這些事件。除非我們創建事件的保護拷貝來傳遞給每一個監聽者,我們需要把事件做成不可變的。如果不這樣,一個監聽者可能會無意或者惡意地修訂事件對象,並破壞系統。

  現在我們需要在LookupManager上調用lookupComplete()事件。我們首先要在LookupManager上添加一個LookupListener的集合:
List listeners = new ArrayList();

  並提供在LookupManager上添加和去除LookupListener的方法:
public void addLookupListener(LookupListener listener){
 listeners.add(listener);
}
public void removeLookupListener(LookupListener listener){
 listeners.remove(listener);
}

  當動作發生時,我們需要調用監聽者的代碼。在我們的例子中,我們將在查找返回時觸發一個lookupCompleted()事件。這意味着在監聽者集合上迭代,並使用一個LookupEvent事件對象調用它們的lookupCompleted()方法。
  我喜歡把這些代碼析取到一個獨立的方法fire[event-method-name] ,其中構造一個事件對象,在監聽器集合上迭代,並調用每一個監聽器上的適當的方法。這有助於隔離主要邏輯代碼和調用監聽器的代碼。下面是我們的 fireLookupCompleted方法:
private void fireLookupCompleted(String searchText, String[] results){
 LookupEvent event = new LookupEvent(searchText, results);
 Iterator iter = new ArrayList(listeners).iterator();
 while (iter.hasNext()) {
  LookupListener listener = (LookupListener) iter.next();
  listener.lookupCompleted(event);
 }
}
  第2行代碼創建了一個新的集合,傳入原監聽器集合。這在監聽器響應事件後決定在LookupManager中去除自己時將發揮作用。如果我們不是安全地拷貝集合,在一些監聽器應該 被調用而沒有被調用時發生令人厭煩的錯誤。

  下面,我們將在動作完成時調用fireLookupCompleted輔助方法。這是lookup方法的返回查詢結果的結束處。所以我們可以改變lookup方法使其觸發一個事件而不是返回字串數組本身。下面是新的lookup方法:
public void lookup(String text) {
 //mimic the server call delay...
 try {
  Thread.sleep(5000);
 } catch (Exception e){
  e.printStackTrace();
 }
 //imagine we got this from a server
 String[] results = new String[]{"Book one", "Book two", "Book three"};
 fireLookupCompleted(text, results);
}
  現在讓我們把監聽器添加到LookupManager。我們希望當查找返回時更新文本區域。以前,我們只是直接調用setText()方法。因爲文本區域是和數據庫調用一起都在UI中執行的。既然我們已經將查找邏輯從UI中抽象出來了,我們將把UI類作爲一個到LookupManager的監聽器,監聽 lookup事件並相應地更新自己。首先我們將在類定義中實現監聽器接口:
public class FixedFrame implements LookupListener

  接着我們實現接口方法:
public void lookupCompleted(final LookupEvent e) {
 outputTA.setText("");
 String[] results = e.getResults();
 for (int i = 0; i < results.length; i++) {
  String result = results[i];
  outputTA.setText(outputTA.getText() + " " + result);
  }
}
  最後,我們將它註冊爲LookupManager的一個監聽器:
public FixedFrame() {
 lookupManager = new LookupManager();
 //here we register the listener
 lookupManager.addListener(this);
 initComponents();
 layoutComponents();}
  爲了簡化,我在類的構造器中將它添加爲監聽器。這在大多數系統上都允許良好。當系統變得更加複雜時,你可能會重構、從構造器中提煉出監聽器註冊代碼,以允許更大的靈活性和擴展性。

  到現在爲止,你看到了所有組件之間的連接,注意職責的分離。用戶界面類負責信息的顯示--並且僅負責信息的顯示。另一方面,LookupManager 類負責所有的lookup連接和邏輯。並且,LookupManager負責在它變化時通知監聽器--而不是當變化發生時應該具體做什麼。這允許你連接任意多的監聽器。

  爲了演示如何添加新的事件,讓我們回頭添加一個lookup開始的事件。我們可以添加一個稱作 lookupStarted()的事件到LookupListener,我們將在查找開始執行前觸發它。我們也創建一個 fireLookupStarted()事件調用所有LookupListener的lookupStarted()。現在lookup方法如下:
public void lookup(String text) {
  fireLookupStarted(text);
  //mimic the server call delay...
  try {
   Thread.sleep(5000);
  } catch (Exception e){
    e.printStackTrace();
  }
  //imagine we got this from a server
  String[] results = new String[]{"Book one", "Book two", "Book three"};
  fireLookupCompleted(text, results);}
  我們也添加新的觸發方法fireLookupStarted()。這個方法等同於fireLookupCompleted()方法,除了我們調用監聽器上的lookupStarted()方法,並且該事件也不包含結果集。下面是代碼:
private void fireLookupStarted(String searchText){
 LookupEvent event = new LookupEvent(searchText);
 Iterator iter = new ArrayList(listeners).iterator();
 while (iter.hasNext()) {
  LookupListener listener = (LookupListener) iter.next();
  listener.lookupStarted(event);
 }
}
  最後,我們在UI類上實現lookupStarted()方法,設置文本區域提示當前搜索的字符串。
public void lookupStarted(final LookupEvent e) {
 outputTA.setText("Searching for: " + e.getSearchText());
}
  這個例子展示了添加新的事件是多麼容易。現在,讓我們看看展示事件驅動脫耦的靈活性。我們將通過創建一個日誌類,當一個搜索開始和結束時在命令行中輸出信息來演示。我們稱這個類爲Logger。下面是它的代碼:
public class Logger implements LookupListener {
 public void lookupStarted(LookupEvent e) {
  System.out.println("Lookup started: " + e.getSearchText());
 }
 public void lookupCompleted(LookupEvent e) {
  System.out.println("Lookup completed: " + e.getSearchText() + " " + e.getResults());
 }
}
  現在,我們添加Logger作爲在FixedFrame構造方法中的LookupManager的一個監聽器。
public FixedFrame() {
 lookupManager = new LookupManager();
 lookupManager.addListener(this);
 lookupManager.addListener(new Logger());
 initComponents();
 layoutComponents();
}
  現在你已經看到了添加新的事件、創建新的監聽器--向您展示了事件驅動方案的靈活性和擴展性。你會發現隨着你更多地開發事件集中的程序,你會更加嫺熟地在你的應用中創建通用動作。像其它所有事情一樣,這只需要時間和經驗。看起來在事件模型上已經做了很多研究,但是你還是需要把它和其它替代方案相比較。考慮開發時間成本;最重要的,這是一次性成本。一旦你創建好了監聽器模型和它們的動作,以後向你的應用中添加監聽器將是小菜一蝶。
  線程

  到現在,我們已經解決了上面的異步問題;通過監聽器使組件脫耦,通過事件對象傳遞變量,通過事件產生和監聽器的註冊的組合決定執行的順序。讓我們回到線程問題,因爲正是它把我們帶到了這兒。實際上非常容易:因爲我們已經有了異步功能的監聽器,我們可以簡單地讓監聽器自己決定它們應該在哪個線程中執行。考慮UI類和LookupManager的分離。UI類基於事件,決定需要什麼處理。並且,該類也是Swing,而日誌類不是。所以讓UI類負責決定它應該在什麼線程中執行將更加有意義。所以,讓我們再次看看UI類。下面是沒有線程的lookupCompleted()方法:
public void lookupCompleted(final LookupEvent e) {
 outputTA.setText("");
 String[] results = e.getResults();
 for (int i = 0; i < results.length; i++) {
  String result = results[i];
  outputTA.setText(outputTA.getText() + " " + result);
 }
}
  我們知道這將在非Swing線程中調用,因爲該事件是直接在LookupManager中觸發的,這將不是在Swing線程中執行。因爲所有的代碼功能上都是異步的(我們不必等待監聽器方法允許結束後才調用其它代碼),我們可以通過SwingUtilities.invokeLater()將這些代碼改道到Swing線程。下面是新的方法,傳入一個匿名Runnable到SwingUtilities.invokeLater():
public void lookupCompleted(final LookupEvent e) {
 //notice the threading
 SwingUtilities.invokeLater( new Runnable() {
  public void run() {
   outputTA.setText("");
   String[] results = e.getResults();
   for (int i = 0; i < results.length; i++) {
    String result = results[i];
    outputTA.setText(outputTA.getText() + " " + result);
   }
  }
 }
);
}
  如果任何LookupListener不是在Swing線程中執行,我們可以在調用線程中執行監聽器代碼。作爲一個原則,我們希望所有的監聽器都迅速地接到通知。所以,如果你有一個監聽器需要很多時間來處理自己的功能,你應該創建一個新的線程或者把耗時代碼放入ThreadPool中等待執行。

  最後的步驟是讓LookupManager在非Swing線程中執行lookup。當前,LookupManager是在JButton的 ActionListener的Swing線程中被調用的。現在是我們做出決定的時候,或者我們在JButton的ActionListener中引入一個新的線程,或者我們可以保證lookup自己在非Swing線程中執行,自己開始一個新的線程。我選擇儘可能和Swing類貼近地管理Swing線程。這有助於把所有Swing邏輯封裝在一起。如果我們把Swing線程邏輯添加到LookupManager,我們將引入了一層不必要的依賴。並且,對於 LookupManager在非Swing線程環境中孵化自己的線程是完全沒有必要的,比如一個非繪圖的用戶界面,在我們的例子中,就是Logger。產生不必要的新線程將損害到你應用的性能,而不是提高性能。LookupManager執行的很好,不管Swing線程與否--所以,我喜歡把代碼集中在那兒。

  現在我們需要將JButton的ActionListener執行lookup的代碼放在一個非Swing線程中。我們創建一個匿名的Thread,使用一個匿名的Runnable執行這個lookup。
private void searchButton_actionPerformed() {
 new Thread(){
  public void run() {
   lookupManager.lookup(searchTF.getText());
  }
 }.start();
}
  這就完成了我們的Swing線程。簡單地在actionPerformed()方法中添加線程,確保監聽器在新的線程中執行照顧到了整個線程問題。注意,我們不用處理像第一個例子那樣的任何問題。通過把時間花費在定義一個事件驅動的體系,我們在和Swing線程相關處理上節約了更多的時間。

  結論

  如果你需要在同一個方法中執行大量的Swing代碼和非Swing代碼,很容易將某些代碼放錯位置。事件驅動的方式將迫使你將代碼放在它應該在的地方--它僅應該在的地方。如果你在同一個方法中執行數據庫調用和更新UI組件,那麼你就在一個類中寫入了太多的邏輯。分析你係統中的事件,創建底層的事件模型將迫使你將代碼放到正確的地方。將費時的數據庫調用代碼放在非UI類中,也不要在非UI組件中更新UI組件。採用事件驅動的體系,UI負責UI更新,數據庫管理類負責數據庫調用。在這一點上,每一個封裝的類都只用關心自己的線程,不用擔心繫統其它部分如何動作。當然,設計、構建一個事件驅動的客戶端也很有用,但是需要花費的時間代價遠超過帶來的結果系統的靈活性和可維護性的提高。 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章