Статьи

Достижения Visual Studio для Windows Phone — разработка ядра

Как вы, наверное, слышали (или читали), ребята из Channel9 выпускают проект под названием Visual Studio Achievements . Если вы когда-либо использовали Xbox, вы знаете, что за некоторые игровые элементы пользователь награждается достижением — чем-то, что выполняется по сюжетной линии, но в то же время обозначает какое-то действие, которое было выполнено (или набор действия). Идея достижений Visual Studio та же: она не требует от разработчиков делать что-то особенное, но продолжает свою обычную работу. По ходу дела достижения будут присуждаться за конкретные этапы.

Идя на мобильный

Однажды увидев объявление, я подумала, что было бы здорово иметь мобильный «Просмотр достижений», тем более что есть открытый API . Приложение для Windows Phone 7 — отличное начало, поэтому я решил начать свой проект хакатона на выходных — Visual Studio Achievements Mobile.

Все данные, возвращаемые API, представлены в формате JSON. Поэтому мне нужно было решить, хочу ли я использовать System.Json или JSON.NET для чтения необходимого контента. Посмотрите, как выглядит простой ответ:

{
   "Name":"visualstudio",
   "FriendlyName":"Visual Studio Achievements",
   "Description":"Earn achievements while you code! With Visual Studio Achievements, your code will be monitored and, as you proceed, you will unlock various achievements based on your activity. When you unlock an achievement, Visual Studio will let you know visually with a pop-up. In addition, your Channel 9 profile will be updated with any achievements you earn. See below for the list of achievements that are part of the plug-in.<br\/><br\/>  Note that the Visual Studio Achievements only works for C# and Visual Basic.  Progress based achievements can only be incremented once a minute. Achievements marked with <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/FxCop-For-VS-Achievements">Uses FxCop<\/a> only work on Visual Studio 2010 Premium or Ultimate unless FxCop is installed.  Visual Studio Achievements <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/Visual-Studio-Achievements-API">has an API<\/a> for those interested.",
   "Icon":"http:\/\/files.ch9.ms\/vsachievements\/VisualStudio_logo1.jpg",
   "InstallLink":"http:\/\/dev9.channel9.msdn.com\/blogs\/c9team\/Coming-Soon-Visual-Studio-Achievements",
   "ID":"21c3de10-d286-11e0-9572-0800200c9a66",
   "PromoDescription":"Earn achievements while you code!  With Visual Studio Achievements, your code will be monitored and, as you proceed, you will unlock various achievements based on your activity. ",
   "TwitterHashtag":"VSAchievements",
   "UserFriendlyName":"karstenj",
   "Achievements":[
      {
         "Name":"MoreThan10OverloadsAchievement",
         "FriendlyName":"Overload",
         "Description":"More than 10 overloads of a method. You could go with this or you could go with that. <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/FxCop-For-VS-Achievements">Uses FxCop<\/a>",
         "Points":"5",
         "Category":"Don't Try This At Home",
         "Icon":"http:\/\/files.ch9.ms\/vsachievements\/scissors.png",
         "IconSmall":"http:\/\/files.ch9.ms\/vsachievements\/scissors_sm.png",
         "DateEarned":"2011-11-22T14:14:30.1189969-08:00"
      },
      {
         "Name":"MoreThan20LongLocalAchievement",
         "FriendlyName":"Job Security",
         "Description":"Write 20 single letter class level variables in one file. Kudos to you for being cryptic! <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/FxCop-For-VS-Achievements">Uses FxCop<\/a>",
         "Category":"Don't Try This At Home",
         "Points":"0",
         "Icon":"http:\/\/files.ch9.ms\/vsachievements\/scissors.png",
         "IconSmall":"http:\/\/files.ch9.ms\/vsachievements\/scissors_sm.png",
         "DateEarned":"2011-11-22T14:14:30.1009959-08:00"
      },
      {
         "Name":"EqualOpportunistAchievement",
         "FriendlyName":"Equal Opportunist",
         "Description":"Write a class with public, private, protected and internal members. It's all about scope. <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/FxCop-For-VS-Achievements">Uses FxCop<\/a>",
         "Category":"Power Coder",
         "Points":"10",
         "Icon":"http:\/\/files.ch9.ms\/vsachievements\/powercoder.png",
         "IconSmall":"http:\/\/files.ch9.ms\/vsachievements\/powercoder_sm.png",
         "DateEarned":"2011-11-22T14:14:30.0819948-08:00"
      }
   ],
   "TotalScore":20
}

