返回介绍

第五章 绘制蓝图

发布于 2022-11-09 22:52:27 字数 112563 浏览 0 评论 0 收藏 0

众所周知,GUI代码是难于阅读和维护的,并且看上去总是一塌糊涂。本章我们将讨论三个驯服你的UI代码的技术。我们将讨论重构代码以使其易于阅读、管理和维护。另一个方面是显示代码和基本的处理对象之间的处理,这也是UI程序员头痛的地方。MVCModel/View/Controller)设计模式是这样一种结构,它保持显示和数据分离以便各自的改变相互不影响。最后,我们讨论对你的wxPython代码进行单元测试的技术。尽管本章的所有例子将使用wxPython,但是其中的多数原则是可以应用到任何UI工具的,代码的设计和体系结构就是所谓的蓝图。一个深思熟虑的蓝图将使得你的应用程序建造起来更简单和更易维护。本章的建议将帮助你为你的程序设计一个可靠的蓝图。

重构如何帮我改进我的代码?

好的程序员为什么也会写出不好的界面或界面代码?这有很多原因。甚至一个简单的用户界面可能都要求很多行来显示屏幕上的所有元素。程序员通常试图用单一的方法来实现这些,这种方法迅速变得长且难于控制。此外界面代码是很容易受到不断改变的影响的,除非你对管理这些改变训练有素。由于写界面代码可能是很枯燥的,所以界面程序员经常会使用设计工具来生成代码。机器生成的代码相对于手工代码来说是很差。

原则上讲,保持UI代码在控制之下是不难的。关键是重构或不断改进现有代码的设计和结构。重构的目的是保持代码在以后易读和易于维护。下表5.1说明了在重构时需要记住的一些原则。最重要的是要记住,某人以后可能会不得不读和理解你的代码。努力让他人的生活更容易些,毕竟那有可能是你。

表5.1 重构的一些重要原则

不要重复:你应该避免有多个相同功能的段。当这个功能需要改变时,这维护起来会很头痛。 一次做一件事情:一个方法应该并且只做一件事情。各自的事件应该在各自的方法中。方法应该保持短小。 嵌套的层数要少:尽量使嵌套代码不多于2或3层。对于一个单独的方法,深的嵌套也是一个好的选择。 避免字面意义上的字符串和数字:字面意义上的字符串和数字应使其出现在代码中的次数最小化。一个好的方法是,把它们从你的代码的主要部分中分离出来,并存储于一个列表或字典中。

这些原则在Python代码中特别重要。因为Python的缩进语法、小而简洁的方法是很容易去读的。然而,长的方法对于理解来说是更困难的,尤其是如果它们在一个屏幕上不能完全显示出来时。类似的,Python中的深的嵌套使得跟踪代码块的开始和结尾很棘手。然而,Python在避免重复方面是十分好的一种语言,特别是因为函数和方法或以作为参数传递。

一个重构的例子

为了展示给你如何在实际工作中应用这些原则,我们将看一个重构的例子。图5.1显示了一个窗口,它可用作访问微软Access类数据库的前端。

图5.1

它的布置比之前我们的所见过的那些要复杂一些。但是按现实中的应用程序的标准,它仍然十分简单。例5.1的代码的结构很差。

例5.1 产生图5.1的没有重构的代码

#!/usr/bin/env python

import wx

class RefactorExample(wx.Frame):

    def __init__(self, parent, id):
        wx.Frame.__init__(self, parent, id, 'Refactor Example',size=(340, 200))
        panel = wx.Panel(self, -1)
        panel.SetBackgroundColour("White")
        prevButton = wx.Button(panel, -1, " PREV", pos=(80, 0))
        self.Bind(wx.EVT_BUTTON, self.OnPrev, prevButton)
        nextButton = wx.Button(panel, -1, "NEXT ", pos=(160, 0))
        self.Bind(wx.EVT_BUTTON, self.OnNext, nextButton)
        self.Bind(wx.EVT_CLOSE, self.OnCloseWindow)

        menuBar = wx.MenuBar()
        menu1 = wx.Menu()
        openMenuItem = menu1.Append(-1, "  ", "Copy in status bar")
        self.Bind(wx.EVT_MENU, self.OnOpen, openMenuItem)
        quitMenuItem = menu1.Append(-1, "  ", "Quit")
        self.Bind(wx.EVT_MENU, self.OnCloseWindow, quitMenuItem)
        menuBar.Append(menu1, "  ")
        menu2 = wx.Menu()
        copyItem = menu2.Append(-1, "  ", "Copy")
        self.Bind(wx.EVT_MENU, self.OnCopy, copyItem)
        cutItem = menu2.Append(-1, "C ", "Cut")
        self.Bind(wx.EVT_MENU, self.OnCut, cutItem)
        pasteItem = menu2.Append(-1, "Paste", "Paste")
        self.Bind(wx.EVT_MENU, self.OnPaste, pasteItem)
        menuBar.Append(menu2, "  ")
        self.SetMenuBar(menuBar)

        static = wx.StaticText(panel, wx.NewId(), "First Name",
                pos=(10, 50))
        static.SetBackgroundColour("White")
        text = wx.TextCtrl(panel, wx.NewId(), "", size=(100, -1),
                pos=(80, 50))

        static2 = wx.StaticText(panel, wx.NewId(), "Last Name",
                pos=(10, 80))
        static2.SetBackgroundColour("White")
        text2 = wx.TextCtrl(panel, wx.NewId(), "", size=(100, -1),
                pos=(80, 80))

        firstButton = wx.Button(panel, -1, "FIRST")
        self.Bind(wx.EVT_BUTTON, self.OnFirst, firstButton)

        menu2.AppendSeparator()
        optItem = menu2.Append(-1, "  ", "Display Options")
        self.Bind(wx.EVT_MENU, self.OnOptions, optItem)

        lastButton = wx.Button(panel, -1, "LAST", pos=(240, 0))
        self.Bind(wx.EVT_BUTTON, self.OnLast, lastButton)

    # Just grouping the empty event handlers together
    def OnPrev(self, event): pass
    def OnNext(self, event): pass
    def OnLast(self, event): pass
    def OnFirst(self, event): pass
    def OnOpen(self, event): pass
    def OnCopy(self, event): pass
    def OnCut(self, event): pass
    def OnPaste(self, event): pass
    def OnOptions(self, event): pass

    def OnCloseWindow(self, event):
        self.Destroy()

