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

YouGrade - Silverlight 多媒体考试套件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (62投票s)

2010 年 5 月 18 日

CPOL

16分钟阅读

viewsIcon

143606

downloadIcon

3097

一个基于 Silverlight 和 Youtube 的多媒体考试套件

YouGrade

目录

引言

这是我在 Code Project 上发表的第一篇 Silverlight 文章。我从去年三月才开始使用 Silverlight,四月就开始为我的公司开发 Silverlight 项目。虽然这篇文章没有深入探讨 Silverlight 的炫酷功能,比如动画效果,但我的真正目的是为读者提供一个小型、可用的概念验证,展示 Silverlight 如何作为多媒体考试套件的一部分。最初的想法是创建一个简单的考试套件,但很快就演变成了多媒体考试套件,你可以为考试中的每个问题分配视频,这得益于 YouTube 灵活地提供了一个可以嵌入到 HTML(当然也可以嵌入到 Silverlight 应用程序)中的小型播放器。

YouTube 确实是一个很棒的视频分享工具,YouGrade 通过为每个考试问题引用 YouTube 视频代码来利用 YouTube 的托管能力。请注意,你可以引用现有的 YouTube 视频(例如我在示例应用程序的“基础法语考试”中使用的视频),也可以创建自己的视频并上传以供新考试使用。

你可以通过观看我上传的 YouTube 视频快速了解应用程序,链接如下

致谢

我想感谢一些作者,是他们为我构建这个应用程序和文章打下了基础。

首先,也许也是最重要的,是 **Katka Vaughan** 的 Silverlight YouTube Jukebox,它让我对如何在浏览器中的 Silverlight 中使用 HTML 内容托管有了一些认识。Katka 使用了 DivElement 的 HtmlHost 控件,这样你就可以在一个特定的 Silverlight 界面区域内嵌入一个 HTML 浏览器。

然后我受到了 **Colin Eberhardt** 关于 Silverlight 中元素绑定的想法的启发。正如你们中的一些人可能知道的,Silverlight 4 已经支持元素绑定,但 Silverlight 3 仍然缺乏这个功能,所以 Colin 的想法对我的文章来说非常受欢迎。

另一个很棒的贡献是 **Patrick Cauldwell** 的 MVVM:在 Silverlight 中绑定到命令 文章,它使我能够使用 MVVM 模式来绑定 Silverlight 3 中一些按钮的命令。

最后,我想感谢 Code Project 上我们多产的作者,比如 **Daniel Vaughan** 和 **Sacha Barber**。因为他们,我受到了鼓励,开始认真学习 Silverlight 和 WPF,也学习了我在本文中使用的 MVVM 模式。

系统要求

为了让应用程序正常运行,如果你没有 VS 2008 和 Silverlight 3,你可以下载以下内容

YouGrade 解决方案

Solution

图 1:解决方案结构

Visual Studio 2008 解决方案由与 Silverlight 项目协作的项目组成,正如我们在下表中看到的

项目 描述
YouGrade.Silverlight 这就是 Silverlight 项目本身。
YouGrade.Silverlight.Controls 这个项目包含了我在此项目中使用的一些自定义控件。
YouGrade.Silverlight.Web 这个项目是启动项目,包含应用程序的入口页面。此外,这个 Web 应用程序还托管了启用 Silverlight 的 WCF 服务,为 Silverlight 项目提供数据服务。
Yougrade.Silverlight.Core 这个 Silverlight 类库项目只包含在项目之间传输的传输对象(TOs)。

接下来,我将解决方案分为文章的两个主要部分:**Silverlight 项目** 和 **启用 Silverlight 的 WCF 服务**

Silverlight 项目

毫无疑问,这是本文的重点。对于这个应用程序的界面,我可以在 WPF、Windows Forms、ASP.NET、ASP.NET MVC、Silverlight 等多种技术选项中进行选择。出于一些原因,我最终决定选择 Silverlight

  • 从用户的角度来看,访问基于 Web 的应用程序比安装和运行 Windows 应用程序更方便。
  • 许多用户在他们的公司机器上没有管理员权限,安装应用程序可能需要额外的技术支持。
  • 我相信 ASP.NET 或 ASP.NET MVC 可以满足此应用程序的需求。但我也想要一种技术,能够为用户提供丰富的应用程序界面体验,而无需开发者了解大量 JavaScript 和 CSS。
  • 最后,我真的很、真的很想有一个强大的动力来开始用 Silverlight 进行开发……

MVVM 模式

与 CodeProject 上许多 WPF 和 Silverlight 的文章一样,我也使用了 Model-View-ViewModel (MVVM) 模式。正如 **Josh Smith 所述**,MVVM 已成为 WPF 开发者的“通用语言”。Silverlight 开发者也可以这么说,因为 Silverlight 框架是 WPF 基础的一个子集。

