Средство динамической смены экранов блокировки для Windows Phone 8, созданное с помощью ASP.NET MVC и Azure Mobile Services


 

Клинт Руткас (Clint Rutkas), Ден Делимарски (Den Delimarsky)

С выпуском Windows Phone 8 стало доступно несколько API для разработчиков, позволяющих сторонним приложениям менять изображение экрана блокировки на устройстве. В этой статье формируется инфраструктура и создается мобильное приложение, которое поддерживает динамический набор, из которого можно выбирать изображения, а затем циклически менять их как фоновые для экрана блокировки.

Что вам понадобится

Вам нужно будет скачать и установить ASP.NET MVC3 для работы с клиентским веб-интерфейсом и Windows Phone 8 SDK для разработки мобильных приложений. Обязательно создайте учетную запись Azure Mobile Services и, конечно же, не забудьте скачать и установить клиентские библиотеки Azure Mobile Services. Все три компонента предоставляются совершенно бесплатно.

Примечание: если на компьютере разработчика не установлен Azure Mobile Services SDK, процесс компиляции приложения для Windows Phone завершится неудачей.

Подготовка хранилища данных

Сначала мы должны определиться с общей архитектурой приложения и организовать рабочий процесс. Приложение будет поддерживать два способа назначения динамического экрана блокировки:

  • с помощью собственных наборов изображений, предоставляемых сервисом;
  • посредством наборов, создаваемых на основе изображений, которые предоставляются сервисом, но управляются конечным пользователем.

Давайте поговорим о модели данных в целом. Каждое изображение относится к определенной категории, и, чтобы отслеживать каждое из них, нам нужна таблица с двумя полями: идентификатором и названием категории. Нам также требуется другая базовая таблица, содержащая ссылки на сами изображения, со следующими полями: URL изображения, описательным названием и идентификатором той категории, к которой оно относится. Общая структура выглядит так:

image

Теперь перейдем на портал управления Windows Azure и создадим новый мобильный сервис.

image

Теперь вы должны указать информацию о базе данных — точно так же, как это делается для стандартной базы данных SQL Server:

image

При создании базу данных можно легко интегрировать с SQL Server Management Studio. Вам понадобится адрес сервера, который можно получить на портале управления Windows Azure. Для входа используйте удостоверения, указанные вами при создании основной базы данных.

Создайте две упомянутые ранее таблицы со следующей конфигурацией полей:

• Categories

  • ID – int
  • Name – varchar(100)

• Images

  • ID – int
  • URL – varchar(500)
  • Name – varchar(100)
  • CategoryID – int

Вы можете создать эти таблицы либо в SQL Server Management Studio, либо через портал управления Windows Azure. Однако вам потребуется Management Studio, чтобы создать структуру полей, так как портал управления Windows Azure на данный момент не поддерживает необходимую для этого функциональность.

По умолчанию поле id будет создано автоматически. Чтобы добавить поле Name в таблицу Categories, выполните этот запрос:

ALTER TABLE c4flockscreen.Categories
ADD Name VARCHAR(100)

Чтобы добавить недостающие поля в таблицу Images, просто выполните следующий запрос:

ALTER TABLE c4flockscreen.Images

ADD URL VARCHAR(500),

Name VARCHAR(100),

CategoryID INT

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

Создание веб-портала

Должен быть какой-то простой способ управления изображениями и постоянного расширения набора возможными обоями (фоновыми изображениями) экрана блокировки. Один из способов сделать это — создать портал управления, способный выполнять базовые CRUD-операции.

Начнем с создания пустого проекта:

image

Если вы еще не знакомы с шаблоном Model-View-Controller (MVC), здесь есть хорошая статья, поясняющая его основы.

Создайте новый контроллер в папке Controllers, присвойте ему имя HomeController. Это будет единственный контроллер в данном проекте. Пока добавьте функцию на основе ActionResult, которая будет возвращать основное представление:

using System.Web.Mvc;

namespace Coding4Fun.Lockscreen.Web.Controllers

{

public class HomeController : Controller

{

public ActionResult MainView()

{

return View();

}

}

}

Контроллер без соответствующих представлений бесполезен, поэтому создайте новое представление в Views/Home и присвойте ему имя MainView. На данный момент не заморачивайтесь визуальной структурой страницы, а уделите внимание функциональному аспекту клиентского веб-интерфейса. Если вы сейчас запустите приложение, то, скорее всего, получите ответ с кодом 404. Это означает, что связанное начальное представление по умолчанию не найдено. Откройте App_Start/RouteConfig.cs и убедитесь, что представлением по умолчанию является MainView, а не Index.

routes.MapRoute(

name: "Default",

url: "{controller}/{action}/{id}",

defaults: new { controller = "Home", action = "MainView", id = UrlParameter.Optional }

);

Базовая часть создана, и теперь, если запустить веб-приложение, вы увидите пока что пустую HTML-страницу:

image

Теперь нам нужно обрабатывать данные, получаемые из базы данных Azure Mobile Services. ASP.NET SDK нет, но к этой базе данных можно легко обращаться через REST API. Однако перед этим мы должны определить модели данных для таблиц Categories и Images. Начнем с создания двух классов в папке Models:

Category.cs:

public class Category

{

public int? id { get; set; }

public string Name { get; set; }

}

Image.cs:

public class Image

{

public int? id { get; set; }

public string URL { get; set; }

public string Name { get; set; }

public int CategoryID { get; set; }

}

Каждое из свойств связывается с соответствующим полем в ранее созданной базе данных. Заметьте, что ID может принимать null-значения. Это введено потому, что по умолчанию индекс будет назначаться автоматически. Когда создается новый экземпляр Category или Image, я не задаю свойство id явным образом, поэтому хранение в нем null вместо потенциально возможного нулевого значения по умолчанию гарантирует, что оно будет должным образом установлено на серверной стороне.

