65.9K
CodeProject 正在变化。 阅读更多。
Home

BBInterfaceNET - 第一个黑莓可视化设计器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (6投票s)

2012年6月18日

CPOL

25分钟阅读

viewsIcon

27678

downloadIcon

522

用于创建黑莓 UI 的可视化设计器应用程序的架构和用法

Sample Image

引言

BBInterfaceNET 是一款可视化设计器应用程序,可用于更轻松地构建黑莓用户界面。用户可以创建一个项目来管理屏幕,然后生成要导入到黑莓项目中的代码文件。

我很高兴能写这篇文章,因为这是我的第一个开源项目。您可以从 CodePlex 上的项目页面下载应用程序安装程序和源代码。

本文将重点介绍应用程序的架构和用法。本文讨论的应用程序旨在衡量黑莓用户界面的可视化设计器工具的潜在实用性。您的意见对于将此项目发展成为一个功能齐全的设计器至关重要,因此请分享您对此事的看法。

文章目录

应用程序架构

BBInterfaceNET 是一个使用 PRISM 的 WPF 应用程序。该应用程序由六个模块组成。

  1. Explorer 模块 - 用于管理项目和项目文件
  2. Toolbox 模块 - 提供所有可用于构建界面的控件
  3. Properties 模块 - 用于编辑选定界面元素的属性
  4. Layout 模块 - 用于呈现当前屏幕文档的层次结构视图
  5. Designer 模块 - 用于呈现要编辑的文档
  6. Controls 模块 - 此模块包含用于构建 BB UI 的初始控件集合

所有这些模块在应用程序启动时都会加载到 shell 中。模块发现是通过使用应用程序配置文件完成的。下面的列表显示了 Toolbox 模块的定义。

<modules>
    <module assemblyFile="ModuleDefinitions\BBInterfaceNET.Toolbox.dll"
            moduleType="BBInterfaceNET.Toolbox.ModuleDefinition.ToolboxModule,
            BBInterfaceNET.Toolbox, Version=1.0.0.0,
            Culture=neutral, PublicKeyToken=null"
            moduleName="ToolboxModule" startupLoaded="True" >
      <dependencies>
        <dependency moduleName="BaseTypesModule"/>
      </dependencies>
    </module>
</modules>

下图显示了主应用程序屏幕以及包含的区域。

该应用程序设计为可扩展的,但在当前版本中,这尚不可行。这种可扩展性将体现为用户可以添加新的控件来构建用户界面。我将在文章后面讨论如何实现这一点。

应用程序模块

ProjectExplorer 模块用于添加创建和管理应用程序所处理文件所需的功能。模块功能体现在其单个视图模型中,由 ExplorerViewModel 类表示。该类包含创建和删除项目文件及文件夹所需的代码。ExplorerViewModel 构造函数提供了一些关于项目浏览器如何与应用程序其他部分通信的提示。代码如下所示

public ExplorerViewModel()
{
    addNewItem = new DelegateCommand<ProjectNodeBase>(OnAddNewItemCommand);
    ExplorerCommands.NewFileCommand.RegisterCommand(addNewItem);

    addExistingItem = new DelegateCommand<ProjectNodeBase>(OnAddExistingItemCommand);
    ExplorerCommands.ExistingFileCommand.RegisterCommand(addExistingItem);

    addDirectory = new DelegateCommand<ProjectNodeBase>(OnAddDirectoryCommand);
    ExplorerCommands.NewDirectoryCommand.RegisterCommand(addDirectory);

    openFile = new DelegateCommand<ProjectNodeBase>(OnOpenFile);
    ExplorerCommands.OpenFileCommand.RegisterCommand(openFile);

    deleteCmd = new DelegateCommand<ProjectNodeBase>(OnDeleteItem);
    ExplorerCommands.DeleteCommand.RegisterCommand(deleteCmd);

    renameCmd = new DelegateCommand<ProjectNodeBase>(OnRenameItem);
    ExplorerCommands.RenameCommand.RegisterCommand(renameCmd);
}

类构造函数将各种局部作用域命令注册到一些模块作用域命令。当用户使用项目浏览器的上下文菜单创建和编辑文件时,这些命令将被触发。在实现功能时,我发现仅定义局部命令是不够的。显然,上下文菜单无法绑定到相应视图模型中的命令。我的猜测是上下文菜单下拉列表位于不同的控件树中。无论如何,如果这些菜单项是全局可访问的,您仍然可以将上下文菜单中的菜单项附加到命令。我认为最好的方法是将命令定义在一个 static 类中,以便它们可以在项目的任何地方访问。当然,这些命令需要是复合命令,因为要执行的逻辑不应全局公开。

通过这种方式,我们将拥有可以数据绑定到上下文菜单项的本地命令。在正常情况下,已注册到复合命令的命令需要取消注册。考虑到只有一个项目资源管理器实例,并且该实例将在应用程序关闭时过期,我认为没有必要取消注册它们。下面的列表显示了单个复合命令的定义。

