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

PlantUML 编辑器:使用 WPF 的快速简单 UML 编辑器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (60投票s)

2010年3月9日

CPOL

16分钟阅读

viewsIcon

273881

downloadIcon

6396

一个 WPF 智能客户端,用于使用 plantuml 工具从纯文本生成 UML 图

Screenshot of PlantUML Editor

引言

PlantUML 编辑器使用 WPF 和 .NET 3.5 构建,是一个 IDE,用于使用强大的 PlantUML 工具绘制 UML 图。如果您之前使用过 PlantUML,您就知道无需在设计师环境中挣扎,即可快速编写 UML 图。特别是那些使用 Visio 绘制 UML 图的人(上帝保佑!),您将如获至宝。这是一种快速获取图表并准备好展示的超快方式。您可以遵循简单的语法,用纯英文*编写* UML 图,并即时生成图表。

这个编辑器确实节省了设计 UML 图的时间。我每天都需要制作快速图表,以便迅速向架构师、设计师和开发人员传达想法。因此,我使用此工具以编码的速度编写一些快速图表,图表即时生成。与其写一封长邮件用英语解释一些复杂的 D 动作或业务流程,不如快速用几乎纯英文的编辑器编写,然后即时生成一个漂亮的活动图/序列图。进行重大更改也和在这里进行搜索替换和复制粘贴块一样容易。在任何传统的基于鼠标的 UML 设计器中都无法获得如此的灵活性。

它是如何工作的

这是一个如何工作的快速屏幕录像

PlantUML Screencast

如何安装

首先安装 GraphViz。然后创建一个名为 GRAPHVIZ_DOT 的环境变量,指向 GraphViz 存储 dot.exe 的位置。例如,C:\Program Files (x86)\Graphviz2.26.3\bin\dot.exe

plantuml 使用 Graphviz 来渲染图表。

然后,您可以下载我制作的安装程序并进行安装。它内置了 plantuml Java jar。

代码

这是一个 WPF 项目,其中包含一些可供您在项目中重用的实用程序类。Utilities 包含一个 BackgroundWork 类,用于在 WPF 中执行后台任务。它比 .NET 的默认 BackgroundWorker 组件或 Dispatcher 更强大,因为您可以更精确地控制后台任务的运行方式、等待所有后台工作完成、中止工作等。我将另写一篇关于 BackgroundWork 库的文章。

PlantUML Editor Solution Tree

MainWindow.xaml 是您在截图中看到的那个主窗口。它包含左侧的文件列表和右侧的欢迎面板。编辑器和图表图像位于 DiagramViewControl.xaml 中,该文件负责显示和编辑特定的图表。

这是我创建的第二个 WPF 应用,请多包涵。

让我们来看看代码中可以提取的精彩部分。

主题

您一定垂涎欲滴,想要拿到这个项目的这个主题。我当时也是。:) 我必须说,我从著名的 Family.Show 项目中汲取了大量灵感(以及复制粘贴的代码),并进行了定制以满足需求。然后,我从 Expression Blend 的某个示例中获得了背景图像。

您应该查看 Skins\Black\BlackResource.xaml 来了解此类自定义主题是如何构建的。这个文件里的智慧令人惊叹。

带有文件内容和图表预览的列表框

左侧的 listbox 显示了实际文件内容和生成的图表的预览。首先,您需要为 ListBox 项定义一个自定义模板。

<ListBox Grid.Row="1" 
	Width="Auto"
	ItemContainerStyle="{DynamicResource ListBoxItemStyle1}"
	SelectionMode="Extended" 
	Background="#64404040"
	ItemsSource="{StaticResource DesignTimeDiagramFiles}" 
	x:Name="DiagramFileListBox" 
	SelectedValuePath="DiagramFilePath" 
	IsSynchronizedWithCurrentItem="True" 
	HorizontalContentAlignment="Stretch"                              
	Foreground="Ivory" 
	BorderBrush="{x:Null}" 
	Margin="0,0,0,10"
	ScrollViewer.CanContentScroll="True"
	VirtualizingStackPanel.IsVirtualizing="True"
	VirtualizingStackPanel.VirtualizationMode="Recycling"
	VerticalAlignment="Stretch" 
	MouseDoubleClick="DiagramFileListBox_MouseDoubleClick" 
	SelectionChanged="DiagramFileListBox_SelectionChanged" >
	  <ListBox.ItemTemplate>
		<DataTemplate>
		  <DataTemplate.Resources>                                        
		  </DataTemplate.Resources>
		  <Border Padding="0,2,0,2" 
				  CornerRadius="2" 
				  x:Name="DiagramItem" 
				  HorizontalAlignment="Stretch" 
				  VerticalAlignment="Top">
			<StackPanel>
			  <WrapPanel HorizontalAlignment="Stretch" 
						 Margin="0,0,10,0">
				<Image MaxWidth="64" 
					   MaxHeight="100"  
					   Source="{Binding Path=ImageFilePath, 
						Converter={StaticResource 
						uriToImageConverter}}" 
					   	Margin="3, 5, 10, 0" 
						VerticalAlignment="Top"></Image>
				<StackPanel>
				  <TextBlock Text=
					"{Binding Path=DiagramFileNameOnly}"  />
				  <TextBlock Foreground="White" 
					TextWrapping="WrapWithOverflow" 
					Text="{Binding Path=Preview}" 
					TextTrimming="CharacterEllipsis" 
					MaxHeight="100" 
					ClipToBounds="True" 
					VerticalAlignment="Top" />
				</StackPanel>                      
			  </WrapPanel>
			  <Separator Foreground="Green" Opacity="0.5" />
			</StackPanel>
		  </Border>
		</DataTemplate>
	  </ListBox.ItemTemplate>
	</ListBox>  

