[Eclipse]GEF入门系列(六、添加菜单和工具条)

我发现一旦稍稍体会到GEF的妙处,就会很自然的被它吸引住。不仅是因为用它做出的图形界面好看,更重要的是,UI中最复杂和细微的问题,在GEF的设计中无不被周到的考虑并以适当的模式解决,当你了解了这些,完全可以把这些解决方法加以转换,用来解决其他领域的设计问题。去年黄老大在一个GEF项目结束后,仍然没有放弃对它的继续研究,现在甚至利用业余时间开发了基于GEF的SWT/JFace增强软件包,Eclipse和GEF的魅力可见一斑。我相信在未来的两年里,由于RCP/GEF等技术的成熟,Java Standalone应用程序必将有所发展,在B/S模式难以实现的那部分领域里扮演重要的角色。

本篇的主题是实现菜单功能,由于Eclipse的可扩展设计,在GEF应用程序中添加菜单要多几处考虑,所以我首先介绍Eclipse里关于菜单的一些概念,然后再通过实例描述如何在GEF里添加菜单、工具条和上下文菜单。

我们知道,Eclipse本身只是一个平台(Platform),用户并不能直接用它来工作,它的作用是为那些提供实际功能的部件提供一个基础环境,所有部件都通过平台指定的方式构造界面和使用资源。在Eclipse里,这些部件被称为插件(Plugins),例如Java开发环境(JDT)、Ant支持、CVS客户端和帮助系统等等都是插件,由于我们从eclipse.org下载的Eclipse本身已经包含了这些常用插件,所以不需要额外的安装,就好象Windows本身已经包含了记事本、画图等等工具一样。如果我们需要新功能,就要通过下载安装或在线更新的方式把它们安装到Eclipse平台上,常见的如XML编辑器、Properties文件编辑器,J2EE开发支持等等,包括GEF开发包也是这类插件。插件一般都安装在Eclipse安装目录的plugins子目录下,也可以使用link方式安装在其他位置。

Eclipse平台的一个优秀之处在于,如此众多的插件能够完美的集成在同一个环境中,要知道,每个插件都可能具有编辑器、视图、菜单、工具条、文件关联等等复杂元素,要让它们能够和平共处可不是件容易事。为此,Eclipse提供了一系列机制来解决由此带来的各种问题。由于篇幅限制,这里只能简单讲一下菜单和工具条的部分,更多内容请参考Eclipse随机提供的插件开发帮助文档。

大多数情况下,我们说开发一个基于Eclipse的应用程序就是指开发一个Eclipse插件(plugin),Eclipse里的每个插件都有一个名为plugin.xml的文件用来定义插件里的各种元素,例如这个插件都有哪些编辑器,哪些视图等等。在视图中使用菜单和工具条请参考以前的贴子,本篇只介绍编辑器的情况,因为GEF应用程序大多数是基于编辑器的。

图1 Eclipse平台的几个组成部分

首先要介绍Retarget Action的概念,这是一种具有一定语义但没有实际功能的Action,它唯一的作用就是在主菜单条或主工具条上占据一个项位置,编辑器可以将具有实际功能的Action映射到某个Retarget Action,当这个编辑器被激活时,主菜单/工具条上的那个Retarget Action就会具有那个Action的功能。举例来说,Eclipse提供了IWorkbenchActionConstants.COPY这个Retarget Action,它的文字和图标都是预先定义好的,假设我们的编辑器需要一个”复制节点到剪贴板”功能,因为”复制节点”和”复制”这两个词的语义十分相近,所以可以新建一个具有实际功能的CopyNodeAction(extends Action),然后在适当的位置调用下面代码实现二者的映射:

IActionBars.setGlobalActionHandler(IWorkbenchActionConstants.COPY,copyNodeAction);

当这个编辑器被激活时,Eclipse会检查到这个映射,让COPY项变为可用状态,并且当用户按下它时去执行CopyNodeAction里定义的操作,即run()方法里的代码。Eclipse引入Retarget Action的目的是为了尽量减少主菜单/工具条的重建消耗,并且有利于用户使用上的一致性。在GEF应用程序里,因为很可能存在多个视图(例如编辑视图和大纲视图,即使暂时只有一个视图,也要考虑到以后扩展为多个的可能),而每个视图都应该能够完成相类似的操作,例如在树结构的大纲视图里也应该像编辑视图一样可以删除选中节点,所以一般的操作都应以映射到Retarget Action的方式建立。

主菜单/主工具条

与视图窗口不同,编辑器没有自己的菜单栏和工具条,它的菜单只能加在主菜单里。由于一个编辑器可以有多个实例,而它们应当具有相同的菜单和工具条,所以在plugin.xml里定义一个编辑器的时候,元素有一个contributorClass属性,它的值是一个实现IEditorActionBarContributor接口的类的全名,该类可以称为”菜单工具条添加器”。在添加器里可以向Eclipse的主菜单/主工具条里添加自己需要的项。还是以我们这个项目为例,它要求对每个操作可以撤消/重做,对画布上的每个元素可以删除,对每个节点元素可以设置它的优先级为高、中、低三个等级。所以我们要添加这六个Retarget Action,以下就是DiagramActionBarContributor类的部分代码:

public class DiagramActionBarContributor extends ActionBarContributor {
    protected void buildActions() {
        addRetargetAction(new UndoRetargetAction());
        addRetargetAction(new RedoRetargetAction());
        addRetargetAction(new DeleteRetargetAction());
        addRetargetAction(new PriorityRetargetAction(IConstants.PRIORITY_HIGH));
        addRetargetAction(new PriorityRetargetAction(IConstants.PRIORITY_MEDIUM));
        addRetargetAction(new PriorityRetargetAction(IConstants.PRIORITY_LOW));
    }
    protected void declareGlobalActionKeys() {
    }
    public void contributeToToolBar(IToolBarManager toolBarManager) {
        ……
    }
    public void contributeToMenu(IMenuManager menuManager) {
        IMenuManager mgr=new MenuManager("&Node","Node");
        menuManager.insertAfter(IWorkbenchActionConstants.M_EDIT,mgr);
        mgr.add(getAction(IConstants.ACTION_MARK_PRIORITY_HIGH));
        mgr.add(getAction(IConstants.ACTION_MARK_PRIORITY_MEDIUM));
        mgr.add(getAction(IConstants.ACTION_MARK_PRIORITY_LOW));
    }
}