对于还不了解 MVVM 的读者,这里有一个简单的解释:几乎所有开发者都知道“M”(Model)和“V”(View)的含义。Model 包含数据,View 展示数据。在大多数系统中,Model 部分映射数据库表,View 部分通过 Windows Forms、纯 HTML、WebForms 向用户展示数据,而在 WPF 和 Silverlight 的情况下,这个角色由 XAML 文件扮演。到目前为止,没有什么新鲜的。但为什么你需要 ViewModel 部分呢?实际上,你并没有被强制要求以任何方式在 Silverlight 中使用 ViewModel,就像你在构建 ASP.NET 应用程序时不必使用 MVC 模式一样。但有人认为 MVC 是 ASP.NET 的一个好模式,就像许多人自然地选择在 XAML 中使用 MVVM 模式一样。事实上,它如此自然,以至于 MVVM 在 WPF 还在开发过程中就诞生了。

话虽如此,使用 ViewModel 和不使用 ViewModel 的区别是什么?没有 ViewModel,你会得到类似传统的 Windows Forms 范例,你在 MyForm.cs 文件中编写代码来从业务层或数据层检索数据,然后填充窗体中的字段。在 Silverlight 中,你可以采用相同的方法,在 MyWindow.xaml.cs 文件中编写代码来获取数据,填充视图中的 XAML 元素,还可以控制按钮点击和列表框选择更改等事件。另一方面,ViewModel 方法将表示层分成 View 和 ViewModel,这样 View 就不再直接从数据源检索数据。相反,每个 View(即 XAML 文件)的元素属性都绑定到 ViewModel 类中的特定属性。一旦这些绑定设置好,View 元素和 ViewModel 属性就会保持同步,也就是说,一个部分的任何修改都会自动更新另一个部分。

正如我们在下面的 **图 2** 中看到的,View 不直接访问数据;相反,它依赖于其 ViewModel 对应物提供的绑定。例如,View 中的一个名为“gridProducts”的网格元素可能绑定到 ViewModel 中名为“Products”的 ObservableCollection 属性。对“Products”集合属性的任何更改都会反映在 View 端的 gridProducts 元素中。另一方面,一个名为“btnInsert”的 Button 元素可能绑定到 ViewModel 中名为“InsertCommand”的 Command 属性,这样用户每次点击按钮时,ViewModel 端的 Insert() 方法都会自动调用。

MVVM

图 2:MVVM 模式的基本结构

登录视图

正如预期的那样,登录视图位于我们应用程序的最前面。它只需验证用户身份,然后阻止或允许访问 YouGrade 应用程序的其余部分。

如果你下载并运行该应用程序,你唯一的用户名是“John Doe”,其登录 **用户名** 是 **code**,密码是 **project**。如果你愿意,也可以创建更多用户,但你应该直接在 User 表中插入用户,因为目前没有合适的界面来实现这一点。

LoginView

图 3:登录视图

你可能会发现,我对这个视图并没有太在意,这仅仅是因为我更专注于 **考试视图**,这是应用程序的核心。如果这是一个真正的应用程序,我可以例如使用 ASP.NET Membership,它提供了强大的身份验证功能。

现在,回到视图:下面的代码片段(参见文件:LoginView.xaml)显示 txtLogin 文本框的 Text 属性绑定到 ViewModel 的 Login 属性。

<TextBox x:Name="txtLogin" Grid.Row="1" Grid.Column="1" 
     Background="#FF202020" 
     Foreground="White" Margin="5" 
     CaretBrush="Yellow" TabIndex="0" 
     Text="{Binding Path=Login, Mode=TwoWay}" 
     TextChanged="txtLogin_TextChanged"  
     ToolTipService.ToolTip="Login is 'code' and password is 'project'"/>

另一方面,LoginViewModel 实现 Login 属性。请注意,这是双向绑定,因此对 txtLogin 元素中文本所做的任何更改都会立即反映在 LoginViewModelLogin 属性中。而 Login 属性的任何更改都会影响 txtLogin 元素中的文本。

public string Login
{
    get { return login; }
    set { 
        login = value;
        OnPropertyChanged("Login");
        EnterCanExecute = login.Length > 0 && password.Length > 0;
        LoginEmpty = (login.Length == 0);
    }
}

值得一提的是,以上属性的 setter 有三种不同的通知:一种通知 Login 属性本身,另一种通知 EnterCanExecute 属性,最后一种通知 LoginEmpty 属性。你可能会想:我为什么需要所有这些通知?原因是我们希望利用 MVVM 模式,而不是显式地基于登录文本框启用/禁用元素的属性。EnterCanExecute 属性启用/禁用“Enter”按钮,而 LoginEmpty 属性决定文本框右侧的黄色星号是否可见。

至于按钮,下面的代码显示 XAML 定义了两个绑定:一个用于定义按钮将执行哪个命令,另一个用于确定按钮是否应该启用或禁用。

<Button x:Name="btnEnter" Grid.Column="0" 
    Content="Enter" Margin="5" 
    sc:ButtonService.Command="{Binding Path=EnterCommand}" 
    IsEnabled="{Binding Path=EnterCanExecute}" Click="btnEnter_Click" 
    ToolTipService.ToolTip="Login is 'Code' and password id 'Project'"/>