internal static class ExplorerCommands
{
    private static CompositeCommand newFileCmd = new CompositeCommand();
    //...
    public static CompositeCommand NewFileCommand
    {
        get { return newFileCmd; }
    }
    //...
}

以下代码显示了此命令与上下文菜单选项的数据绑定。

<MenuItem Header="Add New Item" Command="{x:Static cmd:ExplorerCommands.NewFileCommand}" 
CommandParameter="{Binding}" ></MenuItem>

上述代码中的命令参数表示单击的树中的当前元素。

ExplorerViewModel 类通信的另一种方式是通过普通事件。在以下情况下触发事件:创建、添加(现有项)、打开、删除或重命名项,以及项目关闭时。我选择这种实现是因为我认为该功能可以在不使用 PRISM 的项目中重用。另一种选择是发布 CompositePresentationEvent

由于需要从其他模块访问资源管理器功能,因此资源管理器类实现了 IProjectExplorer 接口。此接口定义如下所示

public interface IProjectExplorer
{
    //opens an existing project
    ProjectInfo OpenProject(string projectFilePath);
    //closes a project
    void CloseProject();
    //creates a brand new project
    void CreateNewProject(ProjectInfo projectInfo);
    //is the project opened?
    bool IsOpened();
    //project path and name
    string ProjectName { get; }
    string ProjectPath { get; }

    event EventHandler<FileEventArgs> ItemCreated;
    event EventHandler<FileEventArgs> ItemAdded;
    event EventHandler<FileEventArgs> ItemOpened;
    event EventHandler<FileEventArgs> ItemDeleted;
    event EventHandler<FileRenamedEventArgs> ItemRenamed;
    event EventHandler<FileEventArgs> ProjectClosed;
}

如您所见,该接口公开了一些基本方法以及我之前讨论过的所有事件。关于这个模块要讨论的最后一件事是视图如何注册以及模块如何与应用程序的其他部分通信。所有这些都在模块定义类中完成。该类如下所示

public class ProjectExplorerModule:IModule
{
    IUnityContainer container;
    IEventAggregator eventAggregator;

    public ProjectExplorerModule(IUnityContainer container, IEventAggregator eventAggregator)
    {
        this.eventAggregator = eventAggregator;
        this.container = container;
    }
    public void Initialize()
    {
        container.RegisterInstance<IProjectExplorer>(new ExplorerViewModel());
        IRegionManager regionManager=container.Resolve<IRegionManager>();
        regionManager.RegisterViewWithRegion("ExplorerRegion", typeof(ExplorerView));
        SubscribeToExplorerEvents();
    }
    private void SubscribeToExplorerEvents()
    {
        IProjectExplorer explorer = container.Resolve<IProjectExplorer>();
        explorer.ItemCreated += (s, args) =>
        {
            eventAggregator.GetEvent<FileCreatedEvent>().Publish(args.FilePath);
        };
        //...
    }
}

Initialize 方法首先注册资源管理器的一个实例,因为在创建、打开或关闭项目时需要从 shell 访问它。然后,该方法将视图注册到资源管理器区域。当区域显示时,将创建一个视图实例,并将注册的 IProjectExplorer 实例注入到视图的构造函数中,如以下代码所示

public ExplorerView(IProjectExplorer viewModel)
{
    InitializeComponent();
    this.Loaded += (s, e) => { DataContext = viewModel; };
}

视图注册后,模块订阅普通事件并触发复合演示事件,这些事件将用于通知应用程序的其他部分。

Toolbox 模块用于显示加载到应用程序中并可用于构建 Blackberry 界面的所有控件。模块定义文件如下所示

public class ToolboxModule:IModule
{
    IUnityContainer container;
    public ToolboxModule(IUnityContainer container)
    {
        this.container = container;
    }
    public void Initialize()
    {
        container.RegisterInstance<IToolboxService>(new ToolboxService(container));
        IRegionManager regionManager = container.Resolve<IRegionManager>();
        regionManager.RegisterViewWithRegion("ToolboxRegion", typeof(ToolboxView));
    }
}

模块首先注册 IToolboxService 的本地实现。此实现检索所有已注册到 Unity 容器的相关控件。每个提供新组件的模块都需要将其组件注册到 UnityContainer。这将使所有新组件都可供使用。在此之后,视图将注册到其对应的区域。视图模型在视图创建时注入到视图的构造函数中。所有视图模型所做的只是公开从服务获取的控件集合。如下所示

public List<ToolboxItem> Items
{
    get
    {
        if (items == null)
        {
            items = toolboxService.GetItems().OrderBy(p => p.Description).ToList();
            CollectionView cv = CollectionViewSource.GetDefaultView(items) as CollectionView;
            if (cv != null)
                cv.GroupDescriptions.Add(new PropertyGroupDescription("Category"));
        }
        return items;
    }
}

PropertiesWindow 模块用于编辑选定控件的属性。当在设计器中选择一个控件时,会引发一个带有相应控件的复合演示事件。PropertiesViewModel 注册此事件并执行所需的逻辑。这可以在下面的代码中看到

