За кулисами: приложение Windows Phone для чтения каналов

Мэтт Строушейн

Я большой поклонник каналов. Мне нравится магия RSS- и Atom-каналов, то, что новости попадают ко мне именно так и никак иначе. Но при удобном доступе к столь большим массивам информации, ее осмысленное использование стало трудной задачей. Поэтому, узнав, что некоторые стажеры в Microsoft занимались разработкой приложения Windows Phone для чтения каналов, я был очень рад посмотреть, как они подошли к решению этой проблемы.

В рамках своей стажировки Франсиско Агилера (Francisco Aguilera), Суман Малани (Suman Malani) и Эйомикуна (Джордж) Океово (Ayomikun [George] Okeowo) за 12 недель разработали приложение Windows Phone, включавшее некоторые новые средства Windows Phone SDK 7.1. Будучи новичками в разработке для Windows Phone, они оказались отличными субъектами тестирования нашей платформы, инструментария и документации.

Изучив различные варианты, они решили создать приложение для чтения каналов, которое демонстрировало бы локальную базу данных, Live Tiles и фоновый агент (background agent). Но им удалось продемонстрировать гораздо больше! В этой статье я подробно расскажу, как они использовали все эти средства. Поэтому установите Windows Phone SDK 7.1, скачайте исходный код и смотрите на свой экран. Приступим!

Использование приложения

Центральный "хаб" приложения - главная страница, MainPage.xaml (рис. 1). Она состоит из четырех секций панорамы: "what"s new", "featured", "all" и "settings". Секция "what"s new" показывает последние обновления в каналах. "Featured" отображает шесть статей, которые, как считает программа на основе вашей истории чтения, должны вам понравиться. В секции "all" перечисляются все ваши категории и каналы. Чтобы загрузить по Wi-Fi только статьи, используйте соответствующий параметр в секции "settings".

*
Рис. 1. Главная страница приложения после создания в Windows Phone категории News

Секции "what"s new" и "featured" предоставляют способ прямого перехода к какой-либо статье. Секция "all" выводит список категорий и каналов. Из этой секции можно перейти к подборке статей, сгруппированных по каналу или категории. Вы также можете использовать панель приложения в секции "all", чтобы добавить новый канал или категорию. На рис. 2 показано, как главная страница связана с остальными восемью страницами приложения.

*
Увеличить

Рис. 2. Карта навигации по страницам (вспомогательные страницы обозначены светло-серым цветом)

Main Page Главная страница
Launch Page Страница запуска
what's new what's new
featured featured
all all
settings settings
Article Page Страница Article
Feed Page Страница Feed
Category Page Страница Category
Add Menu Page Страница меню Add
Share Page Страница Share
New Feed Page Страница New Feed
New Category Page Страница New Category

Вы можете перемещаться по горизонтали на страницах Category, Feed и Article. Когда вы находитесь на одной из этих страниц, на панели приложения появляются стрелки (рис. 3). Эти стрелки позволяют вам выводить данные для предыдущей или следующей категории, канала или статьи в базе данных. Например, если вы просматриваете категорию Business на странице Category, касание стрелки "next" приведет к отображению категории Entertainment на странице Category.

*
Рис. 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 г.) и Microsoft Silverlight 4 SDK. Элементы управления из инструментального набора (toolkit) - Microsoft.Phone.Controls.Toolkit.dll - применяются на большинстве страниц приложения. Так, элементы управления HubTile обеспечивают отображение статей в секции "featured" главной страницы. Для упрощения работы с сетевой частью группа воспользовалась System.ServiceModel.Syndication.dll из Silverlight 4 SDK. Эта сборка не включена в Windows Phone SDK и не оптимизирована для приложений на смартфонах, но члены группы обнаружили, что она прекрасно подходит под их требования.