if __name__ == '__main__':
    app = wx.PySimpleApp()
    frame = RefactorExample(parent=None, id=-1)
    frame.Show()
    app.MainLoop()

根据重构原则,上面这段代码有一点是做到了,就是没有深的嵌套。其它都没有做到。 为了让你有一个关于如何调整的一个思想,我们将把所有的按钮代码分别放到各自的方法中。 下表5.2归纳了我们重构原代码应解决的问题

表5.2

原则 代码要重构的地方

不要重复几个模式不断重复,包括“增加按钮,关联一个方法”,

  • “增加菜单项并关联一个方法”,“创建成对的标签/文本条目”

一次只做一件事代码做了几件事情。除了基本的框架(frame)设置外,它创建了菜单栏,增加了按

  • 钮,增加了文本域。更糟糕的是,功能在代码中混在一起。

避免避免字面意 义上的字符串和数字在构造器中每个按钮、菜单项和文本框都有一个文字字符串和坐标常量

开始重构

例5.2中只包含了前面用于创建按键栏的代码。作为重构的第一步,我们在例5.2中把例5.1中创建按钮栏这些代码抽出来放在了它自己的方法中:

例5.2 按钮栏作为一个单独的方法

def createButtonBar(self):
        firstButton = wx.Button(panel, -1, "FIRST")
        self.Bind(wx.EVT_BUTTON, self.OnFirst, firstButton)

        prevButton = wx.Button(panel, -1, " PREV", pos=(80, 0))
        self.Bind(wx.EVT_BUTTON, , self.OnPrev, prevButton)

        nextButton = wx.Button(panel, -1, "NEXT ", pos=(160, 0))
        self.Bind(wx.EVT_BUTTON, self.OnNext, nextButton)

        lastButton = wx.Button(panel, -1, "LAST", pos=(240, 0))
        self.Bind(wx.EVT_BUTTON, self.OnLast, lastButton)

向上面这样把代码分离出后,所有按钮添加代码之间的共性就很容易看出来了。我们可以把添加按钮的代码写成一个公用的方法来调用 ,而避免了重复。如例5.3所示:

例5.3 一个公用的改进了的按钮栏方法

def createButtonBar(self, panel):
        self.buildOneButton(panel, "First", self.OnFirst)
        self.buildOneButton(panel, " PREV", self.OnPrev, (80, 0))
        self.buildOneButton(panel, "NEXT ", self.OnNext, (160, 0))
        self.buildOneButton(panel, "Last", self.OnLast, (240, 0))

def buildOneButton(self, parent, label, handler, pos=(0,0)):
        button = wx.Button(parent, -1, label, pos)
        self.Bind(wx.EVT_BUTTON, handler, button)
        return button

例5.3代替例5.2有两个好处。第一,简短的方法和有意义的方法名使得代码的可读性更清晰了。第二,它避免了局部变量(诚然,你也可以通过使用ID来避免使用局部变量,但那容易导致重复的ID问题)。不使用局部变量是有好处的,它减少了代码的复杂程序,并且也因为这样几乎排除了通常由剪切和粘贴部分代码而忘记了改变所有变量的名字带来的错误。(在实际的应用中,你可能需要存储按钮为实例变量以备后来访问,但是本例不需要。)另外,buildOneButton()方法容易放进一个工具模块中并可以在别的框架或项目中重用。

进一步重构

上面的例子,已经得到了很多的改善。但是在多处仍有许多常量。其一,就是用于定位的点坐标,当另一 个按钮被添加到按钮栏时可能使代码产生错误,尤其是新的按钮被放置在按钮栏的中间。因此让我们再往 前进一步,我们把这些字面意义上的数据从处理中分离出来。下例5.4展示了一个用于创建按钮的数据驱 动机制。

例5.4 使用分离自代码的数据创建按钮

def buttonData(self):
        return (("First", self.OnFirst),
                (" PREV", self.OnPrev),
                ("NEXT ", self.OnNext),
                ("Last", self.OnLast))

def createButtonBar(self, panel, yPos=0):
        xPos = 0
        for eachLabel, eachHandler in self.buttonData():
                pos = (xPos, yPos)
                button = self.buildOneButton(panel, eachLabel, eachHandler, pos)
                xPos += button.GetSize().width

def buildOneButton(self, parent, label, handler, pos=(0,0)):
        button = wx.Button(parent, -1, label, pos)
        self.Bind(wx.EVT_BUTTON, handler, button)
        return button

在例5.4中,用于不同按钮的数据被存储在内嵌于buttonData()方法的元组中。所选的数据结构及常量方 法的使用不是必然的。数据也可以被存储在一个类级的变量或模块级的变量中,而非一个方法的结果,或 存储于一个外部的文件中。使用方法的好处就是,如果你的按钮数据存储在另一个地方而不是方法中的话 ,只需要改变这个方法而使它返回外部的数据。

createButtonBar()方法遍历buttonData()返回的列表并创建相关数据的按钮。这个方法集依次根据列表 自动计算按钮的x坐标。这是很有帮助的,因为它保证了代码中按钮的次序与将显示在屏幕中的次序一样 ,使得代码更清晰并减少出错的机会。如果你需要将一个按钮添加到按钮栏的中间的话,你只需把数据添 加到这个列表的中间,这个代码确保了所加按钮被放置在中间。

数据的分离有其它的好处。在一个更精心制作的例子中,数据可以被存储到一个外部的资源或XML文件中 。这使得在改变界面的时候不用去关心代码,并且使国际化更容易,很容易改变文本。移除了数据以后, createButtonBar方法现在成了一个公用方法了,它可以容易地在其它框架或项目中被重用。 在经过整合相同的过程,并从菜单和文本域代码中分离出数据后,所得的结果显示在如下例5.5中。

例5.5 一个重构的例子

#!/usr/bin/env python

import wx

class RefactorExample(wx.Frame):

        def __init__(self, parent, id):
                wx.Frame.__init__(self, parent, id, 'Refactor Example',size=(340, 200))
                panel = wx.Panel(self, -1)
                panel.SetBackgroundColour("White")
                self.Bind(wx.EVT_CLOSE, self.OnCloseWindow)
                self.createMenuBar() #简化的init方法
                self.createButtonBar(panel)
                self.createTextFields(panel)

        def menuData(self): #菜单数据
                return (("&File",
                        ("&Open", "Open in status bar", self.OnOpen),
                        ("&Quit", "Quit", self.OnCloseWindow)),
                        ("&Edit",
                        ("&Copy", "Copy", self.OnCopy),
                        ("C&ut", "Cut", self.OnCut),
                        ("&Paste", "Paste", self.OnPaste),
                        ("", "", ""),
                        ("&Options", "DisplayOptions", self.OnOptions)))