public PropertiesViewModel(IEventAggregator eventAggregator)
{
    this.eventAggregator = eventAggregator;
    FieldSelectedEvent evt = eventAggregator.GetEvent<FieldSelectedEvent>();
    evt.Subscribe(OnSelectedFieldChanged, ThreadOption.UIThread);
}
public void OnSelectedFieldChanged(Field selField)
{
    //call ToList() to make a new list with the same elements
    SelectedField = selField;
}

视图模型在值更改时也会发布一个事件。这是为了将当前文档标记为脏文档。执行此操作的代码如下所示

public void RaiseFieldPropertyChanged(object newValue, object oldValue)
{
    FieldChangedEvent evt = eventAggregator.GetEvent<FieldChangedEvent>();
    evt.Publish(new Events.Model.FieldChangedData() 
               { NewValue = newValue, OldValue = oldValue });
}

PropertyGridPropertyValueChanged 事件触发时,此方法从行为中触发。模块定义如下所示

public void Initialize()
{
    regionManager.RegisterViewWithRegion("PropertiesRegion", typeof(PropertiesView));
}

视图模型在创建时被注入到视图的构造函数中。

LayoutWindow 模块用于显示当前文档的层次结构视图。在某些情况下,用户无法仅使用设计器窗口编辑当前文档。在这些情况下,用户可以使用布局窗口进行更多控制。此布局窗口也是用户唯一可以为屏幕添加标题、横幅和状态的方式。鉴于这只是设计器的另一个视图,此模块中没有视图模型。只有视图。下面的代码显示了将视图注册到区域的代码。此代码使用了 RegisterViewWithRegion 方法的第二个重载(不确定原因)。

public void Initialize()
{
    LayoutView view=new LayoutView() { DataContext = null };
    IRegionManager regionManager = container.Resolve<IRegionManager>();
    regionManager.RegisterViewWithRegion("LayoutRegion", () => { return view; });
}

最后一个模块是 Designer 模块。该模块呈现应用程序设计器,即显示活动文档列表的视图。模块初始化方法如下所示

public void Initialize()
{
    container.RegisterInstance<IAddFieldService>(new AddFieldService());
    IRegionManager regionManager=container.Resolve<IRegionManager>();
    regionManager.RegisterViewWithRegion("DesignerRegion", typeof(DesignerView));
}

该模块首先注册一个服务。此服务是一个 UI 交互服务,用于呈现 AddFieldDialog 窗口。用户可以使用此窗口向屏幕添加新字段,而无需使用工具箱拖放操作。在此之后,设计器视图注册到相应的区域。我越想越觉得我应该使用 InteractionRequest 模式而不是 UI 服务。我使用服务是因为 PRISM 不支持 WPF 的 InteractionRequest 模式。与此同时,我复制了 Silverlight 的功能。这将在未来版本中替换。

创建设计器视图时,设计器视图模型将注入到视图中。设计器视图模型注册资源管理器模块发布的所有复合演示事件。如下所示

public DesignerViewModel(IEventAggregator eventAggregator, IUnityContainer container,
    IDesignerPersistenceService persistenceService)
{
    this.eventAggregator = eventAggregator;
    this.container = container;
    files = new ObservableCollection<DocumentViewModel>();
    this.persistenceService = persistenceService;

    CloseProjectEvent clProjEvt = eventAggregator.GetEvent<CloseProjectEvent>();
    clProjEvt.Subscribe(OnProjectClosed, ThreadOption.UIThread);
    FileOpenedEvent openFilesEvt = eventAggregator.GetEvent<FileOpenedEvent>();
    openFilesEvt.Subscribe(OnFileOpened, ThreadOption.UIThread);
    FileDeletedEvent delFilesEvt = eventAggregator.GetEvent<FileDeletedEvent>();
    delFilesEvt.Subscribe(OnFileDeleted, ThreadOption.UIThread);
    FileRenamedEvent renFileEvt = eventAggregator.GetEvent<FileRenamedEvent>();
    renFileEvt.Subscribe(OnFileRenamed, ThreadOption.UIThread);
    FileCreatedEvent createdEvt = eventAggregator.GetEvent<FileCreatedEvent>();
    createdEvt.Subscribe(OnFileCreated, ThreadOption.UIThread);

    this.PropertyChanged += (s, a) =>
    {
        if (a.PropertyName == "SelectedFile")
        {
            IRegionManager rm = container.Resolve<IRegionManager>();
            IRegion reg = rm.Regions["LayoutRegion"];
            if (reg == null) return;
            IView view = reg.Views.FirstOrDefault() as IView;
            if (view != null)
            {
                view.DataContext = SelectedFile;
            }
        }
    };
}

构造函数还创建文档列表并监听其自身属性之一的 PropertyChanged 事件。当选定文档更改时,LayoutView 数据上下文也会更改。我不太喜欢这段代码,但目前我找不到其他解决方案来更新 LayoutView