Теперь создадим механизм подключений, который позволит нам запрашивать содержимое хранилища данных. Для этого я создал папку DataStore, а в ней — класс DataEngine. Нам понадобится уникальный ключ API для каждого запроса, поэтому откройте портал управления Windows Azure и получите его оттуда:

image

Чтобы сохранить согласованность между проектами и получить возможность повторного использования одних и тех же значений ключа Azure Mobile Services API и базового URL, я создал класс AuthConstants в контексте проекта Coding4Fun.Lockscreen.Core. В нем имеются три статических поля:

public static class AuthConstants

{

public static string AmsApiKey = "YOUR_KEY_HERE";

public const string AmsUrl = "https://c4flockscreen.azure-mobile.net/";

public const string AmsTableUrl = AmsUrl + "tables/";

}

В проекте ASP.NET операции запроса выполняются с помощью HttpClient, инициализируемого в конструкторе класса; этот запрос также содержит ключ, используемый для аутентификации запросов через заголовок X-ZUMO-APPLICATION:

private HttpClient client;

public DataEngine()

{

client = new HttpClient();

client.DefaultRequestHeaders.Add("X-ZUMO-APPLICATION", KEY);

client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

}

Это основной каркас поддержки работы с данными. Я также реализовал два базовых метода, чтобы получать все существующие категории:

public IEnumerable<Category> GetAllCategories()

{

var result = client.GetStringAsync(string.Concat(CORE_URL,"Categories")).Result;

IEnumerable<Category> categories = JsonConvert.DeserializeObject<IEnumerable<Category>>(result);

return categories;

}

и изображения:

public IEnumerable<Image> GetAllImages()

{

var result = client.GetStringAsync(string.Concat(CORE_URL, "Images")).Result;

IEnumerable<Image> images = JsonConvert.DeserializeObject<IEnumerable<Image>>(result);

return images;

}

Для каждого из этих методов базовый запрос выдается с именем таблицы, за которым следует базовый URL (представленный константой CORE_URL). Так как JSON.NET теперь включен в ASP.NET, я могу легко десериализовать возвращаемый массив JSON-данных в IEnumerable<Type>. Однако в подходе GetAllImages есть одна проблема. Он подразумевает, что, даже если я хочу использовать LINQ для запроса существующего набора изображений, я должен сначала локально загрузить весь набор.

К счастью, Azure Mobile Services REST API предоставляет конечную точку с фильтрацией, и именно ее я использую в GetCategoryById и GetImagesByCategoryId:

public Category GetCategoryById(int id)

{

string composite = string.Concat(CORE_URL, "Categories?$filter=(id%20eq%20", id.ToString(), ")");

var result = client.GetStringAsync(composite).Result;

IEnumerable<Category> categories = JsonConvert.DeserializeObject<IEnumerable<Category>>(result);

return categories.FirstOrDefault();

}

public IEnumerable<Image> GetImagesByCategoryId(int id)

{

string composite = string.Concat(CORE_URL, "Images?$filter=(CategoryID%20eq%20", id.ToString(), ")");

var result = client.GetStringAsync(composite).Result;

IEnumerable<Image> images = JsonConvert.DeserializeObject<IEnumerable<Image>>(result);

return images();

}

Обратите внимание на параметр ?$filter=, в котором условным выражением является закодированный URL, а все его содержимое заключено в скобки. В запросе категории я проверяю значение id, а в запросе изображения — CategoryID.

В папке Views/Home создайте новое представление и присвойте ему имя Images. Оно будет использоваться для перечисления существующих изображений, связанных с одной из выбранных категорий. Вы также должны модифицировать код контроллера для обработки входящих данных:

using Coding4Fun.Lockscreen.Web.DataStore;

using System.Web.Mvc;

namespace Coding4Fun.Lockscreen.Web.Controllers

{

public class HomeController : Controller

{

DataEngine engine;

public HomeController()

{

engine = new DataEngine();

}

public ActionResult MainView()

{

var categories = engine.GetAllCategories();

return View(categories);
}

public ActionResult Images(int categoryId)

{

var images = engine.GetImagesByCategoryId(categoryId);

if (images != null)

{

return View(images);

}

return View("MainView");

}

}

}

Для основного представления я получаю список категорий и передаю их как связанную модель. Для представления Images идентификатор категории передается как аргумент, который впоследствии позволит механизму возвращать список всех изображений, чей CategoryID имеет это значение. Если возвращаемый список не равен null, это представление появляется на экране. В ином случае основное представление является конечной точкой.

В текущем состоянии этот клиентский интерфейс можно будет использовать для перечисления существующих категорий, но для добавления, удаления или обновления элементов. Добавление категории и изображения требует модификации запроса HttpClient с помощью HttpRequestMessage. Например, вот как можно добавить категорию через мой класс DataEngine:

public HttpStatusCode AddCategory(Category category)

{

var serializedObject = JsonConvert.SerializeObject(category, new JsonSerializerSettings() { NullValueHandling = NullValueHandling.Ignore });

var request = new HttpRequestMessage(HttpMethod.Post, string.Concat(CORE_URL, "Categories"));

request.Content = new StringContent(serializedObject, Encoding.UTF8, "application/json");

var response = client.SendAsync(request).Result;

return response.StatusCode;

}

Чтобы сериализовать объект, подлежащий вставке, используются возможности JSON.NET. Запрос POST выполняется применительно к таблице стандартных URL с JSON-строкой, закодированной в UTF8. Так как клиент уже имеет заголовок базовой аутентификации, достаточно вызвать функцию SendAsync.