可以看到,DiagramActionBarContributor类继承自GEF提供的类ActionBarContributor,后者是实现了IEditorActionBarContributor接口的一个抽象类。buildActions()方法用于创建那些要添加到主菜单/工具条的Retarget Actions,并把它们注册到一个专门的注册表里;而contributeToMenu()方法里的代码把这些Retarget Actions实际添加到主菜单栏,使用IMenuManager.insertAfter()是为了让新加的菜单出现在指定的系统菜单后面,contributeToToolBar()里则是添加到主工具条的代码。

图2 添加到主菜单条和主工具条上的Action

GEF 在ActionBarContributor里维护了retargetActions和globalActionKeys两个列表,其中后者是一个Retarget Actions的ID列表,addRetargetAction()方法会把一个Retarget Action同时加到二者中,对于已有的Retarget Actions,我们应该在declareGlobalActionKeys()方法里调用addGlobalActionKey()方法来声明,在一个编辑器被激活的时候,与globalActionKeys里的那些ID具有相同ID值的(具有实际功能的)Action将被联系到该ID对应的Retarget Action,因此就不需要显式的去调用setGlobalActionHandler()方法了,只要保证二者的ID相同即可实现映射。

GEF已经内置了撤消/重做和删除这三个操作的Retarget Action(因为太常用了),它们的ID分别是IWorkbenchActionConstants.UNDO、REDO和DELETE,所以没有什么问题。而设置优先级这个Action没有语义相近的现成Retarget Action可用,所以我们自己要先定义一个PriorityRetargetAction,内容如下(没有经过国际化处理):

public class PriorityRetargetAction extends LabelRetargetAction{
    public PriorityRetargetAction(int priority) {
        super(null,null);
        switch (priority) {
        case IConstants.PRIORITY_HIGH:
            setId(IConstants.ACTION_MARK_PRIORITY_HIGH);
            setText("High Priority");
            break;
        case IConstants.PRIORITY_MEDIUM:
            setId(IConstants.ACTION_MARK_PRIORITY_MEDIUM);
            setText("Medium Priority");
            break;
        case IConstants.PRIORITY_LOW:
            setId(IConstants.ACTION_MARK_PRIORITY_LOW);
            setText("Low Priority");
            break;
        default:
            break;
        }
    }
}

接下来要在编辑器(CbmEditor)的createActions()里建立具有实际功能的Actions,它们应该是SelectionAction(GEF提供)的子类,因为我们需要得到当前选中的节点。稍后将给出PriorityAction的代码,编辑器的createActions()方法的代码如下所示:

protected void createActions() {
super.createActions();
    //高优先级
    IAction action=new PriorityAction(this, IConstants.PRIORITY_HIGH);
    action.setId(IConstants.ACTION_MARK_PRIORITY_HIGH);
    getActionRegistry().registerAction(action);
    getSelectionActions().add(action.getId());
    //中等优先级
    action=new PriorityAction(this, IConstants.PRIORITY_MEDIUM);
    action.setId(IConstants.ACTION_MARK_PRIORITY_MEDIUM);
    getActionRegistry().registerAction(action);
    getSelectionActions().add(action.getId());
    //低优先级
    action=new PriorityAction(this, IConstants.PRIORITY_LOW);
    action.setId(IConstants.ACTION_MARK_PRIORITY_LOW);
    getActionRegistry().registerAction(action);
    getSelectionActions().add(action.getId());
}

请再次注意在这个方法里每个Action的id都与前面创建的Retarget Action的ID对应,否则将无法对应到主菜单条和主工具条中的Retarget Actions。你可能已经发现了,这里我们只创建了设置优先级的三个Action,而没有建立负责撤消/重做和删除的Action。其实GEF在这个类的父类(GraphicalEditor)里已经创建了这些常用Action,包括撤消/重做、全选、保存、打印等,所以只要别忘记调用super.createActions()就可以了。

GEF提供的UNDO/REDO/DELETE等Action会根据当前选择的editpart(s)自动判断自己是否可用,我们定义的Action则要自己在Action的calculateEnabled()方法里计算。另外,为了实现撤消/重做的功能,一般Action执行的时候要建立一个Command,将后者加入CommandStack里,然后执行这个Command对象,而不是直接把执行代码写在Action的run()方法里。下面是我们的设置优先级PriorityAction的部分代码,该类继承自SelectionAction:

public void run() {
    execute(createCommand());
}

private Command createCommand() {
    List objects = getSelectedObjects();
    if (objects.isEmpty())
        return null;
    for (Iterator iter = objects.iterator(); iter.hasNext();) {
        Object obj = iter.next();
        if ((!(obj instanceof NodePart)) && (!(obj instanceof NodeTreeEditPart)))
            return null;
    }
    CompoundCommand compoundCmd = new CompoundCommand(GEFMessages.DeleteAction_ActionDeleteCommandName);
    for (int i = 0; i < objects.size(); i++) {
        EditPart object = (EditPart) objects.get(i);
        ChangePriorityCommand cmd = new ChangePriorityCommand();
        cmd.setNode((Node) object.getModel());
        cmd.setNewPriority(priority);
        compoundCmd.add(cmd);
    }
    return compoundCmd;
}

protected boolean calculateEnabled() {
    Command cmd = createCommand();
    if (cmd == null)
        return false;
    return cmd.canExecute();
}

因为允许用户一次对多个选中的节点设置优先级,所以在这个Action里我们创建了多个Command对象,并把它们加到一个CompoundCommand对象里,好处是在撤消/重做的时候也可以一次性完成,而不是一个节点一个节点的来。

