Windows Phone

幕后: Windows Phone 源阅读器应用程序

Matt Stroshane

下载代码示例

我是一个源痴迷者。我酷爱 RSS 和 Atom 源提供的奇妙功能,我喜欢以这种形式提供的新闻,而不是其他方式。虽然可以方便地访问如此多的信息,但以一种有意义的方式使用这些信息却是一个难题。因此,当我了解到一些 Microsoft 实习生正在开发 Windows Phone 源阅读器应用程序,我就迫不及待地想知道他们是如何解决这一问题的。

作为其实习工作的一部分,Francisco Aguilera、Suman Malani 和 Ayomikun (George) Okeowo 用 12 周的时间开发出一个 Windows Phone 应用程序,其中包含一些新的 Windows Phone SDK 7.1 功能。作为头一次进行 Windows Phone 开发的新手,他们成为检验我们的平台、工具和文档的理想人选。

在经过仔细考虑后,他们决定开发源阅读器应用程序以说明本地数据库、动态磁贴和后台代理。他们说明的并不只是这些功能!在本文中,我将介绍他们是如何使用这些功能的。因此,请安装 Windows Phone SDK 7.1,下载代码并在您的屏幕上运行该代码。让我们开始吧!

使用应用程序

该应用程序的中枢是主页 MainPage.xaml(图 1)。它包括四个全景面板: “what’s new”(新增内容)、“featured”(特色文章)、“all”(全部)和“settings”(设置)。“what’s new”(新增内容)面板显示最新的源更新。“featured”(特色文章)显示 6 篇文章,它根据您的阅读习惯提供您喜欢的文章。“all”(全部)面板列出您的所有类别和源。要仅通过 Wi-Fi 下载这些文章,请在“settings”(设置)面板上使用该设置。

The Main Page of the App After Creating a Windows Phone News Category图 1 创建 Windows Phone 新闻类别后的应用程序主页

“what’s new”(新增内容)和“featured”(特色文章)面板提供了一种直接导航到某篇文章的方法。“all”(全部)面板提供类别和源列表。从“all”(全部)面板中,您可以导航到按源或类别分组的文章集。您还可以使用“all”(全部)面板上的应用程序栏添加新的源或类别。图 2 显示了主页如何与应用程序的其他 8 个页面相关联。

The Page Navigation Map, with Auxiliary Pages in Gray图 2 页面导航图(以灰色显示辅助页面)

您可以在 Category(类别)、Feed(源)和 Article(文章)页面上进行水平导航,这与旋转类似。当您位于其中的某个页面上时,将在应用程序栏中显示箭头(请参阅图 3)。通过使用这些箭头,您可以显示数据库中的上一个或下一个类别、源或文章的数据。例如,如果您正在查看 Category(类别)页上的 Business(商业)类别,点击“next”(下一个)箭头将显示 Category(类别)页上的 Entertainment(娱乐)类别。

The Category, Feed and Article Pages with Their Application Bars Expanded图 3 Category(类别)、Feed(源)和 Article(文章)页及其展开的应用程序栏

不过,箭头键并不会实际导航到其他 Category(类别)页面,而是将相同页面绑定到不同的数据源。点击手机的“Back”(返回)按钮将返回到“all”(全部)面板,而无需使用任何特殊导航代码。

从 Article(文章)页中,您可以导航到 Share(共享)页面,并通过消息传送、电子邮件或社交网络发送链接。应用程序栏还提供了在 Internet Explorer 中查看文章,将文章“添加到收藏夹”或从数据库中删除文章的功能。

揭秘

在 Visual Studio 中打开解决方案时,您将看到这是一个分为三个项目的 C# 应用程序:

  1. FeedCast: 用户看到的部分 - 前台应用程序(View 和 ViewModel 代码)。
  2. FeedCastAgent: 后台代理代码(定期计划任务)。
  3. FeedCastLibrary: 共享的网络和数据代码。

该团队使用了 Silverlight for Windows Phone Toolkit(2011 年 11 月)和 Microsoft Silverlight 4 SDK。在应用程序的大多数页面中使用了工具包 (Microsoft.Phone.Controls.Toolkit.dll) 中的控件。例如,HubTile 控件用于显示主页的“featured”(特色文章)面板中的文章。为了帮助联网,该团队使用了 Silverlight 4 SDK 中的 System.ServiceModel.Syndication.dll。Windows Phone SDK 中不包含此程序集,它不是专门针对手机应用程序优化的,但团队成员发现它可以很好地满足其需求。

