Copyright © 2004 Cisco Systems Inc.
 Eclipse Corner Article

tag

 

几何图形编辑器

概要

图形编辑框架(Graphical Editing Framework - GEF)为创建用于可视化编辑任意模型的编辑器提供了强大的基础。它的功能依赖于模块化的结构,合理选用的设计模式,和相对独立的组件,这些组件构成了一个完整的编辑器。对于一个新手来说,GEF中所涉及的大量概念和技术可能是令人难以承受的。然而,一旦这些技术被掌握并正确使用,它们就可以帮助开发出具有高扩展性和维护性的软件。本文将对GEF作相对全面的介绍。文中将描述一个几何图形编辑器作为例子-它虽然简单,但是覆盖了GEF中的核心概念。

作者 Bo Majewski, Cisco Systems, Inc.
2004年12月8日

译者 Qi Liang
2005年9月7日


简介

绘图编辑框架(GEF)被设计用来以图形而不是文本的方式来编辑用户数据,一般被称为模型model)。当处理包含多对多,一对多以及其他复杂关系的实体时,GEF是一种很有价值的工具。随着Eclipse Rich Client Platform 的流行,使得编辑器的开发不仅仅局限于编程,GEF的重要性也与日俱增。比如说,数据库schema编辑器 [7],逻辑电路编辑器和任务流管理器,这些例子都很好地展示了GEF是一种可以用于各个不同领域的,具有强大功能和灵活性的框架。

然而,任何通用框架都设计复杂,难于学习,GEF也不例外。到现在为止,最小的例子也将涉及75个类。即使对于最勤勉的开发者来说,要从GEF用户定义类型和GEF提供的上百种类型之间相互作用来理解GEF的独特之处,对耐心和智力的都是一种考验。为了改变这种状况,一个全新的,规模更小的编辑器例子被添加进即将到来的Eclipse 3.1(译:翻译此文时,Eclipse 3.1已经发布)。这个几何图形编辑器(看图1)允许你创建,编辑简单的图。它处理两种对象,矩形和椭圆。你可以在实线和虚线这两种连接类型中选择一种来连接两个对象。每一个连接都是有方向的,也就是说从一个源对象开始,在目标对象处终止。箭头用来表示连接方向。连接可以转移,也就是通过拖动它的源点或目标点到一个新的对象上。编辑器中的对象可以点击选中,也可以通过拖拉一个区域来选择。选中的对象可以被删除。所有的模型操作,比如添加,删除对象,移动对象,改变大小等等,都可以undo或redo。最后,编辑器集成了两个Eclipse标准视图PropertiesOutline。这个编辑器的价值不是在于它的可用性,而是作为例子,通过有限的两种用户定义类型来演示在一个成熟GEF编辑器中会碰到的大多数概念和技术。

Screen shot of the diagram editor
图 1. 运行在Linux下的几何图形编辑器

Try it!将最新的Eclipse 3.1 GEF例子从GEF项目下载页面下载下来,并解压缩至你的Eclipse目录中。按Ctrl-N,会弹出创建向导,将Examples目录展开,选择Shapes Diagram。下面给出几何图内部工作的详细的全面介绍。在我们接触代码前,我们先来看看GEF主要思想。

GEF核心概念

GEF帮助你为数据构造一个可视化的编辑器。数据可以是带有简单温度旋钮的温度调节器,也可以是一个包含几百个路由器,连接和服务质量策略的虚拟局域网。幸亏GEF设计者,他们设法建立一种框架,使得它能够和任何数据一起工作,用GEF的术语来说,就是任何模型(model)。这是通过严格遵循了模型-视图-控制器模式(MVC)来做到的。模型就是你的数据。对于GEF,模式可以是任何普通的Java对象(POJO)。模型不应该知道任何有关于控制器或视图的信息。视图(view)是模型或其某一部分在屏幕上的可视化表示。它可以是矩形,线或椭圆这样的简单图形,也可以是彼此嵌套的逻辑电路。同时,视图也应该对模型和控制器一无所知。虽然任何实现IFigure接口的类都可以作为视图,但是GEF使用Draw2D可视图形(figure)控制器,可称为编辑部件(edit part),是模型和视图之间的桥梁。当你开始编辑你的模型时,一个顶层的控制器被创建出来。如果模型由若干个片段组成,顶层控制器就会将这个信息通知GEF。接下来,每个片段的子控制器被创建出来。如果它们又包含子片段,这个过程就会一直继续下去,直到所有组成模型的对象都有它们的控制器。控制器的另一个任务是创建可视图形来表示模型。一旦模型被设置到某个控制器,GEF就向控制器要合适的IFigure对象。既然模型和视图彼此都不知道对方,控制器负责监听模型的修改,并更新模型的可视化表示。结果,在许多GEF编辑器中,一个常见的模式就是模型发PropertyChangeEvent通知。当一个编辑部件收到事件通知时,它通过调整模型的外观或结构上的表示来作相应的改变。