#创建菜单
        def createMenuBar(self):
                menuBar = wx.MenuBar()
                for eachMenuData in self.menuData():
                        menuLabel = eachMenuData[0]
                        menuItems = eachMenuData[1:]
                        menuBar.Append(self.createMenu(menuItems), menuLabel)
                self.SetMenuBar(menuBar)

        def createMenu(self, menuData):
                menu = wx.Menu()
                for eachLabel, eachStatus, eachHandler in menuData:
                        if not eachLabel:
                                menu.AppendSeparator()
                                continue
                        menuItem = menu.Append(-1, eachLabel, eachStatus)
                        self.Bind(wx.EVT_MENU, eachHandler, menuItem)
                return menu

        def buttonData(self): #按钮栏数据
                return (("First", self.OnFirst),
                        ("<<  PREV", self.OnPrev),
                        ("NEXT >>", self.OnNext),
                        ("Last", self.OnLast))
        #创建按钮
        def createButtonBar(self, panel, yPos = 0):
                xPos = 0
                for eachLabel, eachHandler in self.buttonData():
                        pos = (xPos, yPos)
                        button = self.buildOneButton(panel, eachLabel,eachHandler, pos)
                        xPos += button.GetSize().width

        def buildOneButton(self, parent, label, handler, pos=(0,0)):
                button = wx.Button(parent, -1, label, pos)
                self.Bind(wx.EVT_BUTTON, handler, button)
                return button

        def textFieldData(self): #文本数据
                return (("First Name", (10, 50)),
                        ("Last Name", (10, 80)))
        #创建文本

        def createTextFields(self, panel):
                for eachLabel, eachPos in self.textFieldData():
                        self.createCaptionedText(panel, eachLabel, eachPos)

        def createCaptionedText(self, panel, label, pos):
                static = wx.StaticText(panel, wx.NewId(), label, pos)
                static.SetBackgroundColour("White")
                textPos = (pos[0] + 75, pos[1])
                wx.TextCtrl(panel, wx.NewId(), "", size=(100, -1), pos=textPos)

        # 空的事件处理器放在一起
        def OnPrev(self, event): pass
        def OnNext(self, event): pass
        def OnLast(self, event): pass
        def OnFirst(self, event): pass
        def OnOpen(self, event): pass
        def OnCopy(self, event): pass
        def OnCut(self, event): pass
        def OnPaste(self, event): pass
        def OnOptions(self, event): pass
        def OnCloseWindow(self, event):
        self.Destroy()

if __name__ == '__main__':
        app = wx.PySimpleApp()
        frame = RefactorExample(parent=None, id=-1)
        frame.Show()
        app.MainLoop()

从例5.1改变到例5.5,没有费多少力,但我们所得到的却是很多——代码非常的清楚且减少了出错的机会。代码的布置与数据的布置在逻辑上是匹配的。那些普通的做法(它们劣质的代码结构可能导致错误——如采用大量的复制和粘贴来创建新的对象)已经被去掉。多数函数现在可以很容易地被移到一个超类或公用模块中,以保存代码便于以后继续利用。另外,数据的分离使得把这个布局作为不同数据的模板很容易,包括国际化的数据。

重构虽说完成了,但是例5.5中的代码仍然忽略了一些重要的事情:实际用户的数据。你的应用程序要做很多事依赖于处理数据响应用户要求。你的程序的结构还可以向着灵活性和稳定性方向发展。MVC模式对于管理界面和数据之间的交互是公认的标准。

如何保持模型(Model)与视图(View)分离?

最早可追溯到1970年代后期和Smalltalk-80语言,MVC模式大概是最早明确指出面向对象设计的模式。它是最流行的一种,被几乎所有GUI工具包所采用。MVC模式是结构化程序的标准,包括处理和显示信息。

MVC(Model-View-Controller)系统是什么?

MVC系统有三个子系统。Model包含经常被调用的业务逻辑或由你的系统处理的所有数据和信息。View包含显示数据的对象,Controller管理与用户的交互(Controller处于Modelview中间)。下表5.3归纳了这些组分。

表5.3 标准MVC体系的组成

组分

Model:包含业务逻辑,包含所有由系统处理的数据。它包括一个针对外部存储(如一个数据库)的接口。通常模型(model)只暴露一个公共的API给其它的组分。

View:包含显示代码。这个窗口部件实际用于放置用户在视图中的信息。在wxPython中,处于wx.Window层级中的所有的东西都是视图(view)子系统的一部分。

Controller:包含交互逻辑。该代码接受用户事件并确保它们被系统处理。在wxPython中,这个子系统由wx.EvtHandler层级所代表。

在现代的UI工具包中,ViewController组分是被集成在一起的。这是因为Controller组分自身需要被显示在屏幕上,并且因为经常性的你想让显示数据的窗口部件也响应用户事件。在wxPython中,这种关系实际上已经被放置进去了(所有的wx.Window对象也都是wx.EvtHandler的子类),这意味着它们同时具有View元素和Controller元素的功能。相比之下,大部分web应用架构对于ViewController有更严格的分离,因为其交互逻辑发生在服务器的后台。

图5.2中显示了数据和信息是如何在MVC体系中传递的。

一个事件通知被Controller系统处理(它把事件通知放到一个合适的地方)。如我们在第三章中所看到的,wxPython使用wx.EvtHandler的方法ProcessEvent()管理这个机制。在一个严格的MVC?杓浦校愕拇砥骱赡鼙簧髟谝桓龅ザ赖目刂破鞫韵笾校窃诳蚣芾嘧陨碇小? 对于事件的响应,这个模型(model)对象可以对应用程序数据做一些处理。当处理完成时,模型对象发送一个更新通知。如果这儿有一个控制器(controller)对象,那么该通知通常发送回这个控制器,同时这个控制器对象通知视图(view)对象自我更新。在一个较小的系统或一个较简单的体系中,通知通常直接被视图对象所接受。在wxPython中,来自于模型的更新的关键在于你。你的选择包括从模型或控制器显式地引发自定义的wxPython事件,使模型维护的对象的列表接受更新通知,或使与模型关联的视图接受更新通知。