前台应用程序项目 FeedCast 是解决方案的三个项目中的最大一个。需要重申的是,这是用户看到的应用程序部分。它分为 9 个文件夹:

  1. Converters: 填补数据和 UI 间隙的值转换器。
  2. Icons: 应用程序栏使用的图标。
  3. Images: 在文章没有图像时 HubTiles 使用的图像。
  4. Libraries: 工具包和整合程序集。
  5. Models: 后台代理不使用的数据相关代码。
  6. Resources: 英语和西班牙语的本地化资源文件。
  7. Themes: HeaderedListBox 控件自定义。
  8. ViewModels: ViewModels 和其他帮助程序类。
  9. Views: 前台应用程序中的每个页面的代码。

此应用程序遵循 Model-View-ViewModel (MVVM) 模式。Views 文件夹中的代码主要侧重于 UI。与各个页面关联的逻辑和数据是由 ViewModels 文件夹中的代码定义的。虽然 Models 文件夹包含一些数据相关代码,但数据对象是在 FeedCastLibrary 项目中定义的。前台应用程序和后台代理可重复使用其中的“模型”代码。有关 MVVM 的详细信息,请访问 wpdev.ms/mvvmpnp

FeedCastLibrary 项目包含前台应用程序和后台代理使用的数据和网络代码。此项目包含两个文件夹: Data 和 Networking。在 Data 文件夹中,FeedCast“模型”是由以下四个文件中的分部类描述的: LocalDatabaseDataContext.cs、Article.cs、Category.cs 和 Feed.cs。DataUtils.cs 文件包含用于执行常见数据库操作的代码。使用独立存储设置所需的帮助程序类位于 Settings.cs 文件中。FeedCastLibrary 项目的 Networking 文件夹包含用于从 Web 下载和分析内容的代码,其中最重要的代码是 WebTools.cs 文件中的 Download 方法。

FeedCastAgent 项目中仅包含一个类 (ScheduledAgent.cs),它是后台代理代码。在运行它时,将调用 OnInvoke 方法;而在下载完成时,将调用 SendToDatabase 方法。我稍后将详细讨论下载。

本地数据库

为了获得最大的生产率,每个团队成员将重点放在应用程序的不同方面。Aguilera 将重点放在前台应用程序中的 UI、Views 和 ViewModels 上。Okeowo 从事联网和从源获取数据方面的工作。Malani 从事数据库体系结构和操作方面的工作。

在 Windows Phone 中,您可以将数据存储在本地数据库中。存储在本地部分是因为,这是一个位于独立存储(您的应用程序在设备上的存储段,与其他应用程序隔离)中的数据库文件。实际上,您将数据库表描述为 Plain Old CLR Object,并使用这些对象的属性表示数据库列。这样,该类的每个对象将存储为相应表中的行。要表示数据库,您可以创建一个称为数据上下文的特殊对象,它是从 System.Data.Linq.DataContext 中继承的。

本地数据库的神奇之处在于 LINQ to SQL 运行时(您的数据管家)。当您调用数据上下文 CreateDatabase 方法时,LINQ to SQL 将在独立存储中创建 .sdf 文件。当您创建 LINQ 查询以指定所需的数据时,LINQ to SQL 将返回可绑定到 UI 的强类型化对象。通过使用 LINQ to SQL,在处理所有低级数据库操作时,您可以将重点放在代码上。有关使用本地数据库的详细信息,请访问 wpdev.ms/localdb。

并非键入所有类,Malani 通过 Visual Studio 2010 Ultimate 以不同的方式处理这些类。她以可视化方式创建数据库表,即,使用服务器资源管理器的“添加连接”对话框创建 SQL Server CE 数据库,然后使用“新建表”对话框生成表。

在 Malani 设计了架构后,她使用 SqlMetal.exe 生成数据上下文。SqlMetal.exe 是桌面 LINQ to SQL 中的命令行实用程序。其用途是根据 SQL Server 数据库创建数据上下文类。它生成的代码与 Windows Phone 数据上下文颇为相似。通过使用这种方法,她可以通过可视化方式生成表并快速生成数据上下文。有关 SqlMetal.exe 的详细信息,请访问 wpdev.ms/sqlmetal。