这里的 ListBoxItemSource 属性绑定到一个设计时数据源,我很快就会讲到。它基本上绑定到一个 DiagramFile 类集合,该类保存了图表文件的信息——路径、图表图像路径、实际内容预览等。

图像绑定到图表图像的路径。为了预览图表,我不得不创建一个自定义转换器。否则,Image 会锁定图像文件,不允许更改。这是转换器的代码。

public class UriToCachedImageConverter : IValueConverter
{
    public object Convert(object value, Type targetType, 
	object parameter, System.Globalization.CultureInfo culture)
    {
        if (value == null)
            return null;

        if (!string.IsNullOrEmpty(value.ToString()))
        {
            BitmapImage bi = new BitmapImage();
            bi.BeginInit();
            bi.UriSource = new Uri(value.ToString());
            // OMAR: Trick #6
            // Unless we use this option, the image file is locked and cannot be modified.
            // Looks like WPF holds read lock on the images. Very bad.
            bi.CacheOption = BitmapCacheOption.OnLoad;
            // Unless we use this option, an image cannot be refrehsed. It loads from 
            // cache. Looks like WPF caches every image it loads in memory. Very bad.
            bi.CreateOptions = BitmapCreateOptions.IgnoreImageCache;
            try
            {
                bi.EndInit();
                return bi;
            }
            catch
            {
                return default(BitmapImage);
            }
        }

        return null;
    }

    public object ConvertBack(object value, Type targetType, 
	object parameter, System.Globalization.CultureInfo culture)
    {
        throw new NotImplementedException("Two way conversion is not supported.");
    }
}

它解决了两个问题。首先,它不会锁定文件,因此在编辑图表时可以更改文件。其次,WPF 似乎有一种内部缓存,它会在缓存中保留图像。您无法刷新图像并使其从源加载。因此,您必须使用 BitmapCreateOptions.IgnoreImageCache 技巧来使其生效。

在代码后端,网格在应用程序启动时在后台线程中加载。另外,当您更改左上角位置框中的路径时,网格会刷新。它查找给定文件夹中的所有文本文件,并检查是否有任何文本文件是 plantuml 格式的。如果是,则加载到列表中。

private void LoadDiagramFiles(string path, Action loaded)
{
    _DiagramFiles.Clear();
    
    this.StartProgress("Loading diagrams...");

    BackgroundWork.DoWork<list><diagramfile>>(
        () =>
        {
            var diagrams = new List<diagramfile>();
    
            foreach (string file in Directory.GetFiles(path))
            {
                string content = File.ReadAllText(file);
                if (content.Length > 0)
                {
                    string firstLine = content.Substring(0, 
                        content.IndexOf(Environment.NewLine[0]));
                    if (firstLine.StartsWith("@startuml"))
                    {
                        string imageFileName = firstLine.Substring
					(content.IndexOf(' ') + 1)
                            .TrimStart('"').TrimEnd('"');

                        diagrams.Add(new DiagramFile{
                                              Content = content,
                                              DiagramFilePath = file,
                                              ImageFilePath =
                                          System.IO.Path.IsPathRooted(imageFileName) ? 
                                            System.IO.Path.GetFullPath(imageFileName)
                                            : System.IO.Path.GetFullPath(
                                                System.IO.Path.Combine
						(path, imageFileName))
                                          });
                    }
                }
            }

            return diagrams;
        },
        (diagrams) =>
        {                   
            this._DiagramFiles = new ObservableCollection<diagramfile>(diagrams);
            this.DiagramFileListBox.ItemsSource = this._DiagramFiles;
            this.StopProgress("Diagrams loaded.");
            loaded();
        },
        (exception) =>
        {
            MessageBox.Show(this, exception.Message, "Error loading files", 
                MessageBoxButton.OK, MessageBoxImage.Error);
            this.StopProgress(exception.Message);
        });
}

这里您将看到我 BackgroundWork 类的一个用法。我正在使用它在一个单独的线程中从给定路径加载文件,以便 UI 保持响应。第一个回调用于在单独的线程上执行工作。在这里,我正在构建一个新的图表文件集合。我不能更改已绑定到 ListBox 的现有集合,因为那样会触发对 ListBox 的更新,而 .NET 不允许这样做从单独的线程进行。因此,一旦构建了新集合,就会触发第二个回调,即 onSuccess 回调。它在 UI 线程上执行,我可以在这里重新绑定 ListBox

这是保存每个图表信息的 DiagramFile 类。

public class DiagramFile
{
    public string DiagramFilePath { get; set; }
    public string ImageFilePath { get; set; }
    public string Content { get; set; }

    public string Preview
    {
        get
        {
            // Ignore first @startuml line and select non-empty lines
            return Content.Length > 100 ? Content.Substring(0, 100) : Content;
        }
    }

    public string DiagramFileNameOnly
    {
        get
        {
            return Path.GetFileName(this.DiagramFilePath);
        }
    }

    public string ImageFileNameOnly
    {
        get
        {
            return Path.GetFileName(this.ImageFilePath);
        }
    }

    public override bool Equals(object obj)
    {
        var diagram = obj as DiagramFile;
        return diagram.DiagramFilePath == this.DiagramFilePath;
    }

