🎶 Sym - 一款用 Java 实现的现代化社区(论坛/BBS/社交网络/博客)平台

📕 思源笔记 - 一款桌面端笔记应用,支持 Windows、Mac 和 Linux

🎸 Solo - B3log 分布式社区的博客端节点,欢迎加入下一代社区网络

♏ Vditor - 一款浏览器端的 Markdown 编辑器

使用图形编辑框架创建基于 Eclipse 的应用程序

GEF 概述

GEF 假定您拥有一个希望以图形方式显示和编辑的模型。为了做到这一点,GEF 提供了可在 Eclipse 工作台中任何地方使用的查看器(类型为 EditPartViewer )。象 JFace 查看器一样,GEF 查看器是 SWT 控件上的适配器。但是它们的类似之处仅此而已。GEF 查看器基于模型-视图-控制器(model-view-controller,MVC)体系结构。

控制器作为视图和模型之间的桥梁(请参阅图 1)。每个控制器(即本文所谓的 EditPart)负责将模型映射到它的视图,也负责对模型进行更改。EditPart 还观察模型并更新视图,以反映模型状态中的变化。EditPart 是一种对象,用户将与这种对象进行交互。稍后将更详细地介绍 EditPart。

图 1. 模型-视图-控制器
模型-视图-控制器

GEF 提供了两种查看器类型:图形的和基于树的。每种查看器都主管一种不同类型的 视图。图形查看器使用了在 SWT 画布(Canvas)上绘制的 图形(figure)。图形是在 Draw2D 插件中定义的,该插件是 GEF 的一部分。TreeViewer 将 SWT 树和 TreeItem 用于其视图。





回页首


第 1 步. 选定自己的模型

GEF 对于模型一无所知。任何模型类型都可工作,只要它符合下面描述的特性。

模型中有什么?

所有东西都在模型中。模型是唯一会被持久存储和恢复的东西。您的应用程序应当将所有重要数据都存储在模型中。在编辑、撤销和重做的过程中,模型是唯一保持不变的。随着时间推移,将对图形和 EditPart 进行垃圾收集处理并重新创建。

当用户与 EditPart 交互时,EditPart 并不直接操作模型。而是创建一个封装了更改的 命令(Command)。命令可用来验证用户的交互,并且提供撤销和重做支持。

严格地说,命令概念上也是模型一部分。它们 本身并不是模型,而是一些方法,模型是由这些方法编辑的。命令用于执行用户的所有可撤销的更改。理论上,命令应当只了解模型。它们应当避免引用 EditPart 或图形。类似地,如果可能,命令应当避免调用用户界面(例如弹出式对话框)。

两个模型的故事

一个简单的 GEF 应用程序就是用于绘制图的编辑器。(这里 只意味着图片,而不是类图等)图可以被建模成某些形状。一个形状可能具有位置、颜色等特性,并且可能是多个形状构成的一组结构。这里没有什么可惊讶的,并且前述需求也易于维护(请参阅图 2)。

图 2. 一个简单的模型
一个简单的模型

另一种常见的 GEF 应用程序是 UML 编辑器,例如类图编辑器。图中的一段重要信息就是 (x, y) 位置,类就出现在该位置上。根据前一节的介绍,您可能会以为模型必须将一个 描述成具有 xy特 性。大多数开发人员都不希望由于无意义的属性而“污染”其模型。在这类应用程序中,术语“业务”模型可用于指代基本模型,重要语义的详细信息存储在基本模 型中。而特定于图的信息存储在“视图”模型(它指的是业务模型中某样东西的“视图”;在一个图中可多次查看某个对象)中。有时候这种划分甚至会反映在工作 空间中,其中不同的资源可能被分别用来持久存储图和业务模型。甚至可能有多个图对应于同一个业务模型(请参阅图 3)。

图 3. 划分成业务模型和视图模型的模型
划分成业务模型和视图模型的模型

不管您的模型划分成了两个部分,还是划分成了多个资源,对于 GEF 而言这都是无关紧要的。术语模型用于指代整个应用程序模型。屏幕上的一个对象可能对应于模型中的多个对象。GEF 旨在允许开发人员方便地处理这类映射。

通知策略

对视图进行更新几乎总是由来自模型的通知而导致的。您的模型必须提供某种通知机制,该机制必须映射到您应用程序中相应的更新。而只读模型或不能进行通知的模型(例如文件系统或远程连接)可能是例外。