Проект основной части приложения - FeedCast - самый большой из трех, содержащихся в решении. Он организован в девять папок.

  1. Converters - конвертеры значений, наводящие мосты между данными и UI.
  2. Icons - значки, используемые в панелях приложения.
  3. Images - изображения, используемые в элементах HubTile, когда в статьях нет изображений.
  4. Libraries - сборки Toolkit и Syndication.
  5. Models - код, который относится к работе с данными и не используется фоновым агентом.
  6. Resources - файлы ресурсов локализации на английском и испанском языках.
  7. Themes - ресурсы для настройки внешнего вида элемента управления HeaderedListBox.
  8. ViewModels - классы ViewModel и другие вспомогательные классы.
  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. Папка Networking проекта FeedCastLibrary содержит код для загрузки и разбора контента из сети, в котором наиболее важны методы Download в файле WebTools.cs.

В проекте FeedCastAgent всего один класс, Scheduled¬Agent.cs, который содержит код фонового агента. Метод OnInvoke вызывается при запуске агента, а метод SendToDatabase - по окончании загрузки (скачивания). О загрузке мы поговорим подробнее немного позже.

Локальная база данных

Для большей производительности труда каждый из членов группы сосредоточился на своей области приложения. Агилера занимался UI, View и ViewModel в основном приложении. Океово работал над сетевой частью и получением данных из каналов. Малани трудилась над архитектурой базы данных и операциями с ней.

В Windows Phone данные можно хранить в локальной базе данных. Локальная она потому, что файл базы данных находится в изолированном хранилище (часть хранилища устройства, выделенная для вашего приложения и изолированная от других приложений). По сути, вы описываете таблицы своей базы данных как Plain Old CLR Objects (POCO) со свойствами этих объектов, представляющими столбцы (поля). Это позволяет хранить каждый объект такого класса как строку в соответствующей таблице. Чтобы представить базу данных, вы создаете специальный объект, который называется контекстом данных и который наследует от System.Data.Linq.DataContext.

Волшебный ингредиент локальной базы данных - исполняющая среда LINQ to SQL - ваш уровень доступа к данным. Вы вызываете метод CreateDatabase контекста данных, и LINQ to SQL создает файл .sdf в изолированном хранилище. LINQ-запросы создаются для того, что указать нужные данные, и LINQ to SQL возвращает строго типизированные объекты, которые можно связывать с UI. LINQ to SQL позволяет сосредоточиться на логике, а все низкоуровневые операции с базой данных обрабатывает самостоятельно. Подробнее об использовании локальной базы данных см. по ссылке wpdev.ms/localdb.

Вместо того чтобы набирать код всех классов вручную, Малани пошла по другому пути, воспользовавшись Visual Studio 2010 Ultimate. Она визуально создала таблицы базы данных, применяя диалог Server Explorer Add Connection для создания базы данных SQL Server CE, а затем диалог New Table для построения таблиц.

Спроектировав свою схему, Малани сгенерировала контекст данных с помощью SqlMetal.exe - утилиты командной строки из настольной версии LINQ to SQL. Ее предназначение - создание класса контекста данных на основе базы данных SQL Server. Генерируемый ею код весьма похож на контекст данных Windows Phone. По этой методике Малани смогла визуально построить таблицы и быстро сгенерировать контекст данных. Подробнее о SqlMetal.exe см. по ссылке wpdev.ms/sqlmetal.

Волшебный ингредиент локальной базы данных - исполняющая среда LINQ to SQL - ваш уровень доступа к данным.

База данных, созданная Малани, показана на рис. 4. Три основные таблицы - Category, Feed и Article. Кроме того, связующая таблица, Category_Feed, используется для поддержки отношения "многие ко многим" между категориями и каналами. Каждая категория может быть сопоставлена со множеством каналов. То же самое верно и для канала. Заметьте, что функция "Избранное" в этом приложении является просто особой категорией, которую нельзя удалить.

*
Рис. 4. Схема базы данных

Однако контекст данных, генерируемый SqlMetal.exe, содержит некоторый код, который не поддерживается в Windows Phone. Добавив файл кода контекста данных в проект Windows Phone, Малани скомпилировала проект, чтобы увидеть, какой код недопустим. По ее словам, пришлось удалить один конструктор, но остальное скомпилировалось нормально.