考试视图

ExamView

图 4:考试视图

正如 Katka Vaughan 在她的 Silverlight YouTube Jukebox 文章中解释的那样,不幸的是,直到 Silverlight 4 Beta 发布(请记住,本文针对的是 Silverlight 3),Silverlight 都缺乏一个可以托管 HTML 内容的控件。即使在 Silverlight 4 中,在处理 Silverlight 应用程序外运行(Out Of Browser)时,你也只能使用 WebBrowserHtmlBrush。但对我们来说幸运的是,DivElements 公司免费提供了 HtmlHost 控件,它甚至支持 Silverlight 3 的浏览器内应用程序。

HtmlHost 控件非常简单易用。请看下面的 **图 5**,我们是如何实现 HtmlHost 元素的,并将 SourceHtml 属性设置为绑定到 ViewModel 端的 CurrentSourceHtml 属性。

<Border Name="HtmlHostContainer" Grid.Row="1" Grid.Column="1" Background="Transparent">
        <Border>
        <Border.Background>
            <ImageBrush ImageSource="/Images/ScreenBackground.png" 
               AlignmentX="Left" AlignmentY="Top" Opacity="100" Stretch="Uniform"/>
        </Border.Background>
        <divtools:HtmlHost SourceHtml="{Binding Path=CurrentSourceHtml}">
        </divtools:HtmlHost>
    </Border>
</Border>
图 5:ExamView.xaml 文件中 HtmlHost 控件的使用方法

我们想放入 HtmlHost 控件中的 HTML 仅仅是运行 Youtube 播放器所需的 JavaScript 语句。如 **图 6** 所示,我们使用了一个名为 QuestionConverter 的辅助类,它提供了一组功能,包括使用正确 YouTube 视频为当前问题运行 Youtube 播放器所需的 HTML。

StringBuilder html = new StringBuilder();
html.AppendLine("   <SCRIPT type='text/javascript'> ");
html.AppendLine("       function onYouTubePlayerReady(playerId) {");
html.AppendLine("        alert('onYouTubePlayerReady!');");
html.AppendLine("       }");
html.AppendLine("   </SCRIPT>");

html.AppendLine("<object id=\"myytplayer\" width='322' " + 
                "height='270' bgcolor='#000000' disabled='true'>");
html.AppendLine(string.Format("  <param name='movie' " + 
                "value='http://www.youtube.com/v/{0}&rel=1&" + 
                "color1=0x2b405b&color2=0x6b8ab6&border=1'>", 
videoCode));
html.AppendLine("</param>");
html.AppendLine(string.Format("  <embed src='http://www.youtube.com/" + 
     "v/{0}&rel=0&color1=0x000000&color2=0x808080&border=0" + 
     "&autoplay=1&enablejsapi=1&playerapiid=ytplayer'", 
videoCode));
html.AppendLine("    type='application/x-shockwave-flash' " + 
                "wmode='transparent' bgcolor='#000000' " + 
                "width='315' height='265'></embed>");
html.AppendLine("</object>");
return html.ToString();
图 6:我们如何构建 JavaScript 标签来填充 HtmlHost 控件

你可以从 YouTube 播放器 API 这里了解更多信息。

HtmlHost

图 7:嵌入在 HtmlHost 控件中的 YouTube 插件

关于 **考试视图** 还有一件事很重要:通常,YouTube 播放器会显示广告链接和其他 YouTube 视频的链接。为了确保 YouGrade 的正确使用,用户不应该点击此类链接并导航离开 Silverlight 应用程序。我尝试使用 YouTube 的 JavaScript 端的一些“enabled=false”属性,但没有找到这样的属性。经过一些尝试和错误,幸运的是,我最终创建了一个带有 **透明** 背景的 HtmlHostContainer 边框元素,它覆盖了托管视频播放器的 HtmlHost

<Border Name="HtmlHostContainer" Grid.Row="1" Grid.Column="1" Background="Transparent">
    <Border>
        <Border.Background>
            <ImageBrush ImageSource="/Images/ScreenBackground.png" 
               AlignmentX="Left" AlignmentY="Top" Opacity="100" Stretch="Uniform"/>
        </Border.Background>
        <divtools:HtmlHost Name="htmlHost" IsHitTestVisible="False" 
             SourceHtml="{Binding Path=CurrentSourceHtml}">
        </divtools:HtmlHost>
    </Border>
</Border>

自定义按钮控件

作为一点 XAML 练习,我决定创建一个具有漂亮外观的自定义按钮,我称之为“绿色按钮”。它是一个圆形按钮,看起来有点像塑料,顶部有灯光效果,底部有阴影效果。在中心,你可以选择使用哪张图片作为按钮。这就是为什么 ExamView 上的按钮是同一个控件。唯一的区别是中心图像,如下面的 **图 9** 所示。