上下文菜单

在GEF里实现右键弹出的上下文菜单是很方便的,只要写一个继承org.eclipse.gef. ContextMenuProvider的自定义类,在它的buildContextMenu()方法里编写添加菜单项的代码,然后在编辑器里调用GraphicalViewer. SetContextMenu()即可。GEF为我们预先定义了一些菜单组(Group)用来区分不同用途的菜单项,每个组在外观上表现为一条分隔线,例如有UNDO组、COPY组和PRINT组等等。如果你的菜单项不适合放在任何一个组中,可以放在OTHERS组里,当然如果你的菜单项很多,也可以定义新的组用来分类。

图3 上下文菜单

假设我们要实现如上图所示的上下文菜单,并且已经创建并在ActionRegistry里了这些Action(在Editor的createActions()方法里完成),ContextMenuProvider应该像下面这样写:

public class CbmEditorContextMenuProvider extends ContextMenuProvider {
    private ActionRegistry actionRegistry;
    public CbmEditorContextMenuProvider(EditPartViewer viewer, ActionRegistry registry) {
        super(viewer);
        actionRegistry = registry;
    }
    public void buildContextMenu(IMenuManager menu) {
        // Add standard action groups to the menu
        GEFActionConstants.addStandardActionGroups(menu);
        // Add actions to the menu
        menu.appendToGroup(GEFActionConstants.GROUP_UNDO,getAction(ActionFactory.UNDO.getId()));
        menu.appendToGroup(GEFActionConstants.GROUP_UNDO, getAction(ActionFactory.REDO.getId()));
        menu.appendToGroup(GEFActionConstants.GROUP_EDIT, getAction(ActionFactory.DELETE.getId()));
        menu.appendToGroup(GEFActionConstants.GROUP_REST,getAction(IConstants.ACTION_MARK_PRIORITY_HIGH)); 
        menu.appendToGroup(GEFActionConstants.GROUP_REST,getAction(IConstants.ACTION_MARK_PRIORITY_MEDIUM)); 
        menu.appendToGroup(GEFActionConstants.GROUP_REST,getAction(IConstants.ACTION_MARK_PRIORITY_LOW));
    }
    private IAction getAction(String actionId) {
        return actionRegistry.getAction(actionId);
    }
}

注意buildContextMenu()方法里的第一句是创建缺省的那些组,如果没有忽略了这一步后面的语句会提示组不存在的错误,你也可以通过这个方法看到GEF是怎样建组的以及都有哪些组。让编辑器使用这个类的代码一般写在configureGraphicalViewer()方法里。

因为顺便介绍了Eclipse的一些基本概念,加上代码比较多,所以这篇贴子看起来比较长,其实通过查看GEF对内置的UNDO/REDO等的实现很容易就会明白菜单的使用方法。

[GEF]在非XYLayout布局的container里调整children尺寸

以前只做过两种类型的GEF程序,一种是画布使用XYLayout,子图形可以在上面随意改变大小和位置;另一种是画布使用非XYLayout的布局,子图形的大小和位置由布局决定,用户不能用鼠标拖动的方式改变。现在这个项目有点特殊,因为要实现类似“表格”的功能,所以要求画布使用ToolbarLayout排列表格列,但列的高度要能够改变。我查看了FlowLayoutEditPolicy类,里面没有一个类似“createChangeConstraintCommand”这样的方法,那么该怎样实现这个功能呢?

图1 画布使用ToolbarLayout同时可以调整列的高度

经过黄老大的指点和查看代码,原来要在画布的EditPolicy里覆盖createChildEditPolicy()方法,FlowLayoutEditPolicy缺省是返回一个NonResizableEditPolicy,我们要改为返回一个ResizableEditPolicy,为了只让用户能拖动列的底部,还要稍微设置一下这个EditPolicy,如下所示:

protected EditPolicy createChildEditPolicy(EditPart child) {
    ResizableEditPolicy policy = new ResizableEditPolicy();
    policy.setResizeDirections(PositionConstants.SOUTH);
    return policy;
}

在运行时,这个被返回的EditPolicy会被安装在child的EditPart上,把我们的画布看作parent,列就是child。当用户拖动列图形的handler时,产生一个类型为REQ_RESIZE的请求,这个请求被转发给parent的EditPolicy,所以我们要在画布的EditPolicy里覆盖getCommand()方法对这个请求进行处理,如下所示:

public Command getCommand(Request request) {
    if (REQ_RESIZE_CHILDREN.equals(request.getType())) {
        ChangeColumnHeightCommand cmd = new ChangeColumnHeightCommand();
        Column column = (Column) ((ColumnPart) ((ChangeBoundsRequest) request).getEditParts().get(0)).getModel();
        cmd.setColumn(column);
        cmd.setNewHeight(column.getHeight() + ((ChangeBoundsRequest) request).getSizeDelta().height);
        return cmd;
    }
    return super.getCommand(request);
}

这就会返回一个用来调整列高度的Command,这个命令的具体内容这里不赘述了。createChildEditPolicy是在LayoutEditPolicy里定义的一个抽象方法,GEF提供的与Layout有关的EditPolicy里都会提供一个缺省的实现,但在需求比较特殊的情况下我们要提供自己的实现。这里是OrderedLayoutEditPolicy(FlowLayoutEditPolicy的父类)关于该方法的注释:“Since Ordered layouts generally don’t use constraints, a NonResizableEditPolicy is used by default for children. Subclasses may override this method to supply a different EditPolicy.”很明显GEF已经考虑到了这种情况。

利用Digester把XML转换为Java对象

在一个比较完整的应用系统里,经常需要有一些配置文件。简单的属性使用.properties文件即可,但要配置一些复杂对象,则应该考虑使用xml文件。一般用来读取xml文件的工具包有DOM、SAX和JDOM等,但用过的人都知道,它们属于比较底层的API,写起来代码量很大,而且如果修改了xml文件的格式,代码也要做大幅度的改动。Jakarta Commons项目里的Digester包,可以轻松实现xml文件到Java对象的转换,请看下面这个例子。