一个成功的MVC设计的关键不在于每个对象都彼此了解。相反,一个成功的MVC程序,它的不同部分之间显式地隐藏了一些东西。其目的是使系统最低限度地交互,和方法之间的明确的界定。尤其,这个Model组分应该被完全从ViewController中脱离出来。你应该只改变那些系统而不改变你的Model类。理想上来讲,你甚至应该能够使用相同Model类来驱动非wxPython的界面,但是那很难。

View方面,你应该能够在Model对象的实现中做改变而不改变ViewController。而View依赖于某些公共的方法的存在,它不应该看见Model内私有的东西。无可否认,这在Python中实施是有困难的,但有一个方法可以帮助我们,那就是创建一个抽象的Model类,它定义View可以看见的APIModel的子类可以扮演一个内部的类的代理而被改变,或可以简单地自身包含内部的工作。这第一个方案更结构化些,第二个更容易实现。

下一节,我们将看一看内建于wxPython中的Model类中的一个:wx.grid.PyGridTableBase。这个类使得在一个MVC设计架构中使用grid控件成为可能。这之后,我们将关注一下对于一个定制的窗口部件建造和使用定制的模型类。

一个wxPython模型:PyGridTableBase

wx.grid.Grid是一个电子表格式样的wxPython控件。下图5.3显示了它的外观。

这个网格(grid)控件有很多有趣的特点,包括能够在一个单元一个单元的基础上创建自定义的渲染器和编辑器,以及可拖拽的行和列。这些特性将在第十三章中做更详细的讨论。在这一章,我们将针对基础和展示如何使用一个模型去填充网格。例5.6显示在一个网格中设置单元值的简单的非模型方法。在此例中,网格中的值是1984年芝加哥小熊队的阵容。

例5.6 填充网格(没有使用模型)

import wx
import wx.grid

class SimpleGrid(wx.grid.Grid):
        def __init__(self, parent):
                wx.grid.Grid.__init__(self, parent, -1)
                self.CreateGrid(9, 2)
                self.SetColLabelValue(0, "First")
                self.SetColLabelValue(1, "Last")
                self.SetRowLabelValue(0, "CF")
                self.SetCellValue(0, 0, "Bob")
                self.SetCellValue(0, 1, "Dernier")
                self.SetRowLabelValue(1, "2B")
                self.SetCellValue(1, 0, "Ryne")
                self.SetCellValue(1, 1, "Sandberg")
                self.SetRowLabelValue(2, "LF")
                self.SetCellValue(2, 0, "Gary")
                self.SetCellValue(2, 1, "Matthews")
                self.SetRowLabelValue(3, "1B")
                self.SetCellValue(3, 0, "Leon")
                self.SetCellValue(3, 1, "Durham")
                self.SetRowLabelValue(4, "RF")
                self.SetCellValue(4, 0, "Keith")
                self.SetCellValue(4, 1, "Moreland")
                self.SetRowLabelValue(5, "3B")
                self.SetCellValue(5, 0, "Ron")
                self.SetCellValue(5, 1, "Cey")
                self.SetRowLabelValue(6, "C")
                self.SetCellValue(6, 0, "Jody")
                self.SetCellValue(6, 1, "Davis")
                self.SetRowLabelValue(7, "SS")
                self.SetCellValue(7, 0, "Larry")
                self.SetCellValue(7, 1, "Bowa")
                self.SetRowLabelValue(8, "P")
                self.SetCellValue(8, 0, "Rick")
                self.SetCellValue(8, 1, "Sutcliffe")

class TestFrame(wx.Frame):
        def __init__(self, parent):
                wx.Frame.__init__(self, parent, -1, "A Grid",size=(275, 275))
                grid = SimpleGrid(self)

if __name__ == '__main__':
        app = wx.PySimpleApp()
        frame = TestFrame(None)
        frame.Show(True)
        app.MainLoop()

在例5.6中,我们产生了SimpleGrid类,它是wxPythonwx.grid.Grid的子类。如前所述,wx.grid.Grid有很多种方法,这我们以后再讨论。现在,我们只关心方法SetRowLabelValue()SetColLabelValue()SetCellValue(),它们实际上设置显示在网格中的值。通过对比图5.3和例5.6你可以明白,SetCellValue()方法要求一个行索引、一个列索引和一个值。而其它两个方法要求一个索引和一个值。

上面的代码使用了set*的方法直接把值赋给了网格。然而如果对于一个较大的网格使用这种方法,代码将冗长乏味,并很容易导致错误的出现。即使我们创建公用程序以减轻负担,但是根据重构的原则,代码仍有问题。数据与显示混在一起,对于将来代码的修改是困难的,如增加一列或更换数据。

解决的答案就是wx.grid.PyGridTableBase。根据之前我们所见过的其它的类,前缀Py表明这是一个封装了C++类的特定的Python类。就像我们在第三章中所见的PyEvent类,PyGridTableBase的实现是基于简单封装一个wxWidgets C++类,这样的目的是使得能够继续声明该类(Python形式的类)的子类。PyGridTableBase对于网格是一个模型类。也就是说,网格对象可能使用PyGridTableBase所包含的方法来绘制自身,而不必了解有关绘制数据的内部结构。

PyGridTableBase的方法

wx.grid.PyGridTableBase有一些方法,它们中的许多你不会用到。这个类是抽象的,并且不能被直接实 例化。每次你创建一个PyGridTableBase时,有五个必要的方法必须被定义。表5.4说明了这些方法。

表5.4 wx.grid.PyGridTableBase的必须的方法

GetNumberRows():返回一个表明grid中行数的整数。

GetNumberCols():返回一个表明grid中列数的整数。

IsEmptyCell(row, col):如果索引(row,col)所表示的单元是空的话,返回True

GetValue(row, col):返回显示在单元(row,col)中的值。

SetValue(row, col,value):设置单元(row,col)中的值。如果你想要只读模式,你仍必须包含这个方法,但是你可以在该函数中使用pass

表(table)通过使用网格(grid)SetTable()方法被附加在grid上。在属性被设置后,grid对象将调用表 的方法来得到它绘制网格所需要的信息。grid不再显式使用grid的方法来设置值。

使用PyGridTableBase