При обновлении категории применяется тот же подход, но для запроса используется метод PATCH, и URL содержит индекс категории, которую требуется обновить:

public HttpStatusCode UpdateCategory(Category category)

{

var request = new HttpRequestMessage(new HttpMethod("PATCH"), string.Concat(CORE_URL, "Categories", "/", category.id));

var serializedObject = JsonConvert.SerializeObject(category);

request.Content = new StringContent(serializedObject, Encoding.UTF8, "application/json");

var response = client.SendAsync(request).Result;

return response.StatusCode;

}

Чтобы удалить какую-либо категорию из хранилища данных, нужно просто передать ему параметр, указывающий индекс удаляемой категории:

public HttpStatusCode DeleteCategoryFromId(int categoryId)

{

var request = new HttpRequestMessage(HttpMethod.Delete, string.Concat(CORE_URL, "Categories", "/", categoryId));

var response = client.SendAsync(request).Result;

return response.StatusCode;

}

В случае изображений можно использовать те же методы, передавая таблицу Images как имя цели в составном URL. Теперь вернемся к работе над некоторыми из представлений. Статический список категорий совсем не интересен, поэтому давайте создадим способ добавления новых категорий. Щелкните правой кнопкой мыши папку Views/Home и выберите Add View:

image

Очень удобно, что в Visual Studio можно задействовать базовый шаблон Scaffold при создании строго типизированного представления. В этом случае я сопоставляю его с классом Category и использую шаблон Create. Теперь мне нужно модифицировать код контроллера для обработки запросов к AddCategory. Я должен обрабатывать два типа запросов (GET и POST), поскольку это представление будет отображаться как при добавлении, так и при передаче элемента:

public ActionResult AddCategory()

{

return View();

}

[HttpPost]

public ActionResult AddCategory(Category category)

{

if (ModelState.IsValid)

{

engine.AddCategory(category);

return RedirectToAction("MainView");

}

return View();

}

Для запроса GET я просто возвращаю представление, а для запроса POST я добавляю категорию, которая была определена связанной моделью через локальный экземпляр DataEngine, после чего пользователь перенаправляется в основное представление. Но нам также нужно добавить ActionResult для MainView, чтобы получить список элементов, которые содержатся в данный момент в таблице Categories:

public ActionResult MainView()

{

var categories = engine.GetAllCategories();

return View(categories);

}

Экземпляр DataEngine будет возвращать все категории в форме IEnumerable<Category>; они передаваются в качестве модели для основного представления. Разметка MainView.cshtml может быть столь же простой, как таблица:

@{ 

ViewBag.Title = "Coding4Fun Dynamic Lockscreen";

}

<h2>Coding4Fun Dynamic Lockscreen - Categories</h2>

<table>

<tr>

<td>

<b>ID</b>

</td>

<td>

<b>Category Name</b>

</td>

</tr>

@foreach (var p in Model)

{

<tr>

<td>

@p.id

</td>

<td>

@p.Name

</td>

<td>

@Html.ActionLink("Images", "Images", new { categoryId = p.id })

</td>

<td>

@Html.ActionLink("Edit", "EditCategory", new { categoryId = p.id })

</td>

<td>

@Html.ActionLink("Delete", "DeleteCategory", new { categoryId = p.id })

</td>

</tr>

}

</table>

@Html.ActionLink("Add Category", "AddCategory")

Вспомогательный метод ActionLink позволяет обращаться к представлению и при необходимости передавать ему специфические параметры (например, когда мне нужно идентифицировать категорию, подлежащую удалению или редактированию). Некоторые из перечисленных здесь представлений пока не созданы, но в любом случае можно легко использовать подстановочные имена (placeholder names).

В итоге основная страница будет выглядеть так:

image

Заметьте, что теперь новые категории можно добавлять и щелчком ссылки Add Category внизу страницы. Это действие перенаправит вас в созданное представление AddCategory:

image

Рассмотрим, как реализовать редактирование категорий в клиентском веб-интерфейсе. Прежде всего, создайте новое представление в Views/Home и присвойте ему имя EditCategory. Используйте Scaffold-шаблон Edit. Как и AddCategory, EditCategory нужно обрабатывать в контроллере разными способами для запросов GET и POST:

public ActionResult EditCategory(int categoryId)

{

Category category;

category = engine.GetCategoryById(categoryId);

if (category != null)

return View(category);

return View("MainView");

}

[HttpPost]

public ActionResult EditCategory(Category category)

{

if (ModelState.IsValid)

{

engine.UpdateCategory(category);

return RedirectToAction("MainView");

}

return View();

}

В случае запроса GET мы должны идентифицировать категорию, которую нужно добавить по ее индексу, поэтому мы передаем аргумент categoryId представлению, которое потом используется экземпляром DataEngine для получения категории из хранилища данных. Для операции POST применяется приведенная выше реализация UpdateCategory, где выполняется запрос PATCH с сериализованным объектом, связанным с данным представлением.

Операция Delete не требует дополнительного представления, но контроллеру по-прежнему нужен обработчик, и мы используем такой фрагмент кода:

public ActionResult DeleteCategory(int categoryId)

{

engine.DeleteCategoryFromId(categoryId);

return RedirectToAction("MainView");

}

Тот же подход применим к добавлению, удалению и редактированию элементов в списке изображений. Однако для добавления изображений может понадобиться передача идентификатора категории. Когда изображения перечисляются после выбора категории, необходимо предоставить способ для идентификации категории, в которую должны быть добавлены новые сущности. Для этого при инициации действия Images можно передать индекс категории из основного контроллера в представление:

public ActionResult Images(int categoryId)

{

var images = engine.GetImagesByCategoryId(categoryId);

if (images != null)

{

ViewData["CID"] = categoryId;

return View(images);

}

return View("MainView");

}