( ПРИМЕЧАНИЕ: я вырезал части ответа, чтобы сделать его короче для демонстрационных целей.)

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

Achievement.cs

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

public class Achievement
{
    public string Category { get; set; }
    public DateTime DateEarned { get; set; }
    public string Description { get; set; }
    public string FriendlyName { get; set; }
    public Uri Icon { get; set; }
    public Uri IconSmall { get; set; }
    public string Name { get; set; }
    public int Points { get; set; }
    public bool IsEarned { get; set; }
}

Niner.cs

Этот класс представляет одного пользователя Channel9, зарегистрированного для достижений Visual Studio. Некоторые поля здесь заполняются не через API — прямой синтаксический анализ HTML просто потому, что для этого нет общедоступных конечных точек API.

public class Niner
{
    public string Alias { get; set; }
    public string Name { get; set; }
    public Uri Avatar { get; set; }
    public List<Achievement> Achievements { get; set; }
    public string Caption { get; set; }
    public int Points { get; set; }
}

Поздравляем! Это только две модели, которые я буду использовать. Следующая часть решала, где хранить константы URL, которые будут представлять конечные точки API. Я мог бы использовать файл XML или статический класс со строковыми константами. Чтобы избежать дополнительных накладных расходов на производительность при разборе XML-данных из пакета приложения, статический класс был гораздо лучшим выбором:

public static class URLConstants
{
    public const string NinerProfile = "http://channel9.msdn.com/niners/{0}";
    public const string AllAchievements = "http://channel9.msdn.com/niners/{0}/achievements/visualstudio?json=true";
    public const string UnlockedAchievements = "http://channel9.msdn.com/niners/{0}/achievements/visualstudio?json=true&raw=true";
}

Обратите внимание на индикаторы заполнения — они будут использоваться для простого форматирования строки до необходимого вызова.

Получение данных Найнера

Это делается с помощью одного класса — NinerReader.cs . Он начинается с одной ссылки на экземпляр NinerCurrentNiner . Это будет окончательный результат, который будет добавлен в коллекцию Niners, связанную с устройством.

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

private Niner CurrentNiner = new Niner();

Поскольку я не работаю с данными Niner каким-либо иным способом, кроме как для получения зарегистрированных достижений, я добавляю полученные данные непосредственно в основную коллекцию. Вот как я инициирую запрос:

public void AddNiner(string name, bool isInit)
{
string compositeUrl = string.Empty;

if (isInit)
{
CurrentNiner.Alias = name;
compositeUrl = string.Format(URLConstants.NinerProfile, name);
}
else
{
compositeUrl = string.Format(URLConstants.AllAchievements, name);
}

HttpWebRequest request = (HttpWebRequest)WebRequest.Create(compositeUrl);

if(isInit)
request.BeginGetResponse(new AsyncCallback(CompleteRequest), request);
else
request.BeginGetResponse(new AsyncCallback(CompleteAchievementRequest), request);
}

Псевдоним представляет собой прозвище, так с тех пор я уже получаю его через метод инициирующего, я мог бы также сохранить его в текущем Niner инстанции. URL составлен на основе константных строк, которые я упоминал выше. Это универсальный метод инициирования HTTP-запроса в NinerReader, поэтому я также передаю флаг, который определяет, является ли это первым запросом или нет. Если это так, мне нужно получить основные данные пользователя. Если это не так, мне нужно получить данные о достижениях, связанные с пропущенным пользователем.

Вот запрос обратного вызова:

private void CompleteRequest(IAsyncResult result)
{
    HttpWebRequest request = (HttpWebRequest)result.AsyncState;
    try
    {
        HttpWebResponse response = (HttpWebResponse)request.EndGetResponse(result);

        string HTMLContent;
        using (StreamReader reader = new StreamReader(response.GetResponseStream()))
        {
            HTMLContent = reader.ReadToEnd();
        }

        GetNinerContent(HTMLContent);
    }
    catch
    {
        App.MainPageDispatcher.BeginInvoke(new Action(() => MessageBox.Show("User not registered on Channel9.")));
    }
}

Если запрос не выполняется и имеется активное соединение для передачи данных, есть вероятность, что пользователь не зарегистрирован на канале 9, поэтому имеет смысл отобразить информационное сообщение. MainPageDispatcher является грузоотправитель экземпляр определено в App классе и инициализируется при загрузке MainPage. Он используется для межпоточного взаимодействия между уровнем данных и пользовательским интерфейсом.