一般情况下,有两种使用PyGridTableBase的方法。你可以显式地使你的模型类是PyGridTableBase的子类,或你可以创建一个单独的PyGridTableBase的子类,它关联你的实际的模型类。当你的数据不是太复杂 的时候,第一种方案较简单并且直观。第二种方案需要对模型和视图做很好的分离,如果你的数据复杂的话,这第二种方案是更好的。如果你有一个预先存在的数据类,你想把它用于wxPython,那么这第二种方案也是更好的,因为这样你可以创建一个表而不用去改变已有的代码。在下面一节我们将展示包含这两种方案的一个例子。

使用PyGridTableBase:特定于应用程序(不通用)的子类

我们的第一个例子将使用PyGridTableBase的一个特定于应用程序的子类作为我们的模型。由于我们小熊 队阵容的相对简单些,所以我们使用它。我们把这些数据组织到一个派生自PyGridTableBase的类。我们 把这些实际的数据配置在一个二维Python列表中,并且配置另外的方法来从列表中读。下例5.7展示了生 成自一个模型类的小熊队的阵容。

例5.7 生成自PyGridTableBase模型的一个表

import wx
import wx.grid

class LineupTable(wx.grid.PyGridTableBase):

        data = (("CF", "Bob", "Dernier"), ("2B", "Ryne", "Sandberg"),
                ("LF", "Gary", "Matthews"), ("1B", "Leon", "Durham"),
                ("RF", "Keith", "Moreland"), ("3B", "Ron", "Cey"),
                ("C", "Jody", "Davis"), ("SS", "Larry", "Bowa"),
                ("P", "Rick", "Sutcliffe"))

        colLabels = ("Last", "First")

        def __init__(self):
                wx.grid.PyGridTableBase.__init__(self)

        def GetNumberRows(self):
                return len(self.data)

        def GetNumberCols(self):
                return len(self.data[0]) - 1

        def GetColLabelValue(self, col):
                return self.colLabels[col]

        def GetRowLabelValue(self, row):
                return self.data[row][0]

        def IsEmptyCell(self, row, col):
                return False

        def GetValue(self, row, col):
                return self.data[row][col + 1]

        def SetValue(self, row, col, value):
                pass

class SimpleGrid(wx.grid.Grid):
        def __init__(self, parent):
                wx.grid.Grid.__init__(self, parent, -1)
                self.SetTable(LineupTable()) #设置表

class TestFrame(wx.Frame):
        def __init__(self, parent):
                wx.Frame.__init__(self, parent, -1, "A Grid",size=(275, 275))
                grid = SimpleGrid(self)

if __name__ == '__main__':
        app = wx.PySimpleApp()
        frame = TestFrame(None)
        frame.Show(True)
        app.MainLoop()

在例5.7中,我们已经定义了所有必须的PyGridTableBase方法,并加上了额外的方法GetColLabelValue()GetRowLabelValue()。希望你不要对这两个额外的方法感到诧异,这两个额外的方法使得表(table)能 够分别指定行和列的标签。在重构一节中,使用模型类的作用是将数据与显示分开。在本例中,我们已经 把数据移入了一个更加结构化的格式,它能够容易地被分离到一个外部文件或资源中(数据库容易被增加到这里)。

使用PyGridTableBase:一个通用的例子

实际上,上面的例子很接近一个通用的能够读任何二维Python列表的表了。下列5.8展示这通用的模型的 外观:

例5.8 一个关于二维列表的通用的表

import wx
import wx.grid

class GenericTable(wx.grid.PyGridTableBase):

    def __init__(self, data, rowLabels=None, colLabels=None):
        wx.grid.PyGridTableBase.__init__(self)
        self.data = data
        self.rowLabels = rowLabels
        self.colLabels = colLabels

    def GetNumberRows(self):
        return len(self.data)

    def GetNumberCols(self):
        return len(self.data[0])

    def GetColLabelValue(self, col):
        if self.colLabels:
            return self.colLabels[col]

    def GetRowLabelValue(self, row):
        if self.rowLabels:
            return self.rowLabels[row]

    def IsEmptyCell(self, row, col):
        return False

    def GetValue(self, row, col):
        return self.data[row][col]

    def SetValue(self, row, col, value):
        pass

GenericTable类要求一个数据的二维列表和一个可选的行和列标签列表。这个类适合被导入任何wxPython 程序中。使用一个做了微小改变的格式,我们现在可以使用这通用的表来显示阵容,如下例5.9所示:

例5.9 使用这通用的表来显示阵容

import wx
import wx.grid
import generictable

data = (("Bob", "Dernier"), ("Ryne", "Sandberg"),
        ("Gary", "Matthews"), ("Leon", "Durham"),
        ("Keith", "Moreland"), ("Ron", "Cey"),
        ("Jody", "Davis"), ("Larry", "Bowa"),
        ("Rick", "Sutcliffe"))

colLabels = ("Last", "First")
rowLabels = ("CF", "2B", "LF", "1B", "RF", "3B", "C", "SS", "P")

class SimpleGrid(wx.grid.Grid):
    def __init__(self, parent):
        wx.grid.Grid.__init__(self, parent, -1)
        tableBase = generictable.GenericTable(data, rowLabels,
                colLabels)
        self.SetTable(tableBase)

class TestFrame(wx.Frame):
    def __init__(self, parent):
        wx.Frame.__init__(self, parent, -1, "A Grid",
                size=(275, 275))
        grid = SimpleGrid(self)

if __name__ == '__main__':
    app = wx.PySimpleApp()
    frame = TestFrame(None)
    frame.Show(True)
    app.MainLoop()

使用PyGridTableBase:一个独立的模型类

至于避免重复性,有另一种使用PyGridTableBase的方法值得在这展示给大家。这就是我们早先提到的第 二种方案,数据在一个单独的模型类中,通过PyGridTableBase来访问。Python的自我检查功能在这是非 常有用的,使你能够在每列显示一个属性的列表,然后使用内建函数getattr()去获取实际的值。在这种 情况下,模型要求一个元素的列表。在wxPython中,使用单独的模型对象结构化你的程序有一个大的优势 。在通常的情形下,对于一个grid,你只能调用SetTable()一次,如果你想去改变表,你需要创建一个新 的grid,那是烦人的。然而,在接下来的例子中,你的PyGridTableBase仅存储了对于你的实际数据类的 实例的引用,这样一来,以后你就只需通过改变表中基本的数据对象,就可以更新表中的数据为新的数据 了。