При изучении файла контекста данных, LocalDatabase­DataContext.cs, вы, вероятно, заметите, что все таблицы являются частичными классами. Остальной код, связанный с этими таблицами (не генерируемый автоматически с помощью SqlMetal.exe), хранится в файлах кода Article.cs, Category.cs и Feed.cs. Разделив код таким образом, Малани смогла внести изменения в схему базы данных, не затрагивая определения методов расширения, написанных ею вручную. Не сделай она этого, ей пришлось бы заново добавлять эти методы при каждой автоматической генерации LocalDatabaseDataContext.cs (SqlMetal.exe перезаписывает весь код в файле).

Синхронизация параллельного доступа

Как и в большинстве приложений Windows Phone, UI которых должен быть отзывчивым, это приложение использует для выполнения работы несколько параллельных потоков. Помимо UI-потока, который принимает пользовательский ввод, несколько фоновых потоков могут заниматься загрузкой и разбором RSS-каналов. Каждому из этих потоков в конечном счете нужно вносить изменения в базу данных.

Хотя сама база данных обеспечивает надежный параллельный доступ, класс DataContext не является безопасным в многопоточной среде. Иначе говоря, к единственному глобальному объекту DataContext, используемому в этом приложении, нельзя параллельно обращаться из нескольких потоков без добавления какой-либо модели синхронизации доступа. Для решения этой задачи Малани использовала API параллельной обработки в LINQ to SQL и объект-мьютекс из пространства имен System.Threading.

Для синхронизации доступа к данным в тех случаях, где возможна конкуренция между классами DataContext, используются методы WaitOne и ReleaseMutex мьютекса из файла DataUtils.cs. Например, если несколько параллельных потоков (из основной программы или фонового агента) вызывают метод SaveChangesToDB примерно в одно и то же время, работу продолжает тот код, которому удается первым выполнить WaitOne. Вызов WaitOne от другого потока не завершается, пока первый код не вызовет ReleaseMutex. По этому причине важно помещать вызов ReleaseMutex в выражение finally при использовании для операций с базой данных конструкции try/catch/finally. Без вызова ReleaseMutex другой код будет ждать на вызове WaitOne до тех пор, пока существует поток-владелец. С точки зрения пользователя, это означало бы "навечно".

Как и в большинстве приложений Windows Phone, UI которых должен быть отзывчивым, это приложение использует для выполнения работы несколько параллельных потоков.

Вместо одного глобального объекта DataContext можно использовать более мелкие объекты DataContext в каждом потоке приложения индивидуально. Однако члены группы выбрали более простой подход с глобальным DataContext. Должен также отметить, что, поскольку в этом приложении нужно защищать только доступ между потоками, а не между процессами, они вполне могли бы задействовать блокировку вместо мьютекса. Блокировка могла бы обеспечить более высокую производительность.

Работа с данными

Океово сконцентрировал свои усилия на предоставлении данных приложению. В файле WebTools.cs содержится код, где выполняется большая часть работы. Но класс WebTools используется не только для загрузки информации из каналов, но и для поиска новых каналов в Bing. Для этого создается общий интерфейс IXmlFeedParser, и код разбора абстрагируется в разные классы. Класс SynFeedParser разбирает каналы, а класс SearchResultParser - результаты поиска в Bing.

Однако запрос к Bing на самом деле не возвращает статьи (несмотря на возврат набора объектов Article интерфейсом IXmlFeedParser). Вместо этого он возвращает список названий и URI каналов. В чем дело? Что ж, Океово понимал, что в классе Article уже есть свойства, необходимые для описания канала, и создавать другой класс было незачем. При разборе результатов поиска он использовал ArticleTitle для названия канала и ArticleBaseURI для URI канала. Детали см. в файле исходного кода SearchResultParser.cs.

Хотя агенту было уделено много внимания, поскольку он берет на себя основную черновую работу, его никогда не удалось бы запустить, если бы он не был планируемой задачей.

