使用 WPF 可视化具有循环依赖关系的图






4.98/5 (52投票s)
介绍了一个 WPF 应用程序,该应用程序显示了一个可由用户在运行时重新排列的对象图,并突出显示其节点中的循环依赖关系。

引言
本文介绍了一个 WPF 应用程序,该应用程序渲染了一个对象图,用户可以通过拖放来重新排列该图。该图突出了其节点之间的所有循环依赖关系。该应用程序是在 Visual Studio 2008 中构建的,并针对 .NET Framework v3.5 Service Pack 1 进行了编译。
背景
最近在工作中,我编写了一些代码来分析对象图,以确定其对象的初始化顺序。要求是在初始化一个对象之前,先初始化它所依赖的对象。在这种情况下,对象图不能包含任何循环依赖(即,一个对象间接依赖于自身,例如对象 A 依赖于对象 B,而对象 B 又依赖于对象 A)。
在实现了该逻辑之后,我认为创建一个显示对象图并突出显示其中任何循环依赖关系的 UI 会很有趣。这个周末我有一些空闲时间,所以我写了本文介绍的应用程序,它正好能做到这一点。
程序的功能
下面屏幕截图展示了此应用程序最简单的功能示例

上图中的对象图只有四个节点,没有循环依赖。图中的每个节点都可以由用户移动,以便用户可以按照最合适的布局来排列对象。重新排列了之前的图之后,它可以看起来像这样

现在,让我们来看一个包含循环依赖的图

循环依赖中的节点和节点连接器以红色高亮显示。请注意,此图的右上角有一个按钮。单击该按钮将使图执行 3D 翻转,并显示图表中所有循环依赖关系的详细列表,如下所示

应用程序中有一个更复杂的图,其中包含三个循环依赖

请注意,左侧节点连接器上的鼠标光标已显示一个工具提示,解释了与该连接器关联的节点。在此示例中,节点连接器链接了两个相互依赖的节点。如果我们翻转此图,详细信息视图将列出图中的所有三个循环依赖关系,如下所示

既然我们已经看到了这个应用程序的功能,让我们来看看它是如何工作的。
构建图
图表显示的数据可以来自任何地方。在此应用程序中,每个图表的数据来自源代码中的一个 XML 文件。以下 XML 描述了应用程序中最简单的图(本文上一节中看到的第一个图)
<?xml version="1.0" encoding="utf-8" ?>
<graph title="Simple Graph">
  <node id="A" x="50" y="50">
    <dependency id="B" />
  </node>
  <node id="B" x="50" y="150">
    <dependency id="C" />
    <dependency id="D" />
  </node>
  <node id="C" x="50" y="250">
    <dependency id="D" />
  </node>
  <node id="D" x="200" y="175" />
</graph>
此 XML 将被转换为 Graph 类的实例,该类在下图所示

如上图所示,Graph 类包含一个 Node 对象集合和一个 CircularDependency 对象集合。这些类型可以在下图所示