在一个项目里,需要提供一些统计图,但图的内容暂时未能确定。所以我决定让图可以配置,所有定义保存在一个名为charts.xml(或国际化后的文件名如charts_zh_CN.xml,这里只考虑缺省语言)的文件内,下面是该文件的部分内容:

<?xml version="1.0" encoding="UTF-8" ?>
<charts>
    <chart id="BarChart1" >
        <title>统计图一</title>
        <legendVisible>false</legendVisible>
        <toolTipsVisible>true</toolTipsVisible>
        <type>Bar</type>
        <labelx>时间</labelx>
        <labely>数据</labely>
        <width>500</width>
        <height>360</height>
        <hql>select count(c),c.department.name from edu.pku.pub.aims.model.business.Client c group by c.department</hql>
        <description></description>
    </chart>
</charts>

可以看出,我为每个图定义了id、title、legendVisible等等属性,这些属性的意义都很明显,它们将影响统计图的数据和在页面中的表现。在程序里,我需要把这个文件里的定义读到一个注册表类ChartRegistry里,该注册表维护一个java.util.List类型的registry变量,其中每个元素是一个ChartConfig类。现在Digester该显示它的价值了。

为了方便使用Digester,我们让ChartConfig也具有统计图的每个属性(id、title、legendVisible等等),名称与charts.xml里的元素的属性(子元素)一一对应,并且都具有getter和setter方法,也就是说,ChartConfig是一个bean类。在ChartRegistry类里定义一个deregister()方法,它的作用是用Digester读入并解析指定的xml文件,代码如下;还有一个register()方法用来把ChartConfig对象加到registry里。

public void deregister(URL url) throws IOException,SAXException{
    InputStream is = new FileInputStream(url.getFile());
    Digester digester = new Digester();
    digester.push(this);
    digester.setValidating(false);
    digester.addObjectCreate("charts/chart", ChartConfig.class);
    digester.addSetProperties("charts/chart");
    digester.addBeanPropertySetter("charts/chart/legendVisible");
    digester.addBeanPropertySetter("charts/chart/toolTipsVisible");
    digester.addBeanPropertySetter("charts/chart/title");
    digester.addBeanPropertySetter("charts/chart/type");
    digester.addBeanPropertySetter("charts/chart/labelx");
    digester.addBeanPropertySetter("charts/chart/labely");
    digester.addBeanPropertySetter("charts/chart/width");
    digester.addBeanPropertySetter("charts/chart/height");
    digester.addBeanPropertySetter("charts/chart/hql");
    digester.addBeanPropertySetter("charts/chart/description");
    digester.addSetNext("charts/chart","register");
    digester.parse(is);
    Collections.sort(registry);
}

基本上来说,Digester和SAX解析xml的过程很像,它的原理就是制定一些规则,在遍历每个节点时检查是否有匹配的规则,如果有就执行对应的操作。例如,上面的代码中,“digester.addObjectCreate(“charts/chart”, ChartConfig.class);”这一句的作用是告诉Digester:如果遇到匹配“charts/chart”形式的节点,就执行一个“对象创建”操作,创建什么对象呢,应该创建Class为“ChartConfig.class”的对象;类似的,addSetProperties()是告诉Digester将指定节点的属性全部映射到对象的属性,在这个例子里指的就是id属性;addBeanPropertySetter()是将子节点转换为对象的属性,这个方法还可以有第二个参数,当对象的属性名和子节点的名字不一样时用来指定对象的属性名;addSetNext()是说在遇到匹配节点后,对当前对象的父对象执行一个方法,参数是当前参数,对这个例子来说就是执行ChartConfig.register(ChartConfig)方法。因此这样构造得到的Digester会把charts.xml里的每个元素转换为一个ChartConfig对象,并register到ChartRegistry里。

顺利得到了ChartRegister对象,我就可以在程序里根据它的内容构造统计图了(统计图一般使用jfreechart来生成,这里就不赘述了)。与Digester具有类似功能的工具包其实还有不少,例如Caster、Jato等等,我没有实际使用过它们,但因为我对用过的Jakarta其他项目都很满意(例如BeanUtils、HttpClient,品牌效应?),所以一开始就选择了Digester:简单方便。

宣传一下黄老大的ET2项目

我曾经的supervisor黄老大开发了一个功能十分强大的Eclipse Toolkit,提供了一些Eclipse应用程序经常需要用到的组件,如高度可定制的表格控件等等,是对目前SWT/JFace/GEF的扩展和增强。

“Here is the link: http://sourceforge.net/projects/eclipsetoolkit2/.
An old link is http://www.cs.umb.edu/~huangjun/.

I release all files under epl v1.0.

Sorry for bare documents and samples coz mid-term is coming! I will try my
best to make up.

I do believe there are many ways to impl such stuffs and I am not sure my
way is good enough. I am open to any comments. Thanks for your attention!”

我曾经的supervisor黄老大开发了一个功能十分强大的Eclipse Toolkit,提供了一些Eclipse应用程序经常需要用到的组件,如高度可定制的表格控件等等,是对目前SWT/JFace/GEF的扩展和增强。

“Here is the link: http://sourceforge.net/projects/eclipsetoolkit2/.
An old link is http://www.cs.umb.edu/~huangjun/.

I release all files under epl v1.0.

Sorry for bare documents and samples coz mid-term is coming! I will try my
best to make up.

I do believe there are many ways to impl such stuffs and I am not sure my
way is good enough. I am open to any comments. Thanks for your attention!”

et2.jpg

[Eclipse]GEF入门系列(五、浅谈布局)

虽然很多GEF应用程序里都会用到连接(Connection),但也有一些应用是不需要用连接来表达关系的,我们目前正在做的这个项目就是这样一个例子。在这类应用中,模型对象间的关系主要通过图形的包含来表达,所以大多是一对多关系。

