Статьи

Создание клиента Imgur для Windows Phone — часть 1 — Core & Main Gallery

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

Получение одобрения сервиса

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

  • распространять ваше приложение как платный продукт
  • отображать рекламу в приложении

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

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

Ограничения по умолчанию установлены на:

  • 1250 загрузок в день или
  • ~ 12 500 запросов в день

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

Начинаем создавать приложение

Откройте Visual Studio и создайте новое приложение для Windows Phone. Мы начнем с приложения 7.1, а позже расширим его возможностями 8.0, поэтому убедитесь, что вы выбрали правильную целевую операционную систему.

Прежде всего, нам нужно создать ядро ​​кода — сервисный клиент Imgur. Для этого я создал новую папку в своем решении под названием ImgurAPI . Внутри я создаю класс с именем ImgurClient — это будет центральная точка подключения для любых запросов, выполняемых к Imgur API.

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

private string _clientID;
private string _clientSecret;

public ImgurClient(string clientID, string clientSecret)
{
    _clientID = clientID;
    _clientSecret = clientSecret;
}

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

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

public class ImgurImage
{
  public string ID { get; set; }
  public string Title { get; set; }
  public Int64 DateTime { get; set; }
  public string Type { get; set; }
  [JsonProperty(PropertyName = "animated")]
  public bool IsAnimated { get; set; }
  public int Width { get; set; }
  public int Height { get; set; }
  public Int64 Size { get; set; }
  public Int64 Views { get; set; }
  [JsonProperty(PropertyName = "account_url")]
  public string AccountUrl { get; set; }
  public string Link { get; set; }
  public string Bandwidth { get; set; }
  public int Ups { get; set; }
  public int Downs { get; set; }
  public int Score { get; set; }
  [JsonProperty(PropertyName = "is_album")]
  public bool IsAlbum { get; set; }
}

Есть несколько вещей, чтобы упомянуть здесь. Вы, вероятно, заметили, что в официальном описании некоторые типы данных для таких свойств, как DateTime , помечены как целые числа. В документации не сказано, какое целое число необходимо. Например, для DateTime стандартное целое число ( Int32 ) будет недостаточным, и вместо него необходимо использовать Int64 (long) . Невыполнение этого требования приведет к исключению переполнения.

Также некоторые свойства помечены атрибутом JsonProperty. Я использую Json.NET для обработки и десериализации данных JSON. Добавьте его в проект, щелкнув правой кнопкой мыши на References в Solution Explorer и выбрав Manage NuGet Packages .

По умолчанию десериализатор JSON свяжет каждое свойство с полем с таким же именем в строке JSON. Наличие is_album в качестве свойства C # не является чем-то обычно используемым, поэтому я могу включить атрибут, который переопределит ссылку по умолчанию между полями и именами свойств.

Давайте реализуем функцию в классе ImgurClient, которая будет извлекать данные JSON и возвращать их вызывающему.

/// <summary>
/// Get the images from the main gallery.
/// This call DOES NOT require authentcation.
/// </summary>
/// <param name="section"></param>
/// <param name="sort"></param>
/// <param name="page"></param>
public void GetMainGalleryImages(ImgurGallerySection section, ImgurGallerySort sort, int page, 
    Action<ImgurImageData> onCompletion)
{
    string _sort = sort.ToString().ToLower();
    string _section = section.ToString().ToLower();
    
    WebClient client = new WebClient();
    client.Headers["Authorization"] = "Client-ID " + _clientID;

    client.DownloadStringAsync(new Uri(string.Format(ImgurEndpoints.MainGallery, _section, _sort, page)));
    client.DownloadStringCompleted += (c, s) =>
    {
        var imageData = JsonConvert.DeserializeObject<ImgurImageData>(s.Result);
        onCompletion(imageData);
    };
}

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

public static class ImgurEndpoints
{
    public const string MainGallery = "https://api.imgur.com/3/gallery/{0}/{1}/{2}.json";
}

Когда я десериализирую данные, я получаю объект ImgurImageData вместо общего списка. Вы можете спросить — почему это? Посмотрите на результат, который вы получаете (сырой). Отличным инструментом для форматирования JSON является JSON Formatter & Validator .

Что не так с этим JSON? По сути, ничего, но для того, чтобы десериализовать строку в List <ImgurImage> , мне нужно иметь необработанный массив JSON. Здесь у меня его нет, но у меня есть контейнер данных . Вот почему есть класс ImgurImageData :

public class ImgurImageData
{
    [JsonProperty(PropertyName = "data")]
    public IEnumerable<ImgurImage> Images { get; set; }
}

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

ImgurClient client = new ImgurClient(ConstantContainer.IMGUR_CLIENT_ID,
    ConstantContainer.IMGUR_CLIENT_SECRET);

client.GetMainGalleryImages(ImgurGallerySection.Hot, ImgurGallerySort.Viral, 0, (s) =>
    {
        ImgurImageData data = s;
        Debug.WriteLine(s.Images.First().AccountUrl);
    });