下例5.10展示使用了关于阵容条目的单独的数据类的PyGridTableBase。我们省去了框架的另一列表和数据 创建,它们是与前一例子十分类似的。

例5.10 使用了一个自定义的数据类的阵容显示表

import wx
import wx.grid

class LineupEntry:

    def __init__(self, pos, first, last):
        self.pos = pos
        self.first = first
        self.last = last

class LineupTable(wx.grid.PyGridTableBase):

    colLabels = ("First", "Last") # 列标签
    colAttrs = ("first", "last") #1 属性名

    def __init__(self, entries): #2 初始化模型
        wx.grid.PyGridTableBase.__init__(self)
        self.entries = entries

    def GetNumberRows(self):
        return len(self.entries)

    def GetNumberCols(self):
        return 2

    def GetColLabelValue(self, col):
        return self.colLabels[col] #读列标签

    def GetRowLabelValue(self, col):
        return self.entries[row].pos #3 读行标签

    def IsEmptyCell(self, row, col):
        return False

    def GetValue(self, row, col):
        entry = self.entries[row]
        return getattr(entry, self.colAttrs[col]) #4 读属性值

    def SetValue(self, row, col, value):
        pass

说明:

#1:这个列表包含了一些属性,它们被引用去按列地显示每列的值。

#2:这个模型要求一个条目的列表,每个条目都是LineupEntry的一个实例。(这里我们没有做任何的错误检查)。

#3:要得到行头的标签,我们查看条目的pos属性。

#4:第一步是根据行来得到正确的条目。所要求的属性来自于#1中的列表,然后getattr()被用来引用实际的值。这个机制是可扩展的,即使是在你不知道该名字是否引用一个属性或方法的情况下,你也可以通 过检查 object . attribute 来看是否其可调用。如果可调用,那么使用通常的Python函数语法来调用它 ,并返回它的值。

grid类是wxPython已有的一个有价值的模型组件来帮助你结构化你的应用程序的一个例子。下一节我们将 讨论如何为别的wxPython对象创建模型组件。

自定义模型

创建你的模型对象所基于的基本思想是简单的。首先构造你的数据类而不要担心它们将如何被显示。然后 为数据类作一个公共接口,该接口对显示对象是能够被访问的。很明显,这个工程的大小和复杂性将决定 这个公共声明的形式如何。在一个小的工程中,使用简单的对象,可能足够做简单的事件和使视图对象能 够访问该模型的属性。在一个更复杂的对象中,对于这种使用你可能想定义特殊的方法,或创建一个分离 的模型类,该类是视图唯一看到的东西(正如我们在例5.10所做的)。

为了使视图由于模型中的改变而被得到通知,你也需要某种机制。例5.11展示了一个简单的——一个抽象 的基类,你可以用它作为你的模型类的双亲。你可以把这看成PyGridTableBase用于当显示不是一个网格 (grid)时的一个类似情况。

例5.11 用于更新视图的一个自定义的模型

class AbstractModel(object):

        def __init__(self):
                self.listeners = []

        def addListener(self, listenerFunc):
                self.listeners.append(listenerFunc)

        def removeListener(self, listenerFunc):
                self.listeners.remove(listenerFunc)

        def update(self):
                for eachFunc in self.listeners:
                        eachFunc(self)

我们这里的listener应该是可调用的对象,它需要self作为参数(eachFunc(self))——很明显,self的 实际的类可以是不同的,因此你的listenter很灵活了。同样,我们已经将AbstractModel配置成一个 Python的新型的类,事实上它是object的子类。因此本例要求Python的版本是Python2.2或更高。

我们如何使用这个抽象的类呢?图5.4显示了一个新的窗口,类似于我们本章早先讲重构时所显示的窗口 。这个窗口简单。其中文本框是只读的。敲击上面的任一按钮将在文本框中显示相应的字符。

图片丢失

显示这个窗口的程序使用了一个简单的MVC结构。按钮的处理器方法引起这个模型中的变化,模型中的更 新导致文本域的改变。例5.12展示了这个细节。

例5.12

#!/usr/bin/env python

import wx
import abstractmodel

class SimpleName(abstractmodel.AbstractModel):

    def __init__(self, first="", last=""):
        abstractmodel.AbstractModel.__init__(self)
        self.set(first, last)

    def set(self, first, last):
        self.first = first
        self.last = last
        self.update()   #1 更新

class ModelExample(wx.Frame):

    def __init__(self, parent, id):
        wx.Frame.__init__(self, parent, id, 'Flintstones',
                size=(340, 200))
        panel = wx.Panel(self)
        panel.SetBackgroundColour("White")
        self.Bind(wx.EVT_CLOSE, self.OnCloseWindow)
        self.textFields = {}
        self.createTextFields(panel)
#-------------------------------
    #2 创建模型
        self.model = SimpleName()
        self.model.addListener(self.OnUpdate)
        #-------------------------------
        self.createButtonBar(panel)

    def buttonData(self):
        return (("Fredify", self.OnFred),
                ("Wilmafy", self.OnWilma),
                ("Barnify", self.OnBarney),
                ("Bettify", self.OnBetty))

    def createButtonBar(self, panel, yPos = 0):
        xPos = 0
        for eachLabel, eachHandler in self.buttonData():
            pos = (xPos, yPos)
            button = self.buildOneButton(panel, eachLabel, eachHandler, pos)
            xPos += button.GetSize().width

    def buildOneButton(self, parent, label, handler, pos=(0,0)):
        button = wx.Button(parent, -1, label, pos)
        self.Bind(wx.EVT_BUTTON, handler, button)
        return button

    def textFieldData(self):
        return (("First Name", (10, 50)),
                ("Last Name", (10, 80)))

    def createTextFields(self, panel):
        for eachLabel, eachPos in self.textFieldData():
            self.createCaptionedText(panel, eachLabel, eachPos)

    def createCaptionedText(self, panel, label, pos):
        static = wx.StaticText(panel, wx.NewId(), label, pos)
        static.SetBackgroundColour("White")
        textPos = (pos[0] + 75, pos[1])
        self.textFields[label] = wx.TextCtrl(panel, wx.NewId(),
                "", size=(100, -1), pos=textPos,
                style=wx.TE_READONLY)

    def OnUpdate(self, model): #3 设置文本域
        self.textFields["First Name"].SetValue(model.first)
        self.textFields["Last Name"].SetValue(model.last)
    #-------------------------------------------
    #4 响应按钮敲击的处理器
    def OnFred(self, event):
        self.model.set("Fred", "Flintstone")

    def OnBarney(self, event):
        self.model.set("Barney", "Rubble")

    def OnWilma(self, event):
        self.model.set("Wilma", "Flintstone")

    def OnBetty(self, event):
        self.model.set("Betty", "Rubble")
    #---------------------------------------------
    def OnCloseWindow(self, event):
        self.Destroy()