图1 不使用连接的GEF应用

先简单描述一下我们这个项目,该项目需要一个图形化的模型编辑器,主要功能是在一个具有三行N列的表格中自由增加/删除节点,节点可在不同单元格间拖动,可以合并相邻节点,表格列可增减、拖动等等。由于SWT/Jface提供的表格很难实现这些功能,所以我们选择了使用GEF开发,目前看来效果还是很不错的(见下图),这里就简单介绍一下实现过程中与图形和布局有关的一些问题。

在动手之前首先还是要考虑模型的构造。由于Draw2D只提供了很有限的Layout,如ToolbarLayout、FlowLayout和XYLayout,并没有一个GridLayout,所以不能把整个表格作为一个EditPart,而应该把每一列看作一个EditPart(因为对列的操作比对行的操作多,所以不把行作为EditPart),这样才能实现列的拖动。另外,从需求中可以看出,每个节点都包含在一个列中,但仔细再研究一下会发现,实际上节点并非直接包含在列中,而是有一个单元格对象作为中间的桥梁,即每个列包含固定的三个单元格,每个单元格可以包含任意个节点。经过以上分析,我们的模型、EditPart和Figure应该已经初步成形了,见下表:

  模型 EditPart Figure
画布 Diagram DiagramPart FreeformLayer
Column ColumnPart ColumnFigure
单元格 Cell CellPart CellFigure
节点 Node NodePart NodeFigure

表中从上到下是包含关系,也就是一对多关系,下图简单显示了这些关系:

图2 图形包含关系图

让我们从画布开始考虑。在画布上,列显示为一个纵向(高大于宽)的矩形,每个列有一个头(Header)用来显示列名,所有列在画布上是横向排列的。因此,画布应该使用ToolbarLayout或FlowLayout中的一种。这两种Layout有很多相似之处,尤其它们都是按指定的方向排列显示图形,不同之处主要在于:当图形太多容纳不下的时候,ToolbarLayout会牺牲一些图形来保持一行(列),而FlowLayout则允许换行(列)显示。

对于我们的画布来说,显然应该使用ToolbarLayout作为布局管理器,因为它的子图形ColumnFigure是不应该出现换行的。以下是定义画布图形的代码:

Figure f = new FreeformLayer();
ToolbarLayout layout=new ToolbarLayout();
layout.setVertical(false);
layout.setSpacing(5);
layout.setStretchMinorAxis(true);
f.setLayoutManager(layout);
f.setBorder(new MarginBorder(5));

其中setVertical(false)指定横向排列子图形,setSpacing(5)指定子图形之间保留5象素的距离,setStretchMinorAxis(true) 指定每个子图形的高度都保持一致。

ColumnFigure的情况要稍微复杂一些,因为它要有一个头部区域,而且它的三个子图形(CellFigure)合在一起要能够充满下部区域,并且适应其高度的变化。一开始我用Draw2D提供的Label来实现列头,但有一个不足,那就是你无法设置它的高度,因为Label类覆盖了Figure的getPreferedSize()方法,使得它的高度只与里面的文本有关。解决方法是构造一个HeaderFigure,让它维护一个Label,设置列头高度时实际设置的是HeaderFigure的高度;或者直接让HeaderFiguer继承Label并重新覆盖getPreferedSize()也可以。我在项目里使用的是前者。

第二个问题花了我一些时间才搞定,一开始我是在CellPart的refreshVisuals()方法里手动设置CellFigure的高度为ColumnFigure下部区域高度的三分之一,但这样很勉强,而且还需要额外考虑spacing带来的影响。后来通过自定义Layout的方式比较圆满的解决了这个问题,我让ColumnFigure使用自定义的ColumnLayout,这个Layout继承自ToolbarLayout,但覆盖了layout()方法,内容如下:

class ColumnLayout extends ToolbarLayout {
    public void layout(IFigure parent) {
        IFigure nameFigure=(IFigure)parent.getChildren().get(0);
        IFigure childrenFigure=(IFigure)parent.getChildren().get(1);
        Rectangle clientArea=parent.getClientArea();
        nameFigure.setBounds(new Rectangle(clientArea.x,clientArea.y,clientArea.width,30));
        childrenFigure.setBounds(new Rectangle(clientArea.x,nameFigure.getBounds().height+clientArea.y,clientArea.width,clientArea.height-nameFigure.getBounds().height));
    }
}

也就是说,在layout里控制列头和下部的高度分别为30和剩下的高度。但这还没有完,为了让单元格正确的定位在表格列中,我们还要指定列下部图形(childrenFigure)的布局管理器,因为实际上单元格都是放在这个图形里的。前面说过,Draw2D并没有提供一个像SWT中FillLayout那样的布局管理器,所以我们要再自定义另一个layout,我暂时给它起名为FillLayout(与SWT的FillLayout同名),还是要覆盖layout方法,如下所示(因为用了transposer所以horizontal和vertical两种情况可以统一处理,这个transposer只在horizontal时才起作用):

public void layout(IFigure parent) {
    List children = parent.getChildren();
    int numChildren = children.size();
    Rectangle clientArea = transposer.t(parent.getClientArea());
    int x = clientArea.x;
    int y = clientArea.y;
    for (int i = 0; i < numChildren; i++) {
        IFigure child = (IFigure) children.get(i);
        Rectangle newBounds = new Rectangle(x, y, clientArea.width, -1);

        int divided = (clientArea.height - ((numChildren - 1) * spacing)) / numChildren;
        if (i == numChildren - 1)
            divided = clientArea.height - ((divided + spacing) * (numChildren - 1));
        newBounds.height = divided;
        child.setBounds(transposer.t(newBounds));
        y += newBounds.height + spacing;
    }
}

