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

MoneyPit

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (23投票s)

2014年1月9日

CPOL

22分钟阅读

viewsIcon

38428

downloadIcon

623

Windows Phone 的燃油记录应用程序。

引言

MoneyPit 是作为一个学习练习而开始的。它没有什么惊天动地之处,甚至不是以前没有做过的事情。然而,它给了我一个机会,让我能够使用 Visual Studio 的强大功能在移动平台上工作。它还给了我一个(最终)学习 XAML 和 MVVM 的机会。我一直是 Android 用户,事实上,我也尝试过为这个平台开发一些应用程序。出于某种原因,这从未超出一些小的尝试,实际创建一些我自己可以使用的东西。自从我转用 Windows Phone 平台以来,我决定将我的应用程序开发目标定在此平台。

什么是 MoneyPit?

MoneyPit 是一个简单的燃油日志应用程序。它旨在帮助您跟踪汽车的燃油费用。它将允许您输入一辆或多辆汽车,然后通过在加油时简单地输入加满信息来跟踪汽车的加油情况。

背景

直到我买了我的诺基亚 Lumia 920,我才真正对从头到尾创建一个东西感兴趣。微软提供了出色的工具,可以通过其 Windows Phone 8 SDK [^] 创建 Windows Phone 8 应用程序。它甚至包括 Visual Studio 2012 Express 版本,这是应用程序开发的一个很好的起点。MoneyPit 是使用 Visual Studio 2012/2013 Premium 开发的,但本文中包含的解决方案应该可以在 Windows Phone 8 SDK 中包含的 Visual Studio Express 版本中正常工作,尽管我还没有尝试过。

本文试图解释 MoneyPit 从概念到设计,再到编码,再到认证的开发和发布。这一路走来并非一帆风顺。

功能要求

我为该应用设定了一组简单的要求

  • 该应用程序应遵循 Windows Phone 设计指南 [^]
  • 该应用程序必须支持标准 WP8 深色和浅色主题。
  • 必须支持多辆汽车。
  • 该应用程序必须使用 MVVM 模式设计/编码。
  • 该应用程序必须支持任何货币、距离单位和体积单位(无单位转换)。
  • 必须支持导出到 SkyDrive 和从 SkyDrive 导入。
  • 必须能够存储加油的位置。
  • 必须能够为加油添加备注。
  • 该应用程序必须使用户能够快速获取汽车燃油成本和经济性的历史概览。
  • 该应用程序应尽可能保护用户免受输入错误数据的困扰。
  • 该应用程序必须支持多种语言(首先是英语和荷兰语)。
  • 该应用程序应使用 SQLite,因为 SQL CE 不支持 RT(以防应用程序将来移植)。

工具

MoneyPit 是使用我在应用程序开发过程中发现的一组工具创建的。以下列表(不分先后顺序)包含这些工具的链接。在本文中,我将解释使用这些工具的原因

应用程序设计

微软为 Windows Phone 8 开发创建了一套全面的 设计规则和指南 [^]。在尝试了 MoneyPit 的多种 UI 设计后,我选择将 UI 设计基于“带有详细信息向下钻取列表 [^]”和“应用程序选项卡 [^]”设计。

UI 的主页是数据库中汽车的简单列表。用户可以点击列表中的汽车,深入查看该汽车的详细信息。

汽车的详细信息包含一个透视表,其中包含与所选汽车相关的四个详细信息页面。第一个透视表包含汽车的加油记录。第二个透视表包含汽车燃油成本图表。第三个透视表包含汽车行驶距离和燃油经济性图表。第四个也是最后一个透视表包含有关成本、燃油经济性等的摘要信息。

详情透视表中显示的信息最多为一年的数据。应用程序栏中的菜单允许选择要显示的年份。默认情况下,显示当前年份。

应用程序栏主要用于访问应用程序中不需要日常使用的功能。例如“设置”页面、“关于”页面、导入/导出等功能都通过应用程序栏按钮访问。

为汽车添加加油记录也可以通过汽车详细信息页面上的应用程序栏按钮访问。由于这是该应用程序的主要功能,即记录汽车的加油记录,因此该功能也可以通过将汽车“固定”到开始屏幕来使用。这将在开始屏幕上创建一个磁贴,作为访问汽车“添加加油记录”页面的快捷方式。

应用程序栏

应用程序栏是 Windows Phone 应用程序的重要组成部分。然而,它有一些怪癖,这可能使其使用起来有点挑战性。

应用程序栏的一个问题是,当用户点击按钮或菜单项时,它不会获取控件的焦点。现在您可能会说这不是一个大问题,但当在允许用户输入的页面上使用应用程序栏时,它可能会成为一个问题。`TextBox` 和 `PasswordBox` 控件旨在在失去焦点时更新其绑定源。这意味着当用户正在编辑控件内容,然后点击应用程序栏按钮或菜单项时,编辑的信息不会存储在视图模型中。