可视编辑的另一个方面就是对用户动作和鼠标,键盘事件作出响应。这里的挑战在于提供一种机制,提供合理的缺省行为,并且允许重新定义行为来覆盖缺省行为,以适应所编辑模型。比如鼠标拖动事件,如果我们假设每次检测到鼠标拖动事件,所选中对象都被移动的话,我们就限制编辑器开发者的自由。很有可能有人希望在鼠标拖动的时候,提供放大,缩小的行为。GEF通过使用工具(tool),请求(request)和策略(policy)解决了这个问题。

工具是一种有状态的对象,它将象鼠标按钮被按下,被拖动等低层事件翻译成高层的由Request对象表示的请求。发送哪个请求取决于所激活的工具。例如,连接工具在收到鼠标按钮被按下这样的事件时,会发送一个连接开始或结束的请求。如果是一个创建工具,我们就会收到一个创建请求。GEF包含了大量预定义的工具以及创建应用特定工具的方法。工具可以由程序控制激活,也可以在用户实施一个动作后激活。在大多数情况下,工具将请求发送给鼠标位置下面的图形的EditPart。例如,如果你点击一个代表widget的矩形,与此相关的编辑部件就会收到一个选中请求或者直接编辑的请求。有时候,请求会发送给区域中的所有可视图形的编辑部件,比如MarqueeSelectionTool就是这样。无论一个或多个编辑部件怎样被选择为请求目标,它们自己并不处理请求。而是将这个任务交给所注册的编辑策略edit policies)。每个编辑策略都会为该请求提供一个命令。不希望处理请求的策略将返回一个null。使用策略而不是编辑部件来响应请求的机制使得策略和编辑部件都尽可能短小,功能集中。同时,也意味着调试和维护代码变得更容易。GEF的最后一个部分就是命令command)。GEF并没有直接修改模型,它要求你使用命令来做实际的修改。每个命令应该实现执行对模型或模型一部分的修改和撤销修改。这样,GEF编辑器自动支持模型修改的undo/redo。

除了能够提升你的技能以及设计模式方面的知识外,使用GEF的一个重要的优点在于它能够和Eclipse平台完全集成在一起。在编辑器中选中的对象可以为标准Properties视图提供属性。Eclipse向导可以用来创建,初始化GEF编辑器编辑的模型。Edit菜单中的UndoRedo可以触发GEF编辑修改的撤销和重做。简单地说,GEF编辑器实现IEditorPart接口,是Eclipse平台中的一员,它和文本编辑器或其他workbench编辑器处于同样的集成层次。

模型

创建GEF编辑器的第一步是创建模型。在我们的例子里,模型由四类对象组成:几何图(包含所有的图形),两种类型的图形,和图形间的连接。在我们为这些类编写代码前,我们准备了一些基础结构。

核心模型类

当你创建模型时,你可以参考下面的内容:

上面所列的规则对于所有模型都是相同的,为基本类建立类层次来强化这些规则是很有好处的。ModelElement类继承了Java的Object类,并提供了三个功能:持久化,属性改变和属性源支持。简单的模型持久化可以通过实现tagjava.io.Serializable接口以及tagreadObject方法来完成。这使得你可以将编辑器的模型以二进制格式存储。当需要和某种应用一起工作时,这并不能提供的格式的可移植性。在复杂的情况下,你需要实现将模型以XML或类似的格式存储。模型的改变通过属性改变事件来通知。这个基本类允许编辑部件tag注册和tag撤销注册为属性改变通知的接受者。属性改变通知是通过调用tagfirePropertyChange方法触发的。最后,为了帮助和workbench的Properties视图集成,需要实现IPropertySource接口(细节在图2中忽略)。

public abstract class ModelElement implements tag IPropertySource, tag Serializable {

    private transient PropertyChangeSupport pcsDelegate = 
        new PropertyChangeSupport(this);
    
tag public synchronized void addPropertyChangeListener(PropertyChangeListener l) {
        if (l == null) {
            throw new IllegalArgumentException();
        }
        
        pcsDelegate.addPropertyChangeListener(l);
    }
    
tag protected void firePropertyChange(String property, 
                                      Object oldValue, 
                                      Object newValue) {
        if (pcsDelegate.hasListeners(property)) {
            pcsDelegate.firePropertyChange(property, oldValue, newValue);
        }
    }
    
tag private void readObject(ObjectInputStream in) throws IOException, 
                                                         ClassNotFoundException {
        in.defaultReadObject();
        pcsDelegate = new PropertyChangeSupport(this);
    }
    
tag public synchronized void removePropertyChangeListener(PropertyChangeListener l) {
        if (l != null) {
            pcsDelegate.removePropertyChangeListener(l);
        }
    }
    
    ...
}
图 2. 所有模型对象的基类

椭圆和矩形这两类对象,在许多方面是相同的,它们的公共功能可以被提取出来放在公共类中。尤其是两者都代表着占据某个位置,具有一定大小的对象。它们可以彼此连接。这些属性的任何修改都需要通知监听者。更进一步地说,它们的位置和大小属性都可以通过IPropertySource接口暴露,这允许用户通过Properties视图来查看,和修改它们。