上面这些语句的作用是将父图形的高(宽)度平均分配给每个子图形,如果是处于最后的一位的子图形,让它占据所有剩下的空间(防止除不尽的情况留下空白)。完成了这个FillLayout,只要让childrenFigure使用它作为布局管理器即可,下面是ColumnFigure的大部分代码,列头图形(HeaderFigure)和列下部图形(ChildrenFigure)作为内部类存在:

private HeaderFigure name = new HeaderFigure();
private ChildrenFigure childrenFigure = new ChildrenFigure();
public ColumnFigure() {
    ToolbarLayout layout = new ColumnLayout();
    layout.setVertical(true);
    layout.setStretchMinorAxis(true);
    setLayoutManager(layout);
    setBorder(new LineBorder());
    setBackgroundColor(color);
    setOpaque(true);
    add(name);
    add(childrenFigure);
    setPreferredSize(100, -1);
}
class ChildrenFigure extends Figure {
    public ChildrenFigure() {
        ToolbarLayout layout = new FillLayout();
        layout.setMinorAlignment(ToolbarLayout.ALIGN_CENTER);
        layout.setStretchMinorAxis(true);
        layout.setVertical(true);
        layout.setSpacing(5);
        setLayoutManager(layout);
    }
}
class HeaderFigure extends Figure {
    private String text;
    private Label label;
    public HeaderFigure() {
        this.label = new Label();
        this.add(label);
        setOpaque(true);
    }
    public String getText() {
        return this.label.getText();
    }
    public Rectangle getTextBounds() {
        return this.label.getTextBounds();
    }
    public void setText(String text) {
        this.text = text;
        this.label.setText(text);
        this.repaint();
    }
    public void setBounds(Rectangle rect) {
        super.setBounds(rect);
        this.label.setBounds(rect);
    }
}

单元格的布局管理器同样使用FillLayout,因为在需求中,用户向单元格里添加第一个节点时,该节点要充满单元格;当单元格里有两个节点时,每个节点占二分之一的高度;依次类推。下面的表格总结了各个图形使用的布局管理。由表可见,只有包含子图形的那些图形才需要布局管理器,原因很明显:布局管理器关心和管理的是”子”图形,请时刻牢记这一点。

布局管理器 直接子图形
画布 ToolbarLayout
ColumnLayout 列头部、列下部
-列头部
-列下部 FillLayout 单元格
单元格 FillLayout 节点
节点

这里需要特别提醒一点:在一个图形使用ToolbarLayout或子类作为布局管理器时,图形对应的EditPart上如果安装了FlowLayoutEditPolicy或子类,你可能会得到一个ClassCastException异常。例如例子中的CellFigure,它对应的EditPart是CellPart,其上安装了CellLayoutEditPolicy是FlowLayoutEditPolicy的一个子类。出现这个异常的原因是在FlowLayoutEditPolicy的isHorizontal()方法中会将图形的layout强制转换为FlowLayout,而我们使用的是ToolbarLayout。我认为这是GEF的一个疏忽,因为作者曾说过FlowLayout可应用于ToolbarLayout。幸好解决方法也不复杂:在你的那个EditPolicy中覆盖isHorizontal()方法,在这个方法里先判断layout是ToolbarLayout还是FlowLayout,再根据结果返回合适的boolean值即可。

最后,关于我们的画布还有一个问题没有解决,我们希望表格列增多到一定程度后,画布可以向右边扩展尺寸,前面说过画布使用的是FreeformLayer作为图形。为了达到目的,还必须在editor里设置rootEditPart为ScalableRootEditPart,要注意不是ScalableFreeformRootEditPart,后者在需要各个方向都能扩展的画布的应用程序中经常被使用。关于各种RootEditPart的用法,在后续帖子里将会介绍到。

以上结合具体实例讲解了如何在GEF中使用ToolbarLayout以及自定义简单的布局管理器。我们构造图形应该遵守一个原则,那就是尽量让布局管理器决定每个子图形的位置和尺寸,这样可以避免很多麻烦。当然也有例外,比如在XYLayout这种只关心子图形位置的布局管理器中,就必须为每个子图形指定尺寸,否则图形将因为尺寸过小而不可见,这也是一个开发人员十分容易疏忽的地方。

用Jena获得本体的缺省名称空间

这个标题其实有点问题,因为本体/RDF本身并没有名称空间的概念,它们只关心绝对的URI;在Jena里一旦模型读进内存,就都是使用绝对URI标识资源的,而当使用xml格式存储的时候,才会引出这些xml中的概念。

最近遇到一个问题,在一个程序里要读取多个xml格式的本体文件(*.owl),它们之间有import关系,在读一个文件之前,我需要先确认该文件需要import的那些名称空间所对应的本体(此本体的缺省名称空间是import的值)是否存在,所以我必须知道每个.owl文件的缺省名称空间和import值(后者在这里暂不讨论),这样做的一个好处是不需要让Jena在找不到本地文件时去访问网络,造成延迟。

虽然Jena API没有提供直接得到它的方法,从一个.owl文件得到缺省名称空间的方法其实很简单,但com.hp.hpl.jena.shared.PrefixMapping接口有一个getNsPrefixURI()方法,利用它可以得到xml文档中每个prefix对应的名称空间。

什么是前缀(prefix)呢?prefix主要是为了简化xml的书写,例如下面的语句中,rdf就是prefix,这样在xml文档的其他地方就可以用”rdf:xxx”表示”http://www.w3.org/1999/02/22-rdf-syntax-ns#xxx“这一长串了。

xmlns:rdf=”http://www.w3.org/1999/02/22-rdf-syntax-ns#”

有了这个方法,我们就可以得到rdf、owl、xsd等前缀对应的名称空间,而缺省名称空间对应的前缀是空字符串(””),因此使用model.getNsPrefixURI(“”),而OntModel对象是实现PrefixMapping接口的。

当然,要得到一个xml文件的缺省名称空间有很多方法,只是在一个面向本体而不是xml的应用程序里,使用本体这个层次的API可能更合适一些。