if __name__ == '__main__':
    app = wx.PySimpleApp()
    frame = ModelExample(parent=None, id=-1)
    frame.Show()
    app.MainLoop()

说明:

#1:这行执行更新

#2:这两行创建这个模型对象,并且把OnUpdate()方法注册为一个listener。现在当更新被调用时,OnUpdate()方法将被调用。

#3OnUpdate()方法本身简单地使用模型更新后的值来设置文本域中的值。该方法的代码中可以使用self.model这个实例来代替model(它们是同一个对象)。使用方法作为参数,代码是更健壮的,在这种情况下,同样的代码可以监听多个对象。

#4:按钮敲击的处理器改变模型对象的值,它触发更新。

在这样一个小的例子中,使用模型更新机制似乎有点大才小用了。为什么按钮处理器不能直接设置文本域的值呢。然而,当这个模型类存在一个更复杂的内部状况和处理时,这个模型机制就变得更有价值了。例如,你将能够将内部的分配从一个Python字典改变为一个外部的数据库,而不在视图中做任何改变。 假如你正在处理一个已有的类,而不能或不愿对其做改变,那么AbstractModel可以用作该类的代理,方法与例5.10中的阵容所用的方法大致相同。

另外,wxPython包含两个单独的类似MVC更新机制的实现,它们比我们这里说明的这个有更多的特性。第一个是模块wx.lib.pubsub,它在结构上与我们先前给出的类AbstractModel十分相似。名为Publisher的模型类使得对象能够监听仅特定类型的消息。另一个更新系统是wx.lib.evtmgr.eventManager,它建立在 pubsub之上,并且有一些额外的特性,包括一个更精心的面向对象的设计和事件关联的连接或去除的易用性。

如何对一个GUI程序进行单元测试?

好的重构和MVC设计模式的一个主要的好处是,它使得使用“单元测试”来验证你程序的性能更容易了。单元测试是对你的程序的单个的特定功能的测试。由于重构和MVC设计模式两者的使用,使得你的程序被分成了小的块,因此你更容易针对你程序的个别的部分写特定的单元测试。当重构的时候,结合使用单元测试是特别有用的,因为完整的单元测试使得在你移动你的代码后,你能够检验你是否引入了任何错误。

接下来的难题是在单元测试中如何测试UI代码。测试一个模型是相对简单的,因为模型的大部分功能不依赖于用户的输入。测试界面本身的功能比较困难,因为界面的行为依赖于用户的行为,而用户的行为又是难以封装在内的。在这一节,我们将给你展示如何在wxPython中使用单元测试。尤其是在单元测试期间手工产生事件去触发行为的用法。

unittest模块