对象间连接的管理很值得仔细看一下。这里并没有一个全局的用于存储所有连接的地方。GEF要求模型部件报告它们之间的连接的情况,是源还是目标。这些信息都以List对象的形式提供。Shape类维护了两个数组列表,分别存储tag源连接和tag目标连接。源连接是指那些以当前图形作为源的连接,目标连接是指以当前图形作为目标的连接。两个包可见方法(tagtag)使得图形和连接可以彼此知道相互之间的关系。此外,两个公有方法(tagtag)使得model包外面的类知道图形的连接情况。这些方法都会被相关的图形(形状)控制器所使用,具体内容将在接下来的部分中加以介绍。

public abstract class Shape extends ModelElement {

    private Point location = new Point(0, 0);
    private Dimension size = new Dimension(50, 50);
tag private List sourceConnections = new ArrayList();
tag private List targetConnections = new ArrayList();

    public Point getLocation() {
        return location.getCopy();
    }
    
    public void setLocation(Point newLocation) {
        if (newLocation == null) {
            throw new IllegalArgumentException();
        }
        location.setLocation(newLocation);
        firePropertyChange(LOCATION_PROP, null, location);
    }
    
tag void addConnection(Connection conn) {
        if (conn == null || conn.getSource() == conn.getTarget()) {
            throw new IllegalArgumentException();
        }
        if (conn.getSource() == this) {
            sourceConnections.add(conn);
            firePropertyChange(SOURCE_CONNECTIONS_PROP, null, conn);
        } else if (conn.getTarget() == this) {
            targetConnections.add(conn);
            firePropertyChange(TARGET_CONNECTIONS_PROP, null, conn);
        }
    }
    
tag void removeConnection(Connection conn) {
        if (conn == null) {
            throw new IllegalArgumentException();
        }
        if (conn.getSource() == this) {
            sourceConnections.remove(conn);
            firePropertyChange(SOURCE_CONNECTIONS_PROP, null, conn);
        } else if (conn.getTarget() == this) {
            targetConnections.remove(conn);
            firePropertyChange(TARGET_CONNECTIONS_PROP, null, conn);
        }
    }
    
tag public List getSourceConnections() {
        return new ArrayList(sourceConnections);
    }
    
tag public List getTargetConnections() {
        return new ArrayList(targetConnections);
    }
    
    ...
}
图 3 图形功能

顶层模型类

通过上面的准备,我们可以开始编写顶层模型类。Connection类表示两个图形间的连接。它存储连接的源和目标。通过调用disconnectreconnect方法可以修改连接。连接含有一个boolean值来表示连接是否存在。命令会使用这个值来验证某种操作的合法性。源连接和目标连接都保持一个到源图形的引用,这样使得被断开的连接可以很容易地被重新连接。连接包含一个属性,就是线的类型。EllipticalShapeRectangularShape类都扩展了Shape类,添加了很少的功能。

ShapeDiagram类是ModelElement类的子类,它可以作为一种容器。它维护一组图形,并通知监听器这组图形的变化。命令可以调用tagaddChildtagremoveChild方法,并检查返回的boolean值来验证它们的操作。这个类也提供了tag公共方法给控制器类。

public class ShapesDiagram extends ModelElement {

    ...    
    private Collection shapes = new Vector();
    
tag public boolean addChild(Shape s) {
        if (s != null && shapes.add(s)) {
            firePropertyChange(CHILD_ADDED_PROP, null, s);
            return true;
        }
        return false;
    }
    
tag public List getChildren() {
        return new Vector(shapes);
    }
    
tag public boolean removeChild(Shape s) {
        if (s != null && shapes.remove(s)) {
            firePropertyChange(CHILD_REMOVED_PROP, null, s);
            return true;
        }
        return false;
    }
}
图 4. ShapeDiagram - 图形的容器

实现上需要注意的地方

细心的读者一定意识到这个模型创建了一个有向图的实现,图形作为顶点,连接作为边,所有图形,连接构成的图就是图。这里所形成的表示方式称为邻接点列表表示法,它很适合稀疏图。只要略作修改,这个模型的代码就可以转变为一般的图表示。这里对算法书中的图实现所需要做的就是添加代码使得图,节点,和边在发生改变的时候发送事件。不象数学上的图,节点不是零维的点,而是有矩形边框。最后,图存储了所有的边,而图形并没有存储连接,因为GEF并没有要求这么做。

值得注意的是,由上面的类所提供的解决方案并不是唯一的方法。那些开发计算机图形的人更愿意用另一种方法来存储连接,安排节点和边之间的通信。然而,这些细节并不是那么重要。设计者可以自由地选择他们认为更具普遍性,更快,或者功能更强的模型表示。关键的地方在模型改变的消息通知,模型修改的维护,包括对可视属性和模型持久化的支持。其余的都取决于你的经验和需要,你可以自由地进行选择。

视图