Код в ViewModel страницы (NewFeedPageViewModel.cs в примерах кода) показывает, как используются результаты поиска Bing. Сначала вызывается метод GetSearchString для формирования URI строки поиска в Bing на основе искомых слов, которые пользователь вводит на странице NewFeedPage:

private string GetSearchString(string query)
{
  // Формирование строки поиска
  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 ограничивает количество возвращаемых результатов поиска. Подробнее о доступе к Bing через RSS см. в MSDN Library страницу "Accessing Bing Through RSS" (bit.ly/kc5uYO).

Метод GetSearchString вызывается в методе GetResults, где данные реально извлекаются из Bing (рис. 5). Метод GetResults выглядит немного запутанно, так как он перечисляет лямбда-выражение, которое обрабатывает событие AllDownloadsFinished "в строке" до того, как вызывается код, инициирующий загрузку. При вызове метода Download объект WebTools запрашивает Bing по URI, сформированному с помощью GetSearchString.

Рис. 5. Метод GetResults в NewFeedPageViewModel.cs запрашивает от Bing новые каналы

public void GetResults(string query, Action<int> Callback)
{
  // Очистка ViewModel страницы
  Clear();
  // Получаем строку поиска и помещаем ее в канал
  Feed feed = new Feed { FeedBaseURI = GetSearchString(query) };
  // Лямбда-выражение для добавления результатов
  // в ViewModel страницу по окончании загрузки;
  // _feedSearch является объектом WebTools
  _feedSearch.AllDownloadsFinished += (sender, e) =>
    {
      // Проверяем, вернул ли поиск какие-нибудь результаты
      if (e.Downloads.Count > 0)
      {
        // Добавляем результаты поиска в ViewModel страницы
        foreach (Collection<Article> result in e.Downloads.Values)
        {
          if (null != result)
          {
            Deployment.Current.Dispatcher.BeginInvoke(() =>
              {
                foreach (Article a in result)
                {
                  lock (_lockObject)
                  {
                    // Добавляем в ViewModel страницы
                    Add(a);
                  }
                }
                Callback(Count);
              });
          }
        }
      }
      else
      {
        // Если никаких результатов поиска не возвращено
        Deployment.Current.Dispatcher.BeginInvoke(() =>
          {
            Callback(0);
          });
      }
    };
  // Инициируем загрузку (поиск по Bing)
  _feedSearch.Download(feed);
}

WebTools-метод Download также используется фоновым агентом (рис. 6), но по-другому. Вместо загрузки только из одного канала агент передает в этот метод список из нескольких каналов. Для получения результатов агент использует другую стратегию. Вместо того чтобы ждать окончания загрузки статей из всех каналов (через событие AllDownloadsFinished), агент сохраняет статьи, как только завершается загрузка каждого канала (через событие SingleDownloadFinished).

Рис. 6. Фоновый агент инициирует загрузку (без отладочных комментариев)

protected override void OnInvoke(ScheduledTask 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: обработка ошибок
        catch { }
      });
  }
}

Задача фонового агента - поддерживать актуальность всех ваших каналов. Для этого он передает в метод Download список всех каналов. На выполнение фонового агента отводится лишь малый интервал времени, и когда он истекает, процесс моментально останавливается. Поэтому при загрузке каналов агент посылает статьи в базу данных только из одного канала единовременно. Тем самым вероятность сохранения фоновым агентом всех статей до прекращения его работы резко увеличивается.

Одно- и многоканальный методы Download на самом деле являются перегруженными версиями одного и того же кода. Код загрузки инициирует HttpWebRequest для каждого канала (асинхронно). Как только поступает ответ на первый запрос, вызывается обработчик события SingleDownloadFinished. После этого информация и статьи канала упаковываются в это событие с использованием SingleDownloadFinishedEventArgs. Как показано на рис. 7, метод SendToDatabase связан с методом SingleDownloadFinished. Когда он возвращает управление, SendToDatabase извлекает статьи из аргументов события и передает их в объект DataUtils с именем DataBaseTools.

Рис. 7. Фоновый агент сохраняет статьи в базе данных (без отладочных комментариев)