为了解决这个问题,MoneyPit 使用了一个基于 `ApplicationBarIconButton` 类的类。该类将处理其基类的 `Click` 事件,以找出当前获得焦点的元素的类型。如果它是 `TextBox`、`PhoneTextBox` 或 `PasswordBox`,它将找到其绑定表达式并更新它。

/// <summary>
/// Extended ApplicationBarIconButton class which will make sure the
/// binding source is updated when either a TextBox, a PhoneTextBox or
/// a PasswordBox currently has the focus.
/// </summary>
public class ApplicationBarIconButtonEx : ApplicationBarIconButton
{
    /// <summary>
    /// Constructor. Initializes an instance of the object.
    /// </summary>
    public ApplicationBarIconButtonEx()
    {
        // When the button is clicked we evaluate the currently
        // focused element.
        Click += (s, e) =>
        {
            BindingExpression be = null;
            object focused = FocusManager.GetFocusedElement();
            if(focused == null)
            {
                return;
            }

            // When it is either a PhoneTextBox, a TextBox or a PasswordBox we
            // get it's binding expression.
            var phonetextbox = focused as PhoneTextBox;
            if(phonetextbox != null)
            {
                be = phonetextbox.GetBindingExpression(PhoneTextBox.TextProperty);
            }
            else
            {
                var passwordbox = focused as PasswordBox;
                if(passwordbox != null)
                {
                    be = passwordbox.GetBindingExpression(PasswordBox.PasswordProperty);
                }
                else 
                {
                    var textbox = focused as TextBox;
                    if(textbox != null)
                    {
                        be = textbox.GetBindingExpression(TextBox.TextProperty);
                    }
                }
            }

            // Update the binding expression if it is valid.
            if(be != null)
            {
                be.UpdateSource();
            }
        };
    }
}

使用应用程序栏并希望使用 MVVM 方法的另一个问题是,应用程序栏的按钮和菜单不允许命令绑定。AppBarUtils [^] 为此问题提供了解决方案,其中包括其他问题。它允许您在应用程序栏按钮和菜单上使用命令绑定。它的一个优点是,它还会根据绑定命令的 `CanExecute` 状态启用/禁用应用程序栏按钮或菜单项。

    <phone:PhoneApplicationPage.ApplicationBar>
        <shell:ApplicationBar IsVisible="True">
            <mpctrl:ApplicationBarIconButtonEx IconUri="/Assets/AppBar/add.png"
                                            Text="new" />
            ...
        </shell:ApplicationBar>
    </phone:PhoneApplicationPage.ApplicationBar>
    <i:Interaction.Behaviors>
        <abu:AppBarItemCommand Id="new"
                               Text="{Binding Path=LocalizedResources.CreateCarButtonText, 
                               Source={StaticResource LocalizedStrings}}"
                               IconUri="/Assets/AppBar/add.png"
                               Command="{Binding NewCarCommand}" />
         ...
    </i:Interaction.Behaviors>

您像往常一样声明您的应用程序栏按钮。有趣的部分发生在 `behaviors` 部分。在这里,我们使用 `AppBarUtils` 将命令链接到应用程序栏按钮。您可能已经注意到,`AppBarItemCommand` 的 `Id` 属性与 `ApplicationBarIconButton` 的 `Text` 属性相同。原始 `ApplicationBarIconButton` 的 `Text` 属性用作 `AppBarUtils` 查找它的键。在 `AppBarItemCommand` 中,可以通过其 `Text` 属性设置按钮的实际文本。其 `Command` 属性可用于将其绑定到视图模型中的命令。

MVVM Light

如前所述,MoneyPit 使用 MVVM Light [^] 作为 MVVM 框架。顾名思义,它是一个轻量级框架,可以更容易地实现 MVVM 架构。它提供了简单 IoC 容器、消息传递服务和设计时数据等功能。

ViewModels

MoneyPit 中的所有视图模型都派生自名为 `BaseViewModel` 的类。此类派生自 MVVM Light 类 `ViewModelBase`。`BaseViewModel` 类包含用于设置属性和处理属性相关事件的方法。

/// <summary>
/// Base view model class. All view models and database entity
/// models inherit from this class.
/// </summary>
public class BaseViewModel : ViewModelBase
{
    /// <summary>
    /// Gets or sets the dirty flag for the object. This is only used for
    /// objects that need to be persisted to the database. Since they also derive
    /// from this class this flag is put here.
    /// <remarks>This property is decorated with the SQLite [Ignore] attribute
    /// because it should never be persisted to the database.</remarks>
    /// </summary>
    [Ignore]
    public bool IsDirty { get; set; }