Впоследствии значение categoryId можно получить по ключу CID для ViewData внутри самого представления.

Теперь обсудим, как изображения представляются для каждой категории. Я создал свое представление для перечисления всех изображений, связанных с категорией Images. Если вы посмотрите на приведенный выше код контроллера, то заметите, что я передаю идентификатор категории, используемый при выполнении запроса к набору изображений, а возвращаемый набор устанавливается как связанная модель:

public ActionResult Images(int categoryId)

{

var images = engine.GetImagesByCategoryId(categoryId);

if (images != null)

{

ViewData["CID"] = categoryId;

return View(images);

}

return View("MainView");

}

Если вам нужно добавить какое-то изображение, вызовите представление AddImage. В HomeController.cs содержатся реализации для запросов GET и POST:

public ActionResult AddImage(int categoryId)

{

Image image = new Image();

image.CategoryID = categoryId;

return View(image);

}

[HttpPost]

public ActionResult AddImage(HttpPostedFileBase file, Image image)

{

if (file != null && file.ContentLength > 0)

{

var fileName = Path.GetFileName(file.FileName);

var path = Path.Combine(Server.MapPath("~/Uploads"), image.CategoryID.ToString(), fileName);

string dirPath = Path.GetDirectoryName(path);

if (!Directory.Exists(dirPath))

Directory.CreateDirectory(dirPath);

file.SaveAs(path);

string applicationUrl = string.Format("{0}://{1}{2}",

HttpContext.Request.Url.Scheme,

HttpContext.Request.ServerVariables["HTTP_HOST"],

(HttpContext.Request.ApplicationPath.Equals("/")) ? string.Empty : HttpContext.Request.ApplicationPath

);

image.URL = Path.Combine(applicationUrl, "Uploads", image.CategoryID.ToString(), fileName);

}

if (ModelState.IsValid && image.URL != null)

{

engine.AddImage(image);

return RedirectToAction("Images", new { categoryID = image.CategoryID });

}

return View();

}

Когда запрос GET выполняется применительно к конечной точке AddImage, я передаю идентификатор категории как флаг, указывающий, в какую категорию следует включить данное изображение. Запрос POST может выполняться двумя способами: пользователь либо передает существующую ссылку на размещенное где-то изображение, либо загружает собственное изображение на локальный сервер. Когда загрузка идет от клиента на сервер, контент, который нужно поместить на сервер, содержится в HttpPostedFileBase.

Компонент для такой загрузки в самом представлении создается как форма с поддержкой ввода файла:

<h2>Or you could upload your own file: </h2>

@if (Model != null)

{

using (Html.BeginForm("AddImage", "Home", FormMethod.Post, new { enctype = "multipart/form-data", image = Model }))

{

@Html.HiddenFor(model => model.CategoryID);

<input type="file" name="file" />

<input type="submit" value="OK" />

}

}

Если никакой файл не выбран, система считает, что пользователь просто решил добавить существующий URL.

Важно упомянуть, что рабочий процесс загрузки (закачивания на сервер) полагается на наличие папки Upload. Она создается по умолчанию, когда проект развертывается на сервере, но вам нужно убедиться, что у пользователя ASP.NET на компьютере, где находится IIS, имеются соответствующие права на запись в эту папку.

Платформа приложений Windows Phone 8

Создайте проект нового приложения Windows Phone 8 и добавьте ссылку на Windows Azure Mobile Services Managed Client. Она должна быть доступна в разделе Extensions, если вы установили Windows Azure Mobile Services SDK так, как я говорил в начале этой статьи:

image

В App.xaml.cs нужно создать экземпляр MobileServiceClient, который будет действовать как центральная точка подключения к базе данных. Заметьте, что я использую строковые константы AMS и API KEY:

1

2

public static MobileServiceClient MobileService =

new MobileServiceClient(AuthConstants.AmsUrl, AuthConstants.AmsApiKey);

Мобильное приложение также должно содержать модели данных как для категорий, так и для изображений. Вместе с тем их можно слегка реорганизовать для более удобной структуры связывания с данными. Чтобы обеспечить повторное использование классов из разных компонентов приложения, я вновь использую проект Coding4Fun.Lockscreen.Core.

Создайте новую папку Models и добавьте новый класс Category:

using System.Collections.ObjectModel;

namespace Coding4Fun.Lockscreen.Core.Models

{

public class Category

{

public Category()

{

Images = new ObservableCollection<Image>();

}

public int? id { get; set; }

public string Name { get; set; }

public ObservableCollection<Image> Images { get; set; }

public override string ToString()

{

return Name;

}

}

}

Мы по-прежнему полагаемся на значение индекса, который может быть null, но теперь для изображений у нас есть ObservableCollection. Причина использования этого специфического типа наборов связана с тем, что при добавлении или удалении элементов обновления привязки к ObservableCollection выполняются автоматически, а значит, отпадает необходимость в реализации механизма уведомлений.

Функция ToString переопределена для упрощения выборки данных из связанного объекта. Например, когда набор с категориями будет подключен к списку, мне не придется создавать преобразователь или ссылку на свойство (property link).

Для модели Image создайте новый класс с именем Image в той же папке Models:

namespace Coding4Fun.Lockscreen.Core.Models

{

public class Image

{

public int? id { get; set; }

public string URL { get; set; }

public string Name { get; set; }
namespace Coding4Fun.Lockscreen.Core.Models

{

public class Image

{

public int? id { get; set; }

public string URL { get; set; }

public string Name { get; set; }

public int CategoryID { get; set; }

}

}

public int CategoryID { get; set; }

}

}

Рабочий процесс и хранилище приложения