通 知策略通常是分布式的(每对象)或集中式的(每域)。域通知器了解到对模型中任何对象的每次更改,然后将这些更改向域侦听器广播。如果您的应用程序使用了 这种通知模型,您可能要为每个查看器添加一个域侦听器。当该侦听器接收到更改时,它将查找受影响的 EditPart,然后适当地重新分派该更改。如果您的应用程序使用了分布式通知,那么每个 EditPart 通常都将把自己的侦听器添加到任何一个影响它的模型对象。





回页首


第 2 步. 定义视图

下 一步是决定将如何使用来自 Draw2D 插件的图形显示您的模型。某些图形可直接用来显示模型的某个对象。例如,Label 图形可用来显示 Image 和 String。有时候,通过组合多个图形、布局管理器和/或边框可以获得期望的结果。最后,您可能要编写自己的图形实现,该实现以特定于您应用程序的方式 绘图。

有关组合或实现图形和布局的更多信息可在 Draw2D 开发人员指南中找到,该指南包含在 GEF SDK 中。

在和 GEF 一起使用 Draw2D 时,通过遵循下列方针,可以使您的项目更便于管理,并且可以更灵活地更改需求:

  • 不要从头做起。 您可以组合所提供的布局管理器以呈现大多数东西。请考虑使用工具栏布局(在垂直或水平方向上)和边框布局的组合来组合多个图形。只有在万不得已时才编写自 己的布局管理器。作为参考,请查看 GEF 中提供的调色板。该调色板是使用 Draw2D 中的许多标准图形和布局呈现的。

  • 保持 EditPart 和图形之间“彻底”的分离。 如果您的 EditPart 使用了几个图形、布局和/或边框的复合结构,那么请尽量对 EditPart 隐藏详细信息。让 EditPart 自己构建所有东西是可能的(但不是个好主意)。不过这样做并不会导致控制器和视图之间“彻底”分离。EditPart 非常熟悉图形结构,因此以类似的 EditPart 重用该结构是不可能的。此外,更改外观或结构可能会导致意想不到的错误。

    替代方法是,您应当编写自己的 Figure 子类,该子类掩藏了图形结构的详细信息。然后定义这个子类最少数量的 API,EditPart(控制器)用这些 API 来更新视图。这种实践(称为 关注分离(separation of concerns))可提高重用机会并使错误更少。

  • 不要从图形引用模型或 EditPart。图形不应该具有对 EditPart 或模型的访问权。在某些情形中,EditPart 可能会将自己作为侦听器添加到图形,但是只会认为它是侦听器,而不是 EditPart。这种去耦合(de-coupling)实践也可以产生更多的重用机会。

  • 使用内容窗格。 有时候您拥有一个容器,该容器将包含其它图形元素,但是您需要在容器四周进行一些装饰。例如,一个 UML 类通常显示为框,其顶部标有类名,可能还有一些原型,而底部是为属性和方法保留的。通过组合多个图形可以做到这一点。第一个图形是类的标题框,而另一个图 形被指派为 内容窗格。该图形将最终包含表示属性和方法的图形。稍后在编写 EditPart 实现时,并不一定要表明应该将内容窗格用作所有子元素的父元素。




回页首


第 3 步. 编写您的 EditPart - 控制器

接下来我们将利用控制器(即 EditPart)将模型和视图联接起来。该步骤在 GEF 中放置“框架”。所提供的类是抽象的,因此客户实际上必须编写代码。结果证明,生成子类不仅是人们熟悉的方法,而且可能也是将模型映射到视图最灵活和简单的方法。

所提供的用于生成子类的基本实现有三种。对于出现在树查看器中的 EditPart 使用 AbstractTreeEditPart 。图形查看器中的继承 AbstractGraphicalEditPartAbstractConnectionEditPart 。本文将着重讨论图形 EditPart。相同的原理同样适用于树查看器。

EditPart 生命周期