private void SendToDatabase(object sender,
  SingleDownloadFinishedEventArgs e)
{
  // Проверяем отличие от null!
  if (e.DownloadedArticles != null)
  {
    DataBaseTools.AddArticles(e.DownloadedArticles, e.ParentFeed);
    _remainingDownloads--;
  }
  // Если загрузок не осталось, сообщаем планировщику,
  // что работа фонового агента завершена
  if (_remainingDownloads <= 0)
  {
    NotifyComplete();
  }
}

Если агент вдруг успеет закончить все загрузки в пределах отведенного интервала времени, тогда он вызывает метод NotifyComplete, чтобы уведомить ОС о завершении своей работы. Это позволяет ОС выделять неиспользованные ресурсы другим фоновым агентам.

Перед добавлением статьи в базу данных метод AddArticles класса DataUtils проверяет, что эта статья действительно новая. Обратите внимание нарис. 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;
  // Запрашиваем от локальной базы данных существующие статьи
  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();
    // Определяем, не находятся ли уже
    // какие-либо статьи в базе данных
    bool alreadyInDB = (d.ToList().Count == 0);
    if (alreadyInDB)
    {
      newArticle.Read = false;
      newArticle.Favorite = false;
      numOfNew++;
    }
    else
    {
      // Если да, удаляем их из списка
      newArticles.Remove(newArticle);
      downloadedArticleCount--;
      i--;
    }
  }
  // Пытаемся передать и обновить счетчики
  try
  {
    db.Article.InsertAllOnSubmit(newArticles);
    Deployment.Current.Dispatcher.BeginInvoke(() =>
      {
        feed.UnreadCount += numOfNew;
        SaveChangesToDB();
      });
    SaveChangesToDB();
  }
  // TODO: обработка ошибок
  catch { }
  finally { dbMutex.ReleaseMutex(); }
}

При работе с данными с помощью метода Download основное приложение действует по той же методике, что и фоновый агент. Чтобы сравнить код, загляните в файл ContentLoader.cs.

Планирование фонового агента

Фоновый агент является именно агентом, который выполняет свою работу в фоне для основного приложения. Как вы видели на рис. 6 и 7, код, определяющий эту работу, - класс Scheduled­Agent. Он наследует от Microsoft.Phone.Scheduler.ScheduledTask­Agent, а тот - от 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;
  // Агенты отключены пользователем
  if (periodicDownload != null && !periodicDownload.IsEnabled)
  {
    // Нельзя добавить агент. Возвращаем false!
    wasAdded = false;
  }
  // Если задача уже существует и фоновые агенты разрешены
  // для этого приложения, удаляем агент и добавляем заново,
  // чтобы обновить планировщик
  if (periodicDownload != null && periodicDownload.IsEnabled)
  {
    ScheduledActionService.Remove(periodicTaskName);
  }
  periodicDownload = new PeriodicTask(periodicTaskName);
  periodicDownload.Description =
  "Allows FeedCast to download new articles on a regular schedule.";
  // Планирование агента может быть блокировано, если
  // достигнуто максимальное количество агентов или если
  // объем памяти в смартфоне равен 256 Мб
  try
  {
    ScheduledActionService.Add(periodicDownload);
  }
  catch (SchedulerServiceException) { }
  return wasAdded;
}

Перед планированием периодической задачи StartPeriodicAgent выполняет несколько проверок, так как всегда есть шанс, что вам не удастся запланировать задачу. Начнем с того, что планируемые задачи могут быть отключены пользователем в списке фоновых задач в секции Applications страницы Settings. Также существует ограничение на число одновременно поддерживаемых задач. Оно зависит от конфигурации устройства, но может быть лимитировано всего шестью задачами. Если вы попытаетесь запланировать задачу по превышению лимита или если ваше приложение работает на устройстве с 256 Мб памяти или если вы уже запланировали ту же задачу, метод Add сгенерирует исключение.