Давайте поговорим о том, как будут обрабатываться категории изображений в этом приложении. При запуске приложения база данных запрашивается на наличие доступных категорий, и каждый элемент перечисляется на начальном экране. Если пользователь касается одной из категорий, из базы данных запрашиваются изображения, связанные с индексом выбранной категории.

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

Так как мы работаем с локальным хранилищем, создадим вспомогательный класс LocalStorageHelper в проекте Coding4Fun.Lockscreen.Core в папке Storage. Этот класс будет предоставлять базовые функции чтения и записи, позволяющие нам сохранять данные на внутреннем уровне:

public static class LocalStorageHelper

{

public async static void WriteData(string folderName, string fileName, byte[] content)

{

IStorageFolder rootFolder = ApplicationData.Current.LocalFolder;

if (folderName != string.Empty)

{

rootFolder = await rootFolder.CreateFolderAsync(folderName,

CreationCollisionOption.OpenIfExists);

}

IStorageFile file = await rootFolder.CreateFileAsync(fileName, CreationCollisionOption.ReplaceExisting);

using (var s = await file.OpenStreamForWriteAsync())

{

s.Write(content, 0, content.Length);

}

}

public static async void ClearFolder(string folderName)

{

var folder = await ApplicationData.Current.LocalFolder.GetFolderAsync(folderName);

if (folder != null)

{

foreach (IStorageFile file in await folder.GetFilesAsync())

{

await file.DeleteAsync();

}

}

}

public static async Task<string> ReadData(string fileName)

{

byte[] data;

StorageFolder folder = ApplicationData.Current.LocalFolder;

StorageFile file = await folder.GetFileAsync(fileName);

using (Stream s = await file.OpenStreamForReadAsync())

{

data = new byte[s.Length];

await s.ReadAsync(data, 0, (int)s.Length);

}

return Encoding.UTF8.GetString(data, 0, data.Length);

}

}

Заметьте, что я использую возможности недавно появившейся функциональности StorageFolder/StorageFile. Если вы разрабатывали приложения для Windows Store, то, по-видимому, уже знакомы с этой функциональностью. Application.Current.LocalFolder дает прямой доступ к локальному каталогу, который можно модифицировать из приложения. Он работает в стиле, аналогичном IsolatedStorageFile в Windows Phone 7, но более гибок, когда дело доходит до создания новых папок и файлов, а также обеспечивает эффективный обмен файлами.

Как упоминалось, внутренние данные будут храниться в виде XML. Для этого мне нужен класс с процедурами сериализации и десериализации; кроме того, я могу упростить эту задачу, задействовав функции Serialize.Save<T> и Serialize.Open<T> из Coding4Fun Toolkit. Вызовы этих функций обеспечивают гибкую сериализацию, где по умолчанию статический класс ничего не знает о сериализуемом типе, но может логически определять его на основании входящих данных. Как только для контента получена байтовая структура, я записываю ее в файл с помощью LocalStorageHelper.

Поскольку имеется несколько UI-элементов, которые требуют связывания с экземплярами наборов и объектов, в основном проекте предусмотрен класс CentralBindingPoint, который является моделью основного представления (он реализует INotifyPropertyChanged). Этот класс также реализует шаблон Singleton, поэтому основной экземпляр создается при инициализации и потом при необходимости используется повторно:

using Coding4Fun.Lockscreen.Core.Models;

using System;

using System.Collections.ObjectModel;

using System.ComponentModel;

namespace Coding4Fun.Lockscreen.Mobile

{

public class CentralBindingPoint : INotifyPropertyChanged

{

static CentralBindingPoint instance = null;

static readonly object padlock = new object();

public CentralBindingPoint()

{

Categories = new ObservableCollection<Category>();

CustomCategories = new ObservableCollection<Category>();

}

public static CentralBindingPoint Instance

{

get

{

lock (padlock)

{

if (instance == null)

{

instance = new CentralBindingPoint();

}

return instance;

}

}

}

private ObservableCollection<Category> _categories;

public ObservableCollection<Category> Categories

{

get

{

return _categories;

}

set

{

if (_categories != value)

{

_categories = value;

NotifyPropertyChanged("Categories");

}

}

}

private ObservableCollection<Category> _customCategories;

public ObservableCollection<Category> CustomCategories

{

get

{

return _customCategories;

}

set

{

if (_customCategories != value)

{

_customCategories = value;

NotifyPropertyChanged("CustomCategories");

}

}

}

private Category _currentCategory;

public Category CurrentCategory

{

get

{

return _currentCategory;

}

set

{

if (_currentCategory != value)

{

_currentCategory = value;

NotifyPropertyChanged("CurrentCategory");

}

}

}

public event PropertyChangedEventHandler PropertyChanged;

private void NotifyPropertyChanged(String info)

{

if (PropertyChanged != null)

{

System.Windows.Deployment.Current.Dispatcher.BeginInvoke(

() =>

{

PropertyChanged(this, new PropertyChangedEventArgs(info));

});

}

}

}

}

На главной странице я создаю разметку на основе класса Pivot, чтобы облегчить переход между веб-наборами (категориями) и локальными наборами:

image

Для каждого из типов наборов существует ListBox с собственным DataTemplate, назначаемым каждому элементу. Элементы извлекаются из Categories в случае веб-набора и из CustomCategories в случае локальных наборов — все это делается в модели представления CentralBindingPoint.

Категории загружаются с помощью класса DataEngine, который я добавил в папку Data в основном проекте приложения. Это оболочка для операций над данными через Azure Mobile Services, позволяющая комбинировать список категорий и изображения при условии, что известен индекс категории:

public class DataEngine