Malani 生成的数据库如图 4 中所示。三个主表分别是 Category、Feed 和 Article。此外,还可以使用链接表 Category_Feed 在类别和源之间启用多对多关系。每个类别可以与多个源相关联,而每个源也可以与多个类别相关联。请注意,应用程序的“收藏夹”功能仅仅是一个不能删除的特殊类别。

The Database Schema图 4 数据库架构

不过,SqlMetal.exe 生成的数据上下文仍包含一些 Windows Phone 上不支持的代码。在 Malani 将数据上下文代码文件添加到 Windows Phone 项目后,她编译了该项目以查找无效的代码。她回忆说必须得删除一个构造函数,但其余代码编译都没有问题。

在检查数据上下文文件 LocalDatabaseDataContext.cs 后,您可能会注意到所有表都是分部类。与这些表关联的其余代码(不是由 SqlMetal.exe 自动生成的)存储在代码文件 Article.cs、Category.cs 和 Feed.cs 中。通过以这种方式分离代码,Malani 可以对数据库架构进行更改,而不会影响她手动编写的扩展性方法定义。如果她没有这样做,每次自动生成 LocalDatabaseDataContext.cs 时,她必须得重新添加这些方法(因为 SqlMetal.exe 将覆盖文件中的所有代码)。

保持并发性

与大多数旨在提供响应速度快且非常流畅的体验的 Windows Phone 应用程序一样,此应用程序使用多个并发线程完成其工作。除了 UI 线程(接受用户输入)以外,可能还使用多个后台线程以处理 RSS 源下载和分析。其中的每个线程最终需要对数据库进行更改。

虽然数据库本身提供了可靠的并发访问,但 DataContext 类并不是线程安全的。换句话说,如果未添加某种形式的并发模型,则无法在多个线程之间共享此应用程序中使用的单个全局 DataContext 对象。为了解决该问题,Malani 使用 LINQ to SQL 并发 API 和 System.Threading 命名空间中的互斥体对象。

在 DataUtils.cs 文件中,互斥体 WaitOne 和 ReleaseMutex 方法用于在 DataContext 类之间可能发生争用时同步数据访问。例如,如果多个并发线程(来自前台应用程序或后台代理)几乎同时调用 SaveChangesToDB 方法,无论哪个代码先执行 WaitOne,它将继续执行。在第一个代码调用 ReleaseMutex 后,才会完成其他代码的 WaitOne 调用。为此,在使用 try/catch/finally 执行数据库操作时,一定要将 ReleaseMutex 调用放在 finally 语句中。如果没有 ReleaseMutex 调用,其他代码将等待 WaitOne 调用,直到拥有它的线程退出为止。从用户的角度看,这可能要“花很长时间”。

您也可以设计应用程序以便针对每个线程创建和删除较小的 DataContext 对象,而不是单一全局 DataContext 对象。不过,团队成员发现全局 DataContext 方法可以简化开发过程。我还注意到,由于应用程序只需要禁止跨线程访问,而不禁止跨进程访问,因此,他们还可以使用锁而不是互斥体。锁可以提供更好的性能。

使用数据

Okeowo 将工作重点放在将数据放入应用程序中。WebTools.cs 文件包含用于执行大多数操作的代码。但 WebTools 类并不仅用于下载源,还可以在新源页上使用它以在 Bing 上搜索新源。他通过创建通用接口 IXmlFeedParser 并将分析代码提取到不同的类来实现此目的。SynFeedParser 类分析源,SearchResultParser 类分析 Bing 搜索结果。

不过,Bing 查询并不实际返回文章(尽管 Article 对象集合是由 IXmlFeedParser 接口返回的),而是返回源名称和 URI 列表。这是怎么回事?Okeowo 意识到 Article 类已具有他描述源所需的属性;他不需要创建另一个类。在分析搜索结果时,他使用 ArticleTitle 分析源名称,并使用 ArticleBaseURI 分析源 URI。有关详细信息,请参阅附带的代码下载中的 SearchResultParser.cs。