MVVM

图 9:应用于自定义按钮控件的不同图像

在阅读有关 Silverlight 的一些书籍时,一些作者建议我创建一个新的 Silverlight 类库项目并将控件放在那里。

为了创建自定义按钮,我首先必须实现按钮本身,它继承自 Control 类。

using System;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
using System.Windows.Controls.Primitives;

namespace YouGrade.Silverlight.Controls
{
    [TemplateVisualState(Name = "Normal", GroupName = "ViewStates")]
    [TemplateVisualState(Name = "Highlighted", GroupName = "ViewStates")]
    public class GreenButton : Button
    {
        public static readonly DependencyProperty ImageContentProperty =
        DependencyProperty.Register("ImageContent", typeof(object),
        typeof(GreenButton), null);

        public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register("Text", typeof(string),
        typeof(GreenButton), null);

        public static readonly DependencyProperty IsHighlightedProperty =
        DependencyProperty.Register("IsHighlighted", 
                           typeof(bool), typeof(GreenButton), null);

        public GreenButton()
        {
            DefaultStyleKey = typeof(GreenButton);
            this.IsEnabledChanged += 
              new DependencyPropertyChangedEventHandler(
              GreenButton_IsEnabledChanged);
        }

        void GreenButton_IsEnabledChanged(object sender, 
                         DependencyPropertyChangedEventArgs e)
        {
            if (!(bool)e.NewValue)
            {
                this.IsHighlighted = false;
                ChangeVisualState(true);
                this.Opacity = 0.35;
            }
            else
            {
                this.Opacity = 1.00;
                VisualStateManager.GoToState(this, "Normal", true);
            }
        }

        public object ImageContent
        {
            get
            {
                return base.GetValue(ImageContentProperty);
            }
            set
            {
                base.SetValue(ImageContentProperty, value);
            }
        }

        public object Text
        {
            get
            {
                return base.GetValue(TextProperty);
            }
            set
            {
                base.SetValue(TextProperty, value);
            }
        }

        public bool IsHighlighted
        {
            get
            {
                return (bool)base.GetValue(IsHighlightedProperty);
            }
            set
            {
                base.SetValue(IsHighlightedProperty, value);
                ChangeVisualState(true);
            }
        }

        private void ChangeVisualState(bool useTransitions)
        {
            if (IsHighlighted)
            {
                VisualStateManager.GoToState(this, "Highlighted", useTransitions);
            }
            else
            {
                VisualStateManager.GoToState(this, "Normal", useTransitions);
            }
        }

        public override void OnApplyTemplate()
        {
            base.OnApplyTemplate();
            this.MouseEnter += new MouseEventHandler(GreenButton_MouseEnter);
            this.MouseLeave += new MouseEventHandler(GreenButton_MouseLeave);
            this.MouseMove += new MouseEventHandler(GreenButton_MouseMove);

            this.HorizontalContentAlignment = HorizontalAlignment.Center;
            this.ChangeVisualState(false);
        }

        void GreenButton_MouseMove(object sender, MouseEventArgs e)
        {
            this.IsHighlighted = true;
            ChangeVisualState(true);
        }

        void GreenButton_MouseEnter(object sender, MouseEventArgs e)
        {
            this.IsHighlighted = true;
            ChangeVisualState(true);
        }

        void GreenButton_MouseLeave(object sender, MouseEventArgs e)
        {
            this.IsHighlighted = false;
            ChangeVisualState(true);
        }

        private void NormalButton_Click(object sender, RoutedEventArgs e)
        {
            this.IsHighlighted = !this.IsHighlighted;
            ChangeVisualState(true);
        }
    }
}
图 10:GreenButton.cs 类的实现

一旦我们实现了自定义控件类,我们还需要创建它的模板。所以我创建了一个名为“generic.xml”的文件,位于 Themes 文件夹中,用于描述我的新 GreenButton 控件的模板。