CircularDependency 类将在下一节中发挥作用。构建对象图的过程仅涉及 Graph 和 Node 类。Graph 对象由 GraphBuilder 类创建。它读取 XML 文件并创建一个包含 Node 对象的 Graph。该逻辑在下图所示
// In GraphBuilder.cs
static Graph BuildGraph(string xmlFileName)
{
    string path = string.Format(@"Graphs\{0}", xmlFileName);
    XDocument xdoc = XDocument.Load(path);
    // Create a graph.
    var graphElem = xdoc.Element("graph");
    string title = graphElem.Attribute("title").Value;
    var graph = new Graph(title);
    var nodeElems = graphElem.Elements("node").ToList();
    // Create all of the nodes and add them to the graph.
    foreach (XElement nodeElem in nodeElems)
    {
        string id = nodeElem.Attribute("id").Value;
        double x = (double)nodeElem.Attribute("x");
        double y = (double)nodeElem.Attribute("y");
        var node = new Node(id, x, y);
        graph.Nodes.Add(node);
    }
    // Associate each node with its dependencies.
    foreach (Node node in graph.Nodes)
    {
        var nodeElem = nodeElems.First(
            elem => elem.Attribute("id").Value == node.ID);
        var dependencyElems = nodeElem.Elements("dependency");
        foreach (XElement dependencyElem in dependencyElems)
        {
            string depID = dependencyElem.Attribute("id").Value;
            var dep = graph.Nodes.FirstOrDefault(n => n.ID == depID);
            if (dep != null)
                node.NodeDependencies.Add(dep);
        }
    }
    // Tell the graph to inspect itself for circular dependencies.
    graph.CheckForCircularDependencies();
    return graph;
}
上面方法中的最后一部分是对 Graph 的 CheckForCircularDependencies 方法的调用。该方法是下一节的主题。等等,事情即将变得有趣起来…
检测循环依赖
在图中定位所有循环依赖的过程是 Graph 及其所有 Node 之间的协作工作。Graph 类的 CheckForCircularDependencies 方法会询问每个节点是否属于循环依赖。一个节点会为它找到的每个圆返回一个 CircularDependency 对象。节点将继续查找循环依赖,直到找不到包含自身的任何循环依赖为止。以下是启动处理的 Graph 方法
// In Graph.cs
public void CheckForCircularDependencies()
{
    foreach (Node node in this.Nodes)
    {
        var circularDependencies = node.FindCircularDependencies();
        if (circularDependencies != null)
            this.ProcessCircularDependencies(circularDependencies);
    }
    this.CircularDependencies.Sort();
}
void ProcessCircularDependencies(List<CircularDependency> circularDependencies)
{
    foreach (CircularDependency circularDependency in circularDependencies)
    {
        if (circularDependency.Nodes.Count == 0)
            continue;
        if (this.CircularDependencies.Contains(circularDependency))
            continue;
        // Arrange the nodes into the order in which they were discovered.
        circularDependency.Nodes.Reverse();
        this.CircularDependencies.Add(circularDependency);
        // Inform each node that it is a member of the circular dependency.
        foreach (Node dependency in circularDependency.Nodes)
            dependency.CircularDependencies.Add(circularDependency);
    }
}
此算法的实际逻辑在于 Node 的 FindCircularDependencies 方法。现在让我们将注意力转向它
// In Node.cs
public List<CircularDependency> FindCircularDependencies()
{
    if (this.NodeDependencies.Count == 0)
        return null;
    var circularDependencies = new List<CircularDependency>();
    var stack = new Stack<NodeInfo>();
    stack.Push(new NodeInfo(this));
    NodeInfo current = null;
    while (stack.Count != 0)
    {
        current = stack.Peek().GetNextDependency();
        if (current != null)
        {
            if (current.Node == this)
            {
                var nodes = stack.Select(info => info.Node);
                circularDependencies.Add(new CircularDependency(nodes));
            }
            else
            {
                bool visited = stack.Any(info => info.Node == current.Node);
                if (!visited)
                    stack.Push(current);
            }
        }
        else
        {
            stack.Pop();
        }
    }
    return circularDependencies;
}
private class NodeInfo
{
    public NodeInfo(Node node)
    {
        this.Node = node;
        _index = 0;
    }
    public Node Node { get; private set; }
    public NodeInfo GetNextDependency()
    {
        if (_index < this.Node.NodeDependencies.Count)
        {
            var nextNode = this.Node.NodeDependencies[_index++];
            return new NodeInfo(nextNode);
        }
        return null;
    }
    int _index;
}
此逻辑仅查找包含调用了 FindCircularDependencies 方法的节点的循环依赖。它使用 NodeInfo 类来跟踪在处理完一个节点的依赖项后,接下来要处理哪些依赖项。
渲染节点
构建节点图并检测循环依赖是一个有趣的编程练习,但乐趣不止于此!一个同样具有挑战性和趣味性的问题是如何将该图渲染到屏幕上。本文的这一部分解释了我的应用程序如何渲染节点图并突出显示循环依赖。
节点图由 GraphView 用户控件显示。它包含一个 ItemsControl,其 ItemsSource 绑定到 Graph 对象的 Nodes 属性。为了实现节点基于坐标的定位,我可以使用 Canvas 作为 ItemsPanel。但我选择了使用我的 DragCanvas 面板,以便用户可以移动节点。来自 GraphView 的相关 XAML 如下所示
<!-- In GraphView.xaml -->
<ItemsControl 
  Background="LightGray" 
  ItemsSource="{Binding Path=Nodes}"
  >
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <jas:DragCanvas />
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <Border Style="{StaticResource NodeBorderStyle}">
        <TextBlock Text="{Binding Path=ID}" />
      </Border>
    </DataTemplate>
  </ItemsControl.ItemTemplate>
  <ItemsControl.ItemContainerStyle>
    <Style TargetType="{x:Type ContentPresenter}">
      <Setter 
        Property="Canvas.Left" 
        Value="{Binding Path=LocationX, Mode=TwoWay}" 
        />
      <Setter 
        Property="Canvas.Top" 
        Value="{Binding Path=LocationY, Mode=TwoWay}" 
        />
    </Style>
  </ItemsControl.ItemContainerStyle>