新页面 ViewModel(示例代码中的 NewFeedPageViewModel.cs)中的代码说明了如何使用 Bing 搜索结果。首先,根据用户在 NewFeedPage 上输入的搜索词,使用 GetSearchString 方法将 Bing 搜索字符串 URI 拼接在一起,如下面的代码段中所示:

private string GetSearchString(string query)
{
  // Format the search string.
string search = "http://api.bing.com/rss.aspx?query=feed:" + query +
    "&source=web&web.count=" + _numOfResults.ToString() +
    "&web.filetype=feed&market=en-us";
  return search;
}

_numOfResults 值限制返回的搜索结果数量。 有关通过 RSS 访问 Bing 的详细信息,请参阅 MSDN 库页面“通过 RSS 访问 Bing”(bit.ly/kc5uYO)。

GetSearchString 方法是在 GetResults 方法中调用的,其中,将从 Bing 实际检索数据(请参阅图 5)。 GetResults 方法看起来有点落后,因为在实际调用用于启动下载的代码之前,它列出一个用于“内联”处理 AllDownloadsFinished 事件的 lambda 表达式。 在调用 Download 方法时,WebTools 对象将根据通过 GetSearchString 构造的 URI 查询 Bing。

图 5 NewFeedPageViewModel.cs 中的 GetResults 方法查询 Bing 以查找新源

public void GetResults(string query, Action<int> Callback)
{
  // Clear the page ViewModel.
Clear();
  // Get the search string and put it into a feed.
Feed feed = new Feed { FeedBaseURI = GetSearchString(query) };
  // Lambda expression to add results to the page
  // ViewModel after the download completes.
// _feedSearch is a WebTools object.
_feedSearch.AllDownloadsFinished += (sender, e) =>
    {
      // See if the search returned any results.
if (e.Downloads.Count > 0)
      {
        // Add the search results to the page ViewModel.
foreach (Collection<Article> result in e.Downloads.Values)
        {
          if (null != result)
          {
            Deployment.Current.Dispatcher.BeginInvoke(() =>
              {
                foreach (Article a in result)
                {
                  lock (_lockObject)
                  {
                    // Add to the page ViewModel.
Add(a);
                  }
                }
                Callback(Count);
              });
          }
        }
      }
      else
      {  
        // If no search results were returned.
Deployment.Current.Dispatcher.BeginInvoke(() =>
          {
            Callback(0);
          });
      }
    };
  // Initiate the download (a Bing search).
_feedSearch.Download(feed);
}

后台代理也会使用 WebTools Download 方法(请参阅图 6),但使用该方法的方式有所不同。 代理为方法传递由几个源组成的列表,而不是仅从一个源中下载。 为了检索结果,代理将使用不同的策略。 并非一直等到下载所有源中的文章(通过 AllDownloadsFinished 事件),在下载完每个源文章(通过 SingleDownloadFinished 事件)后,代理将会立即保存该文章。

图 6 后台代理启动下载(没有调试注释)

protected override void OnInvoke(ScheduledTask task)
{
  // Run the periodic task.
List<Feed> allFeeds = DataBaseTools.GetAllFeeds();
  _remainingDownloads = allFeeds.Count;
  if (_remainingDownloads > 0)
  {
    Deployment.Current.Dispatcher.BeginInvoke(() =>
      {
        WebTools downloader = new WebTools(new SynFeedParser());
        downloader.SingleDownloadFinished += SendToDatabase;
        try
        {
          downloader.Download(allFeeds);
        }
        // TODO handle errors.
catch { }
      });
  }
}

后台代理的工作是将所有源保持最新状态。 为此,它为 Download 方法传递由所有源组成的列表。 后台代理的运行时间很短,在时间到期后,将会立即停止该进程。 因此,在代理下载源时,它将文章发送到数据库,每次发送一个源。 这样,在后台代理停止之前,它保存新文章的概率要高得多。

单源和多源 Download 方法实际是用于相同代码的重载。 下载代码为每个源启动 HttpWebRequest(异步)。 在第一个请求返回时,它立即调用 SingleDownloadFinished 事件处理程序。 然后,它使用 SingleDownloadFinishedEventArgs 将源信息和文章打包到事件中。 正如图 7 所示,SendToDatabase 方法将绑定到 SingleDownloadFinshed 方法。 在它返回时,SendToDatabase 从事件参数中提取文章,然后将这些文章传递给名为 DataBaseTools 的 DataUtils 对象。