<!--GreenButton-->
<Style TargetType="local:GreenButton">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="local:GreenButton">
                <Grid>
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="ViewStates">

                            <VisualStateGroup.Transitions>
                                <VisualTransition To="Normal" 
                                          From="Highlighted" GeneratedDuration="0:0:0.1">
                                    <Storyboard>
                                        <ColorAnimation 
                                            Storyboard.TargetName="glowingGradientStop"
                                            Storyboard.TargetProperty="Color" To="#ff202020"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation 
                                            Storyboard.TargetName="centralGradientStop"
                                            Storyboard.TargetProperty="Color" To="#ff008D00"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation 
                                            Storyboard.TargetName="centralGradientStop21"
                                            Storyboard.TargetProperty="Color" To="#ff008D00"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation 
                                            Storyboard.TargetName="centralGradientStop22"
                                            Storyboard.TargetProperty="Color" To="#ff008D00"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation 
                                            Storyboard.TargetName="centralGradientStop23"
                                            Storyboard.TargetProperty="Color" To="#ff004000"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation Storyboard.TargetName="greenFill"
                                            Storyboard.TargetProperty="Color" To="#ff008D00"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <DoubleAnimation 
                                            Storyboard.TargetName="contentPresenter"
                                            Storyboard.TargetProperty="Opacity" To="1.00"
                                            Duration="0:0:0.1">
                                        </DoubleAnimation>
                                    </Storyboard>
                                </VisualTransition>
                                <VisualTransition To="Highlighted" 
                                          From="Normal" GeneratedDuration="0:0:0.1">
                                    <Storyboard>
                                        <ColorAnimation 
                                            Storyboard.TargetName="glowingGradientStop"
                                            Storyboard.TargetProperty="Color" To="Gold"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation 
                                            Storyboard.TargetName="centralGradientStop"
                                            Storyboard.TargetProperty="Color" To="#ff56AB61"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation 
                                            Storyboard.TargetName="centralGradientStop21"
                                            Storyboard.TargetProperty="Color" To="#ff56AB61"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation 
                                            Storyboard.TargetName="centralGradientStop22"
                                            Storyboard.TargetProperty="Color" To="#ff56AB61"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation 
                                            Storyboard.TargetName="centralGradientStop23"
                                            Storyboard.TargetProperty="Color" To="#ff004000"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <ColorAnimation Storyboard.TargetName="greenFill"
                                            Storyboard.TargetProperty="Color" To="#ff56AB61"
                                            Duration="0:0:0.1">
                                        </ColorAnimation>
                                        <DoubleAnimation 
                                            Storyboard.TargetName="contentPresenter"
                                            Storyboard.TargetProperty="Opacity" To="0.80"
                                            Duration="0:0:0.1">
                                        </DoubleAnimation>
                                    </Storyboard>
                                </VisualTransition>
                            </VisualStateGroup.Transitions>

                            <VisualState x:Name="Normal">
                                <Storyboard>
                                    <ColorAnimation 
                                        Storyboard.TargetName="glowingGradientStop"
                                        Storyboard.TargetProperty="Color" To="#ff202020"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation 
                                        Storyboard.TargetName="centralGradientStop"
                                        Storyboard.TargetProperty="Color" To="#ff008D00"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation 
                                        Storyboard.TargetName="centralGradientStop21"
                                        Storyboard.TargetProperty="Color" To="#ff008D00"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation 
                                        Storyboard.TargetName="centralGradientStop22"
                                        Storyboard.TargetProperty="Color" To="#ff008D00"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation 
                                        Storyboard.TargetName="centralGradientStop23"
                                        Storyboard.TargetProperty="Color" To="#ff004000"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation Storyboard.TargetName="greenFill"
                                        Storyboard.TargetProperty="Color" To="#ff008D00"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <DoubleAnimation 
                                        Storyboard.TargetName="contentPresenter"
                                        Storyboard.TargetProperty="Opacity" To="1.00"
                                        Duration="0:0:0.1">
                                    </DoubleAnimation>
                                </Storyboard>
                            </VisualState>
                            <VisualState x:Name="Highlighted">
                                <Storyboard>
                                    <ColorAnimation 
                                        Storyboard.TargetName="glowingGradientStop"
                                        Storyboard.TargetProperty="Color" To="Gold"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation 
                                        Storyboard.TargetName="centralGradientStop"
                                        Storyboard.TargetProperty="Color" To="#ff56AB61"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation 
                                        Storyboard.TargetName="centralGradientStop21"
                                        Storyboard.TargetProperty="Color" To="#ff56AB61"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation 
                                        Storyboard.TargetName="centralGradientStop22"
                                        Storyboard.TargetProperty="Color" To="#ff56AB61"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation 
                                        Storyboard.TargetName="centralGradientStop23"
                                        Storyboard.TargetProperty="Color" To="#ff004000"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <ColorAnimation Storyboard.TargetName="greenFill"
                                        Storyboard.TargetProperty="Color" To="#ff56AB61"
                                        Duration="0:0:0.1">
                                    </ColorAnimation>
                                    <DoubleAnimation 
                                        Storyboard.TargetName="contentPresenter"
                                        Storyboard.TargetProperty="Opacity" To="0.80"
                                        Duration="0:0:0.1">
                                    </DoubleAnimation>
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>

                    <Grid Width="64" Height="64" 
                              HorizontalAlignment="Center" VerticalAlignment="Center">
                        <Grid.RowDefinitions>
                            <RowDefinition Height="*"/>
                            <RowDefinition Height="32"/>
                        </Grid.RowDefinitions>

                        <Grid Width="36" Height="36">
                            <Grid.Background>
                                <RadialGradientBrush 
                                          Center="0.5, 0.5" RadiusX="0.9" RadiusY="0.9">
                                    <GradientStopCollection>
                                        <GradientStop Offset="0.0" Color="#ff202020"/>
                                        <GradientStop x:Name="glowingGradientStop" 
                                               Offset="0.4" Color="#ff202020"/>
                                        <GradientStop Offset="0.6" Color="#ff202020"/>
                                        <GradientStop Offset="1.0" Color="#ff202020"/>
                                    </GradientStopCollection>
                                </RadialGradientBrush>
                            </Grid.Background>
                            <Ellipse Stroke="#ff008D00" StrokeThickness="1" Margin="5"/>
                            <Ellipse Stroke="#80A0C6A5" StrokeThickness="2" Margin="5.5"/>
                            <Ellipse Stroke="#ffA0C6A5" StrokeThickness="1" Margin="6"/>
                            <Ellipse Stroke="#8056AB61" StrokeThickness="2" Margin="6.5"/>
                            <Ellipse Margin="7">
                                <Ellipse.Fill>
                                    <SolidColorBrush x:Name="greenFill" Color="#ff008D00"/>
                                </Ellipse.Fill>
                            </Ellipse>
                            <Ellipse Margin="11,7,11,23">
                                <Ellipse.Fill>
                                    <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                                        <GradientStop Color="#ffffffff" Offset="0.00"/>
                                        <GradientStop x:Name="centralGradientStop" 
                                                 Color="#ff008D00" Offset="1.00"/>
                                    </LinearGradientBrush>
                                </Ellipse.Fill>
                            </Ellipse>
                            <Ellipse Margin="9,19,9,7">
                                <Ellipse.Fill>
                                    <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                                        <GradientStop x:Name="centralGradientStop21" 
                                              Color="#ff008D00" Offset="0.00"/>
                                        <GradientStop x:Name="centralGradientStop22" 
                                              Color="#ff008D00" Offset="0.50"/>
                                        <GradientStop x:Name="centralGradientStop23" 
                                              Color="#ff004000" Offset="1.00"/>
                                    </LinearGradientBrush>
                                </Ellipse.Fill>
                            </Ellipse>
                        </Grid>
                        <ContentPresenter Grid.Row="0" Margin="0,4,0,0" 
                           x:Name="contentPresenter" 
                           Content="{TemplateBinding ImageContent}" 
                           HorizontalAlignment="Center" 
                           VerticalAlignment="Center" Opacity="1.00"/>
                        <TextBlock Grid.Row="1" Foreground="White" 
                           HorizontalAlignment="Center" VerticalAlignment="Top" 
                           TextAlignment="Center" Canvas.Top="32" \
                           Text="{TemplateBinding Text}"/>
                    </Grid>
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>
图 11:generic.xaml 模板文件中 GreenButton 控件模板的实现