ConstantContainer — это статический класс, который содержит предопределенный идентификатор клиента и секрет клиента. Если вы установите точку останова в строке Debug.WriteLine , вы получите хорошее представление о том, что находится в результирующем контейнере:

Пока это выглядит хорошо, но я хочу на самом деле отображать изображения где-то в приложении, а не просто визуализировать их модели в Visual Studio. Чтобы сделать это, я на самом деле происходит , чтобы переместить ImgurClient экземпляр App.xaml.cs .

Очевидно, что данные должны быть вставлены куда-то, поэтому я создал папку ViewModels в своем решении с классом MainPageViewModel внутри него — он будет использоваться для привязки всех данных, которые необходимо обработать на главной странице приложения, и изображения из главной галереи являются частью этого.

using Imagine.ImgurAPI;
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;

namespace Imagine
{
    public class MainPageViewModel : INotifyPropertyChanged
    {

        static MainPageViewModel instance = null;
        static readonly object padlock = new object();

        public MainPageViewModel()
        {
            HomeImages = new ObservableCollection<ImgurImage>();
        }

        public static MainPageViewModel Instance
        {
            get
            {
                lock (padlock)
                {
                    if (instance == null)
                    {
                        instance = new MainPageViewModel();
                    }
                    return instance;
                }
            }
        }

        private ObservableCollection<ImgurImage> _homeImages;
        public ObservableCollection<ImgurImage> HomeImages
        {
            get
            {
                return _homeImages;
            }
            set
            {
                if (_homeImages != value)
                {
                    _homeImages = value;
                    NotifyPropertyChanged("HomeImages");
                }
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;

        private void NotifyPropertyChanged(String info)
        {
            if (PropertyChanged != null)
            {
                System.Windows.Deployment.Current.Dispatcher.BeginInvoke(
                    () =>
                    {
                        PropertyChanged(this, new PropertyChangedEventArgs(info));
                    });
            }
        }
    }
}

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

Вернувшись в MainPage.xaml.cs , убедитесь, что вы добавили обработчик события MainPage_Loaded. Внутри вы можете вставить основной код загрузки изображений галереи:

void MainPage_Loaded(object sender, RoutedEventArgs e)
{
    if ((MainPageViewModel.Instance.HomeImages == null) !=
        (MainPageViewModel.Instance.HomeImages.Count == 0))
    {
        App.ServiceClient.GetMainGalleryImages(ImgurGallerySection.Hot, ImgurGallerySort.Viral, 0, (s) =>
        {
            ImgurImageData data = s;
            MainPageViewModel.Instance.HomeImages =
                new System.Collections.ObjectModel.ObservableCollection<ImgurImage>(s.Images);
            System.Diagnostics.Debug.WriteLine("Main gallery images loaded in MainViewModel.");
        });
    }
}

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

xmlns:vms="clr-namespace:Imagine.ViewModels"

Внутри узла Application.Resources добавьте новый узел для MainPageViewModel . Дайте ему уникальный ключ, так как на него будет ссылаться ссылка:

<!--Application Resources-->
<Application.Resources>
    <vms:MainPageViewModel x:Key="MainPageViewModel"></vms:MainPageViewModel>
</Application.Resources>

Теперь мы можем что-то сделать с базовой коллекцией изображений. В MainPage.xaml добавьте новый ListBox. В целях тестирования мы можем сделать его максимально простым, используя изображение в качестве шаблона ItemTemplate по умолчанию.

<ListBox ItemsSource="{Binding Path=Instance.HomeImages,
    Source={StaticResource MainPageViewModel}}">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <Image Height="240" Width="240" Source="{Binding Link}"></Image>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

Результаты будут в порядке:

Настройка для ОК не достаточно интересна, поэтому давайте поработаем с WrapPanel , изображения будут отображаться в маленьких смежных квадратах, занимая большую часть экрана. Элемент управления не является частью стандартного SDK, поэтому вам нужно добавить пакет WP Toolkit через NuGet:

Добавьте ссылку на новое пространство имен в MainPage.xaml :

xmlns:toolkit="clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone.Controls.Toolkit"

Теперь я могу изменить панель элементов ListBox по умолчанию, чтобы она была WrapPanel :

<ListBox ItemsSource="{Binding Path=Instance.HomeImages,
    Source={StaticResource MainPageViewModel}}">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <Image Stretch="UniformToFill" Height="240" Width="240" Source="{Binding Link}"></Image>
        </DataTemplate>
    </ListBox.ItemTemplate>

    <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <toolkit:WrapPanel ItemWidth="240" ItemHeight="240"/>
        </ItemsPanelTemplate>
    </ListBox.ItemsPanel>
</ListBox>

This should work in theory, but in practice you will most likely get this when the application runs on a physical device:

Why is this happening? Because by default, the returned JSON will get 200+ image references. Keeping separate ImgurImage instances is not a problem, however when all 200+ images are being downloaded at the same time in Image controls (remember that you are referencing the URL as the source), this will cause an application crash.

The solution here would be lazy loading or simply having images pre-downloaded once the JSON data is obtained. I chose to go the second route, so I created an ImageDownloadHelper class, with a DownloadImages function:

/// <summary>
/// Downloads images one-by-one from a given collection.
/// </summary>
/// <param name="images">The collection that contains the images.</param>
/// <param name="startIndex">Starting index at which the download starts.</param>
/// <param name="items">The number of items to download.</param>
/// <param name="onCompletion">Action executed every time an image is downloaded.</param>
public static void DownloadImages(IEnumerable<ImgurImage> images, int startIndex, int items, 
    Action<ImgurImage> onCompletion = null)
{
    if ((images != null) && (images.Count() > 0))
    {
        int count = images.Count();

        if (startIndex < count)
        {
            ImgurImage currentImage = images.ElementAt(startIndex);

            WebClient client = new WebClient();
            if (currentImage.Link != null && !(currentImage.Link.EndsWith(".gif")))
            {
                string thumbnailLocation = currentImage.Link.Insert(currentImage.Link.LastIndexOf('.'), "s");

                client.OpenReadAsync(new Uri(thumbnailLocation));
                client.OpenReadCompleted += (s, e) =>
                    {
                        BitmapImage image = new BitmapImage();
                        image.SetSource(e.Result);
                        currentImage.Image = image;

                        onCompletion(currentImage);

                        ContinueDownloadIfNecessary(images, startIndex, items, onCompletion);
                    };
            }
            else
            {
                ContinueDownloadIfNecessary(images, startIndex, items, onCompletion);
            }
        }
    }
}

I am doing the cross-check to ensure that the item that I will try to download exists and that the index is in the valid range. To avoid downloading large images and blocking memory, I am downloading thumbnails instead, which can be obtained by appending a lowercase ‘s’ to the image ID in the URL (you can see how I am forming the URL above).

Notice that the ImgurImage model now has an Image property. I modified the original model to include it:

[JsonIgnore]
public BitmapImage Image { get; set; }

Once an image is downloaded, I am able to proceed with the next download, if necessary, but I am also giving the user to perform a specific action for each separate download. ContinueDownloadIfNecessary is a helper function that increments the starting index and decrements the number of items to download:

private static void ContinueDownloadIfNecessary(IEnumerable<ImgurImage> images, int defaultStartIndex,
    int defaultItemsToLoad, Action<ImgurImage> onCompletion)
{
    int itemsToLoad = --defaultItemsToLoad;
    int newStartIndex = ++defaultStartIndex;

    if (itemsToLoad > 0)
        DownloadImages(images, newStartIndex, itemsToLoad, onCompletion);
}

By default, the Image control does not support GIF images, so I am making sure that I am not downloading unnecessary stuff either. I will show you how to mitigate this issue later in the series.

The loading routine in the main page effectively becomes this:

if ((MainPageViewModel.Instance.HomeImages == null) !=
    (MainPageViewModel.Instance.HomeImages.Count == 0))
{
    App.ServiceClient.GetMainGalleryImages(ImgurGallerySection.Hot, ImgurGallerySort.Viral, 0, (s) =>
    {
        ImgurImageData data = s;
        Debug.WriteLine("[JSON] Main gallery images loaded.");

        ImageDownloadHelper.DownloadImages(s.Images, 0, 10, (image) =>
            {
                MainPageViewModel.Instance.HomeImages.Add(image);
                Debug.WriteLine(string.Format("[{0}] Image added to HomeImages in MainPageViewModel.",
                    image.Type));
            });
    });
}

Here is a problem, though — once the download is complete, the ImgurImageData instance is gone, and so is the entire deserialized collection of JSON objects. To avoid this, I am going to create another List<ImgurImage> in my main page view model, specifically for storing the serialized items.

private List<ImgurImage> _deserializedHomeImages;
public List<ImgurImage> DeserializedHomeImages
{
    get
    {
        return _deserializedHomeImages;
    }
    set
    {
        if (_deserializedHomeImages != value)
        {
            _deserializedHomeImages = value;
            NotifyPropertyChanged("DeserializedHomeImages");
        }
    }
}

I can use a List because I am not binding it and I do not need to have an implementation of INotifyCollectionChanged.

Now your loading routine becomes much better:

if ((MainPageViewModel.Instance.HomeImages == null) !=
    (MainPageViewModel.Instance.HomeImages.Count == 0))
{
    App.ServiceClient.GetMainGalleryImages(ImgurGallerySection.Hot, ImgurGallerySort.Viral, 0, (s) =>
    {
        ImgurImageData data = s;
        MainPageViewModel.Instance.DeserializedHomeImages = 
            new System.Collections.Generic.List<ImgurImage>(s.Images);
        Debug.WriteLine("[JSON] Main gallery images loaded.");

        ImageDownloadHelper.DownloadImages(MainPageViewModel.Instance.DeserializedHomeImages, 0, 10, (image) =>
            {
                MainPageViewModel.Instance.HomeImages.Add(image);
                Debug.WriteLine(string.Format("[{0}] Image added to HomeImages in MainPageViewModel.",
                    image.Type));
            });
    });
}

If you run the application in its current state, you will get this result:

In the next article of the series, I will be talking about making the ListBox scrollable, loading images as we go, and viewing details about each of the images that is being loaded.