在 编写您的 EditPart 之前,了解它们来自哪里,以及当不再需要它们时怎样处理它们,这是有帮助的。每个查看器都配置有一个用于创建 EditPart 的工厂(factory)。当您设置查看器的内容时,通过提供表示该查看器输入的模型对象,可以做到这一点。输入通常是最顶部的模型对象,通过该对象可遍 历其它所有对象。然后查看器可以使用自己的工厂来构造用于该输入对象的 内容 EditPart。之后,查看器中的每个 EditPart 将填充和管理其自己的子 EditPart(和连接 EditPart),当需要新的 EditPart 时,将委派给 EditPart 工厂,直到填充该查看器。当用户添加新的模型对象时,包含这些对象的 EditPart 将通过构造相应的 EditPart 做出响应。请注意,视图的构造与 EditPart 的构造是同时进行的。因此,构造每个 EditPart 并将它添加到它的父 EditPart 之后,视图(不管是图形还是树项)也会发生同样的过程。

一旦用户除去与某些 EditPart 对应的模型对象,就丢弃这些 EditPart。如果用户撤销了一个删除操作,那么用于表示被恢复对象而重新创建的 EditPart 与原先的 EditPart 是不同的。这就是为什么 EditPart 不能包含长期信息,以及为什么不应由命令引用的原因。

您的第一个 EditPart:内容 EditPart

您编写的第一个 EditPart 是对应于图本身的 EditPart。这个 EditPart 称为查看器的 内容。它对应于模型中最顶部的元素,并且其父元素为查看器的 EditPart(请参阅图 4)。根通过提供各种图形层(例如连接层和句柄层等)以及可能会在查看器级别提供的视图缩放或其它功能,为内容打下基础。请注意,根的功能不依赖于任何模型对象,GEF 为根提供了几个现成的实现。

图 4. 查看器中的 EditPart
查看器中的 EditPart

内 容的图形不是很有趣,并且它通常只是一个空面板,该面板将包含图的子图。它的图形应该为不透明类型(opaque),并且应当利用布局管理器进行初始化, 该布局管理器将对图的子图进行布局。但是,它将拥有结构。图的直系子图是由返回的子模型对象列表确定的。清单 1 显示了一个样本内容 EditPart,它创建了一个不透明类型的图形,后者将使用 XYLayout 定位其子图。


清单 1. 内容 EditPart 的初始实现
public class DiagramContentsEditPart extends AbstractGraphicalEditPart {
protected IFigure createFigure() {
Figure f = new Figure();
f.setOpaque(true);
f.setLayoutManager(new XYLayout());
return f;
}
protected void createEditPolicies() {
...
}
protected List getModelChildren() {
return ((MyModelType)getModel()).getDiagramChildren();
}
}

要确定图上的项,需要实现方法 getModelChildren() 。该方法返回子模型对象的列表(例如图中的节点)。超类将使用这个模型对象列表来创建对应的 EditPart。新创建的 EditPart 被添加到部件的子 EditPart 列表。这样又会将每个子 EditPart 的图形添加到示例图。缺省情况下,将返回一个空的列表,这表明没有子对象。

其它图形 EditPart

其 余的 EditPart(表示图中的项)可能拥有要以图形方式显示的数据。它们可能还拥有自己的结构,例如连接或自己的子 EditPart。许多 GEF 应用程序利用由标签注明的图标之间的连接来描述这些图标。让我们假定您的 EditPart 将使用 Label 作为它的图形,并且您的模型提供了名称、图标以及与标签之间的连接。清单 2 显示了实现这种类型 EditPart 的第一次尝试。


清单 2. “节点”EditPart 的初始实现
public class MyNodeEditPart extends AbstractGraphicalEditPart {
protected IFigure createFigure() {
return new Label();
}
protected void createEditPolicies() {
...
}
protected List getModelSourceConnections() {
MyModel node = (MyModel)getModel();
return node.getOutgoingConnections();
}
protected List getModelTargetConnections() {
MyModel node = (MyModel)getModel();
return node.getIncomingConnections();
}
protected void refreshVisuals() {
MyModel node = (MyModel)getModel();
Label label = (Label)getFigure();
label.setText(node.getName());
label.setIcon(node.getIcon());
Rectangle r = new Rectangle(node.x, node.y, -1, -1);
((GraphicalEditPart) getParent()).setLayoutConstraint(this, label, r);
}
}

这里覆盖了一个新方法 refreshVisuals() 。当需要利用来自模型的数据更新图形时,就调用该方法。在本案例中,模型的名称和图标被反映在标签中。但更为重要的是,该标签是通过将其布局约束传递给父 元素来定位的。在内容 EditPart 中,我们使用了 XY 布局管理器。该布局使用 Rectangle 约束来确定在何处放置子图形。宽和高的值为“-1”,表明应当为该图提供理想的大小。