请注意上面的 XAML,模板中有一个 ContentPresenter 指向 ImageContent 模板绑定。这就是我们的自定义控件如何“绑定”到我们在每个控件实例中使用的图像。

现在,来看看我们在 ExamView 中声明 GreenButton 实例的示例。

<lib:GreenButton x:Name="btnStart" Grid.Column="0" 
        Width="64" Height="64" HorizontalAlignment="Center" Text="Start" 
        sc:ButtonService.Command="{Binding Path=StartCommand}" 
        IsEnabled="{Binding Path=StartCanExecute}" Click="btnStart_Click">
    <lib:GreenButton.ImageContent>
        <Image x:Name="imgStart" Source="/Images/start.png" 
                Width="24" Height="24" Stretch="Fill"/>
    </lib:GreenButton.ImageContent>
</lib:GreenButton>
图 12:GreenButton 在视图中声明的示例

时钟/计时器控件

我们的考生将有有限的时间来完成测试。这就是为什么我们有一个计时器控件。

计时器控件实际上是一组元素,位于一个 Grid 控件内。正如你可以在 **图 13** 中看到的,该控件由一个带有两只指针(分钟和秒)的圆形白色背景组成。

Clock

图 13:时钟控件

时钟指针的移动是通过两个独立的动画实现的,从角度“-90”(12 点)到“270”(6 点)。如下所示,时钟指针是两个细长的矩形,围绕其中一个边缘旋转,每个指针的速度不同。你应该已经明白,分钟指针需要 1 小时完成旋转,而秒针需要 1 分钟。