</ItemsControl>
节点根据其 LocationX 和 LocationY 属性定位。这些属性通过 ItemsContainerStyle 进行绑定。这些绑定将节点的 LocationX 属性链接到显示节点的 ContentPresenter 上的 Canvas.Left 附加属性,LocationY 和 Canvas.Top 属性也是如此。
请注意,上面所示的 ItemTemplate 包含一个 Border 元素,其 Style 属性引用了一个键为 'NodeBorderStyle' 的 Style。该 Style 包含一个 DataTrigger,如果节点属于循环依赖,它将突出显示该节点,如下所示
<!-- In GraphView.xaml -->
<Style x:Key="NodeBorderStyle" TargetType="{x:Type Border}">
  <Setter Property="Background" Value="LightGreen" />
  <Setter Property="BorderBrush" Value="Gray" />
  <Setter Property="BorderThickness" Value="3" />
  <Setter Property="BorderBrush" Value="Gray" />
  <Setter Property="Height" Value="{Binding Path=NodeHeight}" />
  <Setter Property="Padding" Value="4" />
  <Setter Property="TextElement.FontWeight" Value="Normal" />
  <Setter Property="Width" Value="{Binding Path=NodeWidth}" />
  <Style.Triggers>
    <DataTrigger 
      Binding="{Binding Path=HasCircularDependency}" 
      Value="True"
      >
      <Setter Property="Background" Value="Red" />
      <Setter Property="BorderBrush" Value="Black" />
      <Setter Property="TextElement.FontWeight" Value="Bold" />
    </DataTrigger>
  </Style.Triggers>