    public override int GetHashCode()
    {
        return this.DiagramFilePath.GetHashCode();
    }
}

这是一个简单的实体类,用于保存 plantuml 格式图表的信息。例如,plantuml 中的图表看起来像这样。

@startuml img/sequence_img014.png
participant User

User -> A: DoWork
activate A

	A -> A: Internal call
	activate A

		A -> B: << createRequest >>
		activate B
			B --> A: RequestCreated
		deactivate B
	deactivate A
	A -> User: Done
deactivate A

@enduml  

使用 plantuml 渲染时,它看起来像这样。

现在,这是在运行时加载的。设计时环境如何在 ListBox 中显示真实数据?

在设计时在 ListBox 中实时渲染数据

在 WPF 中,您必须创建一个 ObservableCollection<YourClass>,然后从中创建一个 StaticResource 以在设计时显示绑定结果。例如,这里我显示了将图表绑定到 ListBox 和选项卡的结果。

Design time view of data in WPF

这是操作方法。首先,从您的实体类创建一个 ObservableCollection,然后在构造函数中,用虚拟数据填充它。

public class DiagramFiles : ObservableCollection<DiagramFile>
    {
        public DiagramFiles()
        {
            this.Add(new DiagramFile()
            {
                Content = 
@"@startuml cpe.png
actor EndUser
participant SaaS
participant CPE
.
.
.
@enduml",
                DiagramFilePath = "test.txt",
                ImageFilePath = "http://plantuml.sourceforge.net/img/sequence_img009.png"
            });

            this.Add(new DiagramFile()
            {
                Content = 
@"@startuml btconnectjourney.png
actor User
participant AOOJ
participant SaaS
participant DnP
.
.
.
@enduml",
                DiagramFilePath = "test2.txt",
                ImageFilePath = "http://plantuml.sourceforge.net/img/activity_img06.png"
            });
        }
    }  

然后构建它,并且需要为此创建一个 static 资源。首先,您需要在 <Window> 声明中添加一个命名空间引用。

<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
x:Class="PlantUmlEditor.MainWindow"
xmlns:plantuml="clr-namespace:PlantUmlEditor"
xmlns:DesignTimeData="clr-namespace:PlantUmlEditor.DesignTimeData"  

然后在 WindowResourceDictionary 中,创建一个标签来声明一个 static 资源。

<Window.Resources>
    <ResourceDictionary>
      <DesignTimeData:DiagramFiles x:Key="DesignTimeDiagramFiles" />    

就是这样。然后,您可以将控件的 ItemSource 属性绑定到此 static 资源。

平滑自动折叠的网格列,以提供更多空间

您可能已经注意到,当您通过单击图表文本框进入编辑模式时,左侧列会平滑折叠,为您提供更多编辑空间。您可以通过使用自定义动画来减小左侧列的宽度来实现此目的。

首先命名该列,以便您可以在动画中引用它。

<Grid.ColumnDefinitions>
          <ColumnDefinition Width="300" 
          x:Name="LeftColumn" />
          <ColumnDefinition Width="Auto" 
          MinWidth="10" />
          <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>    
    

然后创建两个 Storyboards,一个用于折叠,一个用于展开。您将需要在此处创建自定义动画,因为默认情况下没有可用的动画类来动画化 GridWidthHeight 属性,它们是 GridLength 数据类型。我使用了 这篇文章 中很棒的示例,这节省了很多时间。

这是动画类的样子。

// Source: https://codeproject.org.cn/KB/WPF/GridLengthAnimation.aspx
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows.Media.Animation;
using System.Windows;
using System.Diagnostics;

namespace PlantUmlEditor.CustomAnimation
{
    internal class GridLengthAnimation : AnimationTimeline
    {
        static GridLengthAnimation()
        {
            FromProperty = DependencyProperty.Register("From", typeof(GridLength),
                typeof(GridLengthAnimation));

            ToProperty = DependencyProperty.Register("To", typeof(GridLength), 
                typeof(GridLengthAnimation));
        }

        public override Type TargetPropertyType
        {
            get 
            {
                return typeof(GridLength);
            }
        }

        protected override System.Windows.Freezable CreateInstanceCore()
        {
            return new GridLengthAnimation();
        }

        public static readonly DependencyProperty FromProperty;
        public GridLength From
        {
            get
            {
                return (GridLength)GetValue(GridLengthAnimation.FromProperty);
            }
            set
            {
                SetValue(GridLengthAnimation.FromProperty, value);
            }
        }

        public static readonly DependencyProperty ToProperty;
        public GridLength To
        {
            get
            {
                return (GridLength)GetValue(GridLengthAnimation.ToProperty);
            }
            set
            {
                SetValue(GridLengthAnimation.ToProperty, value);
            }
        }

        public override object GetCurrentValue(object defaultOriginValue, 
            object defaultDestinationValue, AnimationClock animationClock)
        {
            double fromVal = ((GridLength)GetValue
			(GridLengthAnimation.FromProperty)).Value;
            double toVal = ((GridLength)GetValue(GridLengthAnimation.ToProperty)).Value;
            
            if (fromVal > toVal)
            {
                return new GridLength((1 - animationClock.CurrentProgress.Value) * 
			(fromVal - toVal) + toVal, GridUnitType.Pixel);
            }
            else
                return new GridLength(animationClock.CurrentProgress.Value * 
			(toVal - fromVal) + fromVal, GridUnitType.Pixel);
        }
    }
}
    