技巧 #1

决不要使用 setBounds(...) 方法来放置图形。使用诸如 XYLayout 之类的布局管理器确保会正确地更新滚动条。另外,XYLayout 还处理将相对约束转换成绝对位置的工作,并且可用它来将图形自动调整为理想的大小(换而言之,如果约束的宽和高为 -1)。

方法 refreshVisuals() 仅在 EditPart 初始化的过程中调用一次,并且决不会被再次调用。在响应模型通知时,应用程序负责根据需要再次调用 refreshVisuals() 以更新图形。要改进性能,您可能希望将每个模型属性的代码分解成其自己的方法(或者是一个带有“开关(switch)”的方法)。这样,当模型发出通知时,可以运行最少的代码以便只刷新那些发生更改的内容。

另一个有趣的区别是连接支持的代码。与 getModelChildren() 类似, getModelSourceConnections()getModelTargetConnections() 应当返回表示节点之间连接的模型对象。超类在必要时创建对应的 EditPart,并将它们添加到源和目标连接 EditPart 列表。请注意,连接是由每端的节点引用的,而其 EditPart 只需创建一次。GEF 确保只创建一次连接,该工作是通过首先检查查看器中是否已经存在连接来完成的。





回页首


建立连接

编写连接 EditPart 实现没有太大的区别。首先生成 AbstractConnectionEditPart 的子类。跟前面一样,可以实现 refreshVisuals() ,以将属性从模型映射到图形。连接可能还拥有约束,尽管这些约束与前面的约束略有不同。这里,连接路由器使用约束来使连接转向(bend)。此外,连接 EditPart 的图形必须是 Draw2D Connection ,它引入了另一个需求:连接锚(connection anchor)。

连接必须由 ConnectionAnchor “锚定”在两端。因此,必须在连接 EditPart 中或在节点实现中表明使用哪些锚。缺省情况下,GEF 假定节点 EditPart 将通过实现 NodeEditPart 接口而提供锚。这样假定的一个原因是,锚的选择取决于各端上的节点正在使用的图形。连接 EditPart 不应了解节点正在使用的图形的任何内容。另一个原因是,当用户创建连接时,连接 EditPart 是不存在的,因此节点必须能够自己显示反馈。作为清单 2 的延续,我们在清单 3 中添加了必要的锚支持。


清单 3. 将锚支持添加到“节点”EditPart
public class MyNodeEditPart
extends AbstractGraphicalEditPart
implements NodeEditPart
{
...
public ConnectionAnchor getSourceConnectionAnchor(ConnectionEditPart connection) {
return new ChopboxAnchor(getFigure());
}
public ConnectionAnchor getSourceConnectionAnchor(Request request) {
return new ChopboxAnchor(getFigure());
}
public ConnectionAnchor getTargetConnectionAnchor(ConnectionEditPart connection) {
return new ChopboxAnchor(getFigure());
}
public ConnectionAnchor getTargetConnectionAnchor(Request request) {
return new ChopboxAnchor(getFigure());
}
...
}

技巧 #2

别忘记真正实现 NodeEditPart 接口。否则您的方法将永远不会被调用。

以 连接(connection)为参数的方法是对现有连接 EditPart 设置锚时使用的方法。其它两个方法以请求(request)作为参数。这些方法是用户创建新连接时的编辑过程中使用的。对于本示例,所有情形都将返回 chopbox 锚。chopbox 锚只查找线与节点图形的边框相交的点。

实现连接 EditPart 是比较简单的。请注意,甚至无需创建图形,因为缺省的 PolylineConnection 创建适合于大多数场合(请参阅清单 4)。


清单 4. 初始的连接 EditPart 实现
public class MyConnectionEditPart extends AbstractConnectionEditPart {
protected void createEditPolicies() {
...
}
protected void refreshVisuals() {
PolylineConnection figure = (PolylineConnection)getFigure();
MyConnection connx = (MyConnection)getModel();
figure.setForegroundColor(MagicHelper.getConnectionColor(connx));
figure.setRoutingConstraint(MagicHelper.getConnectionBendpoints(connx));
}
}

技巧 #3