</Style>
节点连接器以类似的方式突出显示。说到节点连接器…
渲染节点连接器
两个节点之间的依赖关系是通过绘制一个指向有依赖关系的节点并指向它所依赖的节点的箭头来描绘的。我利用了 Charles Petzold 出色的 ArrowLine 元素来渲染箭头。在我的应用程序中,我继承了 ArrowLine 来创建 NodeConnector。NodeConnector 负责在用户移动其关联节点时移动自身。它还公开了 IsPartOfCircularDependency 属性,该属性由 Style 的触发器使用,以便在连接器指向同一个循环依赖中的两个节点时突出显示该连接器。
以下是 NodeConnector 中用于初始化实例并确保连接器始终指向其节点的代码
// In NodeConnector.cs
public NodeConnector(Node startNode, Node endNode)
{
    _startNode = startNode;
    _endNode = endNode;
    this.SetIsPartOfCircularDependency();
    this.SetToolTip();
    this.UpdateLocations(false);
    _startObserver = new PropertyObserver<Node>(_startNode)
        .RegisterHandler(n => n.LocationX, n => this.UpdateLocations(true))
        .RegisterHandler(n => n.LocationY, n => this.UpdateLocations(true));
    _endObserver = new PropertyObserver<Node>(_endNode)
        .RegisterHandler(n => n.LocationX, n => this.UpdateLocations(true))
        .RegisterHandler(n => n.LocationY, n => this.UpdateLocations(true));
}
void UpdateLocations(bool animate)
{
    var start = ComputeLocation(_startNode, _endNode);
    var end = ComputeLocation(_endNode, _startNode);
    if (animate)
    {
        base.BeginAnimation(ArrowLine.X1Property, CreateAnimation(base.X1, start.X));
        base.BeginAnimation(ArrowLine.Y1Property, CreateAnimation(base.Y1, start.Y)); 
        base.BeginAnimation(ArrowLine.X2Property, CreateAnimation(base.X2, end.X)); 
        base.BeginAnimation(ArrowLine.Y2Property, CreateAnimation(base.Y2, end.Y));
    }
    else
    {
        base.X1 = start.X;
        base.Y1 = start.Y;
        base.X2 = end.X;
        base.Y2 = end.Y;
    }
}
static AnimationTimeline CreateAnimation(double from, double to)
{
    return new EasingDoubleAnimation
    {
        Duration = _Duration,
        Equation = EasingEquation.ElasticEaseOut,
        From = from,
        To = to
    };
}
构造函数创建了两个 PropertyObserver,这是我 MVVM Foundation 库中的一个类。当观察者检测到任一节点的 LocationX 或 LocationY 属性发生更改时,将调用 UpdateLocations 方法。构造函数也会调用 UpdateLocations,但 animate 参数为 false。这确保了当连接器首次出现时,它会立即显示在正确的位置。然而,当节点移动时,animate 参数为 true,这导致连接器平滑地弹到屏幕上的新位置。弹跳效果是通过使用我的 Thriple 库中的 EasingDoubleAnimation 来实现的,其 Equation 属性设置为 ElasticEaseOut。
节点连接器在装饰层中渲染。我创建了一个自定义装饰器,用于渲染图中所有节点连接器,称为 NodeConnectionAdorner。当该装饰器的 Graph 属性被设置时,装饰器会为每个节点依赖关系添加一个节点连接器,如下所示
// In NodeConnectionAdorner.cs
public Graph Graph
{
    get { return _graph; }
    set
    {
        if (value == _graph)
            return;
        _graph = value;
        if (_graph != null)
            this.ProcessGraph();
    }
}
void ProcessGraph()
{
    foreach (Node node in _graph.Nodes)
        foreach (Node dependency in node.NodeDependencies)
            this.AddConnector(node, dependency);
}
void AddConnector(Node startNode, Node endNode)
{
    var connector = new NodeConnector(startNode, endNode);
    _nodeConnectors.Add(connector);
    // Add the connector to the visual and logical tree so that
    // rendering and resource inheritance will work properly.
    base.AddVisualChild(connector);
    base.AddLogicalChild(connector);
}
NodeConnectionAdorner 的实例被应用于包含图节点 ItemsControl。这发生在 NodeConnectionAdornerDecorator 类中,该类是 ItemsControl 的父元素。
<!-- In GraphView.xaml -->
<local:NodeConnectionAdornerDecorator 
  Graph="{Binding Path=.}"
  >
  <ItemsControl ItemsSource="{Binding Path=Nodes}">
    <!-- We saw this ItemsControl previously... -->
  </ItemsControl>
</local:NodeConnectionAdornerDecorator>
如上所示,装饰器具有一个 Graph 依赖项属性,该属性绑定到继承的 DataContext;而 DataContext 恰好是一个 Graph 对象。当装饰器元素加载到 UI 中时,它会将 NodeConnectionAdorner 放入装饰层,并将 Graph 传递给装饰器,以便它创建节点连接器。该代码如下所示
// In NodeConnectionAdornerDecorator.cs
void OnLoaded(object sender, RoutedEventArgs e)
{
    var layer = AdornerLayer.GetAdornerLayer(this);
    if (layer == null)
        return;
    _adorner = new NodeConnectionAdorner(this);
    layer.Add(_adorner);
    this.GiveGraphToAdorner();
}
void GiveGraphToAdorner()
{
    if (_adorner != null && this.Graph != null)
    {
        _adorner.Graph = this.Graph;
    }
}
这个应用程序中还有很多我们在这里没有介绍的内容,所以如果你有兴趣,请务必从本文顶部下载源代码并深入研究!
外部参考
- Charles Petzold 的博客 – ArrowLine来自这里。
- MVVM Foundation – PropertyObserver和ObservableObject来自这里。
- WPF.JoshSmith – DragCanvas来自这里。
- Thriple – EasingDoubleAnimation和ContentControl3D来自这里。
修订历史
- 2009 年 11 月 17 日 - 更新了文章和源代码,以使用一种新的、改进的算法来检测图中的所有循环依赖关系。此外,节点连接器现在会弹入到位,而不是像最初那样缓慢滑动。
- 2009 年 11 月 15 日 - 创建了文章。