    /// <summary>
    /// Simple SetProperty method version which does nothing other than firing the
    /// OnPropertyChanged event.
    /// </summary>
    /// <typeparam name="T">The type of the property that must change.</typeparam>
    /// <param name="value">The new value to store.</param>
    /// <param name="propertyName">The name of the property to set 
    /// (is set automagically by [CallerMemberName])</param>
    protected void SetProperty<t>(T value, [CallerMemberName] string propertyName = null)
    {
        RaisePropertyChanged(propertyName);
    }

    /// <summary>
    /// Checks if the value of the property really did change. If it did not false
    /// is returned. If the value did change the following happens:
    /// 
    /// 1) OnPropertyChanging event is fired.
    /// 2) The value is written to the storage field.
    /// 3) OnPropertyChanged event is fired.
    /// 4) The IsDirty flag is set to true.
    /// </summary>
    /// <typeparam name="T">The type of the property that must change.</typeparam>
    /// <param name="storage">Storage in which the changed value is stored.</param>
    /// <param name="value">The new value to store.</param>
    /// <param name="propertyName">The name of the property to set 
    /// (is set automagically by [CallerMemberName])</param>
    /// <returns>True if the property changed, false if it did not.</returns>
    protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
    {
        if (object.Equals(storage, value)) return false;
        RaisePropertyChanging(propertyName);
        storage = value;
        RaisePropertyChanged(propertyName);
        IsDirty = true;
        return true;
    }
}</t>

视图模型是使用 MVVM Light `SimpleIoc` 容器创建并注入视图的。`ViewModelLocator` 类创建所有视图模型并将其注册到 `SimpleIoc` 容器中。然后,`ViewModelLocator` 类将实例公开为属性,这些属性反过来将从 `SimpleIoc` 容器返回实例。

通常,`SimpleIoc` 容器将创建一个视图模型实例,并在每次引用视图模型属性时返回相同的实例。但是,有时我们希望在每次引用视图模型属性时创建一个新实例。在这种情况下,`SimpleIoc` 提供了使用键获取实例的可能性。但是,使用键创建的实例会被 `SimpleIoc` 内部缓存。我想要的是在每次引用视图模型属性时创建一个新实例,但在创建新实例时销毁每个以前创建的实例。为此,使用键创建一个视图模型。但是,在创建之前,会检查 `SimpleIoc` 是否包含使用先前键创建的实例。如果是,则在创建新实例之前注销该实例。

    /// <summary>
    /// This class contains static references to all the view models in the
    /// application and provides an entry point for the bindings.
    /// <para>
    /// See http://www.galasoft.ch/mvvm
    /// </para>
    /// </summary>
    public class ViewModelLocator
    {
        static readonly string _editModelKey = "798829F7-0753-4450-AC72-F3D0013D048E";
        ...

        static ViewModelLocator()
        {
            ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
	    ...
	    
            SimpleIoc.Default.Register<EditCarViewModel>();
            ...
        }

	...

        /// <summary>
        /// Gets the EditCar property.
        /// </summary>
        [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance",
            "CA1822:MarkMembersAsStatic",
            Justification = "This non-static member is needed for data binding purposes.")]
        public EditCarViewModel EditCar
        {
            get
            {
                // I want a new instance each time this view model is requested but
                // I do not want the previous instance to be cached. What we do here 
                // is to keep track of the last requested instance and, when present,
                // unregister this instance before creating a new one.
                if (!String.IsNullOrEmpty(_editModelKey))
                {
                    SimpleIoc.Default.Unregister<EditCarViewModel>(_editModelKey);
                }
                return SimpleIoc.Default.GetInstance<EditCarViewModel>(_editModelKey);
            }
        }
        ...
    }

设计时数据

MVVM Light 支持设计时数据。在 `ViewModelLocator` 类构造函数中,会进行检查以确定它是否在设计器中运行。如果在设计器下运行,则注册 `DesignMoneyPitRepository` 类的一个实例,而不是 `MoneyPitRepository` 类的一个实例。`DesignMoneyPitRepository` 类负责提供设计时数据。

static ViewModelLocator()
{
    ...
	    
    if (ViewModelBase.IsInDesignModeStatic)
    {
        SimpleIoc.Default.Register<IMoneyPitRepository, Design.DesignMoneyPitRepository>();
    }
    else
    {
        SimpleIoc.Default.Register<IMoneyPitRepository, MoneyPitRepository>();
    }
    ...
}

这将导致您在 Visual Studio/Blend 设计器中看到如下内容。

Designer

这对于设计您的 UI 来说是一个非常大的帮助。