由于这个图形编辑器非常的简单,我们不必创建可视图形来表示我们的模型,而是使用预定义的可视图形。Figure类加上FreeformLayout布局管理器用来表示图。这允许我们将对象拖放到任何位置。RectangleFigureEllipse都可以表示对象。使用预定义的可视图形来表示部分模型并不是通常的做法。即使你的视图没有引用模型或控制器,它都必须为每个用户可能需要查看或修改的模型重要方面都定义可视化属性。因此常常会定义拥有大量可视化属性,比如颜色,文本,嵌套可视图形等,的复杂可视图形,每个属性都对应于它们所表示的模型属性。有关创建复杂可视图形的详细处理,请参考 [4]

部件(part)

对于模型的每个独立部分,我们都必须定义控制器。所谓“独立”,指的是这个实体都可以作为用户操作的对象。一个比较好的原则就是任何可以被选择,或删除的对象都应该有它自己的编辑部件。

编辑部件知道模型,监听模型改变所产生的事件,然后更新视图。由于在模型层所做的设计选择,所有的编辑部件都必需遵循图5所示的模式。每个部件tag都实现PropertyChangeListener接口。当它被激活时tag,它将自己注册为模型的属性修改事件的接收者。当失活时tag,它将自己从监听器的列表中移除。最后,当它收到属性修改事件时tag,它会根据属性名和新旧值来刷新表示模型的可视图形。事实上,这个模式使用非常普遍,在大的应用中,它会建立一个基类来提供这样的行为。

public abstract class SpecificPart extends AbstractGraphicalEditPart
tag    implements PropertyChangeListener {
    
tag public void activate() {
        if (!isActive()) {
            super.activate();
            ((PropertyAwareModel) this.getModel()).addPropertyChangeListener(this);
        }
    }

tag public void deactivate() {
        if (isActive()) {
            ((PropertyAwareModel) this.getModel()).removePropertyChangeListener(this);
            super.deactivate();
        }
    }
    
tag public void propertyChage(PropertyChangeEvent evt) {
        String prop = evt.getPropertyName();
		...    
    }
}
图 5. 知道属性变化的编辑部件

DiagramEditPart

当编辑器成功载入一个几何图,并将它设置在一个图形viewer上,就要求ShapesEditPartFactory创建一个编辑部件来控制图。它创建一个新的DiagramEditPart实例,并将图设置为它的模型。当新创建的编辑部件被激活时,它将自己注册为模型的监听器,并创建一个使用free form布局管理器的可视图形,这种布局管理器允许通过它们的边界来定位图的可视图形。DiagramEditPart通过getModelChildren方法来获取图中包含的所有图形。就象前面提到的,GEF为返回的所有子模型对象都会创建编辑部件和可视图形。

DiagramEditPart类安装了三个策略。所有的策略都在AbstractEditPart类的createEditPolicies方法中定义,同时所有继承自AbstractGraphicalEditPart的实类都必需实现这个方法。编辑部件使用这些策略来处理工具发出的请求。在最简单的情况下,策略负责生成许多命令。策略使用String类型的索引字注册在编辑部件上,这个索引字被称为策略角色。这些索引字对编辑部件本身来说没有什么意义。然而,对软件开放人员,就有意义了,它使得其他人,尤其是扩展你的控制器的人,可以通过这些索引字来关闭或移除策略。就GEF而言,你的索引字可以是“foobar”。然而,你最好告诉你程序员同伴,当布局管理器改变的时候,为了设置新的布局策略,需要安装新的“foobar”策略。由于这可能很有趣,且不是那么显而易见,所以推荐你使用EditPolicy接口定义索引字,这些名字需要很好的表达该策略在编辑部件中的角色。

安装的第一个策略tag的索引字是EditPolicy.COMPONENT_ROLE,它负责阻止模型的根被删除。它重写了createDeleteCommand方法,并返回一个不能被执行的命令。第二个策略tag的索引字是LAYOUT_ROLE,它处理创建请求和边界修改请求。当新的图形被放置到图中,第一个请求被发送出来。布局策略返回一个命令,这个命令添加新的图形到图编辑器中,并把它放置在适当的位置。用户修改图中已存在的图形大小或移动它时,都会发出边界修改请求。第三个installEditPolicy调用tag删除一个策略。它在用户点击模型根所在区域时,阻止根部件提供选择反馈。这里也可以看出一个有意义的策略索引字的重要性。

protected void createEditPolicies() {
tag installEditPolicy(EditPolicy.COMPONENT_ROLE, new RootComponentEditPolicy());
    XYLayout layout = (XYLayout) getContentPane().getLayoutManager();
tag installEditPolicy(EditPolicy.LAYOUT_ROLE, new ShapesXYLayoutEditPolicy(layout));
tag installEditPolicy(EditPolicy.SELECTION_FEEDBACK_ROLE, null);
}
图 6. 图编辑部件中安装的策略 (译:这里和Eclipse 3.1 GEF提供的例子代码略有出入)