在设计器视图中,我使用了几个自动数据模板来显示两种支持的设计器视图:屏幕视图和图像视图。XAML 如下所示

<DataTemplate DataType="{x:Type vm:FileDesignerViewModel}">
    <v:FileDesignerView />
</DataTemplate>

<DataTemplate DataType="{x:Type vm:ImageDesignerViewModel}">
    <v:ImageDesignerView />
</DataTemplate>
<TabControl ItemsSource="{Binding Path=Files}" BorderThickness="0"
            SelectedItem="{Binding Path=SelectedFile, Mode=TwoWay}"
            ItemTemplate="{StaticResource ClosableTabItemTemplate}"
            Background="Transparent" Padding="0"
            >
    <TabControl.ItemContainerStyle>
        <Style TargetType="{x:Type TabItem}">
            <Setter Property="Background" Value="WhiteSmoke"/>
        </Style>
    </TabControl.ItemContainerStyle>
</TabControl>

这将允许设计器代码向文档集合添加新元素,并且这些元素将由于数据模板而自动显示。我认为更优雅的解决方案是在这里设置另一个区域,然后使用新的 PRISM 导航功能。在我开发设计器时,我还没有掌握导航,但我肯定会改变这部分以使用导航,因为这是一个完美的候选者。

当文件打开事件触发时,设计器将创建一个新的视图模型并将其添加到文档集合中。

其余模块是包含用于构建黑莓 UI 的控件的模块。目前,应用程序只包含一个这样的模块。用户如果希望扩展应用程序,可以添加其他模块。此模块中包含一些我乐意讨论的有趣代码。模块初始化方法如下所示

public void Initialize()
{
    //merge the resource dictionary
    IDictionaryMergingService mergingService = container.Resolve<IDictionaryMergingService>();
    mergingService.MergeDictionary(new BaseFieldsRS());

    var types = Assembly.GetExecutingAssembly().GetTypes().Where(
                p => !p.IsAbstract && p.IsSubclassOf(typeof(Field))).ToList();
    foreach (var type in types)
    {
        container.RegisterType(typeof(Field), type, type.AssemblyQualifiedName);
    }            
}

除了注册模块想要添加的新控件外,模块还做了其他事情。它使用 IDictionaryMergingService 将控件的数据模板合并到 shell 应用程序的资源字典中。这就是为什么可以显示由新模块添加的控件,而原始应用程序无需知道它们应该如何显示。IDictionaryMergingService 在 shell 中注册。控件数据模板按类型自动应用,并且当存在需要显示其子级的管理器时,它们会递归应用。

区域和视图注册

区域充当运行时显示的一个或多个视图的占位符。模块可以在不知道区域如何以及在何处显示的情况下,定位布局中的区域并向其添加内容。这允许布局更改而不会影响向布局添加内容的模块。

区域有时用于定义逻辑相关的多个视图的位置。在此场景中,区域控件通常是一个 ItemsControl 派生控件,它将根据其实现的布局策略(例如堆叠或选项卡式布局安排)显示视图。

区域也可以用来定义单个视图的位置;例如,通过使用 ContentControl。在这种情况下,即使有多个视图映射到该区域位置,区域控件也一次只显示一个视图。

BBInterfaceNET shell 包含 5 个区域。在 PRISM 中,区域可以在 xaml 或代码中定义。应用程序使用第一种选项。下面的 xaml 代码显示了区域声明的简化版本。

<ContentControl regions:RegionManager.RegionName="ExplorerRegion" />
<ContentControl regions:RegionManager.RegionName="LayoutRegion" Grid.Row="2" />
<ContentControl Grid.Column="2" regions:RegionManager.RegionName="DesignerRegion" />
<ContentControl  regions:RegionManager.RegionName="ToolboxRegion" />
<ContentControl Grid.Row="2" regions:RegionManager.RegionName="PropertiesRegion"/>

一旦在 ContentControlItemsControl 上设置了 RegionName 附加属性,就会在默认的 RegionManager 中创建一个区域。当应用程序启动并且模块加载时,这些模块会将视图注册到这些区域。

在 PRISM 中,有两种将视图注册到区域的方式:视图发现和视图注入。视图发现用于注册不经常更改的视图。视图注入用于动态添加视图,适用于区域中的视图经常更改的情况。应用程序使用视图发现将视图注册到 shell 中的 5 个区域。通过使用视图发现,当显示特定区域时,注册到该区域的视图会自动加载。每个模块在初始化时都会注册一个相应的视图,从而保证当应用程序的主窗口显示时,这五个视图也显示。下面的代码显示了设计器视图的注册。

public void Initialize()
{
   container.RegisterInstance<IAddFieldService>(new AddFieldService());
   IRegionManager regionManager=container.Resolve<IRegionManager>();
   regionManager.RegisterViewWithRegion("DesignerRegion", typeof(DesignerView));
}

从上面的代码可以看出,当前实现通过视图发现将单个视图注册到设计器视图。我一直在思考这个问题。在设计器中,随着用户打开和关闭文档,视图的数量会频繁变化。