使用设计时数据时需要注意一点:在设计器下运行时,您不能在视图模型中做很多事情。在设计器下运行时,无法访问数据库或类似的东西。您可以使用 MVVM Light 的 `ViewModelBase.IsInDesignModeStatic` 属性来检查您是否在设计器下运行。

在设计器下不允许做的事情出现问题时,表现为设计器中不显示设计器数据。仅此而已。没有更多线索表明数据不显示的原因。这可能很难追踪。解决设计时数据相关问题的一个巨大帮助是运行 Blend,将 Visual Studio 调试器附加到此 Blend 实例,然后打开导致您麻烦的项目和 XAML 页面。如果运气好的话,Visual Studio 会在导致问题的代码处中断。

事件到命令

在使用 MVVM 设计应用程序时,您希望能够将 UI 控件上的任何类型的事件链接到视图模型中的命令。有些控件提供了 `Command` 属性,但这远远不够。幸运的是,MVVM Light 具有 `EventToCommand`。这是一个 `TriggerAction` 派生类,它允许将任何类型的事件映射到视图模型中的命令。

MVVM Light 的 `EventToCommand` 实现有一个问题,那就是当触发器附加的 UI 元素被禁用时,它不会调用命令。对于这种行为有很多话要说,但你不会知道,我需要它在附加的 UI 元素被禁用时也能调用命令。

因此,我创建了自己的 `EventToCommand` 版本,基于原始的 MVVM Light 源代码(开源万岁)。它添加了一个名为 `InvokeWhenSourceDisabled` 的 `DependancyProperty`,当设置为 `true` 时,它会告诉触发器在源 UI 元素被禁用时也调用命令。

信使

在 MoneyPit 中,MVVM Light `Messenger` 用于视图模型之间的通信以及从视图模型显示对话框。MVVM Light `Messenger` 是一个简单的注册和发送系统,允许在一个点注册消息类型,并在另一个点发送消息,而无需相互了解,除了消息定义的接口。这使得保持松散耦合成为可能。

// At the receiver side...
Messenger.Default.Register<BitmapImage>(this, "PictureTaken", (image) => { CarPhoto = image; });
// At the sender side...
Messenger.Default.Send<BitmapImage>(Util.ScaleImage(e.ImageStream), "PictureTaken");

如前所述,信使还用于打开对话框。我为此使用的方法并非我自己的。在寻找从视图模型显示对话框的好方法时,我偶然发现了 这篇博客 [^],它展示了一个很好的方法来做到这一点。基本上,它由一个 `DialogBehaviour` 类组成,可以在 XAML 中实例化,如下所示

<i:Interaction.Behaviors>
    <mpb:DialogBehavior Caption="{Binding LocalizedResources.MessageDialogTitle, Mode=OneWay, 
                        Source={StaticResource LocalizedStrings}}"
                        Text="{Binding LocalizedResources.OdometerFault, 
                        Source={StaticResource LocalizedStrings}}"
                        Buttons="Ok"
                        Identifier="odometerFault" />
</i:Interaction.Behaviors>

然后,在您的视图模型中,您可以简单地显示对话框,如下所示

Messenger.Default.Send(new DialogMessage(String.Empty, null), "odometerFault");

在 `DialogMessage` 中,您可以传入一个 `Action` 来处理对话框中的按钮点击,如果需要的话。

数据库

我最初使用 SQL CE 作为数据库引擎开发 MoneyPit。然而,很快我就意识到,如果我想将应用程序移植到 RT,那不是一个好的选择。Windows RT 不支持 SQL CE。幸运的是,SQLite [^] 在这两个平台上都受支持,因此我转而使用这个嵌入式数据库引擎。

SQLite for Windows Phone 可以简单地作为 Visual Studio 扩展安装。为了使用 SQLite,我还使用了 SQLite-net [^] 包装器。这是一个针对 SQLite 本机 DLL 的 .NET 包装器,它允许在托管环境中使用 SQLite,并且还支持一些 Linq。然而,这个包装器有一个问题,那就是它与官方 SQLite 版本不同步。截至本文撰写之时,该包装器支持 SQLite for Windows Phone 的 3.8.0.2 版本,而 SQLite for Windows Phone 已更新到 3.8.2 版本。

由于 SQLite-net 包装器是一个针对特定版本 SQLite 运行时编译的 C++ 项目,升级 Visual Studio SQLite 扩展将导致问题。到目前为止,我设法通过...手动编辑 *Sqlite.vcxproj* 项目文件来使 SQLite-net 包装器与最新版本的 SQLite 一起使用。这远非理想,但到目前为止,它使我能够使用最新版本的 SQLite。