{

async public Task<List<Category>> GetCategoryList()

{

IMobileServiceTable<Category> table = App.MobileService.GetTable<Category>();

List<Category> data = await table.ToListAsync();

return data;

}

async public Task<List<Image>> GetImagesByCategoryId(int categoryId)

{

IMobileServiceTable<Image> table = App.MobileService.GetTable<Image>();

List<Image> data = await table.Where(x => x.CategoryID == categoryId).ToListAsync();

return data;

}

}

Когда загружается основная страница, с помощью локального экземпляра DataEngine я вызываю GetCategoryList и получаю набор List<Category>, который потом преобразуется в ObservableCollection через один из конструкторов по умолчанию:

async void MainPage_Loaded(object sender, RoutedEventArgs e)

{

CentralBindingPoint.Instance.Categories = new ObservableCollection<Category>(await dataEngine.GetCategoryList());

}

Если в списке веб-наборов выбрана какая-то категория, я назначаю выбранный элемент текущей категорией и перехожу на страницу ImageSetPage.xaml, которая будет отображать связанные изображения:

async void ListBox_SelectionChanged_1(object sender, System.Windows.Controls.SelectionChangedEventArgs e)

{

var box = (ListBox)sender;

if (box.SelectedItem != null)

{

Category selectedCategory = (Category)box.SelectedItem;

selectedCategory.Images = new ObservableCollection<Coding4Fun.Lockscreen.Core.Models.Image>

(await dataEngine.GetImagesByCategoryId((int)selectedCategory.id));

CentralBindingPoint.Instance.CurrentCategory = selectedCategory;

NavigationService.Navigate(new Uri("/ImageSetPage.xaml", UriKind.Relative));

}

}

Заметьте, что изображения не загружаются в тот же момент, что и категории; вместо этого они загружаются, только когда категория выбрана, — с этим и связан вызов GetImagesByCategoryId при выборе.

Для собственного набора процедура почти идентична с тем лишь отличием, что ссылки на изображения уже имеются, так как они были десериализованы из локального хранилища:

private void lstCustomSets_SelectionChanged_1(object sender, SelectionChangedEventArgs e)

{

var box = (ListBox)sender;

if (box.SelectedItem != null)

{

Category selectedCategory = (Category)box.SelectedItem;

CentralBindingPoint.Instance.CurrentCategory = selectedCategory;

NavigationService.Navigate(new Uri("/ImageSetPage.xaml", UriKind.Relative));

}

}

В ImageSetPage.xaml я использую ListBox с WrapPanel в ItemsPanelTemplate, гарантирующем, что в строке будет не более двух изображений, а любые добавленные картинки будут переноситься на другую строку с фиксированной длиной. Такой функционал вы можете получить от WPToolkit (ранее известный как Silverlight Toolkit for Windows Phone, доступный в NuGet).

image

Вот основная XAML-разметка:

<ListBox

SelectionMode="Single"

Margin="24"

x:Name="lstImages" SelectionChanged="lstImages_SelectionChanged_1"

ItemsSource="{Binding Path=Instance.CurrentCategory.Images,

Source={StaticResource CentralBindingPoint}}"


ItemTemplate="{StaticResource ListItemTemplate}">

<ListBox.ItemsPanel>

<ItemsPanelTemplate>

<toolkit:WrapPanel ItemWidth="216" ItemHeight="260"/>

</ItemsPanelTemplate>

</ListBox.ItemsPanel>

</ListBox>

Теперь, когда у нас есть скелет кода для обработки входных данных, посмотрим, как его можно трансформировать в активный экран блокировки, на котором могут циклически меняться фоновые обои. На странице ImageSetPage.xaml в панели приложения имеется кнопка, позволяющая задавать текущую категорию в качестве источника этих фоновых обоев.

В данный момент каждый экземпляр Image содержит URL изображения, а сами изображения могут быть размещены где угодно вне приложения. Однако это может вызвать проблемы в процессе вывода обоев, поскольку API разрешает устанавливать в качестве фона только локально хранящиеся изображения. То есть мне нужно скачивать каждое изображение в локальную папку приложения:

private async void btnSetStack_Click_1(object sender, EventArgs e)

{

var isProvider = Windows.Phone.System.UserProfile.LockScreenManager.IsProvidedByCurrentApplication;

if (!isProvider)

{

var op = await Windows.Phone.System.UserProfile.LockScreenManager.RequestAccessAsync();

isProvider = op == Windows.Phone.System.UserProfile.LockScreenRequestResult.Granted;

}

if (isProvider)

{

downloadableItems = new List<string>();

fileItems = new List<string>();

foreach (var image in CentralBindingPoint.Instance.CurrentCategory.Images)

{

downloadableItems.Add(image.URL);

fileItems.Add(Path.GetFileName(image.URL));

}

SerializationHelper.SerializeToFile(fileItems, "imagestack.xml");

LocalStorageHelper.ClearFolder("CurrentSet");

DownloadImages();

grdDownloading.Visibility = System.Windows.Visibility.Visible;

}

}

Прежде всего, надо убедиться в том, что приложение способно устанавливать фон для экрана блокировки и зарегистрировано в ОС как провайдер. Приложение должно заявить о своем намерении получать доступ к обоям, и для этого в WMAppManifest.xml требуется добавить следующий фрагмент кода сразу под узлом Tokens:

<Extensions>

<Extension ExtensionName="LockScreen_Background" ConsumerID="{111DFF24-AA15-4A96-8006-2BFF8122084F}" TaskID="_default" />

</Extensions>

Набор downloadableItems представляет очередь скачивания (download queue), а fileItems содержит имена локальных файлов для каждого изображения, которое предполагается скачать, сериализовать и использовать в фоновом агенте для перебора файлов в данной категории. Всякий раз, когда начинается процесс скачивания, на экране становится видимым накладной элемент, уведомляющий пользователя о процессе получения изображения.