更好的实现方式是使用视图注入甚至导航。不过,我认为在这里使用视图注入比视图发现或导航效果更好。导航应该在有多个视图需要导航且一次只能显示一个视图时使用。此外,如果用户可能需要在移动到下一步之前验证或保存当前步骤,则应该使用导航。应用程序的下一个版本将使用视图注入。

使用命令在模块之间通信

在 PRISM 中,模块间通信可以通过多种方式实现。我们可以通过命令、区域上下文、事件聚合器和共享服务进行通信。本应用程序提供了几个场景,其中使用命令进行通信是最佳选择。这些情况包括:保存文件、关闭文件和关闭应用程序。

保存文件

用于保存文件的命令是从 shell 触发的。实际保存文件的代码在独立的模块(DesignerModule)中实现。为了连接两者,我们需要使用某种全局命令。在这种情况下使用 DelegateCommand 并使其全局可访问不是正确的选择,因为用户可能希望一次性保存所有文件。幸运的是,PRISM 提供了 CompositeCommand 类。CompositeCommand 类是一个命令集合,它们按顺序执行。当触发 CompositeCommand 时,所有注册的命令都按顺序执行。使用 CompositeCommand 是实现应用程序保存功能的最佳方式。

应用程序有两个保存命令:保存和全部保存。全部保存将保存所有打开的文档,而保存命令只保存活动文档。这里有一个关于保存功能需要提及的有趣之处。对于未修改的文档,保存命令将处于非活动状态。这给全部保存命令带来了问题。默认情况下,CompositeCommand 只有在所有注册命令都可以执行时才能执行。这意味着默认情况下,如果活动文档列表中的文档未更改,则无法使用全部保存命令保存其他文档。

为了改变这一点,我重写了默认的 CompositeCommand 实现。特别是,我派生自 CompositeCommand 类并修改了 CanExecute 实现,以允许在至少一个注册命令可以执行时触发 CompositeCommand。代码如下所示

public class CustomCompositeCommand:CompositeCommand
{
	public CustomCompositeCommand():base()
	{}
	public CustomCompositeCommand(bool monitorCommandActivity)
		: base(monitorCommandActivity)
	{}
	public override bool CanExecute(object parameter)
	{
		ICommand[] commandList;
		bool hasEnabledCommandsThatShouldBeExecuted = false;
		lock (this.RegisteredCommands)
		{
			commandList = this.RegisteredCommands.ToArray();
		}
		foreach (ICommand command in commandList)
		{
			if (this.ShouldExecute(command))
			{
				if (command.CanExecute(parameter))
				{
					hasEnabledCommandsThatShouldBeExecuted = true;
				}
			}
		}
		return hasEnabledCommandsThatShouldBeExecuted;
	}
}

代码中有趣的部分发生在 for 循环中。循环遍历所有注册的命令,如果其中至少有一个可以执行,则 CompositeCommandCanExecute 方法返回 true。这与原始实现有所不同,原始实现仅在所有命令都可以执行时才返回 true

我的实现存在风险。如果用户不进行其他操作,不应该执行的命令可能会执行。为了解决这个问题,每个向此类型的 CompositeCommand 注册命令的视图模型都应该在执行处理程序中添加一个检查,并且只有在当前命令可以执行时才执行该方法。如下所示

private void OnSaveCommand()
{
	if (CanSaveOverride)
		SaveOverride();
}

为了使 CompositeCommand 在模块之间可用,它们被定义为公共可访问类中的 static 成员。

public static class InfrastructureCommands
{
	private static CompositeCommand saveAllCmd, saveCmd, shutdownCmd;

	static InfrastructureCommands()
	{
		saveAllCmd = new CustomCompositeCommand();
		saveCmd = new CustomCompositeCommand(true);
		shutdownCmd = new CompositeCommand();
	}

	public static CompositeCommand SaveAllCommand
	{
		get { return saveAllCmd; }
	}
	public static CompositeCommand SaveCommand
	{
		get { return saveCmd; }
	}
	public static CompositeCommand ShutdownCommand
	{
		get { return shutdownCmd; }
	}
}

然后,视图模型可以将其自己的命令注册到这些命令。下面的代码展示了如何注册文档的保存和全部保存命令。

saveCmd = new DelegateCommand(OnSaveCommand, () => CanSaveOverride);
InfrastructureCommands.SaveAllCommand.RegisterCommand(saveCmd);
InfrastructureCommands.SaveCommand.RegisterCommand(saveCmd);

从上面的代码中可以看到,我们将相同的命令注册到保存和全部保存复合命令。这些命令在一个方面有所不同。保存 CompositeCommandmonitorCommnadActivity 构造函数参数设置为 true。这意味着此 CompositeCommand 只会执行活动且可执行的已注册命令。这可以通过 CompositeCommand 类实现 IActiveAware 接口来确定。