图 7 后台代理将文章保存到数据库中(没有调试注释)

private void SendToDatabase(object sender, 
  SingleDownloadFinishedEventArgs e)
{
  // Ensure download is not null!
if (e.DownloadedArticles != null)
  {
    DataBaseTools.AddArticles(e.DownloadedArticles, e.ParentFeed);
    _remainingDownloads--;
  }
  // If no remaining downloads, tell scheduler the background agent is done.
if (_remainingDownloads <= 0)
  {
    NotifyComplete();
  }
}

如果代理在分配的时间内完成所有下载,它将调用 NotifyComplete 方法以通知操作系统该操作已完成。 这样,操作系统就可以将这些不使用的资源分配给其他后台代理。

在接下来的代码中,DataUtils 类中的 AddArticles 方法检查以确保文章是新的,然后再将文章添加到数据库中。 在图 8 中,请注意如何再次使用互斥体以防止在数据上下文中出现争用问题。 最后,在找到新文章后,通过 SaveChangesToDB 方法将文章保存到数据库中。

图 8 通过 DataUtils.cs 文件将文章添加到数据库中

public void AddArticles(ICollection<Article> newArticles, Feed feed)
{
  dbMutex.WaitOne();
  // DateTime date = SynFeedParser.latestDate;
  int downloadedArticleCount = newArticles.Count;
  int numOfNew = 0;
  // Query local database for existing articles.
for (int i = 0; i < downloadedArticleCount; i++)
  {
    Article newArticle = newArticles.ElementAt(i);
    var d = from q in db.Article
            where q.ArticleBaseURI == newArticle.ArticleBaseURI
            select q;
    List<Article> a = d.ToList();
    // Determine if any articles are already in the database.
bool alreadyInDB = (d.ToList().Count == 0);
    if (alreadyInDB)
    {
      newArticle.Read = false;
      newArticle.Favorite = false;
      numOfNew++;
    }
    else
    {
      // If so, remove them from the list.
newArticles.Remove(newArticle);
      downloadedArticleCount--;
      i--;
    }               
  }
  // Try to submit and update counts.
try
  {
    db.Article.InsertAllOnSubmit(newArticles);
    Deployment.Current.Dispatcher.BeginInvoke(() =>
      {
        feed.UnreadCount += numOfNew;
        SaveChangesToDB();
      });
    SaveChangesToDB();
  }
  // TODO handle errors.
catch { }
  finally { dbMutex.ReleaseMutex(); }
}

前台应用程序使用类似于后台代理中的技术,将数据与 Download 方法一起使用。有关类似的代码,请参阅附带的代码下载中的 ContentLoader.cs 文件。

计划后台代理

后台代理就是指,在后台中为前台应用程序执行工作的代理。正如您之前在图 6图 7 中看到的一样,定义该工作的代码是名为 ScheduledAgent 的类。它是从 Microsoft.Phone.Scheduler.ScheduledTaskAgent 派生的(后者是从 Microsoft.Phone.BackgroundAgent 派生的)。尽管该代理由于执行繁重的工作而受到人们的广泛关注,但如果不是用于计划任务,则从不运行该代理。

计划任务是用于指定后台代理运行时间和频率的对象。此应用程序中使用的计划任务是定期任务 (Microsoft.Phone.Scheduler.PeriodicTask)。定期任务定期运行很短的时间。要实际将该任务添加到计划中以及查询该任务等,您可以使用计划操作服务 (ScheduledActionService)。有关后台代理的详细信息,请访问 wpdev.ms/bgagent。

此应用程序的计划任务代码位于前台应用程序项目的 BackgroundAgentTools.cs 文件中。该代码定义了 StartPeriodicAgent 方法,应用程序构造函数中的 App.xaml.cs 将调用该方法(请参阅图 9)。

图 9 在 BackgroundAgentTools.cs 中计划定期任务(去掉了注释)