编译类后,在 <Window> 标签中添加命名空间引用,然后您就可以在 storyboards 中使用它了。

      <Storyboard x:Key="CollapseTheDiagramListBox">
        <customAnimation:GridLengthAnimation 
        Storyboard.TargetProperty="Width" 
        Storyboard.TargetName="LeftColumn" 
        From="{Binding Path=Width, ElementName=LeftColumn}" 
        To="120" 
        Duration="0:0:0.5" 
        AutoReverse="False"
        AccelerationRatio="0.8"/>
      </Storyboard>
      <Storyboard x:Key="ExpandTheDiagramListBox" >
        <customAnimation:GridLengthAnimation 
        Storyboard.TargetProperty="Width" 
        Storyboard.TargetName="LeftColumn" 
        From="{Binding Path=Width, ElementName=LeftColumn}" 
        Duration="0:0:0.5" 
        AutoReverse="False"
        AccelerationRatio="0.8"/>
      </Storyboard>    

这些动画是从代码触发的。每当您单击编辑器文本框时,GotFocus 事件会在包装文本框的 usercontrol 上触发。在事件中,我启动动画。类似地,当您离开编辑环境并单击 ListBox 时,会触发 LostFocus 事件,我运行 Expand 动画。

private void DiagramView_GotFocus(object sender, RoutedEventArgs e)
{            
    if (this.LeftColumn.ActualWidth > this.LeftColumnLastWidthBeforeAnimation.Value)
        this.LeftColumnLastWidthBeforeAnimation = 
			new GridLength(this.LeftColumn.ActualWidth);
    ((Storyboard)this.Resources["CollapseTheDiagramListBox"]).Begin(this, true);        
}
private void DiagramView_LostFocus(object sender, RoutedEventArgs e)
{
    var storyboard = ((Storyboard)this.Resources["ExpandTheDiagramListBox"]);
    (storyboard.Children[0] as GridLengthAnimation).To = 
			this.LeftColumnLastWidthBeforeAnimation;
    storyboard.Begin(this, true);
}

正如您所见,这并不直接。当列折叠时,您需要记住折叠前的列宽度,以便在展开时可以将其恢复到原始宽度。

酷炫的选项卡

WPF 中的默认选项卡太不酷了。一旦您将 TabControl 放在表单上,您所做的关于制作精美 UI 的努力就白费了。因此,我使用自定义模板自定义了选项卡的外观。

<Style TargetType="{x:Type TabItem}">
<Setter Property="Template">
  <Setter.Value>
    <ControlTemplate TargetType="{x:Type TabItem}">
      <Grid>
        <Border 
        Name="Border"
        Background="Gray"
        BorderBrush="Black" 
        BorderThickness="1,1,1,1"                   
        CornerRadius="6,6,0,0" >
          <ContentPresenter x:Name="ContentSite"
          VerticalAlignment="Center"
          HorizontalAlignment="Center"
          ContentSource="Header"
          Margin="12,2,12,2"/>
        </Border>
      </Grid>
      <ControlTemplate.Triggers>
        <Trigger Property="IsSelected" 
        Value="True">                  
          <Setter TargetName="Border" 
          Property="Background" 
          Value="black" />
          <Setter Property="Foreground" 
          Value="White" />                  
          <Setter TargetName="Border" 
          Property="BorderThickness" 
          Value="4,4,4,1" />
        </Trigger>
        <Trigger Property="IsSelected" 
        Value="False">
          <Setter TargetName="Border" 
          Property="Background" 
          Value="gray" />
          <Setter Property="Foreground" 
          Value="black" />
          <Setter TargetName="Border" 
          Property="Opacity" 
          Value="0.4" />
        </Trigger>
      </ControlTemplate.Triggers>
    </ControlTemplate>
  </Setter.Value>
</Setter>
</Style>    
    

接下来,就像 ListBox 一样,TabControl 绑定到相同的运行时集合,以便您可以在设计视图中看到选项卡的外观。在 TabControl 内部,托管着 DiagramViewControl,它提供单个图表的编辑和预览功能。DiagramViewControlDataContext 属性映射到选项卡的 SelectedItem 属性,以便它获取活动选项卡关联的 DiagramFile 对象。因此,如果您切换选项卡,DiagramViewControlDataContext 属性也会随之更改,以显示正确的选项卡数据。

<TabControl 
        Grid.Column="2" 
        Name="DiagramTabs" 
        Background="{x:Null}" 
        Visibility="Hidden"
        ItemsSource="{StaticResource DesignTimeDiagramFiles}">
          
          <TabControl.ItemTemplate>
            <DataTemplate>
              <TextBlock Text="{Binding Path=DiagramFileNameOnly}"  />
            </DataTemplate>
          </TabControl.ItemTemplate>
          
          <TabControl.ContentTemplate>
            <DataTemplate>
              <plantuml:DiagramViewControl x:Name="DiagramView"
              DataContext="{Binding Path=SelectedItem, 
			ElementName=DiagramTabs, Mode=TwoWay}" 
              HorizontalAlignment="Stretch" 
              VerticalAlignment="Stretch" 
              OnAfterSave="DiagramViewControl_OnAfterSave" 
              OnBeforeSave="DiagramViewControl_OnBeforeSave" 
              OnClose="DiagramViewControl_OnClose"
              GotFocus="DiagramView_GotFocus"
              LostFocus="DiagramView_LostFocus">                
              </plantuml:DiagramViewControl>                            
            </DataTemplate>
          </TabControl.ContentTemplate>          
        </TabControl>    
    