要“更新”*Sqlite.vcxproj*,您应该更改项目文件 `ImportGroup` 部分中的 SQLite 版本号。例如,如果您想将版本从 3.8.0.2 更新到 3.8.2,您需要进行以下更改

<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
    <Import Project="$(MSBuildProgramFiles32)\Microsoft SDKs\Windows Phone\v8.0\ExtensionSDKs\
     SQLite.WP80\3.8.0.2\DesignTime\CommonConfiguration\Neutral\SQLite.WP80.props" />
</ImportGroup>

应改为

<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
    <Import Project="$(MSBuildProgramFiles32)\Microsoft SDKs\Windows Phone\v8.0\ExtensionSDKs\
    SQLite.WP80\3.8.2\DesignTime\CommonConfiguration\Neutral\SQLite.WP80.props" />
</ImportGroup>

简单地将所有出现的“`3.8.0.2`”替换为“`3.8.2`”就足够了。当然,如果 SQLite 的原生 API 发生变化,这种方法就会“失效”,但这不太可能发生。

SQLite-net 包装器的另一个问题是它不支持外键和级联删除,使用“普通”的 `CreateTable` 方法创建表。由于我的数据模型需要外键和级联删除,因此表是使用 `Connection` 类的 `Execute` 方法创建的。使用 `Execute` 方法,您可以运行标准的 SQL 语句来创建表,从而可以更好地控制创建的数据模型。

Connection.Execute("CREATE TABLE IF NOT EXISTS [SchemaVersion] ( [Version] INT );");
Connection.Execute("INSERT INTO [SchemaVersion] ([Version]) VALUES(?);", 1);

Connection.Execute("CREATE TABLE IF NOT EXISTS [Car] ( " +
                    "[Id] INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " +
                    "[Make] NTEXT(50) NOT NULL, " +
                    "[Model] NTEXT(50) NOT NULL, " +
                    "[Picture] IMAGE, " +
                    "[LicensePlate] NTEXT(15) NOT NULL, " +
                    "[BuildYear] INT, " +
                    "[Currency] NTEXT(3) NOT NULL, " +
                    "[VolumeUnit] SMALLINT NOT NULL DEFAULT 1, " + 
                    "[DistanceUnit] SMALLINT NOT NULL DEFAULT 1);");

Connection.Execute("CREATE TABLE IF NOT EXISTS [Fillup] ( " +
                    "[Id] INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " +
                    "[CarId] INTEGER NOT NULL CONSTRAINT [FillupCar] 
                     REFERENCES [Car]([Id]) ON DELETE CASCADE, " +
                    "[Date] DATETIME NOT NULL, " +
                    "[Odometer] DOUBLE NOT NULL, " +
                    "[Volume] DOUBLE NOT NULL, " +
                    "[Price] DOUBLE NOT NULL, " +
                    "[FullTank] BOOL NOT NULL, " +
                    "[Note] NTEXT, " +
                    "[Longitude] DOUBLE, " +
                    "[Latitude] DOUBLE);");

Connection.Execute("CREATE TABLE IF NOT EXISTS [CrashLog] ( " +
                   "[Id] INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " +
                   "[Message] NTEXT NOT NULL, " +
                   "[Stacktrace] NTEXT NOT NULL, " +
                   "[Stamp] DATETIME NOT NULL);");

如您所见,MoneyPit 的数据模型非常简单。有一个 `Car` 表用于存储汽车信息。有一个 `Fillup` 表用于存储加油信息,它对外键约束 `Car` 表。它还定义了一个级联删除,因此链接到汽车的加油记录在删除汽车时会自动删除。`SchemaVersion` 表将在需要更新现有模型时使用。最后是 `CrashLog` 表。在该表中,存储了未处理的异常。如果发生这种情况,并且用户重新启动应用程序,他/她将获得将异常通过电子邮件发送给开发人员的选项。希望这个功能不经常需要...

用户界面

Windows Phone 8 提供了广泛的标准控件,但要真正创造丰富的用户体验,还需要更多。因此,微软创建了 Windows Phone Toolkit [^],它提供了大量的全新控件和效果,使得创建良好的用户体验变得更加容易。MoneyPit 也使用 Windows Phone Toolkit 来增强应用程序的用户界面。

页面过渡

MoneyPit 使用 `SlideTransition` 进行页面导航。当导航到某个页面时,它会从右向左滑入屏幕,而您导航离开的页面则从右向左滑出屏幕。创建这些过渡非常简单。我只是将以下代码添加到 *App.xaml* 中

