对Swing线程的再思索

对Swing线程的再思索 (上)
srx81 翻译  (参与分:240,专家分:1990)   发表:2003-11-5 上午11:22   更新:2003-11-6 上午9:31   版本:1.0   阅读:1775

by Jonathan Simon
10/24/2003
原文:http://today.java.net/pub/a/today/2003/10/24/swing.html?page=1

不正确的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设计,这个确实很丑陋,我承认。
 
图 1. 基本查询用户界面
用户输入书的标题,作者或者其它条件,然后显示一个结果的列表。下面的代码例子演示了按钮的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() + 
                        '/n' + result);
    }
}

如果你运行这段代码(完整的代码可以在这儿下载),你会立即发现存在一些问题。图2显示了查找运行中的一个屏幕截图。
 
图 2. 在Swing线程中进行查找
注意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() +
                        '/n' + result);
    }
}

这种方法有很多问题。注意final String[][] 。这是一个处理匿名内部类和作用域的不得已的替代。基本上,在匿名内部类中使用的,但在外部环绕类作用域中定义的任何变量都需要定义为final。你可以通过创建一个数组来持有变量解决这个问题。这样的话,你可以创建数组为final的,修改数组中的元素,而不是数组的引用自身。既然我们已经解决这个问题,让我们进入真正的问题所在吧。图3显示了这段代码运行时发生的情况:
 
图 3. 在Swing线程外部进行查找
界面显示了一个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() +
                                '/n' + 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的源代码。

对Swing线程的再思索 (下)
srx81 翻译  (参与分:240,专家分:1990)   发表:2003-11-6 上午9:10   版本:1.0   阅读:1740

by Jonathan Simon
10/24/2003
原文:http://today.java.net/pub/a/today/2003/10/24/swing.html?page=2

上一篇:对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() +
                                "/n" + 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() +
                                    "/n" + 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() +
                                    "/n" + 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更新,数据库管理类负责数据库调用。在这一点上,每一个封装的类都只用关心自己的线程,不用担心系统其它部分如何动作。当然,设计、构建一个事件驱动的客户端也很有用,但是需要花费的时间代价远超过带来的结果系统的灵活性和可维护性的提高。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值