最重要的是了解何时使用以及何时不使用 ConnectionEditPart。当用户可以选择某些东西并可与之进行交互的时候,就使用连接 EditPart。它可能与模型中的某个对象直接相关,并且通常可由自己删除。

如果您只是拥有一个需要绘制直线的节点或容器,那么只须用图形的 paint 方法绘制直线,或者组合一个图形,该图形包含 Polyline 图形。

连接始终都必须拥有源和目标。如果您需要一个连接,该连接可以在没有源或没有目标的情况下存在,那么比较好的方法是只继承 AbstractGraphicalEditPart ,并使用连接图形。

侦听模型

创 建 EditPart 之后,它应该开始侦听来自模型的更改通知。由于 GEF 是与模型无关的,因此所有应用程序都必须添加自己的侦听器,并处理产生的通知。接收到通知时,处理程序可以调用某个提供的方法来强制进行一次刷新。例如, 如果删除了一个子模型对象,那么调用 refreshChildren() 将导致对应的 EditPart 及其图形被除去。对于简单的属性更改,可以使用 refreshVisuals() 。正如我们前面提及的,可将该方法分解成几个部分,从而避免没有必要地更新每个显示的属性。

添加侦听器但却忘记除去它们是导致内存泄漏的常见原因。出于这个原因,添加和除去侦听器的地方应该在 API 中清晰地注明。您的 EditPart 必须继承 activate() ,以便添加稍后必须除去的任何侦听器。通过继承 deactivate() 可除去那些相同的侦听器。清单 5 显示了向节点 EditPart 实现添加的模型通知内容。


清单 5. 侦听“节点”EditPart 中的模型更改
public class MyNodeEditPart
extends AbstractGraphicalEditPart
implements NodeEditPart, ModelListener
{
...
public void activate() {
super.activate();
((MyModel)getModel()).addModelListener(this);
}
public void deactivate() {
((MyModel)getModel()).removeModelListener(this);
super.deactivate();
}
public void modelChanged(ModelEvent event) {
if (event.getChange().equals("outgoingConnections"))
refreshSourceConnections();
else if (event.getChange().equals("incomingConnections"))
refreshTargetConnections();
else if (event.getChange().equals("icon")
|| event.getChange().equals("name"))
refreshVisuals();
}
...
}

编辑模型

到 目前为止,我们已经讲解了如何创建 EditPart,它们如何创建自己的可视图(visual)以及当模型发生变化时它们如何自我更新。除此之外,EditPart 也是对模型进行更改的主要参与者。当命令的请求被发送给 EditPart 时就会发生这种情况。请求还用来要求 EditPart 显示诸如在鼠标拖动期间发生的反馈。EditPart 支持、阻止或忽略给定的请求。所支持或阻止的请求类型决定了 EditPart 的行为。

到目前为止,侧重点都是将模型的结构和特性映射到视图。结果表明,这基本上是您在 EditPart 类自身中所做的所有工作。其行为是由一组名为 EditPolicies 的可插入的助手类决定的。在所提供的示例中,我们忽略了方法 createEditPolicies() 。一旦您实现该方法,您就几乎已经完成了您的 EditPart。当然,您仍需要提供编辑策略,该策略知道如何修改您应用程序的模型。

因为编辑行为是可插入的,所以在开发各种 EditPart 实现时,可以根据将模型映射到视图和处理模型更新这种任务,来创建类层次结构。





回页首


第 4 步. 将所有内容组合在一起

现在,您已经完成了以图形方式显示您的模型所需的所有部分。对于最后的组装,我们将使用 IEditorPart 。但是,也可以在视图、对话框或者可以放置控件的几乎任何地方使用 GEF 的查看器。对于本步骤,您必须拥有自己的 UI 插件,它将为正在打开的资源定义编辑器和文件扩展名。可在同一个插件或一个不同的插件中定义您的模型。您还需要一个预填充的模型,因为目前还没有编辑功 能。

提供样本模型数据的方法有几种。对于本示例而言,当编辑器打开时,我们将在代码中创建模型,从而忽略了文 件的实际内容。要做到这一点,我们假定已经存在一个测试工厂。或者,您可以创建一个示例向导,该向导用数据对资源进行预填充(普通向导只创建空图)。最 后,您可以利用文本编辑器以手工方式编写文档的内容。