图编辑部件监视子编辑部件的添加,移除事件。当任何新的图形添加或移除时,ShapesDiagam类将发送这些事件。当图编辑部件检测到这两种属性修改事件时,图编辑部件都会调用AbstractEditPart类中定义的refreshChildren方法。这个方法会遍历所有子模型对象,并相应地添加,移除,或重新排序子编辑部件。

ShapeEditPart

ShapeEditPart类管理所有的图形。当DiagramEditPart会返回子模型列表时,ShapeEditPartShapesEditPartFactory类根据每个模型对象的类型创建。工厂类创建的每个部件都拥有一个它们所控制的子模型。一旦模型对象被设置,编辑部件被要求创建可视图形来表示模型对象。根据模型对象的类型,返回椭圆或矩形的编辑部件。

这个编辑部件关注四类属性修改事件:大小,位置,源连接,和目标连接。如果图形改变了大小或位置,tagrefreshVisual方法会被调用。这个方法在可视图形被创建的时候就会由GEF自动调用。在这个方法中,可视图形的可视属性应该根据模型的状态做相应调整。重用模型更新方法是GEF编辑器中经常碰到的又一种模式。在我们这个编辑部件类中,新的位置和大小被获取并储存在表示图形的可视图形中。此外,新的边界会传给父控制器的布局管理器。当源连接或目标连接改变时,源连接或目标连接改编辑部件会调用AbstractGraphicalEditPart类中的方法刷新。和refreshChildren方法相似,这些方法会遍历所有的连接,并相应添加,删除,或重新定位它们的编辑部件。

class ShapeEditPart extends AbstractGraphicalEditPart 
tag     implements PropertyChangeListener, NodeEditPart {
    
tag protected List getModelSourceConnections() {
        return getCastedModel().getSourceConnections();
    }
    
tag protected List getModelTargetConnections() {
        return getCastedModel().getTargetConnections();
    }
    
tag public ConnectionAnchor getSourceConnectionAnchor(ConnectionEditPart connection) {
        return new ChopboxAnchor(getFigure());
    }
    
tag public ConnectionAnchor getSourceConnectionAnchor(Request request) {
        return new ChopboxAnchor(getFigure());
    }
    
    public void propertyChange(PropertyChangeEvent evt) {
        String prop = evt.getPropertyName();
        
        if (Shape.SIZE_PROP.equals(prop) || Shape.LOCATION_PROP.equals(prop)) {
            refreshVisuals();
        }
        if (Shape.SOURCE_CONNECTIONS_PROP.equals(prop)) {
            refreshSourceConnections();
        }
        if (Shape.TARGET_CONNECTIONS_PROP.equals(prop)) {
            refreshTargetConnections();
        }
    }
    
tag protected void refreshVisuals() {
        Rectangle bounds = new Rectangle(getCastedModel().getLocation(),
                                         getCastedModel().getSize());
        figure.setBounds(bounds);
        ((GraphicalEditPart) getParent()).setLayoutConstraint(this, figure, bounds);
    }
}
图 7. 图形控制器

由于图形可以连接到其他图形,图形编辑部件重写了taggetModelSourceConnections方法和taggetModelTargetConnections方法。这两个方法的任务就是要通知GEF有关该图形的源连接和目标连接。此外,ShapeEditPart实现了tagNodeEditPart接口。通过实现这个接口,编辑部件可以定义源锚点和目标锚点,锚点就是图形和连接接触的连接点。逻辑电路编辑器的例子使用这个功能来指定线如何连接到一个逻辑门元件。既然图形并没有特定的连接点,我们就使用包围矩形锚点,它将连接设置在可视图形的包围矩形上。如果你愿意,你可以为椭圆返回EllipseAnchor,它将返回一个椭圆边界上的点。对于更加复杂的图形,你应该继承AbstractConnectionAnchor类,并实现getLocation方法。注意,有两种方法需要实现:一个使用ConnectionEditPart对象作为参数,另一个使用Request对象。当一个新的连接被创建时,第二个方法tag会被调用以便用户得到反馈,而第一个方法tag用于已建立的连接。

图形编辑部件安装了两个策略。ShapeComponentEditPolicy提供命令将一个图形从图删除。第二个策略处理图形间连接的创建和转移,它的索引字是GRAPHICAL_NODE_ROLE。连接创建工具创建新的连接需要两个步骤。当用户点击模型元素的可视图形时,该策略被要求tag创建一个连接命令。如果这个方法返回null,表示这个连接不能从所给的模型元素开始。如果允许连接的话,将创建新的命令,并作为起始命令存储在请求中。当用户点击另一个可视图形时,会要求策略提供一个tag连接完成命令。这是一个根据起始命令创建的新命令,而起始命令中包含了连接结束点的信息。