Это приложение вызывает метод StartPeriodicTask при каждом запуске, так как срок действия фоновых агентов истекает через 14 дней. Обновление агента при каждом запуске гарантирует, что он по-прежнему сможет работать, даже если приложение запускается раз в несколько дней.

Переменная periodicTaskName на рис. 9, используемая для поиска существующей задачи, содержит значение "FeedCastAgent". Заметьте, что это имя не идентифицирует код соответствующего фонового агента. Это просто понятное название, которое вы можете использовать при работе с ScheduledActionService. Основное приложение и так знает о коде фонового агента, так как он был добавлен в виде ссылки в проект основного приложения. Поскольку код фонового агента был создан как проект типа Windows Phone Scheduled Task Agent, при добавлении ссылки все, что нужно, было корректно связано. Вы можете увидеть, что взаимосвязь между основным приложением и фоновым агентом указана в манифесте основного приложения (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>

Тайлы

Агилера работал над UI, объектами View и ViewModel. Он также занимался локализацией и функцией Tiles. Тайлы (tiles), иногда называемые Live Tiles, отображают динамический контент и ярлык приложения в меню Start. Тайл любого приложения можно закрепить в Start (безо всяких усилий со стороны разработчика). Однако, если вы хотите, чтобы ярлык был связан со страницей вашего приложения, отличной от главной, вам придется реализовать Secondary Tiles. Это обеспечивает пользователю более широкие возможности навигации по вашему приложению из меню Start и переход на любую страницу, которую представляет Secondary Tile.

В FeedCast пользователи могут закреплять в Start какой-либо канал или категорию (Secondary Tile). Одним касанием можно мгновенно открывать свежие статьи, относящиеся к этому каналу или категории. Для этого у пользователей должна быть возможность закреплять канал или категорию в Start. Агилера задействовал с этой целью ContextMenu из Silverlight Toolkit for Windows Phone. Касание пальцем и его удерживание на канале или категории в секции "all" главной страницы приводит к появлению контекстного меню. Из него можно выбрать удаление или закрепление канала или категории в Start. Весь этот процесс с точки зрения пользователя иллюстрирует рис. 10.

*
Рис. 10. Закрепление категории News в Start и запуск страницы Category

На рис. 11 показан XAML, который делает возможным появление контекстного меню. Второй MenuItem отображает "pin to start" (при выборе английского в качестве языка UI). Когда вы касаетесь этого элемента, обработчик события click вызывает метод OnCategoryPinned, начинающий процедуру закрепления. Поскольку данное приложение локализовано, текст для контекстного меню берется из файла ресурсов. Вот почему значение Header связано с LocalizedResources.ContextMenuPinToStartText.

Рис. 11. Контекстное меню для удаления или закрепления категории в Start

<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>

Если вы хотите, чтобы ярлык был связан со страницей вашего приложения, отличной от главной, вам придется реализовать Secondary Tiles.

В этом приложении только два файла ресурсов: один для испанского, а другой для английского языка (по умолчанию). Однако, поскольку локализация имеется, было бы сравнительно легко добавить другие языки. На рис. 12 показан файл ресурсов по умолчанию - AppResources.resx. Подробнее см. по ссылке wpdev.ms/globalized.

*
Рис. 12. Файл ресурсов по умолчанию (AppResources.resx) предоставляет текст в UI для всех языков, кроме испанского

Изначально в группе не было полной уверенности в том, как именно следует определять, какую категорию или канал нужно закреплять. Потом Агилера обнаружил 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 излишне. Вместо Tag можно использовать DataContext объекта Sender. Например, на рис. 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 article) в секции "featured" главной страницы. Здесь трудность заключается в том, что sender связан не с классом Article, а с MainPageFeaturedViewModel. Этот ViewModel содержит шесть статей, поэтому из DataContext нельзя узнать, какая из этих статей была выбрана. В этом случае, используя свойство Tag, можно легко связать соответствующий объект Article.

Так как вы можете закреплять каналы и категории в Start, у метода AddLiveTile имеются две перегруженные версии. Объекты и Secondary Tiles отличаются достаточно, чтобы группа решила не объединять функциональность в один универсальный метод. На рис. 14 показана Category-версия метода AddLiveTile.