monitorCommandActivity 参数为 true 时,CompositeCommand 类表现出以下行为

  • CanExecute. 仅当所有活动命令都可以执行时才返回 true。不活跃的子命令将完全不被考虑。
  • Execute. 执行所有活动命令。不活跃的子命令将完全不被考虑。

为了支持这一点,视图模型应该实现 IActiveAware 接口。该接口主要用于跟踪区域中子视图的活动状态。视图是否处于活动状态由协调特定区域控件中视图的区域适配器决定。例如,选项卡控件使用区域适配器将当前选定选项卡中的视图设置为活动状态。当视图模型的 IsActive 属性更改时,您可以更改相应命令的 IsActive 属性。

设计器目前的实现方式似乎无法很好地与区域管理器自动设置 IsActive 属性协同工作。显然,如果您使用数据模板(当前实现就是这样),区域管理器不会设置 IsActive 属性。为了解决这个问题,我在当前文档更改时手动设置了 IsActive 属性。相关代码可以在 DesignerViewModel 中看到。

public DocumentViewModel SelectedFile
{
	get { return selFile; }
	set
	{
		if (selFile != value)
		{
			if (selFile != null) selFile.IsActive = false;
			selFile = value;
			if (selFile != null) selFile.IsActive = true;
			RaisePropertyChanged(() => SelectedFile);
		}
	}
}

现在,当当前文档更改时,我还会为该视图模型中的所有命令设置 IsActive 属性。如下所示

protected override void OnIsActiveChanged()
{
	base.OnIsActiveChanged();

	SaveCommand.IsActive = IsActive;
	//publish the selected field
	if (IsActive)
	{
		if (Screen != null)
			eventAggregator.GetEvent<DocumentChangedEvent>().Publish(Screen as MainScreen);

		eventAggregator.GetEvent<FieldSelectedEvent>().Publish(selectedField);                
	}
}

应用程序关闭

设计应用程序时遇到的一个问题是,如何最好地处理应用程序关闭。对于不需要保存文档的应用程序,这很简单。您只需在用户从菜单触发关闭命令时触发一个事件。然后调用窗口的 Close 方法。

当您有一个处理文档的应用程序时,关闭操作会稍微困难一些。您需要考虑应用程序可能关闭的所有方式。如果您有未保存的数据,您还需要询问用户如何处理未保存的文档。

对于当前的应用程序,所有这些问题都需要解决。如果用户触发 Exit 命令或按下主窗口上的 X 按钮,应用程序都可以关闭。为了处理 Exit 命令的情况,ShellViewModel 类公开了 Exit 命令和 Shutdown 事件。如果触发了该命令,则事件也会触发。如下所示

private void OnShutDown()
{
	if (Shutdown != null)
		Shutdown(this, EventArgs.Empty);
}

然后在 Bootstrapper 的 InitializeShell 方法中,我订阅了该事件并调用了视图的 Close 方法。

Shell shell = (Shell)this.Shell;
ShellViewModel vm = Container.Resolve<ShellViewModel>();
shell.DataContext = vm;
vm.Shutdown += (s, e) => {
	shell.Close();
};

现在的问题是处理未保存的文档。您必须考虑文档是在另一个模块中实现的,并且 shell 无法访问该代码。为了收集所需的信息,我使用了全局复合命令。在主视图的 Closing 事件处理程序中,执行此命令。

shell.Closing += (s, e) => {
	if (InfrastructureCommands.ShutdownCommand.CanExecute(e))
		InfrastructureCommands.ShutdownCommand.Execute(e);
	//...
}

如您所见,CanExexuteExecute 命令被传入主视图 Closing 事件的 CancelEventArgs 实例。所有打开的文档都将订阅此命令,如果它们需要保存,将修改 Cancel 属性。如下所示

shutdownCmd = new DelegateCommand<CancelEventArgs>(ShutdownOverride);
InfrastructureCommands.ShutdownCommand.RegisterCommand(shutdownCmd);
protected override void ShutdownOverride(CancelEventArgs args)
{
	if (IsDirty) args.Cancel = true;
}

在引导程序中,我们然后检查 Cancel 属性值。Closing 事件处理程序的其余部分如下所示。

if (e.Cancel)
{
	//display the dialog to ask for directions
	IInteractionService intService = Container.Resolve<IInteractionService>();
	intService.ShowConfirmationDialog("Shutdown", 
    "There are still some unsaved documents. Do you want to save them before closing?",
		(res) => {
			if (res!=null && res.Value)
			{//save and exit
				if (InfrastructureCommands.SaveAllCommand.CanExecute(null))
					InfrastructureCommands.SaveAllCommand.Execute(null);
				e.Cancel = false;
			}
			else if (res!=null && !res.Value)
			{//don't save and exit
				e.Cancel = false;
			}
		});
}

如果关闭操作被取消,则表示我们有未保存的文档。在这种情况下,我们会向用户显示一个对话框,询问他们如何继续。用户现在可以在关闭前保存更改,忽略更改或取消关闭。如果用户决定保存,则会触发“保存所有”命令。这反过来会触发每个打开文档中的“保存”命令。然后,Cancel 属性将设置为 false,以允许应用程序关闭。