new GraphicalNodeEditPolicy() {
tag protected Command getConnectionCreateCommand(CreateConnectionRequest request) {
       Shape source = (Shape) getHost().getModel();
       int style = ((Integer) request.getNewObjectType()).intValue();
       ConnectionCreateCommand cmd = new ConnectionCreateCommand(source, style);
       request.setStartCommand(cmd);
       return cmd;
    }

tag protected Command getConnectionCompleteCommand(CreateConnectionRequest request) {
        ConnectionCreateCommand cmd = 
            (ConnectionCreateCommand) request.getStartCommand();
        cmd.setTarget((Shape) getHost().getModel());
        return cmd;
    }
    
    ...
}
图 8. 图形节点编辑策略

图形节点编辑策略的另一个任务是提供连接的转移命令。连接可以修改连接的源或目标实现转移。连接转移命令和连接创建命令有同样的规则。尤其是当一个连接不能转移时,策略返回null。策略也可能通过canExecute方法返回false来得到一个拒绝执行的命令。由于篇幅限制,这些命令的细节就不多说了,读者可以参考代码。

ConnectionEditPart

由于连接也是用户可编辑的模型对象,它们必须有自己的控制器。连接的控制器是由ConnectionEditPart类实现,它继承自AbstractConnectionEditPart类。和其他控制器类似,它也实现了tagPropertyChangeListener接口,并注册自己为模型的监听器。连接部件tag返回一个带有箭头的线作为可视图形。它安装了两个编辑策略。第一个是tagConnectionComponentPolicy,它提供删除命令给Delete菜单项所需要的action。第二个tag比较有意思。它含有一个被选择的连接,这个连接包括起始端和结束端的标识。没有这个策略,就不可能转移连接,因为当一个连接被拖动时,GEF没有办法获取连接两端的标识。GEF的设计者建议所有的ConnectionEditParts都应该有这个策略,即使连接的两端都不能拖动。至少这个策略提供了一种视觉上的选择反馈。propertyChange方法可以收到tag线风格属性的变化,并对线figure作相应的调整。

class ConnectionEditPart extends AbstractConnectionEditPart 
tag     implements PropertyChangeListener {
    
    protected IFigure createFigure() {
tag     PolylineConnection connection = (PolylineConnection) super.createFigure();
        connection.setTargetDecoration(new PolygonDecoration());
        connection.setLineStyle(getCastedModel().getLineStyle());
        return connection;
    }
    
    protected void createEditPolicies() {
tag     installEditPolicy(EditPolicy.CONNECTION_ROLE, new ConnectionEditPolicy() {
            protected Command getDeleteCommand(GroupRequest request) {
                return new ConnectionDeleteCommand(getCastedModel());
            }
        });
tag     installEditPolicy(EditPolicy.CONNECTION_ENDPOINTS_ROLE,
                          new ConnectionEndpointEditPolicy());
    }
    
    public void propertyChange(PropertyChangeEvent event) {
        String property = event.getPropertyName();
tag     if (Connection.LINESTYLE_PROP.equals(property)) {
            ((PolylineConnection) getFigure()).
                setLineStyle(getCastedModel().getLineStyle());
        }
    }
    ...    
}
图 9. 连接控制器

几何图形编辑器

几何图形编辑器继承了GraphicalEditorWithFlyoutPalette类。这个类是图形编辑器的一种特殊形式,它本身也是一种编辑部件,并可以拥有一个提供工具的面板。使用这个类必须实现两个方法,getPaletteRootgetPalettePreferences。第一个方法必须返回包含所有工具选项的面板的根节点。工具选项是一种特殊的面板选项,它将工具安装在编辑器的编辑域上。它们必须位于面板抽屉中,面板抽屉将工具选项很方便地组合起来。一般推荐有一个工具选项作为整个工具面板的缺省选项。一个典型的解决方法就是直接使用SelectionToolEntry类的实例。第二个方法返回的面板首选项中包含的内容有,报告面板是可见还是被折叠起来了,面板停靠的位置,以及面板的宽度。通常的解决方法是将它们存在plug-in的首选项存储区中。

我们上面提到的编辑域起了一个中心控制器的作用。它负责保存工具,载入缺省工具,维护当前激活的工具,并将鼠标和键盘事件转发给当前激活的工具,以及处理命令栈。GEF提供了缺省实现,DefaultEditDomain,你应该在编辑器的构造函数中设置它的实例。

图形编辑器的另一部分工作是创建并初始化图形viewer。图形viewer是一种特殊的EditPartViewer,它能够做点击测试。我们可以使用GraphicalEditor类提供的缺省viewer。然而,还是需要做一些事。在configureGraphicalViewer方法tag中设置编辑部件的工厂类。这个工厂类必须实现一个接口EditPartFactory,这个接口只有一个方法,createEditPart(EditPart, Object)。它的第一个参数是编辑部件,它一般是所创建的编辑部件的父部件,第二个参数是新创建的编辑部件所对应的模型部件。其他要做的包括设置键处理器,上下文菜单等。

