C#/VB.Net高级WPF树状视图系列之四






4.87/5 (9投票s)
关于加载和保存基于WPF树状视图内容的技巧和窍门。
引言
我们正在研究高级WPF树状视图的实现。本系列的第一和第二部分比较了虚拟化和非虚拟化的实现,第三部分则侧重于遍历树结构。
![]() | 要实现第3篇[3]文章中高效的树状视图筛选应用程序,需要对树遍历有基本的了解。但这对于从WPF树状视图保存和加载数据同样是必需的。虽然上一篇文章[3]简要地涉及了保存和加载数据的主题,但我认为有必要用一篇专门的文章来详细说明。本文还涵盖了在关系数据库系统(这里是SQLite)中存储和检索基于树状视图的数据,因为这并非易事,但却经常需要,并且在其他地方没有很好的文档记录。 |
本文围绕2个演示应用程序展开,其中第一个SQLite Demo应用程序专注于在关系数据库后端系统中存储树状结构数据。文章的这一部分对于希望从非WPF技术(如基于Winform的TreeView、Java树结构等)在关系数据库系统中存储和检索树状结构数据的任何人也应该会感兴趣。
文章的第二部分结合了从SQLite Demo中学到的基础知识,并重新审视了第一篇文章中的WPF 00_InplaceEditBoxLib.zip 演示,以集成一个保存/加载后端数据系统。
SQLite模型演示
在关系数据库系统中存储和检索基于树的数据的任务并非易事,因为关系数据受限于表,而树结构可以有各种不同的形式和形状。解决这个问题的一个方法是邻接列表模型[5],它要求我们根据树节点在树中的层级来存储它们。这个解决方案并不困难(现在),因为我们可以使用层序遍历[3]算法来访问和存储各层级的节点。
然后,当我们存储了带有层级信息的树节点后,我们就可以按层级顺序检索它们——从根到叶重建树。从数据库表中检索节点要求我们存储它们的
- 层级(0, 1, 2, 3, ... n)和
- 一个指向父节点的链接(当前节点的父节点ID)
所以,1. 是为了从根到叶按顺序重建树所必需的,而 2. 是为了将从数据库中检索的每个项插入到其正确位置所必需的。让我们用本文附带的SQLite_Demo来探讨这个想法。这个演示实现了2个项目:
- SolutionModelsLib(DLL项目)
- SQLite_Demo(MS-DOS 控制台项目)
SolutionModelsLib 使用3个类实现了一个简化的树状结构模型:
其中SolutionModel代表树模型的容器,而SolutionRootItemModel是集合中的第一个可见项,SolutionItemModel则实现了所有其他可以在树结构中显示的项(文件、文件夹、项目)。
SolutionModelsLib项目的第二部分包含包装了SQLite数据库系统的SQLIteDatabase
类[4],以及实现此演示特定存储和检索方法的SolutionDB
类。
在关系数据库系统中存储树状结构数据
SolutionDB
类中的写入数据库模型区域包含一个ReCreateDBTables
方法,该方法创建空表:itemtype和solution。itemtype表用于存储SolutionItemType
枚举中的枚举值,而solution表包含解决方案的邻接列表(树的数据)。
CREATE TABLE IF NOT EXISTS
[itemtype] (
[id] INTEGER NOT NULL PRIMARY KEY,
[name] VARCHAR(256) NOT NULL
)
CREATE TABLE IF NOT EXISTS
[solution] (
[id] INTEGER NOT NULL PRIMARY KEY,
[parent] INTEGER NOT NULL,
[level] INTEGER NOT NULL,
[name] VARCHAR(256) NOT NULL,
[itemtypeid] INTEGER NOT NULL,
FOREIGN KEY (itemtypeid) REFERENCES itemtype(id)
)
InsertItemTypeEnumeration
方法将itemtype枚举值写入其表中,而InsertSolutionData
方法持久化树状结构数据。
private int WriteToFile(SolutionModel solutionRoot
, SQLiteCommand cmd)
{
int result = 0;
int iKey = 0;
Queue<Tuple<int, SolutionItemModel>> queue = new Queue<Tuple<int, SolutionItemModel>>();
if (solutionRoot.Root != null)
queue.Enqueue(new Tuple<int, SolutionItemModel>(0, solutionRoot.Root));
while (queue.Count() > 0)
{
var queueItem = queue.Dequeue();
int iLevel = queueItem.Item1;
SolutionItemModel current = queueItem.Item2;
current.Id = iKey++;
int parentId = (current.Parent == null ? -1 : current.Parent.Id);
cmd.Parameters.AddWithValue("@id", current.Id);
cmd.Parameters.AddWithValue("@parent", parentId);
cmd.Parameters.AddWithValue("@level", iLevel);
cmd.Parameters.AddWithValue("@name", current.DisplayName);
cmd.Parameters.AddWithValue("@itemtypeid", (int)(current.ItemType));
result += cmd.ExecuteNonQuery();
foreach (var item in current.Children)
queue.Enqueue(new Tuple<int, SolutionItemModel>(iLevel + 1, item));
}
return result;
}
Private Function WriteToFile(ByVal solutionRoot As SolutionModel, ByVal cmd As SQLiteCommand) As Integer
Dim result As Integer = 0
Dim iKey As Integer = 0
Dim queue As Queue(Of Tuple(Of Integer, SolutionItemModel)) = New Queue(Of Tuple(Of Integer, SolutionItemModel))()
If solutionRoot.Root IsNot Nothing Then queue.Enqueue(New Tuple(Of Integer, SolutionItemModel)(0, solutionRoot.Root))
While queue.Count() > 0
Dim queueItem = queue.Dequeue()
Dim iLevel As Integer = queueItem.Item1
Dim current As SolutionItemModel = queueItem.Item2
current.Id = iKey
iKey = iKey + 1
Dim parentId As Integer = (If(current.Parent Is Nothing, -1, current.Parent.Id))
If cmd IsNot Nothing Then
cmd.Parameters.AddWithValue("@id", current.Id)
cmd.Parameters.AddWithValue("@parent", parentId)
cmd.Parameters.AddWithValue("@level", iLevel)
cmd.Parameters.AddWithValue("@name", current.DisplayName)
cmd.Parameters.AddWithValue("@itemtypeid", CInt((current.ItemType)))
result += cmd.ExecuteNonQuery()
Else
Console.WriteLine(String.Format("{0,4} - {1} ({2})", iLevel, current.GetStackPath(), current.ItemType.ToString()))
End If
For Each item In current.Children
queue.Enqueue(New Tuple(Of Integer, SolutionItemModel)(iLevel + 1, item))
Next
End While
Return result
End Function
我们从上一篇文章中认出了这个代码模式,并且知道该代码实现了层序遍历算法,——并且只有标记为粗体的部分对每个遍历的树节点进行操作(这里的操作是在SQLite表中存储树节点数据)。
我们可以看到iKey
表ID是动态计算的,而iLevel
信息几乎是该算法的自然产物。
所以,这就是我们如何在关系表结构中存储树状结构数据的方法。现在让我们转换思路,看看如何加载这些数据。
从关系数据库系统中检索树状结构数据
检索上一节中存储的数据以在加载时重建树出人意料地容易,因为我们在写入数据时已经完成了所有繁重的工作。我们真正需要的只是一个select语句来从solution表中获取树数据。
SELECT * FROM solution ORDER BY level, id
这里重要的部分是ORDER BY level——这确保我们按照写入的顺序从上到下检索树节点。“Order BY id”部分并非必需,但我喜欢在可能的情况下让事情井然有序。
因此,上述语句为我们提供了每个树状视图节点的ID、其父节点的ID、节点的层级,以及一些应用程序相关的数据,如节点的名称和类型。现在,我们可以从上到下检索这些节点,并且如果我们记录下先前检索的节点及其ID,就可以将每个节点插入到其正确的位置(在其父节点下方)。这个想法在SolutionDB
类的ReadSolutionData
方法中实现。
虽然ReadSolutionData
方法中的代码可能看起来很长,但它实际上比我们之前看到的任何遍历方法都简单。如果查询有任何结果,算法的第一部分会插入根项——我们可以这样做,因为根项预计是第一个(它是第一个被写入的),并且这个树状视图实现只允许一个根项(因此所有其他项根据定义都是非根项)。
public int ReadSolutionData(SolutionModel solutionRoot
, SQLiteDatabase db = null)
{
int recordCount = 0;
var query = "SELECT * FROM solution ORDER BY level, id";
using (SQLiteCommand cmd = new SQLiteCommand(query, db.Connection))
{
using (SQLiteDataReader selectResult = cmd.ExecuteReader())
{
Dictionary<int, SolutionItemModel> mapKeyToItem = new Dictionary<int, SolutionItemModel>();
if (selectResult.HasRows == true)
{
if (selectResult.Read() == true)
{
var root = solutionRoot.AddSolutionRootItem(selectResult["name"].ToString());
mapKeyToItem.Add(selectResult.GetInt32(0), root);
recordCount++;
}
while (selectResult.Read() == true)
{
int iParentKey = selectResult.GetInt32(1); // Get parent key from next item
SolutionItemModel parent = null;
if (mapKeyToItem.TryGetValue(iParentKey, out parent) == true)
{
var itemTypeId = (long)selectResult["itemtypeid"];
var item = parent.AddChild(parent
, selectResult["name"].ToString()
, itemTypeId);
mapKeyToItem.Add(selectResult.GetInt32(0), item);
recordCount++;
}
else
{
throw new Exception("Data written is corrupted.");
}
}
}
}
}
return recordCount;
}
Public Function ReadSolutionData(ByVal solutionRoot As SolutionModel, ByVal Optional db As SQLiteDatabase = Nothing) As Integer
If db Is Nothing Then db = Me
Dim recordCount As Integer = 0
Dim query = "SELECT * FROM solution ORDER BY level, id"
Using cmd As SQLiteCommand = New SQLiteCommand(query, db.Connection)
Using selectResult As SQLiteDataReader = cmd.ExecuteReader()
Dim mapKeyToItem As Dictionary(Of Integer, SolutionItemModel) = New Dictionary(Of Integer, SolutionItemModel)()
If selectResult.HasRows = True Then
If selectResult.Read() = True Then
Dim root = solutionRoot.AddSolutionRootItem(selectResult("name").ToString())
' Gets the Id of Root entry
mapKeyToItem.Add(selectResult.GetInt32(0), root)
recordCount += 1
End If
While selectResult.Read() = True
Dim iParentKey As Integer = selectResult.GetInt32(1) ' Get parent key from next item
Dim parent As SolutionItemModel = Nothing
If mapKeyToItem.TryGetValue(iParentKey, parent) = True Then
Dim itemTypeId = CLng(selectResult("itemtypeid"))
Dim item = parent.AddChild(parent, selectResult("name").ToString(), itemTypeId)
mapKeyToItem.Add(selectResult.GetInt32(0), item)
recordCount += 1
Else
Throw New Exception("Data written is corrupted.")
End If
End While
End If
End Using
End Using
Return recordCount
End Function
如果我们认识到只需要存储那些本身可以有子项的对象(文件夹、项目、根项),而文件对象不能有子项,因此不需要存储以备后续在字典中查找,那么上述代码中的第二个mapKeyToItem.Add(...)
语句可以进一步优化。
最近的一篇文章[4]介绍了平面文件数据库系统。该文章开发了一个名为SQLiteDatabase
的SQLite数据库包装类——这个类在这里被重用,并用自定义的功能进行了扩展。
我们现在能够运行附带的SQLite_Demo并理解其概念了——如果有什么疑问,请务必在调试器中设置一些断点,或者在文章下方的论坛中提问...
所以,这个概念在简单的控制台应用程序中是可行的,但我们如何用WPF来实现呢?我们应该让数据库类消费数据并吐出ViewModel项,还是有更好的方法?
解决方案资源管理器演示
附带的WPF演示示例应用程序是第一篇文章所附的00_InplaceEditBoxLib.zip项目的进一步开发。该应用程序现在是多线程和虚拟化的。还有一些编辑功能,用户可以根据需要编辑、添加或删除项目。
上述窗口中的上下文菜单是在InPlaceEditBoxDemo项目的MainWIndow.xaml
文件中的TreeItemContentPresenter
中实现的。这些命令中的大多数直接绑定到一个
SolutionLib.ViewModels.Browser.SolutionViewModel
类的对象,该类是解决方案树状视图的根ViewModel(ViewModel部分)。
所有代表树状视图中一个项(节点)的ViewModel类要么用于ItemViewModel
类,要么用于ItemChildrenViewModel
类。这种设计证明是有利的,因为我们可以清楚地区分可以有子项的项(项目、文件夹、根项)和不能有子项的其他项(文件)。
保存和加载命令没有在SolutionViewModel
类中实现。为什么?因为事实证明,在保存之前将ViewModel表示转换为模型表示是有意义的。这样做有各种各样的理由,其中最重要的有:
- 视图和视图模型不应该直接依赖于底层数据层——这意味着我们应该能够更换不同的存储技术(例如:XML、关系数据库等)而无需重写整个应用程序。
- ViewModel通常包含在UI活动时有用的项,例如当前选定的节点。这些与UI和状态相关的大多数数据对于持久化来说是不需要的。
现在,我们可能会争辩说,为了在重新加载树状视图时恢复该状态,可能需要知道最后一个选定的项是什么。这是一个有效的观点——但是如果我们考虑在重新加载其数据后恢复树状视图的状态,我们应该基于两个文件来做(一个用于树状结构数据的配置文件和一个用于其最后状态的会话文件),而不是把所有东西都写在一个文件中。
这种配置与状态的分离是一种很好的做法,因为用户可能希望将设置文件存储在GitHUB(或其他地方),而最后的会话可能对其他人并不总是那么有趣......
所以,这些只是引导我们得出结论的两个重要点:ViewModel在存储前几乎总应转换为模型——而检索则需要我们检索模型,将其转换为ViewModel,以便在绑定的视图中显示其数据。
SaveSolutionCommand
和LoadSolutionCommand
是在InPlaceEditBoxDemo项目的AppViewModel
类中实现的。这两个命令都接受一个ISolution
对象作为参数,并依次调用一个方法。这是SaveSolutionCommand
调用的方法:
private async Task SaveSolutionCommand_ExecutedAsync(ISolution solutionRoot)
{
var explorer = ServiceLocator.ServiceContainer.Instance.GetService<IExplorer>();
var filepath = explorer.SaveDocumentFile(UserDocDir + "\\" + "New Solution",
UserDocDir, true, solutionRoot.SolutionFileFilter);
if (string.IsNullOrEmpty(filepath) == true) // User clicked Cancel ...
return;
// Convert to model and save model to file system
var solutionModel = new ViewModelModelConverter().ToModel(solutionRoot);
var result = await SaveSolutionFileAsync(filepath, solutionModel);
}
Private Async Function SaveSolutionCommand_ExecutedAsync(ByVal solutionRoot As ISolution) As Task
Dim explorer = ServiceLocator.ServiceLocator.ServiceContainer.Instance.GetService(Of IExplorer)()
Dim filepath = explorer.SaveDocumentFile(UserDocDir & "\" & "New Solution",
UserDocDir,
True,
solutionRoot.SolutionFileFilter)
If String.IsNullOrEmpty(filepath) = True Then Return ' User clicked Cancel ...
' Convert to model and save model to file system
Dim solutionModel = New ViewModelModelConverter().ToModel(solutionRoot)
Dim result = Await SaveSolutionFileAsync(filepath, solutionModel)
End Function
上述代码片段的最后两行显示了如何通过ViewModelModelConverter().ToModel()
调用来获取解决方案的模型。然后,这个模型(而不是ViewModel)通过对SaveSolutionFileAsync(....)
的额外调用被保存。因此,让我们在接下来的部分中回顾这些内容。
层序转换(重温)
上一篇文章中有一个层序转换部分,提示了将模型转换为ViewModel(加载时)的方法,并声称这在相反方向(保存时)也应该是可能的。这种转换在双向都在
InPlaceEditBoxDemo.ViewModels.ViewModelModelConverter
类中实现。转换方法ToModel()
和ToViewModel()
在AppViewModel
类中的SaveSolutionCommand
和LoadSolutionCommand
中使用。ToModel()
方法在保存操作发生之前被调用。
该转换基于TreeLib库项目的层序算法。下面的foreach
循环执行实际的转换,同样基于在itemId
中动态计算的ID。
public SolutionModelsLib.Models.SolutionModel ToModel(ISolution solutionRoot)
{
IItem treeRootVM = solutionRoot.GetRootItem();
long itemId = 0;
var items = TreeLib.BreadthFirst.Traverse.LevelOrder(treeRootVM
, (i) =>
{
var it = i as IItemChildren;
if (it != null)
return it.Children;
// Emulate an emtpy list if items have no children
return new List<IItemChildren>();
});
var dstIdItems = new Dictionary<long, IItemChildrenModel>();
var solutionModel = new SolutionModel();
foreach (var item in items.Select(i => i.Node))
{
item.SetId(itemId++);
if (item.Parent == null)
{
solutionModel.AddSolutionRootItem(item.DisplayName, item.GetId());
dstIdItems.Add(solutionModel.Root.Id, solutionModel.Root);
}
else
{
IItemChildrenModel modelParentItem;
IItemModel modelNewChild;
dstIdItems.TryGetValue(item.Parent.GetId(), out modelParentItem);
modelNewChild = ConvertToModel(solutionModel, modelParentItem, item);
modelNewChild.Id = item.GetId();
// Store only items that can have children for later lock-up
if (modelNewChild is IItemChildrenModel)
dstIdItems.Add(modelNewChild.Id, modelNewChild as IItemChildrenModel);
}
}
return solutionModel;
}
Public Function ToModel(ByVal solutionRoot As ISolution) As SolutionModelsLib.Models.SolutionModel
Dim treeRootVM As IItem = solutionRoot.GetRootItem()
Dim itemId As Long = 0
Dim items = TreeLib.BreadthFirst.Traverse.LevelOrder(treeRootVM, Function(i)
Dim it = TryCast(i, IItemChildren)
If it IsNot Nothing Then Return it.Children
' Emulate an emtpy list if items have no children
Return New List(Of IItemChildren)()
End Function)
Dim dstIdItems = New Dictionary(Of Long, IItemChildrenModel)()
Dim solutionModel = New SolutionModel()
For Each item In items.[Select](Function(i) i.Node)
item.SetId(Math.Min(System.Threading.Interlocked.Increment(itemId), itemId - 1))
If item.Parent Is Nothing Then
solutionModel.AddSolutionRootItem(item.DisplayName, item.GetId())
dstIdItems.Add(solutionModel.Root.Id, solutionModel.Root)
Else
Dim modelParentItem As IItemChildrenModel = Nothing
Dim modelNewChild As IItemModel = Nothing
dstIdItems.TryGetValue(item.Parent.GetId(), modelParentItem)
modelNewChild = ConvertToModel(solutionModel, modelParentItem, item)
modelNewChild.Id = item.GetId()
' Store only items that can have children for later lock-up
If TypeOf modelNewChild Is IItemChildrenModel Then dstIdItems.Add(modelNewChild.Id, TryCast(modelNewChild, IItemChildrenModel))
End If
Next
Return solutionModel
End Function
上述代码(使用一个辅助方法)将SolutionLib项目中的ViewModel对象转换为在SolutionModelsLib项目中定义的模型对象集合。SolutionModelsLib项目中的模型定义结构与SolutionLib项目中的ViewModel非常相似。这种相似性主要是为了让转换更容易。但请注意,模型和ViewModel的结构也可以根据UI和所用存储技术的要求而大相径庭。
层序存储
上一节解释了从ViewModel到模型的转换。本节解释了模型如何通过AppViewModel.SaveSolutionFileAsync()
方法调用被存储。SaveSolutionFileAsync()
方法调用SaveSolutionFile()
方法,该方法又调用实现后端的SQLite特定方法。这里有趣的一行是这个语句:
// Write solution tree data file
recordCount = db.InsertSolutionData(solutionRoot);
这引导我们到SolutionModelsLib.SQLite.SolutionDB
类中的WriteToFile
方法。
private int WriteToFile(SolutionModel solutionRoot
, SQLiteCommand cmd)
{
int result = 0;
int iKey = 0;
var items = TreeLib.BreadthFirst.Traverse.LevelOrder<IItemModel>(solutionRoot.Root
, (i) =>
{
var it = i as IItemChildrenModel;
if (it != null)
return it.Children;
// Emulate an emtpy list if items have no children
return new List<IItemChildrenModel>();
});
foreach (var item in items)
{
int iLevel = item.Level;
IItemModel current = item.Node;
current.Id = iKey++;
long parentId = (current.Parent == null ? -1 : current.Parent.Id);
if (cmd != null)
{
cmd.Parameters.AddWithValue("@id", current.Id);
cmd.Parameters.AddWithValue("@parent", parentId);
cmd.Parameters.AddWithValue("@level", iLevel);
cmd.Parameters.AddWithValue("@name", current.DisplayName);
cmd.Parameters.AddWithValue("@itemtypeid", (int)(current.ItemType));
result += cmd.ExecuteNonQuery();
}
else
{
Console.WriteLine(string.Format("{0,4} - {1} ({2})"
, iLevel, current.GetStackPath(), current.ItemType.ToString()));
}
}
return result;
}
Private Function WriteToFile(ByVal solutionRoot As SolutionModel,
ByVal cmd As SQLiteCommand) As Integer
Dim result As Integer = 0
Dim iKey As Integer = 0
Dim items = TreeLib.BreadthFirst.Traverse.LevelOrder(Of IItemModel)(solutionRoot.Root,
Function(i)
Dim it = TryCast(i, IItemChildrenModel)
If it IsNot Nothing Then Return it.Children
' Emulate an emtpy list if items have no children
Return New List(Of IItemChildrenModel)()
End Function)
For Each item In items
Dim iLevel As Integer = item.Level
Dim current As IItemModel = item.Node
current.Id = Math.Min(System.Threading.Interlocked.Increment(iKey), iKey - 1)
Dim parentId As Long = (If(current.Parent Is Nothing, -1, current.Parent.Id))
If cmd IsNot Nothing Then
cmd.Parameters.AddWithValue("@id", current.Id)
cmd.Parameters.AddWithValue("@parent", parentId)
cmd.Parameters.AddWithValue("@level", iLevel)
cmd.Parameters.AddWithValue("@name", current.DisplayName)
cmd.Parameters.AddWithValue("@itemtypeid", CInt((current.ItemType)))
result += cmd.ExecuteNonQuery()
Else
Console.WriteLine(String.Format("{0,4} - {1} ({2})", iLevel, current.GetStackPath(), current.ItemType.ToString()))
End If
Next
Return result
End Function
这个方法看起来与上一节中显示的转换器方法非常相似。这里,我们使用了来自TreeLib库项目的相同遍历方法。foreach
循环(再次)是实际对每个树节点进行操作的部分——这里的操作是在SQLite数据库文件中存储数据。
结论
许多编程初学者在操作树状结构数据项时会遇到问题,这在一定程度上限制了他们的WPF树状视图实现。本文和前一篇文章的发表,是希望遍历方法能在这方面带来一些启示。在上一篇文章中描述并在此处用于转换和加载/保存的IEnumerable/Yield方法的用法表明,应用这些遍历方法可以很简单,同时仍能产生出色的性能。
模型和视图模型之间的额外转换似乎是额外的工作,没有任何直接的优势。但在一个较大的团队中工作时,应该能从这种设计中获益。当未来需要包含更改时,这种设计方法也将带来更大的灵活性。如果您坚持使用模型和视图模型并进行适当分离,那么更改重要的东西,比如UI控件套件或存储后端,或者只是进一步开发,都应该很容易。
历史
- 2017-12-08 所有代码示例也提供VB.Net版本