关闭文档

要关闭文档,用户将按下相应选项卡项的 X 按钮。此按钮绑定到基类 DocumentViewModelCloseCommand。所有文档都将派生自此基类。关闭命令是一个常规的 DelegateCommand,可以随时执行。执行处理程序的实现如下所示

private void OnClose()
{
	CancelEventArgs args = new CancelEventArgs();
	CloseOverride(args);

	if (!args.Cancel)
	{
		InfrastructureCommands.SaveAllCommand.UnregisterCommand(SaveCommand);
		InfrastructureCommands.SaveCommand.UnregisterCommand(SaveCommand);
		InfrastructureCommands.ShutdownCommand.UnregisterCommand(ShutdownCommand);
		FileClosed(this, EventArgs.Empty);
	}
}

该方法首先创建一个 CancelEventArgs 实例并将其传递给 CloseOverride 方法。此方法是一个虚拟方法,可以在派生类中重写。在派生类中,当前无法关闭的文档将通过将参数 Cancel 属性设置为 true 来取消关闭操作。方法返回后,将分析此属性。如果 Cancel 属性为 false,视图模型将取消注册保存和关闭命令并触发 FileClosed 事件。

在现有的派生 DocumentViewModel 中,只有 FileDesignerViewModel 具有取消文件关闭操作的选项。这是因为只有这种类型的文档可以修改。此视图模型中 CloseOverride 的实现如下所示

protected override void CloseOverride(CancelEventArgs args)
{
	if (IsDirty)
	{
		IConfirmationService service = container.Resolve<IConfirmationService>();
		service.ShowConfirmationDialog("Close File", 
		"The file has been modified. Do you want to save before closing?",
			res => {
				if (res == null)
					args.Cancel = true;
				else if (res != null && res.Value)
				{//save
					SaveOverride();
				}
				else{/*don't save*/}
			});
	}
}

您可以看到,我们仅在文档处于“脏”状态时运行代码。如果文档已被修改并且用户选择关闭,则交互服务会显示一个确认对话框。根据响应,文档在关闭前保存、更改被丢弃或关闭操作被取消。

当前实现使用自定义交互服务。另一种选择是使用 InteractionRequest 模式。在这种情况下,我们需要使用自定义窗口类型。这是因为默认的确认窗口只有两个按钮。对于此交互,我们需要三个(是、否和取消)。

使用应用程序

该应用程序有两个主要使用场景:创建和编辑 UI 屏幕以及生成 Java 代码。

创建和编辑 UI 屏幕

为了开始使用应用程序,我们需要创建一个项目。该项目将用于管理屏幕文件。下图显示了新建项目对话框。

从上图中可以看出,您可以使用此窗口指定项目存储路径、BB 操作系统版本和 BB 设备型号。最后两个设置在编辑文档时对于设置屏幕尺寸和默认字体大小是必要的。

点击确定后,项目将被创建,项目文件将显示在资源管理器窗口中。现在用户可以向项目添加文件以创建 BB 屏幕。这可以通过右键单击资源管理器中的项目名称并选择添加新项选项来完成。

文件创建后,该文件将显示在资源管理器窗口中,并且会自动在应用程序的主区域(设计器中)打开。如下图所示

设计器表面大小取决于设备型号。这就是为什么我们在创建项目时需要指定它的原因。文件创建后,用户可以开始向设计器添加控件。这可以通过从工具箱窗口拖放控件或使用布局窗口来完成。下图显示了一个屏幕,其中已经从工具箱中拖放了一些元素。

我们添加控件到屏幕的另一个选项是使用布局窗口。在此窗口中,我们可以向任何管理器添加和移除控件。此窗口还可以用于设置屏幕的标题、横幅和状态。事实上,目前这是唯一可以更改这些屏幕属性的方法。

要将元素添加到管理器,请使用加号图标。按下此图标将打开“添加新字段”对话框。如果单击“确定”,该字段将作为当前选定控件的同级或子级添加,具体取决于控件类型(分别为非管理器或管理器)。下图显示了此窗口

为了更改所选元素的属性,我们可以使用“属性窗口”。下图演示了如何编辑标签字段的背景颜色。

保存更改并生成代码

屏幕设计完成后,我们可以保存它们以开始代码生成。为了生成代码,我们使用“项目”菜单的“生成”菜单选项。文件以 XML 格式保存。不仅如此,XML 结构非常简单。这是为了支持未来版本,该版本将允许用户通过编写 XML 来向屏幕添加控件。下图显示了此类文件的结构。

该应用程序将为我们的项目文件生成相应的 Java 代码。生成的代码是 MVC 代码。应用程序将使用文件名作为视图类(派生自 MainScreen 类的类)的名称。如果文件名不以“View”结尾,应用程序将自动为文件名添加后缀。应用程序还为每个视图类生成一个控制器类。这可以在下图中看到。该图像显示了项目文件列表、生成的视图和生成的控制器。