protected void configureGraphicalViewer() {
    super.configureGraphicalViewer();
    
    GraphicalViewer viewer = getGraphicalViewer();
    viewer.setRootEditPart(new ScalableRootEditPart());
tag viewer.setEditPartFactory(new ShapesEditPartFactory());
    viewer.setKeyHandler(
        new GraphicalViewerKeyHandler(viewer).setParent(getCommonKeyHandler()));
    ContextMenuProvider cmProvider = 
        new ShapesEditorContextMenuProvider(viewer, getActionRegistry());
    viewer.setContextMenu(cmProvider);
    getSite().registerContextMenu(cmProvider, viewer);
}

protected void initializeGraphicalViewer() {
    super.initializeGraphicalViewer();
    GraphicalViewer graphicalViewer = getGraphicalViewer();
tag graphicalViewer.setContents(getModel());
tag graphicalViewer.addDropTargetListener(createTransferDropTargetListener());
}
图 10. 配置并初始化一个图形viewer

一旦工厂类被设置,你应该在图形viewer中tag设置内容。内容自然就是从IEditorInput实例恢复得到的对象,IEditorInput实例通过setInput方法传递给编辑器。这个例子在图形viewer上添加tag一个目标放置监听器。它允许用户使用拖放的方式添加新图形,而不是选择加点击的方式。这个目标放置监听器使用TemplateTransferDropTargetListener的子类,它使用CreateRequest来获得添加对象到模型的命令,这个模型当然就是拖放动作结束时所在的编辑部件所表示的模型。

除了上面谈到的任务,编辑器还负责监视命令栈来报告当前编辑的内容是否被修改。这是一个比较好的解决方法,因为它可以使这个标记和用户所做的undo和redo同步起来。注意,命令栈含有上次存储的位置信息,这个信息在doSavedoSaveAs这两个方法中被标记。编辑器的其他细节,比如模型的实际存储和恢复,这里就不讨论了,因为它们和具体的应用相关。接下来,我们讨论编辑器的如何将编辑器内容暴露给其视图,如何将菜单选项和编辑器的action联系起来,以及其他workbench协作的技术。

和workbench集成在一起

到目前为止,我们谈的都是这个几何图形编辑器如何工作。然而,它没有和workbench很好地集成。例如,Edit菜单动作,比如DeleteUndoRedo,就不能工作。其他视图不能用其他方式显示编辑器内容。换句话说,目前所完成的编辑器没有很好地利用Eclipse workbench的优势。在下面的三小节,将解释如何将这个孤立的编辑器变成workbench的一部分。

编辑器Action

ShapesEditor类创建了大量缺省动作,它们在编辑器初始化过程中被createActions方法中创建。这些动作是undo,redo,select all,delete,save和print。为了将标准菜单选项连接到这些动作,你应该在plugin.xml文件中定义一个action bar contributor。在这个action bar contributor中,你需要实现两个方法。第一个是tagbuildActions,它可以为undo,redo和delete创建可重定位的动作。如果你需要使用键盘选择所有的widget,你需要在第二个方法declareGlobalActionKeys中为所选择的动作tag添加一个全局动作关键字。

public class ShapesEditorActionBarContributor extends ActionBarContributor {
tag protected void buildActions() {
        this.addRetargetAction(new UndoRetargetAction());
        this.addRetargetAction(new RedoRetargetAction());
        this.addRetargetAction(new DeleteRetargetAction());
    }

    public void contributeToToolBar(IToolBarManager toolBarManager) {
        super.contributeToToolBar(toolBarManager);
        toolBarManager.add(getAction(ActionFactory.UNDO.getId()));
        toolBarManager.add(getAction(ActionFactory.REDO.getId()));
    }

    protected void declareGlobalActionKeys() {
tag     this.addGlobalActionKey(ActionFactory.SELECT_ALL.getId());
    }
}
图 11. 连接菜单动作

我们来仔细看一下当用户在Edit菜单中选择Delete时发生了些什么(看图12)。ShapesEditor类的父类将删除动作添加到动作注册表中。当删除动作被执行时,它检查当前的所选择的对象是否是EditPart类的实例。对每个对象,它都从编辑部件中请求一个命令。接下来,每个编辑部件检查是否有编辑策略可以处理删除请求。对几何图形,ShapeComponentEditPolicy可以处理删除请求,并且返回ShapeDeleteCommand实例。动作执行该命令,从而将图形从图中删除。图发送一个属性修改事件,DiagramEditPart收到该事件,最终使得代表被删除图形的矩形或椭圆从显示中被删除。

Delete action call sequence
图 12. 删除动作执行序列

提供属性

每个图形编辑器都是可以发送选择事件。你可以建立一个视图,并将它作为选择监听器注册在workbench site的页面上。每次你在图形编辑器中选择一个对象,你的视图都会在selectionChanged方法中收到一个通知。Eclipse的一个标准视图,Properties视图,会监听选择事件,并且每次都检查这个对象是否实现了IPropertySource接口。如果是的话,它使用这个接口的方法来查询所选择的对象属性,并以表格的方式显示出来。

通过上面所描述的,在图形编辑器中提供对象的属性只要实现IPropertySource接口就可以了。通过查看Shape类,你可以看到对象的位置和大小是如何在Properties视图中显示的。

提供Outline