public bool StartPeriodicAgent()
{
  periodicDownload = ScheduledActionService.Find(periodicTaskName) as PeriodicTask;
  bool wasAdded = true;
  // Agents have been disabled by the user.
if (periodicDownload != null && !periodicDownload.IsEnabled)
  {
    // Can't add the agent.
Return false!
wasAdded = false;
  }
  // If the task already exists and background agents are enabled for the
  // application, then remove the agent and add again to update the scheduler.
if (periodicDownload != null && periodicDownload.IsEnabled)
  {
    ScheduledActionService.Remove(periodicTaskName);
  }
  periodicDownload = new PeriodicTask(periodicTaskName);
  periodicDownload.Description =
    "Allows FeedCast to download new articles on a regular schedule.";
  // Scheduling the agent may not be allowed because maximum number
  // of agents has been reached or the phone is a 256MB device.
try
  {
    ScheduledActionService.Add(periodicDownload);
  }
  catch (SchedulerServiceException) { }
  return wasAdded;
}

在计划定期任务之前,StartPeriodicAgent 执行一些检查,因为您有时可能无法安排计划的任务。 首先,用户可以在应用程序的 Settings(设置)面板的后台任务列表中禁用计划任务。 每次可以在设备上启用的任务数存在一些限制。 它因每个设备配置而有所不同,但可能最低为 6 个。 如果您尝试在超出限制后安排计划的任务,应用程序在 256MB 设备上运行,或您已经计划同样的任务,Add 方法将引发异常。

此应用程序在每次启动时调用 StartPeriodicTask 方法,因为后台代理将在 14 天后过期。 通过每次启动时刷新代理,可确保即使应用程序在几天内未再次启动也会继续运行代理。

图 9 中的 periodicTaskName 变量(用于查找现有的任务)相当于“FeedCastAgent”。请注意,此名称无法识别相应的后台代理代码。 这只是一个友好名称,您可以使用该名称处理 ScheduledActionService。 前台应用程序已了解后台代理代码,因为这是作为对前台应用程序项目的引用添加的。 由于后台代理代码是作为“Windows Phone 计划任务代理”类型的项目创建的,因此,这些工具可以在添加引用时正确绑定内容。 您可以查看在前台应用程序清单(示例代码中的 WMAppManifest.xml)中指定的前台应用程序-后台代理关系,如下所示:

<Tasks>
  <DefaultTask Name="_default" 
    NavigationPage="Views/MainPage.xaml" />
  <ExtendedTask Name="BackgroundTask">
    <BackgroundServiceAgent Specifier="ScheduledTaskAgent" 
      Name="FeedCastAgent"
      Source="FeedCastAgent" Type="FeedCastAgent.ScheduledAgent"/>
  </ExtendedTask>
</Tasks>

磁贴

Aguilera 从事 UI、View 和 ViewModel 方面的工作。 他还从事本地化和磁贴功能的工作。 磁贴(有时称为动态磁贴)显示动态内容以及指向“开始”菜单中的应用程序的链接。 可以将任何应用程序的应用程序磁贴固定到“开始”菜单(开发人员无需完成任何工作)。 不过,如果要链接到应用程序主页以外的任意位置,则需要实现辅助磁贴。 通过使用这些磁贴,您可以使用户导航到应用程序中更深层次的页面(主页以外),可以针对辅助磁贴表示的任何内容自定义该页面。

在 FeedCast 中,用户可以将源或类别(辅助磁贴)固定到“开始”菜单。 只需点击一下,他们就可以立即阅读与该源或类别有关的最新文章。 要实现此体验,他们需要能够先将源或类别固定到“开始”菜单。 Aguilera 使用 Silverlight Toolkit for Windows Phone ContextMenu 来帮助实现该操作。 在主页的“all”(全部)面板中点击并按住某个源或类别将显示上下文菜单。 用户可以从中选择删除源或类别,或将其固定到“开始”菜单。 图 10 从用户角度说明了端到端过程。


图 10 将 Windows Phone 新闻类别固定到“开始”菜单并启动类别页

图 11 显示实现上下文菜单的 XAML。 第二个 MenuItem 显示“pin to start”(如果显示语言是英语)。 在点击该项时,click 事件将调用 OnCategoryPinned 方法以启动固定操作。 由于此应用程序已本地化,上下文菜单文本实际来自于资源文件。 这就是为什么将 Header 值绑定到 LocalizedResources.ContextMenuPinToStartText 的原因。

图 11 用于删除类别或将其固定到“开始菜单”的上下文菜单