<Style x:Key="TransitionPageStyle"
       TargetType="phone:PhoneApplicationPage">
    <Setter Property="toolkit:TransitionService.NavigationInTransition">
        <Setter.Value>
            <toolkit:NavigationInTransition>
                <toolkit:NavigationInTransition.Backward>
                    <toolkit:SlideTransition Mode="SlideRightFadeIn" />
                </toolkit:NavigationInTransition.Backward>
                <toolkit:NavigationInTransition.Forward>
                    <toolkit:SlideTransition Mode="SlideLeftFadeIn" />
                </toolkit:NavigationInTransition.Forward>
            </toolkit:NavigationInTransition>
        </Setter.Value>
    </Setter>
    <Setter Property="toolkit:TransitionService.NavigationOutTransition">
        <Setter.Value>
            <toolkit:NavigationOutTransition>
                <toolkit:NavigationOutTransition.Backward>
                    <toolkit:SlideTransition Mode="SlideRightFadeOut" />
                </toolkit:NavigationOutTransition.Backward>
                <toolkit:NavigationOutTransition.Forward>
                    <toolkit:SlideTransition Mode="SlideLeftFadeOut" />
                </toolkit:NavigationOutTransition.Forward>
            </toolkit:NavigationOutTransition>
        </Setter.Value>
    </Setter>
</Style>

之后,只需在 XAML 页面的初始化中添加以下代码即可

<phone:PhoneApplicationPage ...
                            Style="{StaticResource TransitionPageStyle}"
                            toolkit:TiltEffect.IsTiltEnabled="True">

`toolkit:TiltEffect.IsTiltEnabled="True"` 是为了启用当用户点击 `ListPicker` 或 `Button` 等控件时,它们会产生倾斜效果。这是 Windows Phone Toolkit 的另一个不错的功能。

Charts

不幸的是,无论是默认的 Windows Phone 8 控件还是 Windows Phone Toolkit 都不包含图表控件。因为我确实需要在应用程序中拥有图表功能,所以我寻找了一个免费的解决方案。选择不多(我找到了 amCharts [^] 和 Sparrow Toolkit [^])。我选择使用 Sparrow Toolkit,因为它在不费吹灰之力的情况下就给了我快速的结果。我必须承认,我从未真正尝试过 amCharts,因为 Sparrow Toolkit 满足了我所有需求,而且我首先尝试了它。

然而,在使用 Sparrow Toolkit 时,我确实遇到了一个非常烦人的“功能”。在应用程序中,我创建了 `CategoryPoint` 数据列表。`CategoryPoint` 对象是一个简单的结构,定义了一个值和该值所属的类别。你可能会觉得这没什么惊人的,如果不是因为 `CategoryPoint` 类依赖于 `SparrowChart` 类中静态字段的正确设置的话。这些字段是在第一次创建图表时设置的。这不是一个很好的设计。问题是,我在创建任何图表之前就创建了 `CategoryPoint` 对象列表,从而导致了异常。我通过以下方式解决了这个问题

class MoneyPitRepository
{
    private SparrowChart _stupidWorkAround;
    
    ...
    
    /// <summary>
    /// Converts a List of MonthlyChartData into a List of CategoryPoint objects. 
    /// Months for which there is not data in the chart data will have a value of 0.
    /// The result will always have 12 CategoryPoint objects regardless whether or not the
    /// MonthlyChartData has values for a specific month or not.
    /// </summary>
    /// <param name="data">The monthly data to convert </param>
    /// <returns>A list of 12 CategoryPoint objects. The Category field is represented by
    /// the abbreviated month name. The Value field contains the summarized values from the
    /// MonthlyChartData input.</returns>
    private List<CategoryPoint> GetFullYearAsMonthCategory(List<MonthlyChartData> data)
    {
        // This sucks...
        // The SparrowChart CategoryPoint object is dependent on some static fields
        // in SparrowChart being setup properly. This setup is done in the SparrowChart
        // constructor. Since this code can be called before any SparrowChart control has
        // been created we need to instantiate a dummy for the fields to be setup properly.
        if (_stupidWorkAround == null)
        {
            DispatcherHelper.CheckBeginInvokeOnUI(() =>
            {
                _stupidWorkAround = new SparrowChart();
            });
        }

        var yearData = new List<CategoryPoint>();
        for (int i = 1; i <= 12; i++)
        {
            var dataPoint = new CategoryPoint 
            { 
                Value = data.Where(m => m.Month == i).Select(m => m.Value).SingleOrDefault(),
                Category = CultureInfo.CurrentUICulture.DateTimeFormat.GetAbbreviatedMonthName(i)
            };
            if (Double.IsInfinity(dataPoint.Value))
            {
                dataPoint.Value = 0.0;
            }
            yearData.Add(dataPoint);
        }
        return yearData;
    }
}