<Grid x:Name="grdClock" Grid.Column="6" Width="64" Height="64">
    <Grid.RowDefinitions>
        <RowDefinition Height="32"/>
        <RowDefinition Height="24"/>
    </Grid.RowDefinitions>
    <Ellipse Grid.Row="0" Width="32" Height="32" Stroke="DarkGray" 
       StrokeThickness="3" Fill="White" HorizontalAlignment="Center"/>
    <Rectangle Grid.Row="0" Width="10" Height="1" 
          Margin="12,0,0,0" Stroke="Black" StrokeThickness="1">
        <Rectangle.RenderTransform>
            <RotateTransform x:Name="rotMinutes" 
                     Angle="-90" CenterX="0" CenterY="0">
            </RotateTransform>
        </Rectangle.RenderTransform>
        <Rectangle.Triggers>
            <EventTrigger RoutedEvent="Rectangle.Loaded">
                <BeginStoryboard>
                    <Storyboard>
                        <DoubleAnimation x:Name="animMinutes" 
                            Storyboard.TargetName="rotMinutes" 
                            Storyboard.TargetProperty="Angle" From="-90" 
                            To="270" Duration="0:0:0" BeginTime="18:0:0" 
                            RepeatBehavior="Forever"/>
                    </Storyboard>
                </BeginStoryboard>
            </EventTrigger>
        </Rectangle.Triggers>
    </Rectangle>
    <Rectangle Grid.Row="0" Width="10" Height="1" 
            Margin="12,0,0,0" Stroke="Red" StrokeThickness="1">
        <Rectangle.RenderTransform>
            <RotateTransform x:Name="rotSeconds" 
                    Angle="-90" CenterX="0" CenterY="0">
            </RotateTransform>
        </Rectangle.RenderTransform>
        <Rectangle.Triggers>
            <EventTrigger RoutedEvent="Rectangle.Loaded">
                <BeginStoryboard>
                    <Storyboard>
                        <DoubleAnimation x:Name="animSeconds" 
                             Storyboard.TargetName="rotSeconds" 
                             Storyboard.TargetProperty="Angle" From="-90" 
                             To="270" Duration="0:0:0" BeginTime="18:0:0" 
                             RepeatBehavior="Forever"/>
                    </Storyboard>
                </BeginStoryboard>
            </EventTrigger>
        </Rectangle.Triggers>
    </Rectangle>
    <TextBlock Grid.Row="1" Foreground="White" 
      HorizontalAlignment="Center" Text="{Binding Path=Time}"/>
</Grid>
图 14:时钟控件的实现

请注意,在时钟元素正下方,我们有指示剩余时间的数字。

<TextBlock Grid.Row="1" Foreground="White" 
           HorizontalAlignment="Center" Text="{Binding Path=Time}"/>

还有一件事:考试计时器在用户点击“开始”按钮之前无法启动。**图 15** 显示了我们在测试开始时如何设置时钟指针的角度。

void examViewModel_TimerStarted(object sender, EventArgs e)
{
    rotMinutes.Angle = -90;
    animMinutes.BeginTime = new TimeSpan(0, 0, 0);
    animMinutes.From = -90;
    animMinutes.To = 270;
    animMinutes.Duration = new TimeSpan(1, 0, 0);
    rotSeconds.Angle = -90;
    animSeconds.BeginTime = new TimeSpan(0, 0, 0);
    animSeconds.From = -90;
    animSeconds.To = 270;
    animSeconds.Duration = new TimeSpan(0, 1, 0);
}
图 15:在 ExamView.xaml.cs 代码隐藏文件中启动时钟的方法

启用 Silverlight 的 WCF 服务

YouGrade 应用程序中最重要的几点之一是数据访问。我们必须能够执行一系列数据访问操作,例如验证用户身份、获取考试定义以及将考试结果保存到数据库。

正如你们中的许多人所知,Silverlight 应用程序无法访问本地资源,也只能有限地访问网络资源。最简单的方法是通过 WCF 服务(其他方法是 HTTP 通信和套接字通信)。幸运的是,Visual Studio 2008 有一个名为 **Silverlight-Enabled WCF Service** 的模板,正如其名称所示,它通过自动添加引用和修改 web.config 文件等方式,简化了 Silverlight 应用程序对 WCF 服务的消耗。

验证用户

用户通过输入登录名和密码并与数据库中的用户进行比较来进行验证。

[OperationContract]
public User GetUser(string login, string password)
{
    using (YouGradeEntities1 db = new YouGradeEntities1())
    {
        var query = db.User
            .Where(e => e.Login.Equals(login, 
                   StringComparison.InvariantCultureIgnoreCase))
            .Where(e => e.Password.Equals(password, 
                   StringComparison.InvariantCultureIgnoreCase)
            );

        if (query.Any())
        {
            return query.AsQueryable().First();
        }
        else
        {
            return null;
        }
    }
}
图 16:验证用户

获取考试定义

在验证用户之后,我们必须从数据库获取考试定义。请注意,我们正在使用 Entity Framework 进行数据访问。请注意,Include("QuestionDef.Alternative") 方法告诉 Entity Framework 不仅返回考试定义,还返回与考试定义关联的问题和选项。

[OperationContract]
public ExamDef GetExamDef()
{
    using (YouGradeEntities1 ctx = new YouGradeEntities1())
    {
        return ctx.ExamDef.Include("QuestionDef.Alternative").First();
    }
}
图 17:获取考试定义

将结果保存到数据库

将结果保存到数据库稍微复杂一些。请注意,在下面的示例中,我使用了 ExamTakeTO 作为参数,而不是 Exam 实体。ExamTakeTO 是一个 DTO(数据传输对象),也就是说,一个 POCO(纯旧 CLR 对象),它保存“考试进行”的数据,即包含用户信息以及用户为问题选择的答案的数据。