请注意双向绑定。这是因为在 DiagramViewControl 内部对内容所做的任何更改都会反映到关联的 DiagramFile 对象中。

现在您会注意到,当您双击 ListBox 中的文件时,会动态创建一个新选项卡来显示该特定图表。在运行时,TabControl 被连接到另一个名为 OpenDiagramsObservableCollection<DiagramFile>。您所要做的就是向此集合添加一个 DiagramFile 对象,然后一个新的选项卡就会弹出。数据绑定的乐趣!

private void DiagramFileListBox_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
    var diagramFile = this.DiagramFileListBox.SelectedItem as DiagramFile;
    this.OpenDiagramFile(diagramFile);
}

private void OpenDiagramFile(DiagramFile diagramFile)
{
    if (!_OpenDiagrams.Contains(diagramFile))
    {
        _OpenDiagrams.Add(diagramFile);

        this.DiagramTabs.ItemsSource = _OpenDiagrams;
        this.DiagramTabs.Visibility = Visibility.Visible;
        this.WelcomePanel.Visibility = Visibility.Hidden;
    }
    
    this.DiagramTabs.SelectedItem = diagramFile;            
}    
    

就是这样!

图表编辑器 - 强大的代码编辑器

代码编辑器使用 ICSharpCode.AvalonEdit 控件,主要是因为它支持块缩进,而 WPF 的默认文本编辑器不支持。使用此控件非常简单。添加 DLL 引用,在 <UserControl> 标签内声明命名空间,然后像这样使用它。

<avalonEdit:TextEditor 
      x:Name="ContentEditor" 
      Grid.Row="0" 
      HorizontalAlignment="Stretch" 
      VerticalAlignment="Stretch" 
      FontFamily="Consolas" 
      FontSize="11pt"
      Background="Black"
      Foreground="LightYellow"
      Opacity="0.8"
      Text=""
      Padding="10"
      TextChanged="ContentEditor_TextChanged">        
      </avalonEdit:TextEditor>  

就是这样。唯一的缺点是它不支持将 Text 属性绑定到 string。因此,我无法将其绑定到 DiagramFile.Content 属性来显示和反映文本中的更改。所以,我通过 UserControlDataContextChanged 事件来完成,该事件在 DataContext 更改为其他对象时触发。

private void UserControl_DataContextChanged
	(object sender, DependencyPropertyChangedEventArgs e)
{
    if (e.NewValue != null)
    {
        var newDiagram = (e.NewValue as DiagramFile);
        ContentEditor.Text = newDiagram.Content;
        ContentEditor.Tag = newDiagram.DiagramFilePath;
    }                
}    

这使得文本编辑器控件的 DataContext 与用户控件的 DataContext 保持同步。

带缩放支持的图表图像预览

您可以使用 WPF 中的 Image 控件来显示本地文件、URL 或 MemoryStream 中的图像。Image 控件功能非常强大。它支持复杂的变换。您可以缩放、扭曲、以多种方式变换图像。您还可以将变换绑定到其他 WPF 控件。例如,您可以将缩放变换绑定到滑块,这样当您上下滑动滑块时,图像就会放大和缩小。

<Image.ContextMenu>
    <ContextMenu x:Name="ImageContextMenu" Style="{DynamicResource ContextMenuStyle}">
        <MenuItem Style="{DynamicResource MenuItemStyle}" 
                  Header="Copy to Clipboard" 
                  Click="CopyToClipboard_Click"></MenuItem>
        <MenuItem Style="{DynamicResource MenuItemStyle}" 
                  Header="Open in explorer" 
                  Click="OpenInExplorer_Click"></MenuItem>                
    </ContextMenu>
    </Image.ContextMenu>
    <Image.RenderTransform>
      <TransformGroup>
        <ScaleTransform ScaleX="{Binding ElementName=ZoomSlider, Path=Value}" 
        ScaleY="{Binding ElementName=ZoomSlider, Path=Value}"/>
      </TransformGroup>
    </Image.RenderTransform>
</Image>      

在这里您可以看到,我将 ScaleTransform 绑定到了 ZoomSlider 控件的值。

每当您保存图表时,它都会使用 plantuml 重新生成,然后刷新图表图像。由于我们必须使用自定义值转换器将图像绑定到路径,因此我们失去了自动刷新功能。Image 控件不再监听文件更改。因此,我们必须使用自定义代码强制刷新图像。

BindingOperations.GetBindingExpression
	(DiagramImage, Image.SourceProperty).UpdateTarget();

然而,仅仅这样做并不能刷新 Image。WPF 有其内部缓存,它会根据 Uri 记住图像。因此,必须更改自定义值转换器以停止缓存。

public class UriToCachedImageConverter : IValueConverter
{
    public object Convert(object value, Type targetType, 
	object parameter, System.Globalization.CultureInfo culture)
    {
        if (value == null)
            return null;