这不会赢得任何奖项,但它确实解决了我的问题。也许有一天,我​​会重新审视这个问题并提出一个更好的解决方案,但目前,我​​会保持原样。

地图控件

MoneyPit 允许最终用户使用位置服务记录加油发生的位置。当手机位置服务激活并且用户同意 MoneyPit 使用位置服务时,会记录新加油的位置。当记录了位置时,编辑加油页面将获得一个额外的枢轴项目,其中包含一个 `Map` 控件,该控件显示带有 `Pin` 的位置。

现在,编辑加油页面的视图模型包含两个 `GeoCoordinate` 属性,它们最初将被设置为相同的位置。一个位置用于 `Map` 的中心坐标,一个用于 `Pin` 的位置。通常,您会说它们是相同的位置,所以只使用一个属性,并从 `Map` 和 `Pin` `OneWay` 绑定到它,然后就完成了。不幸的是,这不起作用。您必须将 `Map` 控件的 `Center` 属性 `TwoWay` 绑定,否则绑定将不起作用。这意味着当用户滚动地图时,视图模型中的 `CenterPosition` 属性也会更新。毕竟,必须使用 `TwoWay` 绑定。现在,如果此属性同时用于 `Map` 中心位置和 `Pin` 位置,则意味着当用户滚动地图时,图钉也会移动。我们不希望这样,这就是为什么我们有一个用于 `Map` 的 `CenterPosition` 属性和一个用于 Pin 的 `PinPosition` 属性。

应用程序图标

MoneyPit 最初使用的是 Windows Phone SDK 提供的标准应用程序图标。很快就发现这还不够,于是我开始寻找可以用于应用程序的图标。很快,我找到了 Modern UI Icons [^] 网站,您可以从那里下载一个图标包,这对于任何类型的应用程序来说都应该绰绰有余。它甚至包括 XAML 路径和 Blend 设计器文件。这是一项非常全面的工作。

只需阅读许可证(它包含在本文源代码下载的 *Assets* 文件夹中),即可了解如何使用这些图标。

SkyDrive

MoneyPit 可以将信息导出到您的 SkyDrive 帐户并再次导入该信息。微软创建了 Live SDK [^],它允许(除其他外)与 SkyDrive 通信。在编写 SkyDrive 代码时,我发现当应用程序在调用 SkyDrive 正在运行时被停用时,有时(并非总是)在应用程序恢复时会抛出 `TaskCanceledException`。为了使行为一致,我创建了一个解决方案,它将在应用程序停用之前取消任何正在运行的 SkyDrive 任务。这样,我可以确保在应用程序恢复时,我会收到 `TaskCanceledException`。

我通过在每次执行 SkyDrive 函数时创建一个 `CancellationTokenSource` 来实现这一点。这个 `CancellationTokenSource` 被传递给用于 SkyDrive 函数的所有异步 Live SDK 方法。`CancellationTokenSource` 在使用时也存储在列表中。

public static class SkyDrive
{
    ...
    
    /// <summary>
    /// Creates a new CancellationTokenSource and adds it to the internal list.
    /// The tokens are used to cancel outgoing SkyDrive requests when the application
    /// is deactivated or tombstoned.
    /// </summary>
    /// <returns>The newly created CancellationTokeSource.</returns>
    private static CancellationTokenSource GetCancellationTokenSource()
    {
        var result = new CancellationTokenSource();
        lock (_tokenLock)
        {
            _tokens.Add(result);
        }
        return result;
    }

    /// <summary>
    /// Get a list of files from the MoneyPit folder on SkyDrive.
    /// </summary>
    /// <returns>A list of files which were found in the MoneyPit folder
    /// on SkyDrive.</returns>
    public static async Task<SkyDriveData> GetFilesInMoneyPitFolder()
    {
        var result = new SkyDriveData { Result = SkyDriveResult.NotConnected, Data = null };
        var cts = GetCancellationTokenSource();
        if (cts != null)
        {
            try
            {
                result.Data = await GetFilesFromFolder(cts.Token);
                result.Result = SkyDriveResult.ImportOk;
            }
            catch (LiveConnectException)
            {
                result.Result = SkyDriveResult.LiveConnectError;
            }
            catch (TaskCanceledException)
            {
                result.Result = SkyDriveResult.TaskCanceledError;
            }
            finally
            {
                ClearCancellationTokenSource(cts);
            }
        }
        return result;
    }

    /// <summary>
    /// Cancels all running tasks by canceling all the active
    /// CancellationTokenSource objects.
    /// </summary>
    public static void CancelAllRunningTasks()
    {
        lock (_tokenLock)
        {
            foreach (var cts in _tokens)
            {
                cts.Cancel();
            }
            _tokens.Clear();
        }
    }
    
    ...
}