关于Eclipse RCP的两个小问题和解决方法

1、如果你想在RCP应用程序里使用Eclipse提供的Resource Navigator(它是IDE plugin的一部分),要在WorkbenchAdvisor里加入如下代码,否则在Navigator里新建的项目和文件名都会变为空:

WorkbenchAdapterBuilder.registerAdapters();

一 般加在preWindowOpen()方法里就可以了,否则可能需要手动refresh才能看到已有的项目。当然,你还要在plugin dependencies里加上org.eclipse.ui.ide和org.eclipse.ui.views,所以你的RCP程序会变得更大,这就 是代价,目前这个ResourceNavigator不被鼓励用在RCP程序里,以后版本的Eclipse可能会提供更合适的插件。

2、若你在运行RCP Application时遇到以下异常:

java.lang.NoSuchMethodException: com.your.YourPlugin.<init>(org.eclipse.core.runtime.IPluginDescriptor)
    at java.lang.Class.getConstructor0(Unknown Source)
    at java.lang.Class.getConstructor(Unknown Source)
    at org.eclipse.core.internal.plugins.PluginDescriptor.internalDoPluginActivation(PluginDescriptor.java:403)
    at org.eclipse.core.internal.plugins.PluginDescriptor.doPluginActivation(PluginDescriptor.java:359)

或是:

org.eclipse.core.runtime.CoreException[1]: java.lang.ClassNotFoundException: com.your.YourApplication
    at org.eclipse.osgi.framework.internal.core.BundleLoader.findClass(BundleLoader.java:404)
    at org.eclipse.osgi.framework.adaptor.core.AbstractClassLoader.loadClass(AbstractClassLoader.java:93)
    at java.lang.ClassLoader.loadClass(Unknown Source)
    at org.eclipse.osgi.framework.internal.core.BundleLoader.loadClass(BundleLoader.java:307)

可以检查一下你是否在plugin dependencies里增加了org.eclipse.core.runtime.compatibility,我不太清楚这个plugin是做什么用的,但只要有它在我的RCP Application就无法启动。
最后,如果希望在Eclipse的console里显示log而不是直接写入到文件中,可以在运行时加上-consoleLog参数。

[Eclipse]GEF入门系列(四、其他功能)

最近由于实验室任务繁重,一直没有继续研究GEF,本来已经掌握的一些东西好象又丢掉了不少,真是无奈啊,看来还是要经常碰碰。刚刚接触GEF的朋友大都会有这样的印象:GEF里概念太多,比较绕,一些能直接实现的功能非要拐几个弯到另一个类里做,而且很多类的名字十分相似,加上不知道他们的作用,感觉就好象一团乱麻。我觉得这种情况是由图形用户界面(GUI)的复杂性所决定的,GUI看似简单,实际上包含了相当多的逻辑,特别是GEF处理的这种图形编辑方式,可以说是最复杂的一种。GEF里每一个类,应该说都有它存在的理由,我们要尽可能了解作者的意图,这就需要多看文档和好的例子。

在Eclipse里查看文档和代码相当便利,比如我们对某个类的用法不清楚,一般首先找它的注释(选中类或方法按F2),其次可以查看它在其他地方用法(选中类或方法按Ctrl+Shift+G),还可以找它的源代码(Ctrl+鼠标左键或F3)来看,另外Ctrl+Shift+T可以按名称查找一个类等等。学GEF是少不了看代码的,当然还需要时间和耐心。

好,闲话少说,下面进入正题。这篇帖子将继续上一篇内容,主要讨论如何实现DirectEdit、属性页和大纲视图,这些都是一个完整GEF应用程序需要提供的基本功能。

实现DirectEdit