Outline视图是另一种,常常也是更简洁的查看模型对象的方式。在Java编辑器中,它可以用来显示一个类所import的类,所包含的变量,和方法,却不需要用户深入代码。图形编辑器也可以使用这个视图。图形编辑器和逻辑电路编辑器类似,可以以树的方式显示所编辑的内容(看图1)。数据库schema编辑器[7]也提供了类似的视图。

为了将所编辑的内容提供给Outline视图,你需要重写getAdapter方法,并当adapter类为IContentOutlinePage接口时,返回一个outline实现。实现outline的最简单的方法是扩展ContentOutlinePage类,并提供适当的EditPartViewer

public Object getAdapter(Class type) {
    // returns the content outline page for this editor
tag if (type == IContentOutlinePage.class) {
        if (outlinePage == null) {
            outlinePage = new ShapesEditorOutlinePage(this, new TreeViewer()); 
        }
        return outlinePage;
    }
    return super.getAdapter(type);
}
图 13. 提供一个outline

在我们这个例子中,编辑部件视图是有一个TreeViewer实现的。你应该和主编辑器一样提供给它同样的编辑域。TreeViewer,就象其他EditPartViewer,需要一个创建子编辑部件的方法。编辑器和DiagramEditPart一样,都是设置一个编辑部件工厂。此外,outline中的选择和主编辑窗口的选择需要通过选择同步器同步起来,选择同步器是一个GEF工具类,它协调两个编辑部件的选择状态。ShapesTreeEditPartFactory根据模型类型,返回ShapeTreeEditPartDiagramTreeEditPart的实例。通过这些类,读者应该可以轻易地发现这些模式很熟悉。两个编辑部件都实现了PropertyChangeListener接口,并通过调整模型的可视化表示来对属性变化做出响应。它们都安装编辑策略来控制通过它们所暴露的交互类型。

GEF用到的设计模式

GEF通过大量使用设计模式来得到它的灵活性。下面是一下经常碰到的模式的小结。详细内容,请参考 [2]

模型-视图-控制器(Model-View-Controller )
MVC模式被GEF用来解除用户界面,行为和表示之间的耦合。模型可以用任何Java对象来表示。视图必须实现IFigure接口。控制的类型必须是EditPart或它的子类。
命令(Command )
命令封装了模型修改,因此支持可撤销的操作。
责任链(Chain of Responsibility )
责任链通过将请求传递给多个对象,并给这些对象机会处理请求,从而将请求的发送者和接受者解除耦合。在GEF中,多个编辑策略可以收到请求,返回Commands,这些Commands以链的方式组织在一起。
状态(State )
允许编辑器在内部状态发生改变的时候,修改编辑器的行为。对于GEF编辑器,用户切换工具可以改变编辑器的状态。例如,对于鼠标按下事件,编辑器在激活选区工具和激活创建工具下的行为是截然不同的。
抽象工厂(Abstract Factory )
提供接口创建一系列相关或相依赖的对象。这个模式在根据模型部件创建编辑部件时被使用。
工厂方法(Factory Method )
定义了方法创建对象,但是允许子类决定实例化的类。这个模式没有被单独讨论,但是它是创建编辑部件的另一种可选的方法。createChild方法允许你不使用工厂就创建子编辑部件。

总结

我希望能够对这个简单图形编辑器的大多数方面作详细的描述。提供足够的信息使得人们能够读完这篇文章,去看更大的例子,比如逻辑电路编辑器。通过理解象CircuitEditPartAndGateFigure和其他类的角色,你可以关注其他例子的更复杂的方面。在GEF的众多领域和技术中,有很多我甚至都没有涉及过。然而,这些技术只有在很好地理解基础内容的情况下,才可能去学习。毕竟,如果你为了使Select All菜单项工作都要花数小时,那么设计一个拖反馈的目的又是什么呢?

感谢

我想感谢Randy Hudson,他的意见帮助提高了本文结构和准确性。我也感谢Jill Sueoka仔细检查我所写一个又一个版本。

参考书目

[1] Eric Bordeau, Using Native Drag and Drop with GEF, Eclipse Corner Article, August 2003
[2] Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software, Addison Wesley, 1995, ISBN 0-201-63361-2
[3] Randy Hudson, Create an Eclipse-based application using the Graphical Editing Framework, IBM developerWorks, July 2003
[4] Daniel Lee, Display a UML Diagram using Draw2D Diagram, Eclipse Corner Article, August 2003
[5] Xavier Mehaut et al., Synthetic GEF description, June 2004
[6] William Moore, David Dean, Anna Gerber, Gunnar Wagenknecht and Philippe Vanderheyden, Eclipse Development using the Graphical Editing Framework and the Eclipse Modeling Framework, IBM RedBooks, 2004, ISBN 0738453161
[7] Phil Zoio, Building a Database Schema Diagram Editor with GEF, Eclipse Corner Article, September 2004

Java and all Java-based trademarks and logos are trademarks or registered trademarks of Sun Microsystems, Inc. in the United States, other countries, or both.