        if (!string.IsNullOrEmpty(value.ToString()))
        {
            BitmapImage bi = new BitmapImage();
            bi.BeginInit();
            bi.UriSource = new Uri(value.ToString());
            // OMAR: Trick #6
            // Unless we use this option, the image file is locked and cannot be modified.
            // Looks like WPF holds read lock on the images. Very bad.
            bi.CacheOption = BitmapCacheOption.OnLoad;
            // Unless we use this option, an image cannot be refrehsed. It loads from 
            // cache. Looks like WPF caches every image it loads in memory. Very bad.
            bi.CreateOptions = BitmapCreateOptions.IgnoreImageCache;    

您可以阅读注释来理解我经历的痛苦,您将不必再经历它了。

在资源管理器中显示图表图像

这是一个简单的技巧,但值得一提。您可以右键单击图像并选择“在资源管理器中显示”,这将启动 Windows 资源管理器,其中图像已被选中。您可以立即复制粘贴文件或执行任何其他文件操作。这是通过以下方式实现的。

private void OpenInExplorer_Click(object sender, RoutedEventArgs e)
{
    Process
        .Start("explorer.exe","/select," + this.CurrentDiagram.ImageFilePath)
        .Dispose();
}   

简单但实用的功能。

自动刷新和在后台渲染图表

当您在代码编辑器中打字时,它会每 10 秒刷新一次图表。这就是我的 BackgroundWork 类发挥作用的地方。我可以检查是否已有后台工作正在运行。如果没有,我可以在 10 秒后在单独的线程中排队一项工作。

private void ContentEditor_TextChanged(object sender, EventArgs e)
{
    if (AutoRefreshCheckbox.IsChecked.Value)
    {
        if (!BackgroundWork.IsWorkQueued())
        {
            BackgroundWork.DoWorkAfter(SaveAndRefreshDiagram, 
                                       TimeSpan.FromSeconds(
                                           int.Parse(RefreshSecondsTextBox.Text)));
        }
    }
}   

这可以防止多个图表生成请求被排队并导致竞争条件。

图表是通过运行 plantuml 命令行程序生成的。它接受一个图表文本文件的路径,并生成该图表文本文件中指定的图表图像。

BackgroundWork.WaitForAllWork(TimeSpan.FromSeconds(20));
BackgroundWork.DoWork(
    () =>
    {
        // Save the diagram content
        File.WriteAllText(diagramFileName, content);

        // Use plantuml to generate the graph again                    
        using (var process = new Process())
        {
            var startInfo = new ProcessStartInfo();
            startInfo.FileName = plantUmlPath;
            startInfo.Arguments = "\"" + diagramFileName + "\"";
            startInfo.WindowStyle = ProcessWindowStyle.Hidden; // OMAR: Trick #5
            startInfo.CreateNoWindow = true; // OMAR: Trick #5
            process.StartInfo = startInfo;
            if (process.Start())
            {
                process.WaitForExit(10000);
            }
        }
    },
    () =>
    {
        BindingOperations.GetBindingExpression
		(DiagramImage, Image.SourceProperty).UpdateTarget();

        OnAfterSave(this.CurrentDiagram);
    },
    (exception) =>
    {
        OnAfterSave(this.CurrentDiagram);
        MessageBox.Show(Window.GetWindow(this), 
		exception.Message, "Error running PlantUml",
                        MessageBoxButton.OK, MessageBoxImage.Error);
    });     

首先,它等待所有后台工作完成。然后,它在一个单独的线程中开始运行 plantuml 进程。该程序在没有窗口的情况下运行,以便不会将焦点从您的代码编辑器中移开。当图表成功生成后,图表图像将被刷新。

将 Java 程序作为 EXE 运行

我使用了出色的 JSmooth 工具将 plantuml jar 创建成一个 EXE。它内置了复杂的 JVM 检测功能。它创建的 EXE 包装器可以检测正确的 JVM,然后运行 jar。

您可以将 jar 嵌入生成的 EXE 中,创建一个单一的 EXE 文件,该文件会自动提取并运行 jar。

通过按钮单击自动显示正确的上下文菜单

有一个“添加”按钮,单击该按钮后,会以编程方式显示一个上下文菜单。

这还不是全部。它还记得您上次单击了哪个子菜单,并会自动打开该子菜单,为您节省一次单击。当您使用用例图并添加用例元素时,您很可能会继续添加用例项。单击“用例”然后从中选择一个项很麻烦。智能上下文菜单可以避免您进行不必要的单击。

private void AddStuff_Click(object sender, RoutedEventArgs e)
{
    // Trick: Open the context menu automatically whenever user
    // clicks the "Add" button
    AddContextMenu.IsOpen = true;

    // If user last added a particular diagram items, say Use case
    // item, then auto open the usecase menu so that user does not
    // have to click on use case again. Saves time when you are adding
    // a lot of items for the same diagram
    if (_LastMenuItemClicked != default(WeakReference<MenuItem>))
    {
        MenuItem parentMenu = (_LastMenuItemClicked.Target.Parent as MenuItem);
        parentMenu.IsSubmenuOpen = true;
    }
}    

在这里,上下文菜单是通过使用 IsOpen 属性以编程方式打开的。然而,在打开它之后,我检查了上次单击的子菜单项是什么,并且也自动打开了它。

如果您查看按钮的 XAML,您会注意到没有单击处理程序。有如此多的子菜单和孙子菜单,以至于很难从标记中添加事件处理程序。

          <Button Style="{DynamicResource RedButtonStyle}" 
          Height="Auto" 
          x:Name="AddStuff" 
          Width="Auto" 
          Padding="20, 0, 20, 0"
          Click="AddStuff_Click" 
          Content="_Add..." >
            <Button.ContextMenu>
              <ContextMenu x:Name="AddContextMenu" 
		Style="{DynamicResource ContextMenuStyle}">                
                <MenuItem Style="{DynamicResource MenuItemStyle}" Header="Use Case">
                  <MenuItem Style="{DynamicResource MenuItemStyle}" Header="Actor">
                    <MenuItem.Tag>
                      <![CDATA[
	actor :Last actor: as Men << Human >>
	]]>
                    </MenuItem.Tag>
                  </MenuItem>       

相反,我以编程方式遍历菜单层次结构,并为其添加单击处理程序。

public DiagramViewControl()
{
    InitializeComponent();

    foreach (MenuItem topLevelMenu in AddContextMenu.Items)
    {
        foreach (MenuItem itemMenu in topLevelMenu.Items)
        {
            itemMenu.Click += new RoutedEventHandler(MenuItem_Click);
        }
    }
}  

在菜单单击处理程序内部,我设置了 LastMenuItemClicked 变量来记住上次单击的是哪个菜单项。

private void MenuItem_Click(object sender, RoutedEventArgs e)
{
    this._LastMenuItemClicked = e.Source as MenuItem;
    this.AddCode((e.Source as MenuItem).Tag as string);
}    

那么,那些认识我的人,您很清楚我对强引用内存泄漏很谨慎。我不会做像在 private 变量中保留 UI 元素的引用,或者试图从匿名委托访问父范围的控件那样卑鄙的事情。那是邪恶的!因此,我使用强类型的 WeakReference<T> 类来持有 UI 元素的引用,并将弱引用传递给委托。

例如,您可以看到弱引用在匿名委托中的用法。

WeakReference<ListBox> listbox = this.DiagramFileListBox;
this.LoadDiagramFiles(this.DiagramLocationTextBox.Text, 
  () => 
  {
      var diagramOnList = this._DiagramFiles.First
			(d => d.DiagramFilePath == diagramFileName);
      ListBox diagramListBox = listbox;
      diagramListBox.SelectedItem = diagramOnList;
      this.OpenDiagramFile(diagramOnList);
  });  

我有一个方便的 WeakReference<T> 类可用,它支持与任何类的隐式转换。这使得使用引用变得容易,就好像它可以接受任何类并变成任何类一样。如上面的代码示例所示,没有将类型转换为 ListBox 数据类型。它的行为几乎就像一个隐式指针。

所以,这就是它……

具有隐式运算符转换的强类型 WeakReference<T>

我从 Damien 的博客中借鉴了这一点,并对其进行了一些改进。

using System;
using System.Runtime.InteropServices;

public class WeakReference<T> : IDisposable        
{
    private GCHandle handle;
    private bool trackResurrection;

    public WeakReference(T target)
        : this(target, false)
    {
    }

    public WeakReference(T target, bool trackResurrection)
    {
        this.trackResurrection = trackResurrection;
        this.Target = target;
    }

    ~WeakReference()
    {
        Dispose();
    }

    public void Dispose()
    {
        handle.Free();
        GC.SuppressFinalize(this);
    }

    public virtual bool IsAlive
    {
        get { return (handle.Target != null); }
    }

    public virtual bool TrackResurrection
    {
        get { return this.trackResurrection; }
    }

    public virtual T Target
    {
        get
        {
            object o = handle.Target;
            if ((o == null) || (!(o is T)))
                return default(T);
            else
                return (T)o;
        }
        set
        {
            handle = GCHandle.Alloc(value,
              this.trackResurrection ? GCHandleType.WeakTrackResurrection : 
		GCHandleType.Weak);
        }
    }

    public static implicit operator WeakReference<T>(T obj)  
    {
        return new WeakReference<T>(obj);
    }

    public static implicit operator T(WeakReference<T> weakRef)  
    {
        return weakRef.Target;
    }
}    

我添加了隐式运算符转换,以使 WeakReference<T> 的使用更加便捷。

在 MenuItem 中填充大量数据

您一定已经注意到了我对 Tag 属性的巧妙运用?如果没有,那么它又来了。我这样将 UML 片段存储在每个菜单项的 Tag 中。

<MenuItem Style="{DynamicResource MenuItemStyle}" Header="Note beside usecase">
<MenuItem.Tag>
  <![CDATA[
note right of (Use)\r
  A note can also\r
  be on several lines\r
end note
	]]>
  </MenuItem.Tag>
</MenuItem>    

当单击菜单项时,我可以从 Tag 中读取 UML 片段,然后使用它将其注入到代码编辑器中。我使用剪贴板复制 UML 片段,然后将其粘贴到编辑器中。这样,它就保留了缩进。

private void MenuItem_Click(object sender, RoutedEventArgs e)
{
    this._LastMenuItemClicked = e.Source as MenuItem;
    this.AddCode((e.Source as MenuItem).Tag as string);
}

private void AddCode(string code)
{
    ContentEditor.SelectionLength = 0;

    var formattedCode = code.Replace("\\r", Environment.NewLine) 
        + Environment.NewLine
        + Environment.NewLine;

    Clipboard.SetText(formattedCode);
    ContentEditor.Paste();

    this.SaveAndRefreshDiagram();
}

有人知道更好的方法吗?

自动更新程序

没有自动更新的智能客户端是什么?当您启动此应用程序时,它会启动一个后台线程来检查是否有针对此应用程序的更新已提交。现在,此应用程序已上传到 Google Code 站点。因此,我需要检查是否有更新版本的安装程序文件。我通过向下载 URL 发送 Web 请求并检查 Last-Modified 响应头来执行此操作。然后,我将其与您计算机上运行的 EXE 的最后修改日期进行比较。如果它们相差一天,那么我们就有一个更新。

public static bool HasUpdate(string downloadUrl)
{
    HttpWebRequest request = WebRequest.Create(downloadUrl) as HttpWebRequest;
    using (var response = request.GetResponse())
    {
        var lastModifiedDate = default(DateTime);
        if (DateTime.TryParse(response.Headers["Last-Modified"], out lastModifiedDate))
        {
            var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
            var localFileDateTime = File.GetLastWriteTime(path);

            return (localFileDateTime < lastModifiedDate.AddDays(-1));
        }
    }

    return false;
}    

当有新更新可用时,以下函数会下载最新文件并将其存储在本地文件中以运行它。

public static void DownloadLatestUpdate(string downloadUrl, string localPath)
{
    DownloadedLocation = localPath;
    using (WebClient client = new WebClient())
    {
        client.DownloadFileCompleted += 
	new System.ComponentModel.AsyncCompletedEventHandler
	(client_DownloadFileCompleted);
        client.DownloadProgressChanged += 
	new DownloadProgressChangedEventHandler(client_DownloadProgressChanged);
        client.DownloadFileAsync(new Uri(downloadUrl), localPath);
    }
}    

WebClient 类有一个异步版本的 DownloadFile,用于在单独的线程中下载文件,并通过事件处理程序提供进度和下载完成/取消的通知。

您会在 UpdateChecker 中找到此代码,这是一个可重用类,您也可以在您的项目中使用。我在 MainWindow 中使用它的方式是。

// Check if there's a newer version of the app
BackgroundWork.DoWork<bool>(() => 
{
    return UpdateChecker.HasUpdate(Settings.Default.DownloadUrl);
}, (hasUpdate) =>
{
    if (hasUpdate)
    {
        if (MessageBox.Show(Window.GetWindow(me),
            "There's a newer version available. Do you want to download and install?",
            "New version available",
            MessageBoxButton.YesNo,
            MessageBoxImage.Information) == MessageBoxResult.Yes)
        {
            BackgroundWork.DoWork(() => {
                var tempPath = Path.Combine(
                    Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
                    Settings.Default.SetupExeName);

                UpdateChecker.DownloadLatestUpdate
		(Settings.Default.DownloadUrl, tempPath);
            }, () => { },
                (x) =>
                {
                    MessageBox.Show(Window.GetWindow(me),
                        "Download failed. When you run next time, 
			it will try downloading again.",
                        "Download failed",
                        MessageBoxButton.OK,
                        MessageBoxImage.Warning);
                });
        }
    }
},
(x) => { });    

当下载进行中时,会触发 DownloadProgressChanged 事件;当下载完成/取消时,会触发 DownloadCompleted 事件。完成事件处理程序负责完成安装。

UpdateChecker.DownloadCompleted = new Action<AsyncCompletedEventArgs>((e) =>
{
    Dispatcher.BeginInvoke(new Action(() =>
    {
        if (e.Cancelled || e.Error != default(Exception))
        {
            MessageBox.Show(Window.GetWindow(me),
                        "Download failed. When you run next time, 
				it will try downloading again.",
                        "Download failed",
                        MessageBoxButton.OK,
                        MessageBoxImage.Warning);
        }
        else
        {
            Process.Start(UpdateChecker.DownloadedLocation).Dispose();
            this.Close();
        }
    }));
});

UpdateChecker.DownloadProgressChanged = 
	new Action<DownloadProgressChangedEventArgs>((e) =>
{
    Dispatcher.BeginInvoke(new Action(() =>
    {
        this.StartProgress("New version downloaded " + e.ProgressPercentage + "%");
    }));
});   

下载完成后,它会运行安装程序并退出应用程序,以便安装程序可以安装最新版本。

安装程序

Visual Studio 仍然不提供创建只生成单个 EXE 或 MSI 文件的设置项目,该文件包含一个引导程序来检测 .NET Framework 版本,在需要时下载并安装,然后运行实际的 MSI。因此,我不得不使用著名的 dotnetInstaller。但让它检测 .NET Framework 3.5 是一项挑战。它没有内置支持。此外,创建一个具有自解压安装程序的单一 EXE 并不那么直接。这是我的方法。

首先,您必须向配置文件添加一个自定义组件,以便检测 .NET Framework。解决方案在此

然后,您必须添加一个指向 MSI 文件的 MSI 组件。但是文件路径有一个技巧。

dotnetInstaller path trick

您必须以这种格式提供路径。并且您必须在添加的.msi组件下添加一个 Embed File,并将路径设置得完全如此。

dotnetInstaller path trick

请注意 targetfilepath 为空。

然后,您需要创建一个文件夹,您将把 dotnetinstaller.exe 文件和 .msi 文件复制到其中。这两个文件都需要放在同一个文件夹中。然后使项目准备好创建单一安装程序。

结论

这个编辑器确实节省了设计 UML 图的时间。我每天都需要制作快速图表,以便迅速向架构师、设计师和开发人员传达想法。因此,我使用此工具以编码的速度编写一些快速图表,图表即时生成。与其写一封长邮件用英语解释一些 D 动作或流程,不如快速在编辑器中编写文本,然后生成一个漂亮的活动图/序列图。进行重大更改也和在这里进行搜索替换和复制粘贴块一样容易。在任何传统的基于鼠标的 UML 设计器中都无法获得如此的灵活性。希望您觉得这个工具有用,如果觉得有用,请分享。

Follow omaralzabir on Twitter

kick it on DotNetKicks.com Shout it

© . All rights reserved.