既然您有了样本模型,那么让我们创建将显示模型的编辑器部件。有一种快速的方法,就是生成子类或复制 GEF 的 GraphicalEditor 。该类创建 ScrollingGraphicalViewer 的一个实例,并且构造一个画布来充当编辑器的控件。它是一个很方便的类,用来帮助您开始使用 GEF;一个可以正确工作的 Eclipse 编辑器需要考虑很多其它事情,例如不利的团队环境(pessimistic team environment)和要删除或移动资源等。

清单 6 显示了一个样本编辑器实现。有几个必须实现的抽象方法。出于本文所讨论范围的限制,我们将忽略模型持久性和标记。要让您的图出现在图形查看器中,您必须做 两件事情。首先,利用自己的 EditPart 工厂配置查看器,以从第 3 步构造 EditPart。然后,将图模型对象传递给查看器。


清单 6. 实现您的编辑器部件(Editor Part)
public class MyEditor extends GraphicalEditor {
public MyEditor() {
setEditDomain(new DefaultEditDomain(this));
}
protected void configureGraphicalViewer() {
super.configureGraphicalViewer(); //Sets the viewer's background to System "white"
getGraphicalViewer().setEditPartFactory(new MyGraphicalEditpartFactory());
}
protected void initializeGraphicalViewer() {
getGraphicalViewer().setContents(MagicHelper.constructSampleDiagram());
}
public void doSave(IProgressMonitor monitor) {
...
}
public void doSaveAs() {
...
}
public void gotoMarker(IMarker marker) {
...
}
public boolean isDirty() {
...
}
public boolean isSaveAsAllowed() {
...
}
}





回页首


接下来的步骤

我们已经经历了从仅拥有一个模型到在图形编辑器中显示该模型的全过程。但是我们只打下了基础。我们简要提及了编辑策略。通过阅读与 GEF SDK 一起提供的开发人员文档,可以获得有关编辑策略的更多信息。还可从 GEF 的主页(请参阅文章结尾的 参考资料)获得一个示例,该示例演示了如何使用各种编辑策略类型。

GEF 还提供了一个调色板。该调色板显示了一组工具,用于在图中创建对象。用户可以激活这些工具,或者使用本机拖放直接从该调色板拖动项。它还支持让用户定制内容。

在 GEF 中还可以使用几个 JFace 操作。应用程序可以在菜单、工具栏或上下文菜单中使用诸如撤销、对齐和删除之类的操作。

最 后,您的应用程序应当支持大纲(outline)视图和特性(properties)视图。大纲视图用于导航和有限的编辑用途。GEF 的 TreeViewer 和/或概述(overview)窗口可以在这里使用。特性表(property sheet)允许用户查看和编辑任何当前选定项的详细特性。

为了显示所选择的项并允许用户进行更改,您必须将编辑策略添加到 EditPart。请参阅 GEF 主页上的示例,并参阅与 GEF SDK 一起提供的开发人员文档。

有关 GEF 和 Eclipse 工作台提供的其它功能的详细信息不在本文的讨论范畴之内,但是您可能有兴趣对它们稍做了解:

  • 调色板。该工具的调色板是用于在图中创建新对象的 实际方法。GEF 包含了一个功能丰富的调色板,它支持拖放、多绘图程序和布局设置,如果应用程序希望,它甚至可支持让用户定制内容。
  • 操作栏。编辑器(Editor)和视图(View)可以为工具栏、菜单和上下文菜单提供操作(Action)。GEF 提供了几个可重用的操作实现,但是将它们显示在什么地方取决于应用程序。
  • 特性表。特性表可用于显示所选项特性的详细信息。GEF 允许您在 EditPart 上或在模型中添加特性表支持。
  • 大纲。大纲视图通常用于显示图的结构化表示,但是一般来说,它可用于任何工作。GEF 的 TreeViewer 通常在大纲视图中使用。


参考资料



关于作者


Randy Hudson 是北卡罗来纳州 Research Triangle Park 的 IBM 软件工程师。作为图形编辑框架(Graphical Editing Framework,GEF)的技术领导,他帮助将这个曾经是内部的项目转变成了开放源码技术。他目前的工作侧重于可用性、图形编辑、图形布局和边缘路由 (edge routing)。可以通过 buchu at nc.rr.com与 Randy 联系。

 

欢迎注册黑客派社区,开启你的博客之旅。让学习和分享成为一种习惯!

留下你的脚步