Кроме того, обратите внимание на тот факт, что я вызываю LocalStorageHelper.ClearFolder, передавая имя папки как первый аргумент. Я не хочу хранить изображения для неактивных наборов, поэтому, когда выбирается новый набор, хранившиеся до этого момента изображения удаляются из папки CurrentSet и заменяются теми, которые скачиваются для нового набора. Реализация функции ClearFolder выглядит так:

public static void ClearFolder(string folderName

{

if (store.DirectoryExists(folderName))

{

foreach (string file in store.GetFileNames(folderName + "\\*.*"))

{

store.DeleteFile(folderName + "\\" + file);

}

}

}

После сохранения имен файлов в imagestack.xml изображения скачиваются с помощью DownloadImages:

void DownloadImages()

{

WebClient client = new WebClient();

string fileName = Path.GetFileName(downloadableItems.First());

client.OpenReadAsync(new Uri(downloadableItems.First()));

client.OpenReadCompleted += (sender, args) =>

{

Debug.WriteLine("Downloaded " + fileName);

LocalStorageHelper.WriteData("CurrentSet", fileName, StreamToByteArray(args.Result));

downloadableItems.Remove(downloadableItems.First());

if (downloadableItems.Count != 0)

DownloadImages();

else

{

grdDownloading.Visibility = System.Windows.Visibility.Collapsed;

LocalStorageHelper.CycleThroughImages();

//ScheduledActionService.LaunchForTest("LockscreenChanger", TimeSpan.FromSeconds(5));

}

};

}

Здесь видно, что я вызываю LocalStorageHelper.CycleThroughImages — функцию, которая считывает файл, содержащий текущий набор, и извлекает первое изображение, назначая его в качестве текущих обоев, затем перемещает его в конец списка, чтобы сменить на следующее, и весь процесс повторяется:

public static void CycleThroughImages()

{

List<string> images = Coding4Fun.Phone.Storage.Serialize.Open<List<string>>("imagestack.xml");

if (images != null)

{

string tempImage = images.First();

Uri currentImageUri = new Uri("ms-appdata:///Local/CurrentSet/" + tempImage, UriKind.Absolute);

Windows.Phone.System.UserProfile.LockScreen.SetImageUri(currentImageUri);

images.Remove(tempImage);

images.Add(tempImage);

Coding4Fun.Phone.Storage.Serialize.Save<List<string>>("imagestack.xml", images);

}

}

Возможно, вас интересует, почему я не пользуюсь для этого Queue<T>. В конце концов, Enqueue и Dequeue могли бы немного упростить мне работу. Проблема в том, что экземпляр Queue нельзя сериализовать напрямую без преобразования в линейный список. А я придерживаюсь использования минимальных ресурсов в обработке и поэтому оперирую экземпляром List<T>.

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

Фоновый агент

К этому моменту у нас есть локально сохраненные изображения, и они перечислены в XML-файле. Если пользователь принимает предложение системы, приложение также регистрируется как провайдер фоновых изображений экрана блокировки, но пока у нас нет кода, отвечающего за цикл смены обоев. Для этого создайте в своем решении новый проект Background Agent. Я назвал свой так: Coding4Fun.Lockscreen.Agent.

Функция OnInvoke в ScheduledAgent.cs выполняется с интервалом в 30 минут. Это временной лимит, определенный типом фонового агента PeriodicTask, который мы будем использовать здесь. Вам нужно добавить следующий фрагмент кода:

protected override void OnInvoke(ScheduledTask task)

{

var isProvider = Windows.Phone.System.UserProfile.LockScreenManager.IsProvidedByCurrentApplication;

if (isProvider)

{

LocalStorageHelper.CycleThroughImages();

}

NotifyComplete();

}

Как и во фрагменте со скачиванием, я проверяю, что перед попыткой смены обоев приложение является зарегистрированным провайдером. В ином случае будет сгенерировано исключение, и фоновый агент рухнет. Скверно то, что в случае двух сбоев подряд периодически выполняемой задачи она удаляется из очереди задач и фоновые изображения меняться не будут.

Если приложение является провайдером, вызовите CycleThroughImages, чтобы задать новый фон и переместить старый в конец списка. Чтобы быть уверенным в выборе разных изображений, исходный десериализованный список модифицируется: первое изображение становится последним, остальные изображения в стеке продвигаются вверх, а после этого список сериализуется обратно в imagestack.xml.

Фоновый агент нужно зарегистрировать в WMAppManifest.xml. Добавьте ExtendedTask в узел Tasks:

<ExtendedTask Name="LockscreenChangerTask">

<BackgroundServiceAgent Specifier="ScheduledTaskAgent"

Name="LockscreenChanger"

Source="Coding4Fun.Lockscreen.Agent"

Type="Coding4Fun.Lockscreen.Agent.ScheduledAgent" />

</ExtendedTask>

Кроме того, при запуске приложения вы должны убедиться, что задача зарегистрирована (если это не так, зарегистрируйте ее). Для этого используйте обработчик события Application_Launching:

private void Application_Launching(object sender, LaunchingEventArgs e)

{

string taskName = "LockscreenChanger";

var oldTask = ScheduledActionService.Find(taskName) as PeriodicTask;

if (oldTask != null)

{

ScheduledActionService.Remove(taskName);

}

PeriodicTask task = new PeriodicTask(taskName);

task.Description = "Change lockscreen wallpaper.";

ScheduledActionService.Add(task);

LoadCustomCategories();

}

Здесь LoadCustomCategories будет десериализовать существующие собственные категории, чтобы их можно было показывать на основной странице после запуска приложения:

private async void LoadCustomCategories()

{

try

{

CentralBindingPoint.Instance.CustomCategories =

(ObservableCollection<Category>)await SerializationHelper.DeserializeFromFile(

typeof(ObservableCollection<Category>), "customcat.xml");

}

catch

{

Debug.WriteLine("No customcat.xml - no registered custom categories.");

}

}

Теперь фоновые изображения будут автоматически сменяться на основе веб-наборов, которые вы будете активировать через каждые 30 минут.

Работа с собственными категориями

Давайте создадим несколько собственных наборов. Для контроля пользовательского ввода я применяю элемент управления CustomMessageBox, доступный в Windows Phone Toolkit. Он достаточно гибок и позволяет выбирать между добавлением элемента управления TextBox (для создания новой категории) и использованием ListPicker (для отображения имеющихся собственных категорий в соответствующей UI-разметке).

Когда пользователь решает создать новую категорию, он касается кнопки «плюс» в панели приложения на основной странице:

image

Реализация весьма проста:

private void btnSetStack_Click_1(object sender, EventArgs e)

{

TextBox textBox = new TextBox();

CustomMessageBox box = new CustomMessageBox()

{

Caption = "Add Custom Category",

Message = "Enter a unique name for the new category.",

LeftButtonContent = "ok",

RightButtonContent = "cancel",

Content = textBox

};

box.Dismissed += (s, boxEventArgs) =>

{

if (boxEventArgs.Result == CustomMessageBoxResult.LeftButton)

{

if (!string.IsNullOrWhiteSpace(textBox.Text))

{

var categoryCheck = (from c in CentralBindingPoint.Instance.CustomCategories

where

c.Name == textBox.Text

select c).FirstOrDefault();

if (categoryCheck == null)

{

Category category = new Category() { Name = textBox.Text };

CentralBindingPoint.Instance.CustomCategories.Add(category);

Coding4Fun.Toolkit.Storage.Serialize.Save<ObservableCollection<Category>>(

"customcat.xml", CentralBindingPoint.Instance.CustomCategories);

}

else

{

MessageBox.Show("Add Custom Category",

"This category name was already taken!",

MessageBoxButton.OK);

}

}

}

};

box.Show();

}

Когда окно сообщения удаляется, я проверяю, какая кнопка была нажата, чтобы предпринять соответствующие действия. Предположим, что пользователь решил добавить новую категорию. В этом случае надо проверить, что в существующем наборе нет категории с тем же названием. Если ее нет, создается новый экземпляр Category, добавляется в набор в модель главного представления и сериализуется в customcat.xml.

У пользователя также должна быть возможность добавлять изображения из любой категории в собственную. Для этого я решил предоставить вариант переноса названия и URL изображения, когда пользователь касается изображения в ImageSetPage.xaml.

Помните: если на данный момент не зарегистрирована ни одна собственная категория, пользователя следует уведомить о том, что он должен сначала создать такую категорию, поэтому альтернативным путем в диалоге с возможностью выбора названия собственной категории должно быть оповещение через окно сообщения:

image

Вот фрагмент кода, который именно это и делает:

private void lstImages_SelectionChanged_1(object sender, SelectionChangedEventArgs e)

{

if (CentralBindingPoint.Instance.CustomCategories.Count > 0)

{

if (lstImages.SelectedItem != null)

{

ListPicker picker = new ListPicker()

{

Header = "Custom category name:",

ItemsSource = CentralBindingPoint.Instance.CustomCategories,

Margin = new Thickness(12, 42, 24, 18)

};

CustomMessageBox messageBox = new CustomMessageBox()

{

Caption = "Add To Custom Category",

Message = "Select a registered custom category to add this image to.",

Content = picker,

LeftButtonContent = "ok",

RightButtonContent = "cancel"

};

messageBox.Dismissing += (s, boxEventArgs) =>

{

if (picker.ListPickerMode == ListPickerMode.Expanded)

{

boxEventArgs.Cancel = true;

}

};

messageBox.Dismissed += (s2, e2) =>

{

switch (e2.Result)

{

case CustomMessageBoxResult.LeftButton:

{

if (picker.SelectedItem != null)

{

Category category = (from c in CentralBindingPoint.Instance.CustomCategories

where c.Name == picker.SelectedItem.ToString()

select c).FirstOrDefault();

if (category != null)

{

category.Images.Add((Coding4Fun.Lockscreen.Core.Models.Image)lstImages.SelectedItem);

Coding4Fun.Toolkit.Storage.Serialize.Save<ObservableCollection<Category>>(

"customcat.xml", CentralBindingPoint.Instance.CustomCategories);

}

lstImages.SelectedItem = null;

lstImages.IsEnabled = true;

}

break;

}

case CustomMessageBoxResult.RightButton:

case CustomMessageBoxResult.None:

{

lstImages.SelectedItem = null;

break;

}

}

};

messageBox.Show();

}

}

else

{

MessageBox.Show("Add To Custom Category",

"Tapping on an image will prompt you to add it to a custom category" + Environment.NewLine +

"Seems like you don't have any custom categories yet.", MessageBoxButton.OK);

}

}

Как только категория выбрана из списка, изображение добавляется в набор Images в экземпляре Category, и список категорий сериализуется для сохранения изменений. Никаких ограничений на то, из каких категорий можно выбирать изображения в другие категории, нет — можно даже выбирать изображения из собственных категорий и включать их в другие категории. Кроме того, изображение можно неоднократно добавлять в одну и ту же категорию.

Заключение

С помощью Azure Mobile Services и управляемого SDK, доступного для Windows Phone, а также открытого REST API сравнительно легко создавать подключенные приложения для нескольких платформ одновременно без существенной переработки логики и кодовой базы.

Skip to main content