下面的列表显示了为其中一个设计的视图生成的代码。从这个列表中我们可以看到视图对相应的控制器有一个引用。这将帮助我们在用户事件触发时委托任务。

//the class definition
public class HomeView extends MainScreen{

    //Constructors
    public HomeView(HomeViewController controller){
        super();
        this.controller=controller;
        initComponents();
    }
    public HomeView(HomeViewController controller, long style){
        super(style);
        this.controller=controller;
        initComponents();
    }
    //Field initialization
    private void initComponents(){
        labelField1 = new LabelField();
        labelField1.setText("Click to go the settings page");
        labelField1.setBackground(BackgroundFactory
            .createSolidTransparentBackground(0x00C8C800, 200));
        this.add(labelField1);

        buttonField1 = new ButtonField(Field.FIELD_RIGHT);
        buttonField1.setLabel("Start");
        this.add(buttonField1);
    }
    //Fields
    public LabelField labelField1;
    public ButtonField buttonField1;
    private HomeViewController controller;
}

下面列出了相应控制器的代码

public class HomeViewController {

    private MainScreen view;
    
    public HomeViewController(){
        
    }
    public MainScreen getView(){
        if(view==null)
            view=new HomeView(this);
        return view;
    }
    public void showView(){
        UiApplication.getUiApplication().pushScreen(getView());
    }
}

将生成的文件集成到黑莓项目中

此时,构建我们的黑莓应用程序所需的一切只是将文件复制到黑莓项目中,并使用 JDE 导入它们。考虑到应用程序会生成正确的文件夹结构,这项工作变得更加容易。下图显示了一个黑莓项目。

以下代码清单显示了用于启动应用程序的应用程序类代码

public class App extends UiApplication {
    public App() {
        HomeViewController ctrl=new HomeViewController();
        ctrl.showView();
    }
    public static void main(String[] args) {
        App app=new App();
        app.enterEventDispatcher();
    }
}

下面的代码使用其中一个生成的控制器类来显示第一个应用程序屏幕。接下来,我们需要添加一些导航代码,以便在用户按下按钮时更改屏幕。由于 MVC 架构,这是一项非常容易的任务。要移动到下一个屏幕,我们将在 HomeView 视图类中添加一个按钮处理程序,并在该处理程序中委托给控制器类。这将在 initComponents 方法中完成。

//...
buttonField1.setChangeListener(new FieldChangeListener() {
    public void fieldChanged(Field arg0, int arg1) {
        controller.moveToNextPage();
    }
});

moveToNextPage 方法的代码如下所示

//...
public void moveToNextPage(){
    SettingsViewController ctrl= new SettingsViewController();
    ctrl.showView();
}

运行黑莓应用程序

下图显示了两个屏幕在黑莓模拟器中的显示效果。

下图显示了设计好的屏幕在 BBInterfaceNET 应用程序中的显示效果。

用户界面有些不同,但这可以通过调整控件样式来解决。

已知问题

此应用程序远未完成。我决定将其公开,是为了看看业界是否真的需要一款黑莓可视化设计器。我一直不明白为什么没有黑莓设计器,尽管其他所有现代移动技术都有(WP7、Android 和 iOS 都有可视化设计器。顺便说一句,WP7 设计器棒极了)。

以下是一些已知问题。我希望我能尽快解决它们。

  • 并非所有标准黑莓控件都已实现。
  • 现有控件的实现未考虑操作系统版本。SDK 控件在不同版本之间行为不同。
  • 控件样式与 BB 样式不完全匹配。
  • 目前,应用程序并不是真正可扩展的。该应用程序将允许用户添加自定义控件库,以支持更多的组件。这可以通过开发新的模块来实现,并且由于模块发现是通过配置文件完成的,因此模块集成将非常容易。
  • 生成 Java 代码的 T4 模板是硬编码的,只能转换少量控件。在此阶段,即使用户开发了一个带有新控件的新模块并使用了它,这些控件也不会用于代码生成(但它们会被保存)。这里需要使用某种映射文件,以使 T4 模板真正通用。
  • 还存在一些架构问题。这是我的第一个 PRISM 应用程序。尽管我通过构建它学到了很多,但我知道有很多地方我可以做得更好。我计划在未来的版本中纠正这些问题。

兴趣点

尽管应用程序远未完成,但我认为它具有巨大的潜力。它对于初级黑莓开发者尤其有用,可以帮助他们快速编写结构良好的代码。该应用程序对于经验丰富的开发者也很有用,可以让他们将注意力从调整 UI 转移到他们需要实现的实际业务逻辑上。

编写这个应用程序让我非常开心,尤其是考虑到我是为了学习 PRISM 而做的。

如果您喜欢这篇文章,并且认为这个应用程序对您有用,请花点时间投票并发表您的评论或建议。

历史

  • 2012年6月17日 - 初次发布
  • 2012年6月19日 - 添加了模块描述
  • 2012年6月20日 - 添加了区域和视图注册部分
  • 2012年6月24日 - 添加了命令通信部分
© . All rights reserved.