Sonic:WPF(混合智能客户端)可搜索媒体库






4.87/5 (228投票s)
一个可查询的工作MP3播放器,使用了一些很酷的LINQ功能。
目录
引言
这篇文章制作了一段时间,有点像爱的劳动。当我刚开始时,这篇文章的唯一目的是为了更深入地理解 LINQ 的一些内部原理;我只是想更好地理解 IQueryProvider
及其在整体中的作用。我需要一个进行自我学习的场所,所以我决定构建一个可用/可搜索的 MP3 播放器。该 MP3 播放器将基于 MP3 ID3 标签元数据工作,这些元数据可以使用 IQueryProvider
进行查询,并允许用户的 MP3 存储到数据库(SQL Server)中。
本质上,这就是本文的全部内容。
在继续阅读本文的其余部分之前,我恳请您先阅读本文底部的 投票游戏 部分。
开始之前
在您尝试在家/工作运行 Sonic 之前,您需要安装 SQL Server 数据库并更改 Settings 文件中的连接字符串,并创建自己的 musicLocationPath
(请参阅 App.Config)。
本文使用了一些 .NET 3.5 SP1 中的项目,因此这是先决条件。抱歉。
概述
本质上,Sonic(代号,与音乐相关)看起来像这样。它由许多不同的视图组成,每个视图都由一个专门的 ViewModel 驱动,这使得视图能够与 ViewModel 数据无缝绑定。这也意味着每个视图中的代码隐藏非常少,因为所有繁重的工作都由与给定视图关联的 ViewModel 完成。
我们将在本文后面更详细地研究每个视图/视图模型,但现在,这里有一个简要的介绍
MainWindow
:托管一个MediaView
视图和一个顶部控制横幅,还包含一些用于最小化/最大化/关闭的常用按钮。MediaView
视图:托管一个ItemsControl
,其中包含多个由各自 ViewModel 驱动的AlbumView
视图。它还托管另一个ItemsControl
,其中包含多个MP3FileView
项(同样,由各自的 ViewModel 驱动),最后,它托管一个 3D 专辑封面视图,名为AlbumView3D
。
我们稍后会深入探讨这一切是如何协同工作的,但现在,请注意有许多视图构成了整个应用程序,并且每个视图都有一个 ViewModel 来处理其逻辑。
预期工作方式
我试图以一种逻辑的方式编写这个应用程序,我希望它能像大多数人期望的那样工作。所以,事不宜迟,让我深入探讨我打算 Sonic 如何工作。
当 Sonic 首次运行时,它将检查与 Sonic 应用程序相关的设置,并查看最初设置为 true
的“ReReadAllFiles
”设置。如果它发现此标志设置为 true
,Sonic 将使用您在 App.Config 中使用自定义 Sonic 配置部分指定的路径,扫描所有可用且有效的音乐(基本上只有 MP3)。
找到有效 MP3 后,每个有效文件关联的 ID3 元数据将存储在 SQL Server 表中(创建该数据库的脚本请参阅本文顶部)。
重要提示
一旦所有 musicLocationPath
(如 App.Config 中配置)都已扫描完毕,Sonic 设置“ReReadAllFiles
”将被设置为 false
,以便 Sonic 将来的运行不会导致整个扫描过程发生。因此,在首次运行 Sonic 之前,您需要配置 App.Config 以指向您的音乐路径。
因此,假设您已成功扫描所有音乐,您将能够使用关联的 ID3 可用元数据进行搜索。为此,我允许对以下元数据执行搜索
- 艺术家首字母
- 流派
- 歌曲名称
- 艺术家名称
当搜索产生结果时,MediaView
中的 ItemsControl
将被填充以显示匹配的专辑(AlbumView
)。您可以点击其中一张专辑(AlbumView
),它将显示该专辑中的曲目列表(多个 MP3FileView
),专辑封面将以 3D 类型视图(AlbumView3D
)的形式在更大的视图中显示。
当曲目列表(多个 MP3FileView
)显示时,您将能够点击曲目,它将使用 WPF MediaPlayer
元素播放。
本质上,就是这样。显然,它比这稍微复杂一些;否则,我就不会花这么长时间来写了。
配置
好的,既然您知道我的目标是什么,您应该知道需要进行一些用户配置才能使 Sonic 正常工作。为此,我创建了一个自定义配置部分,允许您(用户)指定自己的 MP3 目录位置。
对我来说,我将所有音乐都存储在一个或两个顶级文件夹中,所以我的个人 Sonic 配置部分只有一两个条目。您的可能有所不同。
无论如何,那都是次要的,让我们看看处理自定义 Sonic 配置部分的 App.Config 部分;嗯,对我来说,它是这样配置的
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<section name="MusicLocationLookup"
type="Sonic.MusicLocationLookupConfigSection, Sonic" />
</configSections>
<MusicLocationLookup>
<MusicRepository>
<!-- Add your MP3 directories here, my uses
1 or 2 top levels, your may be different-->
<add musicLocationPath="E:\MP3's" />
</MusicRepository>
</MusicLocationLookup>
一切都很好;我们可以将与 Sonic 相关的数据放入其自己的配置部分,但这究竟是如何工作的呢?嗯,它通过使用两个专门的配置类来工作。我们来看看它们,好吗?
MusicLocationLookupConfigSection
:这是实际的自定义配置节类
/// <summary>
/// The Class that will have the XML config file data
/// loaded into it via the configuration Manager.
/// </summary>
public class MusicLocationLookupConfigSection : ConfigurationSection
{
#region Public Properties
/// <summary>
/// The value of the property here "SkinNameToTheme"
/// needs to match that of the config file section
/// </summary>
[ConfigurationProperty("MusicRepository")]
public MusicLocationElementCollection MusicLocations
{
get { return
((MusicLocationElementCollection)
(base["MusicRepository"])); }
}
#endregion
}
MusicLocationElementCollection
:它允许将 MusicLocationElement
(多个)集合添加到 App.Config 中
/// <summary>
/// A MusicLocation collection class that will store the
/// list of each MusicLocationElement item that is
/// returned back from the configuration manager.
/// </summary>
[ConfigurationCollection(typeof(MusicLocationElement))]
public class MusicLocationElementCollection
: ConfigurationElementCollection
{
#region Overrides
protected override ConfigurationElement
CreateNewElement()
{
return new MusicLocationElement();
}
protected override object GetElementKey(
ConfigurationElement element)
{
return ((MusicLocationElement)(element)).musicPath;
}
#endregion
#region Public Properties
/// <summary>
/// Gets ThemeElement at the index provided
/// </summary>
public MusicLocationElement this[int idx]
{
get
{
return (MusicLocationElement)BaseGet(idx);
}
}
#endregion
}
MusicLocationElement
:这是您的音乐文件实际存储的目录名称
/// <summary>
/// The class that holds information for a single
/// element returned by the configuration manager.
/// </summary>
public class MusicLocationElement
: ConfigurationElement
{
#region Public Properties
[ConfigurationProperty("musicLocationPath",
DefaultValue = "", IsKey = true,
IsRequired = true)]
public string musicPath
{
get { return ((string)(base["musicLocationPath"])); }
set { base["musicLocationPath"] = value; }
}
#endregion
}
我真的很喜欢微软开放配置以供扩展这一事实,这使得它用起来非常方便。
设置
如前所述,Sonic 使用许多设置来执行各种操作。这些设置如下
ReReadAllFiles
:当设置为 true 时,将强制 Sonic 重新读取所有音乐,这些音乐通过您在 App.Config 中指定的MusicLocationElement
进行扫描。Sonic 在首次扫描后会将此设置设置为 false。设置文件很奇怪,当在基于设置的类上调用Save()
方法时,实际上会存储一个本地副本。这就是 Sonic 所做的。基本上,在首次扫描后,“ReReadAllFiles
”设置被设置为 false,并且设置被保存,这意味着即使您在 App.Config 中将“ReReadAllFiles
”设置重新设置为 true,Sonic 也会使用之前保存的版本。因此,如果您忘记配置有效的MusicLocationElement
(或多个),或者希望 Sonic 在首次运行后重新扫描您的所有音乐,您将需要找到并删除保存的设置文件。对我来说,这位于名为 C:\Users\sacha\AppData\Local\Sonic\ Sonic.vshost.exe_Url_hgefzieuxosag5swhmgx4xzo5rtoslkn\0.0.0.0 的目录中(您的会不同,但大致位于您的配置文件的相同位置)。AttemptToGainWebAlbumArt
:指示 Sonic 您想通过 Google(没错,Sonic 能够进行 Google 搜索)搜索专辑封面图片,而不是在本地硬盘上查找存储的专辑封面。需要注意的是,这样做会产生很大的影响。例如,如果 Sonic 为一个查询返回 20 张专辑的 MP3,当不尝试获取基于网络的封面艺术时,这些几乎可以立即加载,但当尝试从 Google 获取专辑封面图片时,大约需要一分钟。这需要一段时间。但我认为拥有所有正确的封面艺术的效果是值得等待的。但如果这种延迟让您感到烦恼,只需将此标志切换为 false,Sonic 将尝试使用本地封面艺术,如果没有可用,则将使用默认图像作为专辑封面。
自定义控件
在构建 Sonic 的过程中,我不得不创建许多实现各种功能的自定义控件,现在我将对它们进行描述。
CircularProgressBar
我想要一个跑马灯式的进度条,就像现在流行的基于网络的圆形进度条。所以我研究了一下,想出了一个简单的想法;只需在 XAML 中排列一些椭圆,然后对整个控件进行永不停歇的旋转。它非常有效,看起来像这样
这里唯一值得一提的是,我通过在 CircularProgressBar
控件的构造函数中重写与 StoryBoard
类型关联的元数据,改变了默认的动画帧率。
static CircularProgressBar()
{
//Use a default Animation Framerate of 30, which uses less CPU time
//than the standard 50 which you get out of the box
Timeline.DesiredFrameRateProperty.OverrideMetadata(
typeof(Timeline),
new FrameworkPropertyMetadata { DefaultValue = 30 });
}
这就是 XAML。很简单,对吧?
<UserControl x:Class="Sonic.CircularProgressBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="120" Width="120" Background="Transparent">
<Grid x:Name="LayoutRoot" Background="Transparent"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Grid.RenderTransform>
<ScaleTransform x:Name="SpinnerScale"
ScaleX="1.0" ScaleY="1.0" />
</Grid.RenderTransform>
<Canvas RenderTransformOrigin="0.5,0.5"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="120" Height="120" >
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="20.1696"
Canvas.Top="9.76358" Stretch="Fill"
Fill="Red" Opacity="1.0"/>
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="2.86816"
Canvas.Top="29.9581" Stretch="Fill"
Fill="Orange" Opacity="0.9"/>
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="5.03758e-006"
Canvas.Top="57.9341" Stretch="Fill"
Fill="Orange" Opacity="0.8"/>
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="12.1203"
Canvas.Top="83.3163" Stretch="Fill"
Fill="Orange" Opacity="0.7"/>
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="36.5459"
Canvas.Top="98.138" Stretch="Fill"
Fill="Orange" Opacity="0.6"/>
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="64.6723"
Canvas.Top="96.8411" Stretch="Fill"
Fill="Orange" Opacity="0.5"/>
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="87.6176"
Canvas.Top="81.2783" Stretch="Fill"
Fill="Orange" Opacity="0.4"/>
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="98.165"
Canvas.Top="54.414" Stretch="Fill"
Fill="Orange" Opacity="0.3"/>
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="92.9838"
Canvas.Top="26.9938" Stretch="Fill"
Fill="Orange" Opacity="0.2"/>
<Ellipse Width="21.835" Height="21.862"
Canvas.Left="47.2783"
Canvas.Top="0.5" Stretch="Fill"
Fill="Orange" Opacity="0.1"/>
<Canvas.RenderTransform>
<RotateTransform x:Name="SpinnerRotate" Angle="0" />
</Canvas.RenderTransform>
<Canvas.Triggers>
<EventTrigger RoutedEvent="ContentControl.Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="SpinnerRotate"
Storyboard.TargetProperty="(RotateTransform.Angle)"
From="0" To="360" Duration="0:0:01"
RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Canvas.Triggers>
</Canvas>
</Grid>
</UserControl>
FrictionScrollViewer
我希望查询匹配的专辑列表包含在一个特殊的摩擦式 ScrollViewer
中;为此,我创建了这个类,它完成了这项工作
/// <summary>
/// Provides a scrollable ScrollViewer which
/// allows user to apply friction, which in turn
/// animates the ScrollViewer position, giving it
/// the appearance of sliding into position
/// </summary>
public class FrictionScrollViewer : ScrollViewer
{
#region Data
// Used when manually scrolling.
private DispatcherTimer animationTimer = new DispatcherTimer();
private Point previousPoint;
private Point scrollStartOffset;
private Point scrollStartPoint;
private Point scrollTarget;
private Vector velocity;
private Point autoScrollTarget;
private bool shouldAutoScroll = false;
#endregion
#region Ctor
/// <summary>
/// Overrides metadata
/// </summary>
static FrictionScrollViewer()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(FrictionScrollViewer),
new FrameworkPropertyMetadata(typeof(FrictionScrollViewer)));
}
/// <summary>
/// Initialises all friction related variables
/// </summary>
public FrictionScrollViewer()
{
Friction = 0.95;
animationTimer.Interval = new TimeSpan(0, 0, 0, 0, 20);
animationTimer.Tick += HandleWorldTimerTick;
animationTimer.Start();
}
#endregion
#region DPs
/// <summary>
/// The ammount of friction to use. Use the Friction property to set a
/// value between 0 and 1, 0 being no friction 1 is full friction
/// meaning the panel won’t "auto-scroll".
/// </summary>
public double Friction
{
get { return (double)GetValue(FrictionProperty); }
set { SetValue(FrictionProperty, value); }
}
// Using a DependencyProperty as the backing store for Friction.
public static readonly DependencyProperty FrictionProperty =
DependencyProperty.Register("Friction", typeof(double),
typeof(FrictionScrollViewer), new UIPropertyMetadata(0.0));
#endregion
#region overrides
/// <summary>
/// Get position and CaptureMouse
/// </summary>
/// <param name="e"></param>
protected override void OnMouseDown(MouseButtonEventArgs e)
{
if (IsMouseOver)
{
shouldAutoScroll = false;
// Save starting point, used later when determining how much to scroll.
scrollStartPoint = e.GetPosition(this);
scrollStartOffset.X = HorizontalOffset;
scrollStartOffset.Y = VerticalOffset;
// Update the cursor if can scroll or not.
Cursor = (ExtentWidth > ViewportWidth) ||
(ExtentHeight > ViewportHeight) ?
Cursors.ScrollAll : Cursors.Arrow;
CaptureMouse();
}
base.OnMouseDown(e);
}
/// <summary>
/// If IsMouseCaptured scroll to correct position.
/// Where position is updated by animation timer
/// </summary>
protected override void OnMouseMove(MouseEventArgs e)
{
if (IsMouseCaptured)
{
shouldAutoScroll = false;
Point currentPoint = e.GetPosition(this);
// Determine the new amount to scroll.
Point delta = new Point(scrollStartPoint.X -
currentPoint.X, scrollStartPoint.Y - currentPoint.Y);
scrollTarget.X = scrollStartOffset.X + delta.X;
scrollTarget.Y = scrollStartOffset.Y + delta.Y;
// Scroll to the new position.
ScrollToHorizontalOffset(scrollTarget.X);
ScrollToVerticalOffset(scrollTarget.Y);
}
base.OnMouseMove(e);
}
/// <summary>
/// Release MouseCapture if its captured
/// </summary>
/// <param name="e"></param>
protected override void OnMouseUp(MouseButtonEventArgs e)
{
if (IsMouseCaptured)
{
Cursor = Cursors.Arrow;
ReleaseMouseCapture();
}
base.OnMouseUp(e);
}
#endregion
#region Animation timer Tick
/// <summary>
/// Animation timer tick, used to move the scrollviewer incrementally
/// to the desired position. This also uses the friction setting
/// when determining how much to move the scrollviewer
/// </summary>
private void HandleWorldTimerTick(object sender, EventArgs e)
{
if (IsMouseCaptured)
{
Point currentPoint = Mouse.GetPosition(this);
velocity = previousPoint - currentPoint;
previousPoint = currentPoint;
}
else
{
if (shouldAutoScroll)
{
Point currentScroll = new Point(ScrollInfo.HorizontalOffset +
ScrollInfo.ViewportWidth / 2.0,
ScrollInfo.VerticalOffset + ScrollInfo.ViewportHeight / 2.0);
Vector offset = autoScrollTarget - currentScroll;
shouldAutoScroll = offset.Length > 2.0;
// FIXME: 10.0 here is the scroll speed factor, a higher value
//means slower auto-scroll, 1 means no animation
ScrollToHorizontalOffset(HorizontalOffset + offset.X / 10.0);
ScrollToVerticalOffset(VerticalOffset + offset.Y / 10.0);
}
else
{
if (velocity.Length > 1)
{
ScrollToHorizontalOffset(scrollTarget.X);
ScrollToVerticalOffset(scrollTarget.Y);
scrollTarget.X += velocity.X;
scrollTarget.Y += velocity.Y;
velocity *= Friction;
System.Diagnostics.Debug.WriteLine("Scroll @ " +
ScrollInfo.HorizontalOffset + ", " +
ScrollInfo.VerticalOffset);
}
}
InvalidateScrollInfo();
InvalidateVisual();
}
}
#endregion
#region Public Methods/Properties
public Point AutoScrollTarget
{
set
{
autoScrollTarget = value;
shouldAutoScroll = true;
}
}
public void ScrollToCenterTarget(Point target)
{
ScrollToHorizontalOffset(target.X - ScrollInfo.ViewportWidth / 2.0);
ScrollToVerticalOffset(target.Y - ScrollInfo.ViewportHeight / 2.0);
}
#endregion
}
您可以在 Sonic 的这个区域看到它的使用:只需将鼠标放下,快速向左或向右拖动然后松开,要停止它,再次点击鼠标(它只在不直接位于专辑上方时拖动)
FancyButton
我拥有的唯一另一个控件是一个漂亮的(非常漂亮的,我从 Blend 示例中偷来的)按钮。由于我偷了它,我不会深入探讨代码,但它看起来像这样,并且有很多很酷的 StoryBoard
动画使其工作。反正我喜欢它。
自定义窗口
我不太喜欢标准 WPF Window
的外观,所以我决定重新设计它的样式。幸运的是,微软通过一些标准 XAML 控件模板技术使这变得非常容易。实际上有一个很好的 MSDN 链接展示了标准控件(其中 Window
是一个)使用的 默认控件模板。
所以有了这些知识,剩下的就是按照我想要的方式重新设计窗口样式了。所以,我不会用这种 Window
样式
我将得到以下结果,它基本上是一个空白的可调整大小(带 Grip)窗口。这使我可以放置其他控件,用于最小化/最大化/关闭等功能。
您确实需要指定一些 Window
级别属性才能实现此目的。这是一个例子
<Window x:Class="Sonic.Views.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Sonic : Music Library"
Background="{x:Null}"
Topmost="False"
WindowStartupLocation="CenterScreen"
WindowState="Normal"
MinHeight="620"
MinWidth="950"
WindowStyle="None"
Template="{StaticResource WindowTemplateKey}"
ResizeMode="CanResizeWithGrip" AllowsTransparency="True">
<Grid Background="WhiteSmoke">
</Grid>
</Window>
其中实际的 Window
控件模板定义如下。在所附的演示应用程序中,所有样式都在 ResourceDictionary
AppStyles.xaml 中声明。
<!-- Custom Window : to allow repositioning of ResizeGrip-->
<ControlTemplate x:Key="WindowTemplateKey" TargetType="{x:Type Window}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<AdornerDecorator>
<ContentPresenter/>
</AdornerDecorator>
<ResizeGrip Visibility="Collapsed"
HorizontalAlignment="Right" x:Name="WindowResizeGrip"
Style="{DynamicResource ResizeGripStyle1}"
VerticalAlignment="Bottom" IsTabStop="false"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="ResizeMode" Value="CanResizeWithGrip"/>
<Condition Property="WindowState" Value="Normal"/>
</MultiTrigger.Conditions>
<Setter Property="Visibility"
TargetName="WindowResizeGrip" Value="Visible"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
MP3 / ID3
Sonic 严重依赖于可能存在或不存在于扫描文件中的 ID3 标签元数据。目前 ID3 规范有两个主要版本:ID3v1,它相当简单,实际上看起来像这样
这很容易进行字节剥离;事实上,我以前在一个项目中自己做过;然而,ID3v2 则完全是另一种情况。坦率地说,我懒得去做,所以我四处寻找,发现了一个非常优秀的 .NET 免费 ID3 库,叫做 UltraID3Lib,它可以读取 ID3v1 和 ID3v2 标签。它非常易于使用,并且显然已包含在 Sonic 中。
下面是如何读取给定文件的 ID3 标签的示例,我返回了一个专门的 MP3
对象,它具有 Sonic 中所需的额外功能
/// <summary>
/// Obtain the ID3 information for the given filename
/// </summary>
public static MP3 ProcessSingleMP3File(String fileName)
{
MP3 mp3File = null;
Boolean hasTag = false;
String album = String.Empty;
String artist = String.Empty;
String genreName = String.Empty;
String title = String.Empty;
//Use the ID3 Library (and why not)
UltraID3 readMP3File = new UltraID3();
readMP3File.Read(fileName);
//check for ID3 v2 Tag 1st
if (readMP3File.ID3v2Tag.ExistsInFile)
{
hasTag = true;
album = readMP3File.ID3v2Tag.Album;
artist = readMP3File.ID3v2Tag.Artist;
genreName = readMP3File.ID3v2Tag.Genre;
title = readMP3File.ID3v2Tag.Title;
}
//check for ID3 v1 Tag
if (readMP3File.ID3v1Tag.ExistsInFile && !hasTag)
{
hasTag = true;
album = readMP3File.ID3v1Tag.Album ?? "Uknown";
artist = readMP3File.ID3v1Tag.Artist ?? "Uknown";
genreName = readMP3File.ID3v1Tag.GenreName ?? "Uknown";
title = readMP3File.ID3v1Tag.Title ?? "Uknown";
}
//Only create an actual MP3File if we actually found
//a ID3 Tag
if (hasTag)
{
mp3File = new MP3();
mp3File.FileName = fileName;
mp3File.Album = album;
mp3File.Artist = artist;
mp3File.GenreName = genreName;
mp3File.Title = title;
}
return mp3File;
}
LINQ 提供程序
正如我在本文开头所说,我想写这篇文章的主要原因之一是为了更熟悉 LINQ 和 IQueryProvider
。我应该立即指出,Sonic 不会也永远不会创建完整的 IQueryProvider
实现,那是一项极其庞大的工作。基本上,Sonic 所做的是作弊。由于 Sonic 在后台使用 SQL Server(LINQ to SQL 是 IQueryProvider
的一个实现,您不知道吗?),我只是拦截了原始查询(它是一个 Expression
树,这是 IQueryProvider
实现必须处理的),并将其委托给一些逻辑,这些逻辑使用我的 SQL Server DataContext
完成工作,该 DataContext
处理将 Expression
树解析为 SQL 命令。
你可能会问自己,我为什么要那样做,而你问得对。嗯,老实说,你会完全绕过 Sonic 中的 IQueryProvider
实现,直接使用 LINQ to SQL 数据库 DataContext
,但这有什么乐趣呢?我们想更好地理解,不是吗?基本上,这就是我做这个额外步骤的原因。
尝试编写一个完整的 IQueryProvider
实现不适合胆小的人。如果您想了解更多关于此主题的信息,可以在 Matt Warren 的网站上阅读更多内容:LINQ:构建 IQueryable 提供程序系列。
好的,既然如此,这一切在 Sonic 中是如何工作的呢?
嗯,很简单,它的工作原理是这样的
MediaViewModel
有许多预构建的参数驱动的Expression
,用于馈入我自己的MP3Provider
,该MP3Provider
实现了QueryProvider
(这是我从 Matt Warren 的网站上偷来的一个基类)。QueryProvider
的作用是,通过重写QueryProvider
的public override object Execute(Expression expression)
方法,使用传入的Expression
树。- 然后,传递给
QueryProvider
的public override object Execute(Expression expression)
方法的Expression
树被用于传递给一个帮助类,该类完成使用Expression
树并将其编译成委托(Func<T,TResult>
)的实际工作,该委托可以用于 Sonic 数据库DataContext
(正如我所说的,它是一个IQueryProvider
实现)。
基本上,在使用 IQueryProvider
时,我们必须使用 Expression
树,而不是委托(Func<T,TResult>
);原因在于 IQueryProvider
旨在处理像 SQL 这样的使用其他存储机制/语法的对象,并且无法理解如何处理委托(Func<T,TResult>
),因此必须使用 Expression
树。通常,整个 Expression
树将使用访问者模式进行检查,该模式允许动态构建访问的 Expression
树的正确查询。因此,您可以看到,通过拥有特定的 IQueryProvider
实现,您可以将相同的 Expression
树与多个 IQueryProvider
实现一起使用。每个 IQueryProvider
实现本质上都会根据为其提供值的对象的正确语法/语义形成自己的特定查询。在 LINQ to SQL(这是一个 IQueryProvider
实现)的情况下,这将是创建 SQL 查询。
这一切听起来相当疯狂,但也许通过一个例子会变得更清楚。
好的,让我们从查询的来源开始,它通过 MainWindow
上的搜索按钮,当按下时,将指示嵌入的 MediaView
(它使用其 ViewModel 来完成工作)执行特定类型的查询。它通过使用 Commands 来实现,但我们稍后会讨论。现在,让我们专注于理解这种查询机制。
我们在 MediaViewViewModel
中有一个 Expression
,它看起来像这样,它是一个使用 Func<MP3,Boolean>
作为选择器(或谓词)来过滤 MP3
类型集合的表达式
private Expression<Func<MP3, Boolean>> queryExpression = null;
当我们进行某种搜索时,例如尝试按“艺术家首字母”搜索
这将把 MediaViewViewModel
中的当前 Expression
设置为类似以下内容
public String CurrentArtistLetter
{
get { return currentArtistLetter; }
set
{
currentArtistLetter = value;
NotifyPropertyChanged("CurrentArtistLetter");
//create the correct Query type and Expression for the query
QueryToPerform = OverallQueryType.ByArtistLetter;
queryExpression =
mp3 => mp3.Artist.ToLower().
StartsWith(currentArtistLetter.ToLower());
}
}
我们现在在 MediaViewViewModel
中有一个 Expression
,可用于搜索 MP3
类型。接下来,我们需要了解这个查询是如何用于 Sonic QueryProvider
实现的。
当查询在 MediaViewViewModel
中运行时,RunQuery()
方法会运行,它看起来像这样
/// <summary>
/// Runs the Query using the Expression tree for the Query
/// and then does some LINQ Grouping into Albums, such that
/// the Album cover image can be searched for and all related
/// MP3s that go with the album are kept together
/// </summary>
/// <param name="expr">The Expression tree for the Query</param>
private void RunQuery(Expression<Func<MP3, Boolean>> expr)
{
try
{
//use a Threadpool thread to run the query
ThreadPool.QueueUserWorkItem(x =>
{
IsBusy = true;
MP3s MP3 = new MP3s();
IQueryable<MP3> query = MP3.Files.Where<MP3>(expr);
//Create a concrete list
var mp3sMatched = query.ToList();
//group the result of the matched MP3s into albums
var albumsOfMP3s =
from mp3 in mp3sMatched
group mp3 by mp3.Album;
//Now create a ObservableCollection of the grouped results
//just because an ObservableCollection is easier to bind to
//then a Dictionary which isnt even Observable
//This grouping is the grouping of tracks to Albums
ObservableCollection<AlbumOfMP3ViewModel> albums =
new ObservableCollection<AlbumOfMP3ViewModel>();
double animationOffset = 100;
double currentAnimationTime = 0;
//Didn't want to use for loop here, as its grouped,
//foreach is better, for grouped LINQ objects
//Allocate Albums with tracks
foreach (var album in albumsOfMP3s)
{
List<MP3> albumFiles = album.ToList();
AlbumOfMP3ViewModel albumOfMP3s = new AlbumOfMP3ViewModel
{
Album = albumFiles.First().Album,
Artist = albumFiles.First().Artist,
Files = albumFiles
};
albumOfMP3s.ObtainImageForAlbum();
albumOfMP3s.AnimationDelayMs =
currentAnimationTime += animationOffset;
albums.Add(albumOfMP3s);
}
//Store the Albums
AlbumsReturned = albums;
IsBusy = false;
});
}
catch (Exception ex)
{
Console.WriteLine("Ooops, its busted " + ex.Message);
}
finally
{
IsBusy = false;
}
}
其中最重要的部分是这两行
MP3s MP3 = new MP3s();
IQueryable<MP3> query = MP3.Files.Where<MP3>(expr);
这里发生的是一个包含内部 Sonic QueryProvider
实现的新 MP3
对象。
public class MP3s
{
private IQueryProvider provider;
public IQueryable<MP3> Files;
public MP3s()
{
this.provider = new MP3Provider();
this.Files = new Query<MP3>(this.provider);
}
public IQueryProvider Provider
{
get { return this.provider; }
}
}
既然我们了解了这一切,我们就可以把注意力转向 Sonic QueryProvider
的实现。
其工作原理如下
public class MP3Provider : QueryProvider
{
/// <summary>
/// Returns objects that match the Expression
/// input
/// </summary>
public override object Execute(Expression expression)
{
//Get MethodCallExpression where original
//Expression would have been something
//like :
//
// MP3.Files.Where<MP3>(mp3 => mp3.FileName.ToLower().Contains("prison"));
MethodCallExpression mex = expression as MethodCallExpression;
//get out the lambdaExpression
Expression<Func<MP3,Boolean>> lambdaExpression =
(Expression<Func<MP3, Boolean>>)
(mex.Arguments[1] as UnaryExpression).Operand;
//get out the Func
Func<MP3, Boolean> filter = lambdaExpression.Compile();
//And now query the actual database using this filter
//NOTE : To be honest we could have gone straight to the
//QueryXML.GetMatchingMP3Files() method without this
//QueryProvider....But I wanted to write it, to see
//if I could understand QueryProviders a bit more.
//So there.
return XMLAndSQLQueryOperations.GetMatchingMP3Files(filter);
}
}
它继承自(来自 Matt Warren 的网站)QueryProvider
基类。
/// <summary>
/// A basic abstract LINQ query provider
/// </summary>
public abstract class QueryProvider : IQueryProvider
{
#region Ctor
protected QueryProvider()
{
}
#endregion
#region IQueryProvider Members
IQueryable<T> IQueryProvider.CreateQuery<T>(Expression expression)
{
return new Query<T>(this, expression);
}
public IQueryable CreateQuery(Expression expression)
{
throw new NotImplementedException();
}
public T Execute<T>(Expression expression)
{
return (T)this.Execute(expression);
}
public abstract object Execute(Expression expression);
#endregion
}
这看起来可能完全疯了,但所发生的一切只是将原始的 Expression
(例如)
queryExpression =
mp3 => mp3.Artist.ToLower().
StartsWith(currentArtistLetter.ToLower());
编译成一个 Func<MP3,Boolean>
选择器(如果你喜欢,也可以说是谓词),它可以用于 IEnumerable<MP3>
或 Query<MP3>
。
如果我们检查使用此 Func<MP3,Boolean>
谓词的 XMLAndSQLQueryOperations.GetMatchingMP3Files(filter)
方法,希望会变得更清楚。
public static IQueryable<MP3> GetMatchingMP3Files(Func<MP3, Boolean> filter)
{
SQLMP3sDataContext datacontext = new SQLMP3sDataContext();
return datacontext.MP3s.Where(filter).AsQueryable();
}
你看,在幕后,它只是使用了标准的 LINQ to SQL IEnumerable<MP3>
对象,这些对象与标准的 LINQ to SQL DataContext
一起工作;它唯一做的就是将其强制转换,以确保结果使用的是 IQueryable<MP3>
。基本上,如果一个类型支持 IQueryable<T>
,它将使用 IQueryable<T>
,如果它不支持查询,它将使用 IEnumerable<T>
,后者使用委托而不是 Expression
树。
回想一下我们使用过这样一行代码
MP3s MP3 = new MP3s();
IQueryable<MP3> query = MP3.Files.Where<MP3>(expr);
所以我们返回 IQueryable<MP3>
,但 LINQ to SQL 是如何做到这一点的呢?它不是开箱即用的。还有一个最终步骤,就是使您想要查询的类型可查询。方法如下(同样,借鉴自 Matt Warren 的网站)
/// <summary>
/// A default implementation of IQueryable for use with QueryProvider
/// </summary>
public class Query<T> : IQueryable<T>, IQueryable,
IEnumerable<T>, IEnumerable,
IOrderedQueryable<T>, IOrderedQueryable
{
IQueryProvider provider;
Expression expression;
public Query(IQueryProvider provider)
{
if (provider == null)
{
throw new ArgumentNullException("provider");
}
this.provider = provider;
this.expression = Expression.Constant(this);
}
public Query(QueryProvider provider, Expression expression)
{
if (provider == null)
{
throw new ArgumentNullException("provider");
}
if (expression == null)
{
throw new ArgumentNullException("expression");
}
if (!typeof(IQueryable<T>).IsAssignableFrom(expression.Type))
{
throw new ArgumentOutOfRangeException("expression");
}
this.provider = provider;
this.expression = expression;
}
Expression IQueryable.Expression
{
get { return this.expression; }
}
Type IQueryable.ElementType
{
get { return typeof(T); }
}
IQueryProvider IQueryable.Provider
{
get { return this.provider; }
}
public IEnumerator<T> GetEnumerator()
{
return ((IEnumerable<T>)
this.provider.Execute(this.expression))
.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable)
this.provider.Execute(this.expression))
.GetEnumerator();
}
public override string ToString()
{
if (this.expression.NodeType == ExpressionType.Constant &&
((ConstantExpression)this.expression).Value == this)
{
return "Query(" + typeof(T) + ")";
}
else
{
return this.expression.ToString();
}
}
}
深吸一口气,你成功了。我知道这看起来很疯狂(至少对我来说是这样),但请记住,你可以绕过这一切,只需将搜索声明为 Func<MP3,Boolean>
谓词而不是 Expression 树,然后跳过所有这些,直接使用 Func<MP3,Boolean>
谓词对抗 LINQ to SQL,并改为使用 IEnumerable<MP3>
。
我只是想深入研究一下。
Model-View-ViewModel 模式
如果您正在努力掌握 WPF 开发,您会希望使用 MVVM 模式。
关于这种模式有许多很棒的资源;这里有一些链接
Sonic 实际上使用了许多不同的视图,每个视图都有自己的 ViewModel。
回想这张图片
这些视图中的每一个都有一个专门的 ViewModel。基本思想是视图能够将其操作委托给 ViewModel,并且能够使用 WPF 数据绑定技术绑定到其 ViewModel。
命令的委派基本上意味着不在代码隐藏中做事情,而是让 ViewModel 完成工作并更新其属性,视图看到这些属性并可以调整其视觉表示以反映这些属性。
为了实现这一点,有几个关键点。
Commands
WPF 提供了 RoutedCommand(s),它允许 ViewModel 包含 Command(ICommand
实现),这些 Command 可以包含在命令从使用该命令的项运行时执行的方法。标准的 RoutedCommand(s) 理念很酷,但需要在 View 中进行一些 XAML/代码隐藏工作,一些聪明的人花了一些时间提出了替代方案。其中一人是 Marlon Grech,他编写了一个不错的委托式命令,这意味着您不必在 View 中完成工作,而是在 ViewModel 中完成。
这是一个例子。
ICommand
的实现看起来像
/// <summary>
/// Implements the ICommand and wraps up all the verbose
/// stuff so that you can just pass 2 delegates 1 for the
/// CanExecute and one for the Execute
/// </summary>
public class SimpleCommand : ICommand
{
/// <summary>
/// Gets or sets the Predicate to execute when the
/// CanExecute of the command gets called
/// </summary>
public Predicate<object> CanExecuteDelegate { get; set; }
/// <summary>
/// Gets or sets the action to be called when the
/// Execute method of the command gets called
/// </summary>
public Action<object> ExecuteDelegate { get; set; }
#region ICommand Members
/// <summary>
/// Checks if the command Execute method can run
/// </summary>
/// <param name="parameter">THe command parameter to
/// be passed</param>
/// <returns>Returns true if the command can execute.
/// By default true is returned so that if the user of
/// SimpleCommand does not specify a CanExecuteCommand
/// delegate the command still executes.</returns>
public bool CanExecute(object parameter)
{
if (CanExecuteDelegate != null)
return CanExecuteDelegate(parameter);
return true;// if there is no can execute default to true
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
/// <summary>
/// Executes the actual command
/// </summary>
/// <param name="parameter">THe command parameter to be passed</param>
public void Execute(object parameter)
{
if (ExecuteDelegate != null)
ExecuteDelegate(parameter);
}
#endregion
}
这使我们可以在 ViewModel 上简单地拥有 ICommand
属性,View 可以绑定到这些属性。
public class MediaViewViewModel : ViewModelBase
{
//Commands
private ICommand runQueryCommand = null;
public MediaViewViewModel()
{
//wire up command
runQueryCommand = new SimpleCommand
{
CanExecuteDelegate = x => !IsBusy && queryExpression != null,
ExecuteDelegate = x => RunQuery(queryExpression)
};
private void RunQuery(Expression<Func<MP3, Boolean>> expr)
{
....
....
....
}
}
}
这使得 View 可以简单地像这样使用此命令
<local:FancyButton ButtonToolTip="Search For Music Using This Query"
ButtonCommand="{Binding Path=MediaViewVM.RunQueryCommand}"/>
你可以从中看出,我们可以将 View 连接到 ViewModel 逻辑。没问题。
INotifyPropertyChanged
另一个圣杯是 INPC,它只是允许绑定看到更改通知。
我通常会创建一个基类来处理这个问题。这是一个例子
/// <summary>
/// Provides a bindable ViewModel base class
/// </summary>
public abstract class ViewModelBase : INotifyPropertyChanged
{
#region INotifyPropertyChanged implementation
public event PropertyChangedEventHandler PropertyChanged;
protected void NotifyPropertyChanged(String info)
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(info));
}
}
#endregion
}
好了,既然我们已经介绍了基本知识,那么我们来看看其中的一些 ViewModel,好吗?我不会涵盖所有,但我希望能花一点时间谈谈其中的一两个。
我个人注意到的一件事是,根本没有例子来处理比仅仅显示一列 X 并更新某个 X 更复杂的问题。我理解为什么会这样;那是因为人们理解那个问题域。然而,现实生活并非那么简单,所以我决定让 Sonic 涉及动画等内容。
事不宜迟,让我们考虑一两个 ViewModel。
AlbumOfMP3ViewModel
MediaViewViewModel
实际上有一个属性,是一个 ObservableCollection<AlbumOfMP3ViewModel> AlbumsReturned
,用于表示与特定搜索匹配的 MP3
分组专辑。
//This grouping is the grouping of tracks to Albums
ObservableCollection<AlbumOfMP3ViewModel> albums =
new ObservableCollection<AlbumOfMP3ViewModel>();
double animationOffset = 100;
double currentAnimationTime = 0;
//Didn't want to use for loop here, as its grouped,
//foreach is better, for grouped LINQ objects
//Allocate Albums with tracks
foreach (var album in albumsOfMP3s)
{
List<MP3> albumFiles = album.ToList();
AlbumOfMP3ViewModel albumOfMP3s = new AlbumOfMP3ViewModel
{
Album = albumFiles.First().Album,
Artist = albumFiles.First().Artist,
Files = albumFiles
};
albumOfMP3s.ObtainImageForAlbum();
albumOfMP3s.AnimationDelayMs = currentAnimationTime += animationOffset;
albums.Add(albumOfMP3s);
}
//Store the Albums
AlbumsReturned = albums;
现在,Sonic 对这些所做的是在 MediaViewView
中,有一个 ItemsControl
绑定到 MediaViewViewModel
的 AlbumsReturned
属性;这是 XAML
<ItemsControl x:Name="albumItems"
VerticalAlignment="Center" Height="130"
HorizontalAlignment="Stretch" Margin="0"
ItemsSource="{Binding MediaViewVM.AlbumsReturned}"
ItemTemplate="{StaticResource albumItemsTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
可以看出 ItemsControl
绑定到 MediaViewViewModel
的 AlbumsReturned
属性。这很酷。那么这些 AlbumOfMP3ViewModel
对象是什么样子的呢?嗯,它是一个 ViewModel,所以它只是一个类。这是代码。我应该提一下,这个 ViewModel 做了一些很酷的事情来尝试获取专辑封面。它基本上会检查设置,看看是应该在硬盘上搜索专辑封面还是进行 Google 搜索。
这在 设置 部分有更详细的讨论。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Text;
using System.ComponentModel;
//Google .NET API, see GAPI.dll
using System.Timers;
using Gapi.Search;
using System.IO;
namespace Sonic
{
[DebuggerDisplay("{ToString()}")]
public class AlbumOfMP3ViewModel : ViewModelBase
{
#region Data
private String album = String.Empty;
private String artist = String.Empty;
private List<MP3> files = new List<MP3>();
private String albumCoverArtUrl = String.Empty;
private Boolean isAnimatable = false;
private Double animationDelayMs = 500;
private Timer delayStartAnimationTimer = new Timer();
public event EventHandler<EventArgs>
AnimationStartTimerExpiredEvent;
private List<String>
allowableLocalImageFormats = new List<String>();
#endregion
public AlbumOfMP3ViewModel()
{
delayStartAnimationTimer.Enabled = true;
delayStartAnimationTimer.Elapsed += DelayStartAnimationTimer_Elapsed;
//add allowable local image formats
allowableLocalImageFormats.Add("*.jpg");
allowableLocalImageFormats.Add("*.png");
allowableLocalImageFormats.Add("*.gif");
}
#region Public Methods
public void OnAnimationStartTimerExpiredEvent()
{
// Copy to a temporary variable to be thread-safe.
EventHandler<EventArgs> temp = AnimationStartTimerExpiredEvent;
if (temp != null)
temp(this, new EventArgs());
}
public void StartDelayedAnimationTimer()
{
delayStartAnimationTimer.Start();
}
/// <summary>
/// If the AttemptToGainWebAlbumArt setting is on will
/// create a google image search for the current Album name
/// and will attempt to obtain the web page as a string that the
/// search results url to see if the image is truly available.
///
/// If it is not available we will get a "404 File Not Found" html
/// error code. In this case or in the case where we get a WebException,
/// simply use a defulat application stored image
///
/// It should be noted that doing a google search takes time, and does
/// mean there is a lag in getting the search results
/// </summary>
/// <returns></returns>
public Boolean ObtainImageForAlbum()
{
Boolean attemptToGainWebAlbumArt = false;
if (Boolean.TryParse(Sonic.Properties.Settings.
Default.AttemptToGainWebAlbumArt,
out attemptToGainWebAlbumArt));
//if the setting is on, we should we use the google .NET api
//to do a search on google for an image for the album, otherwise
//try and find a hard drive stored album image, and if that fails
//finally use a default image for the album
if (attemptToGainWebAlbumArt)
{
Boolean foundValidImage = false;
String tempImageUrl = String.Empty;
WebClient webClient = new WebClient();
String downloadedContent = String.Empty;
try
{
SearchResults searchResults =
Searcher.Search(SearchType.Image,
String.Format("{0}", Album));
if (searchResults.Items.Count() > 0)
{
for (int i = 0; i < 1; i++)
{
downloadedContent =
webClient.DownloadString(searchResults.Items[i].Url);
if (!(downloadedContent.Contains("404") ||
downloadedContent.ToLower().
Contains("file not found")))
{
tempImageUrl = searchResults.Items[i].Url;
foundValidImage = true;
break;
}
else
{
foundValidImage = false;
break;
}
}
}
}
catch (WebException)
{
foundValidImage = false;
}
if (foundValidImage)
albumCoverArtUrl = tempImageUrl;
else
albumCoverArtUrl = "../Images/NoImage.png";
}
//not doing web search so look locally for an image
else
{
if (!FoundHardDiskImage())
{
albumCoverArtUrl = "../Images/NoImage.png";
}
}
return true;
}
#endregion
#region Private Methods
/// <summary>
/// Signal that the animation start delay has
/// occurred so tell View to start its
/// loading animation via the AnimationStartTimerExpiredEvent
/// </summary>
private void DelayStartAnimationTimer_Elapsed(
object sender, ElapsedEventArgs e)
{
delayStartAnimationTimer.Enabled = false;
delayStartAnimationTimer.Stop();
OnAnimationStartTimerExpiredEvent();
}
/// <summary>
/// finds a hard disk stoerd album image if one is available
/// </summary>
/// <returns></returns>
private Boolean FoundHardDiskImage()
{
try
{
FileInfo f = new FileInfo(files[0].FileName);
foreach (String allowableLocalImageFormat in
allowableLocalImageFormats)
{
String[] imageFiles =
Directory.GetFiles(f.Directory.FullName,
allowableLocalImageFormat);
if (imageFiles.Length > 0)
{
albumCoverArtUrl = imageFiles[0];
return true;
}
}
return false;
}
catch
{
albumCoverArtUrl = "../Images/NoImage.png";
return false;
}
}
#endregion
#region Public Properties
public Double AnimationDelayMs
{
private get { return animationDelayMs; }
set
{
animationDelayMs = value;
delayStartAnimationTimer.Interval = animationDelayMs;
}
}
public Boolean IsAnimatable
{
get { return isAnimatable; }
set
{
isAnimatable = value;
NotifyPropertyChanged("IsAnimatable");
}
}
public String Album
{
get { return album; }
set
{
album = value;
NotifyPropertyChanged("Album");
}
}
public String Artist
{
get { return artist; }
set
{
artist = value;
NotifyPropertyChanged("Artist");
}
}
public List<MP3> Files
{
get { return files; }
set
{
files = value;
NotifyPropertyChanged("Files");
}
}
public String AlbumCoverArtUrl
{
get { return albumCoverArtUrl; }
set
{
albumCoverArtUrl = value;
NotifyPropertyChanged("AlbumCoverArtUrl");
}
}
public String ToolTipDisplay
{
get { return ToString(); }
}
#endregion
#region Overrides
public override string ToString()
{
return String.Format(
"Album : {0}, Artist : {1}",
Album, Artist);
}
#endregion
}
}
嗯,那太棒了。那么我们如何才能真正看到这个 ViewModel 的用户界面呢?嗯,如果我们重新检查 ItemsControl
的 XAML。
<ItemsControl x:Name="albumItems"
VerticalAlignment="Center" Height="130"
HorizontalAlignment="Stretch" Margin="0"
ItemsSource="{Binding MediaViewVM.AlbumsReturned}"
ItemTemplate="{StaticResource albumItemsTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
我们可以看到,每个项目都涉及一个 albumItemsTemplate ItemTemplate
模板。让我们看看其中一个。
<DataTemplate x:Key="albumItemsTemplate">
<local:AlbumView DataContext="{Binding}"/>
</DataTemplate>
可以看出,ItemsControl
中的每个项(实际上是一个 AlbumOfMP3ViewModel
)都允许我们为绑定的 AlbumOfMP3ViewModel
项显示一些 UI。
这是一种非常强大的技术,它允许我们基本上将 UI 分割成更小的部分,所有这些部分都由 ViewModel 控制。
我之前提到过,我想支持动画等功能。嗯,如果我们坚持使用这个 AlbumOfMP3ViewModel
ViewModel 示例,并在运行时查看它,我们会注意到 ItemsControl
中的每个 Item
(实际上显示的是单个 AlbumView
View)都会根据关联的 ViewModel 属性进行动画定位。
AlbumOfMP3ViewModel
对 View 一无所知,但它会启动一个计时器,在计时器结束后,会设置一个 AlbumOfMP3ViewModel
属性,AlbumView
知道这个属性,因此会启动自己的动画。你看到了吗,ViewModel 控制着 View,甚至不需要知道它?如果我们查看 AlbumView
View 的代码,可能会更清楚。
这是 XAML
<UserControl x:Class="Sonic.AlbumView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
HorizontalAlignment="Left"
Height="100" Width="100"
x:Name="userControl">
<UserControl.Resources>
<Storyboard x:Key="OnLoaded1">
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="btn"
Storyboard.TargetProperty="(UIElement.RenderTransform).
(TransformGroup.Children)[0].(ScaleTransform.ScaleX)">
<SplineDoubleKeyFrame KeyTime="00:00:00" Value="0"/>
<SplineDoubleKeyFrame KeyTime="00:00:00.1500000" Value="1.5"/>
<SplineDoubleKeyFrame KeyTime="00:00:00.2000000" Value="1.25"/>
<SplineDoubleKeyFrame KeyTime="00:00:00.50" Value="1.0"/>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="btn"
Storyboard.TargetProperty="(UIElement.RenderTransform).
(TransformGroup.Children)[0].(ScaleTransform.ScaleY)">
<SplineDoubleKeyFrame KeyTime="00:00:00" Value="0"/>
<SplineDoubleKeyFrame KeyTime="00:00:00.1500000" Value="1.5"/>
<SplineDoubleKeyFrame KeyTime="00:00:00.2000000" Value="1.25"/>
<SplineDoubleKeyFrame KeyTime="00:00:00.50" Value="1.0"/>
</DoubleAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="btn"
Storyboard.TargetProperty="(UIElement.Visibility)">
<DiscreteObjectKeyFrame KeyTime="00:00:00.05"
Value="{x:Static Visibility.Visible}"/>
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</UserControl.Resources>
<Button x:Name="btn" Margin="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="Auto"
ToolTip="{Binding ToolTipDisplay}"
Click="btn_Click"
Template="{StaticResource GlassButton}"
RenderTransformOrigin="0.5,0.5">
<Button.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1" ScaleY="1"/>
<SkewTransform AngleX="0" AngleY="0"/>
<RotateTransform Angle="0"/>
<TranslateTransform X="0" Y="0"/>
</TransformGroup>
</Button.RenderTransform>
<Image Margin="4" Source="{Binding AlbumCoverArtUrl}"
Stretch="UniformToFill"/>
</Button>
</UserControl>
这是此视图的代码隐藏
public delegate void AlbumClickedEventHandler(object sender,
AlbumClickedEventArgs e);
/// <summary>
/// Interaction logic for AlbumView.xaml
/// </summary>
public partial class AlbumView : UserControl
{
public AlbumView()
{
InitializeComponent();
this.DataContextChanged+=AlbumView_DataContextChanged;
}
#region Events
/// <summary>
/// Raised when Album item clicked
/// </summary>
public static readonly RoutedEvent AlbumClickedEvent =
EventManager.RegisterRoutedEvent(
"AlbumClicked", RoutingStrategy.Bubble,
typeof(AlbumClickedEventHandler),
typeof(AlbumView));
public event AlbumClickedEventHandler AlbumClicked
{
add { AddHandler(AlbumClickedEvent, value); }
remove { RemoveHandler(AlbumClickedEvent, value); }
}
#endregion
#region Private methods
/// <summary>
/// Hook up the associated AlbumOfMP3ViewModel
/// AnimationStartTimerExpiredEvent event
/// </summary>
private void AlbumView_DataContextChanged(object sender,
DependencyPropertyChangedEventArgs e)
{
AlbumOfMP3ViewModel viewModel = e.NewValue as AlbumOfMP3ViewModel;
if (viewModel != null)
{
viewModel.StartDelayedAnimationTimer();
viewModel.AnimationStartTimerExpiredEvent +=
ViewModel_AnimationStartTimerExpiredEvent;
}
}
/// <summary>
/// Start the animation Storyboard after the associated AlbumOfMP3ViewModel
/// timer expires and raises its AnimationStartTimerExpiredEvent event
/// </summary>
private void ViewModel_AnimationStartTimerExpiredEvent(object sender, EventArgs e)
{
//As the call that populated this control was on a different thread,
//we need to do some threading trickery
this.Dispatcher.InvokeIfRequired(() =>
{
Storyboard sb = this.TryFindResource("OnLoaded1") as Storyboard;
if (sb != null)
{
sb.Begin(this.btn);
}
}, DispatcherPriority.Normal);
}
private void btn_Click(object sender, RoutedEventArgs e)
{
//raise our custom AlbumClickedEvent event
AlbumClickedEventArgs args = new
AlbumClickedEventArgs(AlbumClickedEvent,
this.DataContext as AlbumOfMP3ViewModel);
RaiseEvent(args);
}
#endregion
}
AlbumView3D 视图
简单地显示 3D 专辑封面动画
MainWindow 视图/ViewModel
是所有其他视图的容器。它的 ViewModel 设置流派/艺术家字母,并具有 IsBusy
状态。
MediaView 视图/ViewModel
是 MainWindow 中的主要区域,并托管 n 个 AlbumView 和 n 个 MP3FileView。
MP3FileView 视图/ViewModel
表示单个 MP3 曲目。
所有这些额外的视图/视图模型都以上述方式工作。
拖放支持
即使已完成初始扫描,Sonic 实际上也允许添加更多音乐。这是通过拖放实现的,用户可以拖动整个目录或单个文件。这通过将项目拖到 Sonic 的拖放区域来完成。
既然您对整个 View/ViewModel 模式有了更好的理解,我想我可以假设您知道每个 View 都有一个 ViewModel 来管理该 View。MainWindow
也不例外;它使用 MainWindowViewModel
,该 ViewModel 具有 IsBusy
/IsNotBusy
属性。
发生的情况是,MainWindow
检查 MainWindowViewModel
的 IsNotBusy
属性,如果它为 false
,则将拖放功能委托给一个 DragAndDropHelper
帮助类,该类实际执行拖放操作。
private void StackPanel_DragOver(object sender, DragEventArgs e)
{
if (mainWindowViewModel.IsNotBusy)
dragAndDropHelper.DragOver(e);
}
private void StackPanel_Drop(object sender, DragEventArgs e)
{
if (mainWindowViewModel.IsNotBusy)
dragAndDropHelper.Drop(e);
}
基本思想是:如果拖动的是一个目录,则扫描其所有文件,如果这些文件不在 Sonic 数据库中,则将其添加到数据库,但仅限于它们是有效的音频(仅限 MP3)文件。
如果项目是文件,则过程如上所述。
/// <summary>
/// File types for Drag operation
/// </summary>
public enum FileType { Audio, NotSupported }
/// <summary>
/// Drag and drop helper
/// </summary>
public class DragAndDropHelper
{
#region Public Methods
/// <summary>
/// Do Drop, which will stored the items in the database
/// </summary>
public void Drop(DragEventArgs e)
{
try
{
e.Effects = DragDropEffects.None;
string[] fileNames =
e.Data.GetData(DataFormats.FileDrop, true)
as string[];
//is it a directory, get the files and check them
if (Directory.Exists(fileNames[0]))
{
string[] files = Directory.GetFiles(fileNames[0]);
AddFilesToDatabase(files);
}
//not a directory so assume they are individual files
else
{
AddFilesToDatabase(fileNames);
}
}
catch
{
e.Effects = DragDropEffects.None;
}
finally
{
// Mark the event as handled, so control's native
//DragOver handler is not called.
e.Handled = true;
}
}
/// <summary>
/// Show the Copy DragDropEffect if files are supported
/// </summary>
public void DragOver(DragEventArgs e)
{
try
{
e.Effects = DragDropEffects.None;
string[] fileNames =
e.Data.GetData(DataFormats.FileDrop, true)
as string[];
//is it a directory, get the files and check them
if (Directory.Exists(fileNames[0]))
{
string[] files = Directory.GetFiles(fileNames[0]);
CheckFiles(files, e);
}
//not a directory so assume they are individual files
else
{
CheckFiles(fileNames, e);
}
}
catch
{
e.Effects = DragDropEffects.None;
}
finally
{
// Mark the event as handled, so control's native
//DragOver handler is not called.
e.Handled = true;
}
}
/// <summary>Returns the FileType </summary>
/// <param name="fileName">Path of a file.</param>
public FileType GetFileType(string fileName)
{
string extension = System.IO.Path.GetExtension(fileName).ToLower();
if (extension == ".mp3")
return FileType.Audio;
return FileType.NotSupported;
}
#endregion
#region Private Methods
/// <summary>
/// Checks that the files being dragged are valid
/// </summary>
private void CheckFiles(string[] files, DragEventArgs e)
{
foreach (string fileName in files)
{
FileType type = GetFileType(fileName);
// Only Image files are supported
if (type == FileType.Audio)
e.Effects = DragDropEffects.Copy;
}
}
/// <summary>
/// Adds dragged files to database
/// </summary>
/// <param name="files"></param>
private void AddFilesToDatabase(String[] files)
{
SQLMP3sDataContext datacontext = new SQLMP3sDataContext();
try
{
foreach (string fileName in files)
{
FileType type = GetFileType(fileName);
// Handles image files
if (type == FileType.Audio)
{
MP3 mp3File = XMLAndSQLQueryOperations.
ProcessSingleMP3File(fileName);
if (mp3File != null)
{
datacontext = new SQLMP3sDataContext();
//does it already exist in the database, if it does return
if (datacontext.MP3s.Where(mp3 =>
mp3.Album == mp3File.Album).Count() > 0)
return;
//Doesn't exist so add it in to DB
datacontext.MP3s.InsertOnSubmit(mp3File);
datacontext.SubmitChanges();
}
}
}
}
catch (Exception ex)
{
//Oooops, something went wrong reading file
//not much we can do about it, just skip it
}
}
#endregion
}
投票游戏
我在这篇文章上花费了大量的时间和精力,所以如果有人投票低于 5 分,他们至少能告诉我为什么吗?是技术内容不对吗?您不喜欢文章的布局等等?
那么你觉得呢?
好了,就这样,欢迎评论。