Рис. 14. Закрепление объекта Category в Start

public static void AddLiveTile(Category cat)
{
  // Существует ли данный Tile?
  // Если да, не пытаемся создавать его заново.
  ShellTile tileToFind = ShellTile.ActiveTiles.FirstOrDefault(x =>
    x.NavigationUri.ToString().Contains("/Category/" +
    cat.CategoryID.ToString()));
  // Создаем Tile, если его нет
  if (tileToFind == null)
  {
    // Создаем изображение для категории, если таковой нет
    if (cat.ImageURL == null // cat.ImageURL == String.Empty)
    {
      cat.ImageURL = ImageGrabber.GetDefaultImage();
    }
    // Создаем объект 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 + "!",
    };
    // Создаем Tile и закрепляем его в Start. Это приводит
    // к переходу в Start и деактивации приложения.
    ShellTile.Create(
      new Uri("/Category/" + cat.CategoryID, UriKind.Relative),
      newTileData);
    cat.IsPinned = true;
    App.DataBaseUtility.SaveChangesToDB();
  }
}

Перед добавлением Category Tile метод AddLiveTile использует класс ShellTile, чтобы проверить URI навигации изо всех активных тайлов и определить, не добавлена ли уже эта категория. Если нет, он продолжает и получает URL изображения, чтобы сопоставить его с новым Tile. Всякий раз, когда вы создаете новый Tile, фоновое изображение нужно брать из локального ресурса. В данном случае с помощью класса ImageGrabber извлекается случайным образом назначаемый локальный файл изображения. Однако после создания Tile вы можете обновить фоновое изображение по удаленному URL. Но в данном приложении этого не делается.

Вся информация, которую нужно указать для создания нового Tile, содержится в классе StandardTileData. Этот класс используется для размещения текста, чисел и фоновых изображений в тайлах. При создании Tile методом Create в качестве параметра передается StandardTileData. Другой важный параметр - URI навигации Tile. Это URI, который позволяет пользователям переходить в нужно место вашего приложения.

В данном приложении URI навигации из Tile позволяет перейти только в само приложение. Для более глубокой навигации применяется базовый класс UriMapper, направляющий пользователей на нужную страницу. Навигационный элемент App.xaml определяет все сопоставления URI для приложения. В каждом элементе UriMapping значение, указываемое атрибутом Uri, является входящим URI. Значение, задаваемое атрибутом MappedUri, - та страница, на которую перейдет пользователь. Для сохранения контекста конкретной категории, канала или статьи из входящего URI в сопоставленный передается значение id в фигурных скобках - {id}:

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

Для использования URI-мэппера у вас могут быть и другие причины (например, расширение функциональности поиска), но использовать Secondary Tile не обязательно. В этом приложении применение URI-мэппера было чисто стилевым решением. Члены группы сочли, что короткие URI выглядят более элегантно и удобнее в использовании. В качестве альтернативы Secondary Tiles могли бы указывать URI, специфичный для страницы (например, значения MappedUri), и был бы достигнут тот же результат.

Независимо от конкретной реализации после сопоставления URI из Secondary Tile с соответствующей страницей пользователь попадает на страницу Category со списком своих статей. Миссия выполнена. Подробнее о тайлах см. по ссылке wpdev.ms/secondarytiles.

Заключение

В этом приложении содержится куда больше того, о чем я смог рассказать. Обязательно изучите код, чтобы узнать больше о том, как группа подходила к решению этих и других задач. Например, в SynFeedParser.cs есть удобный способ чистки данных из каналов, которые иногда бывают перегружены HTML-тегами.

Просто помните, что это своего рода обзор результатов 12-недельной работы стажеров. Профессиональные разработчики могут предпочесть по-другому кодировать некоторые части такого приложения. Тем не менее, полагаю, что в приложении проделана отличная работа по интеграции локальной базы данных, фонового агента и тайлов. Надеюсь, вам было интересно заглянуть "за кулисы". Удачи в кодировании!


Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=30898