[OperationContract]
public double SaveExamTake(ExamTakeTO examTakeTO)
{
    double grade = 0;
    try
    {
        using (YouGradeEntities1 ctx = new YouGradeEntities1())
        {
            User user = ctx.User.Where(e => e.Id == examTakeTO.UserId).First();
            ExamDef examDef = ctx.ExamDef.Where(e => e.Id == examTakeTO.ExamId).First();

            service.ExamTake newExamTake = service.ExamTake.CreateExamTake
                (
                    0,
                    examTakeTO.StartDateTime,
                    examTakeTO.Duration,
                    examTakeTO.Grade,
                    examTakeTO.Status.ToString()
                );

            newExamTake.User = user;
            newExamTake.ExamDef = examDef;

            ctx.AddToExamTake(newExamTake);

            ctx.SaveChanges();

            foreach (AnswerTO a in examTakeTO.Answers)
            {
                ExamTake examTake = ctx.ExamTake.Where(e => e.Id == newExamTake.Id).First();
                Alternative alternative = ctx.Alternative
                .Where(e => e.QuestionId == 
                       a.QuestionId).Where(e => 
                       e.Id == a.AlternativeId).First();
                Answer newAnswer = Answer
                .CreateAnswer(newExamTake.Id, a.QuestionId, a.AlternativeId, a.IsChecked);
                newAnswer.ExamTake = examTake;
                newAnswer.Alternative = alternative;
                ctx.AddToAnswer(newAnswer);
            }

            ctx.SaveChanges();

            foreach (QuestionDef q in ctx.QuestionDef)
            {
                var query = from qd in ctx.QuestionDef
                            join a in ctx.Answer on qd.Id equals a.QuestionId
                            join alt in ctx.Alternative on 
                            new { qId = a.QuestionId, aId = a.AlternativeId } 
                            equals new { qId = alt.QuestionId, aId = alt.Id }
                            where qd.Id == q.Id
                            where a.ExamTakeId == newExamTake.Id
                            select new { alt.Correct, a.IsChecked };

                bool correct = true;
                foreach (var v in query)
                {
                    if (v.Correct != v.IsChecked)
                    {
                        correct = false;
                        break;
                    }
                }
                grade += correct ? 1 : 0;
            }

            int examTakeId = examTakeTO.Id;
        }

        using (YouGradeEntities1 ctx = new YouGradeEntities1())
        {
            ExamTake et = ctx.ExamTake.First();
            string s = et.Status;
        }
        
        return grade;
    }
    catch (Exception exc)
    {
        string s = exc.ToString();
        throw;
    }
}
图 18:将结果保存到数据库

显示结果报告

在用户完成测试后,就该显示报告了。

用户将根据他们得分是否高于或低于测试所需的最低分数(即,Exam Definition 实体的 MinimumOfCorrectAnswers 属性)来决定是通过还是失败。

正如我们在下面的 **图 19** 中看到的,用户 John Doe 考试不及格,因为他只得了 4 分,而最低要求是 10 分。

Failing the Test

图 19:考试不及格

然后可怜的 John Doe 更加努力地学习,并在另一次场合通过了考试,得了 13 分。

Passing the Test

图 20:考试通过

实体数据模型

下面的实体数据模型(edmx 文件)与我们的数据库模型完全匹配。下面是对每个实体的简要说明。

实体 描述
用户 代表正在申请测试的用户/学生。
ExamDef 代表考试定义,包含名称、定义、最低正确答案数和持续时间。
QuestionDef 代表考试问题(以及 YouTube 视频 ID)。
替代方案 代表问题选项。
ExamTake 代表每个用户/学生考试申请的数据。
答案 代表用户/学生为每个问题的选项所做的选择的答案。

Entity Data Model

图 21:实体数据模型

愿望清单和已知问题

应用程序仍存在一些问题,以及一些期望的改进,我希望我能尽快纠正。

  • 有一个考试定义编辑器会很棒。我计划用 WPF 做一个,并在另一篇文章中发布。
  • 在数据库模型中,用户与考试定义没有任何关联。最好有一个日程安排,以便你可以为不同的用户分配不同的考试,控制考试可用性、有效期等。
  • 有时应用程序在从一个问题切换到另一个问题时,无法正确停止 YouTube 音频播放。
  • 我在从 Silvelight `SaveExamTake` 方法传递 ExamDef 实体时遇到了一些问题,这就是为什么我使用了传输对象来传递数据。虽然这不是一个严重的问题,但如果我在这些通信中只使用 Entity Framework 实体,那会更好、更清晰。

最终思考

我希望你喜欢这个小应用程序和这篇文章。请给我反馈!请告诉我你的想法,并随时写下你的抱怨、建议和忠告。

历史

  • 2010-05-18:第一个版本。
  • 2010-05-28:解释了登录视图。
  • 2010-06-03:解释了考试视图。
  • 2010-06-05:添加了 YouTube 视频。
© . All rights reserved.