<toolkit:ContextMenuService.ContextMenu>
  <toolkit:ContextMenu>
    <toolkit:MenuItem Tag="{Binding}"
      Header="{Binding LocalizedResources.ContextMenuRemoveText,
               Source={StaticResource LocalizedStrings}}"
      IsEnabled="{Binding IsRemovable}"
      Click="OnCategoryRemoved"/>
    <toolkit:MenuItem Tag="{Binding}"
      Header="{Binding LocalizedResources.ContextMenuPinToStartText,
               Source={StaticResource LocalizedStrings}}"
      IsEnabled="{Binding IsPinned, 
      Converter={StaticResource IsPinnable}}"
      Click="OnCategoryPinned"/>
  </toolkit:ContextMenu>
</toolkit:ContextMenuService.ContextMenu>

此应用程序只有两个资源文件,一个用于西班牙语,另一个用于英语(默认)。 不过,由于已提供了本地化功能,添加更多语言会相对容易一些。 图 12 显示默认资源文件 AppResources.resx。 有关详细信息,请访问 wpdev.ms/globalized

The Default Resource File, AppResources.resx, Supplies the UI Text for All Languages Except Spanish
图 12 默认资源文件 AppResources.resx 提供除西班牙语以外的所有语言的 UI 文本

最初,该团队并不十分清楚如何准确确定需要固定哪个类别或源。 随后,Aguilera 想到了 XAML Tag 属性(请参阅图 11)。 团队成员意识到,他们可以通过编程方式将其绑定到 ViewModel 中的类别或源对象,然后检索各个对象。 在主页上,类别列表绑定到 MainPageAllCategoriesViewModel 对象。 在调用 OnCategoryPinned 方法时,它使用 GetTagAs 方法获取与列表中的该特定项目对应的 Category 对象(绑定到 Tag),如下所示:

private void OnCategoryPinned(object sender, EventArgs e)
{
  Category tappedCategory = GetTagAs<Category>(sender);
  if (null != tappedCategory)
  {
    AddTile.AddLiveTile(tappedCategory);
  }
}

GetTagAs 方法是一个通用方法,可用于获取已绑定到容器的 Tag 属性的任何对象。 虽然这种方法是很有效的,但实际上大多不需要在 MainPage.xaml.cs 上使用该方法。 列表中的项目已绑定到该对象,因此,将项目绑定到 Tag 有点多余。 您可以使用 Sender 对象的 DataContext,而不是使用 Tag。 例如,图 13 显示 OnCategoryPinned 使用建议的 DataContext 方法时的显示效果。

图 13 使用 DataContext 而不是 GetTagAs 的示例

private void OnCategoryPinned(object sender, EventArgs e)
{
  Category tappedCategory = null;
  if (null != sender)
  {
    FrameworkElement element = sender as FrameworkElement;
    if (null != element)
    {
      tappedCategory = element.DataContext as Category;
      if (null != tappedCategory)
      {
        AddTile.AddLiveTile(tappedCategory);
      }
    }
  }
}

DataContext 方法非常适合 MainPage.xaml.cs 上的所有案例,但 OnHubTileTapped 方法除外。 这是在点击主页的“featured”(特色文章)面板中的特色文章时触发的。 难题是由于以下问题造成的:Sender 未绑定到 Article 类,它绑定到 MainPageFeaturedViewModel。 该 ViewModel 包含 6 篇文章,因此,无法从 DataContext 中明确知道点击的是哪篇文章。 在这种情况下,使用 Tag 属性可简化绑定到相应文章的过程。

由于您可以将源和类别固定到“开始”菜单,AddLiveTile 方法有两个重载。 对象和辅助磁贴差别很大,因此,该团队决定不将功能合并到单个通用方法中。 图 14 显示了 AddLiveTile 方法的 Category 版本。

图 14 将 Category 对象固定到“开始”菜单