所谓DirectEdit(也称In-Place-Edit),就是允许用户在原本显示内容的地方直接对内容进行修改,例如在Windows资源管理器里选中一个文件,然后按F2键就可以开始修改文件名。实现DirectEdit的原理很直接:当用户发出修改请求(REQ_DIRECT_EDIT)时,就在文字内容所在位置覆盖一个文本框(也可以是下拉框,这里我们只讨论文本的情况)作为编辑器,编辑结束后,再将编辑器中的内容应用到模型里即可。(作为类似的功能请参考:给表格的单元格增加编辑功能

图1 Direct Edit

在GEF里,这个弹出的编辑器由DirectEditManager类负责管理,在我们的NodePart类里,通过覆盖performRequest()方法响应用户的DirectEdit请求,在这个方法里一般要构造一个DirectEditManager类的实例(例子中的NodeDirectEditManager),并传入必要的参数,包括接受请求的EditPart(就是自己,this)、编辑器类型(使用TextCellEditor)以及用来定位编辑器的CellEditorLocator(NodeCellEditorLocator),然后用show()方法使编辑器显示出来,而编辑器中显示的内容已经在构造方法里得到。简单看一下NodeCellEditorLocator类,它的关键方法在relocate()里,当编辑器里的内容改变时,这个方法被调用从而让编辑器始终处于正确的坐标位置。DirectEditManager有一个重要的initCellEditor()方法,它的主要作用是设置编辑器的初始值。在我们的例子里,初始值设置为被编辑NodePart对应模型 (Node)的name属性值;这里还另外完成了设置编辑器字体和选中全部文字(selectAll)的功能,因为这样更符合一般使用习惯。

在NodePart里还要增加一个角色为DIRECT_EDIT_ROLE的EditPolicy,它应该继承自DirectEditPolicy,有两个方法需要实现:getDirectEditCommand()和showCurrentEditValue(),虽然还未遇到过,但前者的作用你不应该感到陌生–在编辑结束时生成一个Command对象将修改结果作用到模型;后者的目的是更新Figure中的显示,虽然我们的编辑器覆盖了Figure中的文本,似乎并不需要管Figure的显示,但在编辑中时刻保持这两个文本的一致才不会出现”盖不住”的情况,例如当编辑器里的文本较短时。

实现属性页

在GEF里实现属性页和普通应用程序基本一样,例如我们希望可以通过属性视图(PropertyView)显示和编辑每个节点的属性,则可以让Node类实现IPropertySource接口,并通过一个IPropertyDescriptor[]类型的成员变量描述要在属性视图里显示的那些属性。有朋友问,要在属性页里增加一个属性都该改哪些地方,主要是三个地方:首先要在你的IPropertyDescriptor[]变量里增加对应的描述,包括属性名和属性编辑方式(比如文本或是下拉框,如果是后者还要指定选项列表),其次是getPropertyValue()和setPropertyValue()里增加读取属性值和将结果写入的代码,这两个方法里一般都是像下面的结构(以前者为例):

public Object getPropertyValue(Object id) {
    if (PROP_NAME.equals(id))
        return getName();
    if (PROP_VISIBLE.equals(id))
        return isVisible() ? new Integer(0) : new Integer(1);
    return null;
}

也就是根据要处理的属性名做不同操作。要注意的是,下拉框类型的编辑器是以Integer类型数据代表选中项序号的,而不是int或String,例如上面的代码根据visible属性返回第零项或第一项,否则会出现ClassCastException。

图2 属性页

实现大纲视图

在Eclipse里,当编辑器(Editor)被激活时,大纲视图自动通过这个编辑器的getAdapter()方法寻找它提供的大纲(大纲实现IcontentOutlinePage接口)。GEF提供了ContentOutlinePage类用来实现大纲视图,我们要做的就是实现一个它的子类,并重点实现createControl()方法。ContentOutlinePage是org.eclipse.ui.part.Page的一个子类,大纲视图则是PageBookView的子类,在大纲视图中有一个PageBook,包含了很多Page并可以在它们之间切换,切换的依据就是当前活动的Editor。因此,我们在createControl()方法里要做的就是构造这个Page,简化后的代码如下所示:

private Control outline;
public OutlinePage() {
    super(new TreeViewer());
}
public void createControl(Composite parent) {
    outline = getViewer().createControl(parent);
    getSelectionSynchronizer().addViewer(getViewer());
    getViewer().setEditDomain(getEditDomain());
    getViewer().setEditPartFactory(new TreePartFactory());
    getViewer().setContents(getDiagram());
}

由于我们在构造方法里指定了使用树结构显示大纲,所以createControl()里的第一句就会使outline变量得到一个Tree(见org.eclipse.gef.ui.parts.TreeViewer的代码),第二句把TreeViewer加到选择同步器中,从而让用户不论在大纲或编辑区域里选择EditPart时,另一方都能自动做出同样的选择;最后三行的作用在以前的帖子里都有介绍,总体目的是把大纲视图的模型与编辑区域的模型联系在一起,这样,对于同一个模型我们就有了两个视图,体会到MVC的好处了吧。

实现大纲视图最重要的工作基本就是这些,但还没有完,我们要在init()方法里绑定UNDO/REDO/DELETE等命令到Eclipse主窗口,否则当大纲视图处于活动状态时,主工具条上的这些命令就会变为不可用状态;在 getControl()方法里要返回我们的outline成员变量,也就是指定让这个控件出现在大纲视图中;在dispose()方法里应该把这个TreeViewer从选择同步器中移除;最后,必须在PracticeEditor里覆盖getAdapter()方法,前面说过,这个方法是在Editor激活时被大纲视图调用的,所以在这里必须把我们实现好的OutlinePage返回给大纲视图使用,代码如下:

public Object getAdapter(Class type) {
    if (type == IContentOutlinePage.class)
        return new OutlinePage();
    return super.getAdapter(type);
}

这样,树型大纲视图就完成了,见下图。很多GEF应用程序同时具有树型和缩略图两种大纲,实现的基本思路是一样的,但代码会稍微复杂一些,因为这两种大纲一般要通过一个PageBook进行切换,缩略图一般由org.eclipse.draw2d.parts.ScrollableThumbnail负责实现,这里暂时不讲了(也许以后会详细说),你也可以通过看logic例子的LogicEditor这个类的代码来了解。

图3 大纲视图

P.S.写这篇帖子的时候,我对例子又做了一些修改,都是和这篇帖子所说的内容相关的,所以如果你以前下载过,会发现那时的代码与现在稍有不同(功能还是完全一样的,下载)。另外要说一下,这个例子并不完善,比如删除一个节点的时候,它的连接就没同时删除,一些键盘快捷键不起作用,还存在很多被注释掉的代码等等。如果有兴趣你可以来修改它们,也是不错的学习途径。

设置Eclipse RCP程序的外观和首选项

RCP应用程序的缺省外观是一个空白窗口,一般我们要通过一个WorkbenchAdvisor类对界面进行定制。 WorkbenchAdvisor有很多回调方法,可以在preWindowOpen()方法里设置菜单、工具条、状态栏、进度栏、透视图切换工具是否可 见,在fillActionBars()方法里添加菜单和工具条项,在getInitialWindowPerspectiveId()方法里指定首选的 透视图。

缺省情况下,透视图切换工具位于窗口左上角,在Eclipse里可以通过Window->Preferences-> Workbench->Appearance改变它的位置,那么怎样用程序控制它呢?有两个方法,第一个是使用如下代码设置 IPreferenceStore中的变量:

IPreferenceStore apiStore = PrefUtil.getAPIPreferenceStore(); 
apiStore.setValue(IWorkbenchPreferenceConstants.DOCK_PERSPECTIVE_BAR, IWorkbenchPreferenceConstants.TOP_RIGHT);

另一个方法是在plugin所在目录建一个名为plugin_customization.ini的文件,里面写如下内容:

your.plugin.id/DOCK_PERSPECTIVE_BAR = topRight

其他与plugin相关的Preference值可以用同样方法设置。

Update:在最新的Eclipse 3.1M5a版本中,对RCP应用程序菜单和工具条的定制方法有所改变,应该使用新加入的ActionBarAdvisor类来完成此项工作。