当写用户测试的时候,使用已有的测试引擎来节省减少重复的写代码的运行你的测试是有帮助的。自2.1版以来,Python已发布了unittest模块。unittest模块使用一名为PyUnit的测试框架(参见http://pyunit.sourceforge.net/)。PyUnit模块由Test,TestCase,TestSuite组成。下表5.5说明了这三个组成。

表5.5

Test:被PyUnit引擎调用的一个单独的方法。根据约定,一个测试方法的名字以test开头。测试方法通常执行一些代码,然后执行一个或多个断定语句来测试结果是否是预期的。

TestCase:一个类,它定义了一个或多个单独的测试,这些测试共享一个公共的配置。这个类定义在PyUnit中以管理一组这样的测试。TestCase在测试前后对每个测试都提供了公共配置支持,确保每个测试分别运行。TestCase也定义了一些专门的断定方法,如assertEqual

TestSuite:为了同时被执行而组合在一起的一个或多个test方法或TestCase对象。当你告诉PyUnit去执行测试时,你传递给它一个TestSuite对象去执行。

单个的PyUnit测试可能有三种结果:success(成功), failure(失败), 或error(错误)。success表明测试完成,所有的断定都为真(通过),并且没有引发错误。也就是说得到了我们所希望的结果。Failureerror表明代码存在问题。failure意味着你的断定之一返回false,表明代码执行成功了,但是没有做你预期的事。error意味着测试执行到某处,触发了一个Python异常,表明你的代码没有运行成功。在单个的测试中,failureerror一出现,整个测试就终止了,即使在代码中还有多个断定要测试,然后测试的执行将移到到下一个单个的测试。

一个unittest范例

下例5.13展示了一个使用unittest模块的范例,其中对例5.12中的模型例子进行测试。

例5.13 对模型例子进行单元测试的一个范例

import unittest
import modelExample
import wx

class TestExample(unittest.TestCase): #1 声明一个TestCase

    def setUp(self): #2 为每个测试所做的配置
        self.app = wx.PySimpleApp()
        self.frame = modelExample.ModelExample(parent=None, id=-1)

    def tearDown(self): #3 测试之后的清除工作
        self.frame.Destroy()

    def testModel(self): #4 声明一个测试(Test)
        self.frame.OnBarney(None)
        self.assertEqual("Barney", self.frame.model.first,
                msg="First is wrong") #5 对于可能的失败的断定
        self.assertEqual("Rubble", self.frame.model.last)

def suite(): #6 创建一个TestSuite
    suite = unittest.makeSuite(TestExample, 'test')
    return suite

if __name__ == '__main__':
    unittest.main(defaultTest='suite') #7 开始测试

说明:

#1:声明unittest.TestCase的一个子类。为了最好的使每个测试相互独立,测试执行器为每个测试创建该类的一个实例。

#2setUp()方法在每个测试被执行前被调用。这使得你能够保证每个对你的应用程序的测试都处在相同的状态下。这里我们创建了一个用于测试的框架(frame)的实例。

#3tearDown()方法在每个测试执行完后被调用。这使得你能够做一些清理工作,以确保从一个测试转到另一个测试时系统状态保持一致。通常这里包括重置全局数据,关闭数据库连接等诸如此类的东东。这里我们对框架调用了Destroy(),以强制性地使wxWidgets退出,并且为下一个测试保持系统处在一个良好的状态。

#4 :测试方法通常以test作为前缀,尽管这处于你的控制之下(看#6)。测试方法不要参数。我们这里的测试方法中,通过调用OnBarney事件处理器方法来开始测试行为。

#5 :这行使用assertEqual()方法来测试模型对象的改变是否正确。assertEqual()要两个参数,如果这两个参数不相等,则测试失败。所有的PyUnit断定方法都有一个可选的参数msg,如果断定失败则显示msg(msg的默认值几乎够表达意思了)

#6: 这个方法通过简单有效的机制创建一组测试。makeSuite()方法要求一个Python的类的对象和一个字符串前缀作为参数,并返回一组测试(包含该类中所有前缀为参数“前缀”的方法)。还有其它的机制,它们使得可以更明确设置测试组中的内容,但是makeSuite()方法通过足够了。我们这里写的suite()方法是一个样板模板,它可被用在你的所有测试模块中。

#7 :这行调用了PyUnit的基于文本的执行器。参数是一个方法的名字(该方法返回一测试组)。然后suite被执行,并且结果被输出到控制台。如果你想使用GUI测试执行器,那么这行调用应使用 unittest.TextTestRunner的方法而非unittest.main

在控制台中PyUnit测试的结果如下:

.


Ran 1 test in 0.190s

OK

这是一个成功的测试。第一行的"."号表明测试成功。每个测试都得到一个字符并显示在这行。"."表明成功,"F"表明失败,"E"表明错误。然后是一个简单的列表,其中包含测试的数量、总的测试时间和OKOK表明所有测试通过。

对于一个失败或错误的测试,你将得到一堆跟踪提示(显示了Python得到的错误处的情况)。比如,如果你将#5改为self.assertEqual("Fife", self.frame.model.first),我们将得到如下结果:

F
======================================================================
FAIL: testModel (__main__.TestExample)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\wxPyBook\book\1\Blueprint\testExample.py", line 18, in testModel
self.assertEqual("Fife", self.frame.model.last)
File "c:\python23\lib\unittest.py", line 302, in failUnlessEqual
raise self.failureException, \
AssertionError: 'Fife' != 'Rubble'

----------------------------------------------------------------------
Ran 1 test in 0.070s

FAILED (failures=1)

"F"表明了失败,“testModel”是产生失败的方法名,下面的跟踪显示出18号上的断定失败,以及失败的原因。你一般需要根据这些去找到产生失败的实际位置。

测试用户事件

当然,上面的测试还不完整。我们还可以对框架中的TextField在模型更新后,其中值的更新情况进行测试。这个测试是很简单的。另一个你可能想要做的测试是,自动生成按钮敲击事件,以及确保正确的处理器被调用。这个测试有点难度。下例5.14展示了一个例子:

例5.14 生成一个用户事件的测试

def testEvent(self):
panel = self.frame.GetChildren()[0]
        for each in panel.GetChildren():
                if each.GetLabel() == "Wilmafy":
                        wilma = each
                        break
        event = wx.CommandEvent(wx.wxEVT_COMMAND_BUTTON_CLICKED,  wilma.GetId())
        wilma.GetEventHandler().ProcessEvent(event)
        self.assertEqual("Wilma", self.frame.model.first)
        self.assertEqual("Flintstone", self.frame.model.last)

本例开始的几行寻找一个适当的按钮(这里是"Wilmafy"按钮)。由于我们没有显式地把这些按钮存储到变量中,所以我们就需要遍历panel的孩子列表,直到我们找到正确的按钮。接下来的两行创建用以被按钮发送的wx.CommandEvent事件,并发送出去。参数wx.wxEVT_COMMAND_BUTTON_CLICKED是一个常量,它表示一个事件类型,是个整数值,它被绑定到EVT_BUTTON事件绑定器对象。(你能够在wx.py文件中发现这个整数常量)。wilma.GetId()的作用是设置产生该事件的按钮ID。至此,该事件已具有了实际wxPython事件的所有相关特性。然后我们调用ProcessEvent()来将该事件发送到系统中。如果代码按照计划工作的话,那么模型的firstlast中的名字将被改变为“Wilma” 和 “Flintstone”。

通过生成事件,你能够从头到尾地测试你的系统的响应性。理论上,你可以生成一个鼠标按下和释放事件以确保响应按钮敲击的按钮敲击事件被创建。但是实际上,这不会工作,因为低级的wx.Events没有被转化为本地系统事件并发送到本地窗口部件。然而,当测试自定义的窗口部件时,可以用到类似于第三章中两个按钮控件的处理。此类单元测试,对于你的应用程序的响应性可以给你带来信心。

本章小结

1、众所周知,GUI代码看起来很乱且难于维护。这一点可以通过一点努力来解决,当代码以后要变动时,我们所付出的努力是值得的。

2、重构是对现存代码的改进。重构的目的有:避免重复、去掉无法理解的字面值、创建短的方法(只做一件事情)。为了这些目标不断努力将使你的代码更容易去读和理解。另外,好的重构也几乎避免了某类错误的发生(如剪切和粘贴导致的错误)。

3、把你的数据从代码中分离出来,使得数据和代码更易协同工作。管理这种分离的标准机制是MVC机制。用wxPython的术语来说,V(View)wx.Window对象,它显示你的数据;C(Controller)wx.EvtHandler对象,它分派事件;M(Model)是你自己的代码,它包含被显示的信息。

4、或许MVC结构的最清晰的例子是wxPython的核心类中的wx.grid.PyGridTableBase,它被用于表示数据以在一个wx.grid.Grid控件中显示。表中的数据可以来自于该类本身,或该类可以引用另一个包含相关数据的对象。

5、你可以使用一个简单的机制来创建你自己的MVC设置,以便在模型被更新时通知视图(view)。在wxPython中也有现成的模块可以帮助你做这样的事情。

6、单元测试是检查你的程序的正确性的一个好的方法。在Python中,unittest模块是执行单元测试的标准方法中的一种。使一些包,对一个GUI进行单元测试有点困难,但是wxPython的可程序化的创建事件使得这相对容易些了。这使得你能够从头到尾地去测试你的应用程序的事件处理行为。

在下一章中,我们将给你展示如何去建造一个小型的应用程序以及如何去做一些事情,这些对你将建造的wxPython应用程序将是通用的。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文