当应用程序在某个 SkyDrive 函数仍在运行时被停用时,与该 SkyDrive 函数关联的 `CancellationTokenSource` 会在应用程序停用前取消。这样,我就可以确保在应用程序恢复后收到 `TaskCanceledException`,并可以相应地采取行动。

public partial class App : Application
{
    ...
    
    // Code to execute when the application is deactivated (sent to background)
    // This code will not execute when the application is closing
    private void Application_Deactivated(object sender, DeactivatedEventArgs e)
    {
        // Cancel any running SkyDrive tasks.
        SkyDrive.CancelAllRunningTasks();
        IsolatedStorageSettings.ApplicationSettings.Save();
    }
    
    ...
}

Visual Studio 项目

我已在解决方案中启用了 NuGet 包恢复,这应该在您首次构建解决方案时为您下载必要的包。但是,您可能需要在解决方案编译之前安装 SQLite 扩展。另外,请确保您对 *SQLite.vcxproj* 项目文件进行了必要的更改,以反映您正在使用的 SQLite 版本。本文中包含的解决方案使用 SQLite 3.8.2 版本。

该应用程序使用 SkyDrive 和地图。这意味着它使用 Live Connect 客户端 ID 以及地图应用程序 ID 和身份验证令牌。这些位于 Helpers 源代码文件夹中的 `Tokens` 类中。发布的源代码不包含 MoneyPit 使用的应用程序 ID 和令牌。您需要自己获取这些才能使功能在您的手机上正常工作。

要将应用部署到手机,它需要 开发者解锁 [^]。它不会部署到锁定的手机。

如果你要部署到手机,请记住你需要将解决方案中的两个项目都切换到 ARM 构建(有基于 x86 的手机吗?)。如果你要部署到模拟器,请使用 x86 构建。

我将尽力保持本文的更新,但本文可能与商店中发布的版本不同步。根据更新本文或应用程序发布所需的时间,CodeProject 文章可能包含比用于构建商店中最新发布的应用程序更新或更旧的源代码。

应用认证

关于这一点,真的没什么好说的。MoneyPit 第一次就通过了认证过程。最重要的是通读 应用认证要求 [^],并确保您遵守适用于您应用程序的那些要求。列表很长,但通读一遍可以确保您的应用程序不会因为您错过了其中一个要求而导致认证失败。

MoneyPit 的认证过程耗时四天。我不知道这是否是正常的时间,但我可以想象这可能需要更长的时间,具体取决于认证队列中有多少应用程序以及应用程序的复杂程度。应用程序中的功能越多,实际检查其是否符合所有要求所需的工作量就越大。

参考文献

MoneyPit 并非凭空创造。我使用了网络上许多许多资源来创建它,所以列出所有这些资源几乎是不可能的。但是,下面列出了最重要的资源

  • Windows Phone 开发中心 [^]
    我想这是任何 Windows Phone 开发的起点。特别是,Jumpstart 视频提供了大量信息来帮助您入门。
  • Live Connect 开发中心 [^]
    Live Connect 资源的网站。MoneyPit 的 SkyDrive 功能是使用 Live SDK 创建的,可以从该网站下载。
  • DialogBehaviour [^]
    我从这里得到了行为驱动对话框的想法。
  • NumericInputBox [^]
    MoneyPit 中使用的不区分文化的数字输入框。
  • 导航和 MVVM [^]
    一篇解释导航服务思想的博客文章,用于从视图模型控制页面导航。MoneyPit 导航服务就是围绕这个想法构建的。

结论

正如我在本文开头提到的,我将这个项目作为学习练习。我这样做是为了进入移动开发,但更重要的是(甚至可能更多)为了进入 XAML、MVVM 和最新的 .NET 框架。我知道 MVVM 和 XAML 已经存在很长一段时间了,但我以前从未有机会真正做些什么。

我相信对于经验丰富的 Windows Phone/.NET 开发人员来说,有很多事情本来可以做得更好/更容易/不同。对于这些人,我想说“教教我”。请让我知道什么可以做得更好或不同。让我知道我做错了什么,以及为什么我做错了。

历史

版本 1.3

  • Bug 修复:数据库恢复并非总是有效
  • 一些外观上的更改
  • 从源代码下载中删除了屏幕截图。它们一开始就不应该在那里。我深表歉意。
  • 添加了汽车总历史的摘要概述

版本 1.2

  • 对资源进行了拼写检查
  • 添加了数据库备份和恢复(在 SkyDrive 上)
  • 在加油列表中添加了位置和备注标记
  • 修复了一些设计时数据问题
  • 稍微增强了 UI

版本 1.1

  • 修复了一些 bug

版本 1.0

  • 首次发布
© . All rights reserved.