public static void AddLiveTile(Category cat)
{
  // Does Tile already exist?
If so, don't try to create it again.
ShellTile tileToFind = ShellTile.ActiveTiles.FirstOrDefault(x => 
    x.NavigationUri.ToString().Contains("/Category/" + 
    cat.CategoryID.ToString()));
  // Create the Tile if doesn't already exist.
if (tileToFind == null)
  {
    // Create an image for the category if there isn't one.
if (cat.ImageURL == null || cat.ImageURL == String.Empty)
    {
      cat.ImageURL = ImageGrabber.GetDefaultImage();
    }
    // Create the Tile object and set some initial properties for the Tile.
StandardTileData newTileData = new StandardTileData
    {
      BackgroundImage = new Uri(cat.ImageURL, 
      UriKind.RelativeOrAbsolute),
      Title = cat.CategoryTitle,
      Count = 0,
      BackTitle = cat.CategoryTitle,
      BackContent = "Read the latest in " + cat.CategoryTitle + "!",
    };
    // Create the Tile and pin it to Start.
// This will cause a navigation to Start and a deactivation of the application.
ShellTile.Create(
      new Uri("/Category/" + cat.CategoryID, UriKind.Relative), 
      newTileData);
    cat.IsPinned = true;
    App.DataBaseUtility.SaveChangesToDB();
  }
}

在添加类别磁贴之前,AddLiveTile 方法使用 ShellTile 类从所有活动磁贴中查看导航 URI,以确定是否已添加了类别。 如果未添加,它将继续并获取一个图像 URL 以便与新的磁贴相关联。 每次创建新的磁贴时,背景图像需要来自本地资源。 在这种情况下,它使用 ImageGrabber 类获取随机分配的本地图像文件。 不过,在创建磁贴后,您可以使用远程 URL 更新背景图像。 但该特定应用程序不执行此操作。

在创建新的磁贴时需要指定的所有信息包含在 StandardTileData 类中。 该类用于将文本、数字和背景图像放在磁贴中。 在使用 Create 方法创建磁贴时,将传递 StandardTileData 以作为参数。 传递的另一个重要参数是磁贴导航 URI。 该 URI 用于将用户转到应用程序中的有意义的位置。

在此应用程序中,来自磁贴的导航 URI 仅将用户转到应用程序中的位置。 要转到以外的位置,需要使用一个基本 UriMapper 类将用户转到正确的页面。 App.xaml 导航元素为应用程序指定了所有 URI 映射。 在每个 UriMapping 元素中,Uri 属性指定的值为传入 URI。 MappedUri 属性指定的值是将用户导航到的位置。 为了保持特定类别、源或文章的上下文,可以将括号中的 id 值 {id} 从传入 URI 传送到映射的 URI,如下所示:

<navigation:UriMapping Uri="/Category/{id}" MappedUri=
  "/Views/CategoryPage.xaml?id={id}"/>

您可能出于其他原因使用 URI 映射程序(如搜索可扩展性),但不需要使用辅助磁贴。在此应用程序中,这是使用 URI 映射程序的风格决策。该团队认为,较短的 URI 更简洁且更易于使用。另外,辅助磁贴可能指定了页面特定的 URI(如 MappedUri 值)以实现同样的效果。

无论使用哪种方法,在将辅助磁贴中的 URI 映射到相应的页面后,用户将到达包含他的文章列表的 Category(类别)页面。任务完成了。有关磁贴的详细信息,请访问wpdev.ms/secondarytiles。

别急,还有一些内容!

除了此处介绍的内容以外,还有许多关于此应用程序的内容。一定要查看代码,以了解该团队是如何解决这些问题和其他问题的。例如,SynFeedParser.cs 提供了一种很好的方法,清理来自源的数据(有时包含很多 HTML 标记)。

但要记住,这是实习生们在 12 周结束时的工作快照(删除了一些清理代码)。专业开发人员可能希望以不同的方式编写某些部分的代码。不过,我认为该应用程序在集成本地数据库、后台代理和磁贴方面做得非常好。我希望您喜欢此处提供的“幕后”信息。祝您编码愉快!

Matt Stroshane  负责为 Windows Phone 团队编写开发人员文档。他在 MSDN 库中发表的其他文章主要涉及 SQL Server、SQL Azure 和 Visual Studio 等产品。在他不工作的时候,您可能会在西雅图的大街上看见他正在为下一次马拉松比赛进行训练。有关他的情况,请访问 Twitter twitter.com/mattstroshane

衷心感谢以下技术专家对本文的审阅: Francisco AguileraThomas FennelJohn GallardoSean McKennaSuman MalaniAyomikun (George) OkeowoHimadri Sarkar