Первый запрос выполняется к странице профиля, поэтому я читаю данные HTML, чтобы получить URL-адрес аватара пользователя, полное имя и заголовок профиля. GetNinerContent делает именно это:

private void GetNinerContent(string HTMLContent)
{
    int location = HTMLContent.IndexOf("<div class=\"author\">");
    string stripped = HTMLContent.Substring(location, HTMLContent.Length - location);
    location = stripped.IndexOf("</div>");
    stripped = stripped.Substring(0, location+6);

    XDocument doc = XDocument.Parse(stripped);

    CurrentNiner.Name = (from c in doc.Root.Elements() where c.Name == "img" select c).FirstOrDefault().Attribute("alt").Value;

    string avatarUrl = (from c in doc.Root.Elements() where c.Name == "img" select c).FirstOrDefault().Attribute("src").Value;
    CurrentNiner.Avatar = new Uri(avatarUrl);

    string caption = (from c in doc.Root.Elements() where c.Name == "span" && c.Attribute("class").Value == "caption" select c).FirstOrDefault().Value;
    CurrentNiner.Caption = caption;

    AddNiner(CurrentNiner.Alias, false);
}

Во-первых, я нахожу автора DIV и удаляю остальной ненужный контент. Затем я просто беру необходимый фрагмент и использую его как последовательность в формате XML для упрощения обработки. Грязный хак, я знаю. Пока это работает, поэтому я более чем счастлив видеть это в доказательстве концепции.

Когда пришло время получать достижения, я звоню AddNiner с ложным флагом. Я получу данные в этом обратном вызове:

private void CompleteAchievementRequest(IAsyncResult result)
{
    HttpWebRequest request = (HttpWebRequest)result.AsyncState;
    try
    {
        HttpWebResponse response = (HttpWebResponse)request.EndGetResponse(result);

        string JSONContent;
        using (StreamReader reader = new StreamReader(response.GetResponseStream()))
        {
            JSONContent = reader.ReadToEnd();
        }

        CurrentNiner.Achievements = new List<Achievement>();

        System.Json.JsonValue obj = System.Json.JsonObject.Parse(JSONContent);
        JsonValue value = obj["Achievements"];

        foreach (JsonValue aValue in value)
        {
            Achievement achievement = new Achievement();
            achievement.Category = aValue["Category"];

            try
            {
                DateTime dateEarned = new DateTime();
                DateTime.TryParse(aValue["DateEarned"], out dateEarned);
                achievement.DateEarned = dateEarned;
                achievement.IsEarned = true;
            }
            catch (KeyNotFoundException ex)
            {
                achievement.IsEarned = false;
            }

            achievement.Description = aValue["Description"];
            achievement.FriendlyName = aValue["FriendlyName"];
            achievement.Icon = new Uri(aValue["Icon"]);
            achievement.IconSmall = new Uri(aValue["IconSmall"]);
            achievement.Name = aValue["Name"];
            string data = aValue["Points"];
            achievement.Points = Convert.ToInt32(data);

            CurrentNiner.Achievements.Add(achievement);

            if (achievement.IsEarned)
                CurrentNiner.Points += achievement.Points;
        }

        App.MainPageDispatcher.BeginInvoke(new Action(() => BindingPoint.Niners.Add(CurrentNiner)));
    }
    catch
    {
        App.MainPageDispatcher.BeginInvoke(new Action(() => MessageBox.Show("User not registered for achievements.")));
    }
}

Это может показаться довольно обширным обратным вызовом, но оно действительно базовое. Я анализирую содержимое JSON и проверяю, все ли данные получены правильно. Обратите внимание на попытку … ловить блоки. Это касается части обработки даты, потому что конкретные достижения могут быть недоступны для конкретного пользователя — в тех случаях, когда они еще не заработаны. Второй блок предназначен для обнаружения ситуации, когда пользователь является участником Channel9, но не зарегистрирован для программы Achievements. 

Как вы можете видеть, JSON анализируется с помощью простого цикла foreach вместо запуска процесса десериализации — таким образом я экономлю некоторые ресурсы приложения, тем более что данные довольно просты. Как только чтение завершено, данные добавляются в коллекцию Niner в классе BindingPoint:

public class BindingPoint
{
    public static ObservableCollection<Niner> Niners { get; set; }
}

Это основное ядро ​​приложения Visual Studio Achievements Mobile. Следите за обновлениями пользовательского интерфейса приложения и дополнительными возможностями Mango.