Ноябрь 2015

Том 30, номер 12

Windows 10 - Ускорение файловых операций с помощью индексатора поиска

Адам Уилсон | Ноябрь 2015

Продукты и технологии:

Windows 10, индексатор поиска, Windows.Storage, Universal Windows Platform, Cortana

В статье рассматривается:

  • как в Windows 10 индексатор поиска (search indexer) работает со всеми приложениями Universal Windows Platform (UWP);
  • как индексатор поиска улучшает взаимодействие приложений с файловой системой;
  • как использовать индексатор для отслеживания изменений в файловой системе, быстро визуализировать представления и ускорять выполнение запросов.

Индексатор поиска является частью Windows на протяжении уже многих версий, обеспечивая эффективную поддержку от библиотечных представлений в File Explorer до адресной строки в IE, а также предоставляя функциональность поиска в меню Start и в Outlook. С появлением Windows 10 мощь индексатора стала доступной не только на настольных компьютерах, но и всем приложениям на платформе Universal Windows Platform (UWP). Хотя это также позволяет Cortana эффективнее выполнять операции поиска, самое интересное в том, что это же значительно улучшает способы взаимодействия приложений с файловой системой.

Индексатор дает возможность приложениям выполнять больше интересных операций вроде сортировки и группирования файлов, а также отслеживания изменений в файловой системе. Большинство API индексатора доступно UWP-приложениям через пространства имен Windows.Storage и Windows.Storage.Search. Приложения уже обеспечивают с помощью индексатора удобную среду своим пользователям. В этой статье я пошагово поясню, как использовать индексатор для отслеживания изменений в файловой системе, быстро визуализировать представления и предлагать некоторые подсказки для улучшения запросов приложения.

Быстрый доступ к файлам и метаданным

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

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

Например, вызов StorageFolder.GetFilesAsync — это рецепт катастрофы, если вы не контролируете перечисляемую папку. Пользователи могут поместить в один каталог миллиард файлов, но попытка создать объекты StorageFile для каждого из них приведет к тому, что приложение очень быстро исчерпает всю память. Даже в менее экстремальных случаях этот вызов все равно будет выполняться очень медленно, поскольку системе потребуется создать тысячи описателей файлов и выполнить их маршалинг обратно в контейнер приложения. Чтобы избежать этой проблемы, система предоставляет классы StorageFileQueryResults и StorageFolderQueryResults.

StorageFileQueryResults — это лучший класс, если вы пишете приложение, обрабатывающее нетривиальное количество файлов. Он не только обеспечивает эффективное перечисление и модификацию результатов сложных запросов поиска, но и, — поскольку обрабатывает запрос на перечисление как запрос «*», — подходит для более прозаичных случаев.

Максимальное применение индексатора везде, где это имеет смысл, — первый шаг к ускорению работы вашего приложения. Понимаю, что, поскольку я занимаюсь индексаторами, это звучит, как призыв человека, пекущегося о собственных интересах, но для сказанного мной есть веская причина. Объекты StorageFile и StorageFolder разрабатывались в расчете на индексатор. Значения свойств, кешируемых в объекте, можно быстро извлекать из индексатора. Если вы не пользуетесь индексатором, системе придется искать соответствующие значения на диске и в реестре, что требует интенсивного ввода-вывода, а значит, вызовет проблемы с производительностью как приложения, так и системы.

Чтобы убедиться в использовании индексатора, создайте объект QueryOptions и задайте свойству QueryOptions.IndexerOption значение OnlyUseIndexer:

QueryOptions options = new QueryOptions();
options.IndexerOption = IndexerOption.OnlyUseIndexer;

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

options.IndexerOption = IndexerOption.UseIndexerWhenAvailable;

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

Иногда создание объекта QueryOptions выглядит несколько избыточным для элементарного перечисления и в таких случаях имеет смысл не заботиться, присутствует ли индексатор. Если у вас есть контроль за содержимым папки, предпочтительнее вызывать StorageFolder.GetItemsAsync. Это простая строка кода, а любые проблемы с производительностью будут скрываться в случаях, где в каталоге находится всего несколько файлов.

Другой способ ускорить перечисление файлов — не создавать ненужные объекты StorageFile или StorageFolder.

Другой способ ускорить перечисление файлов — не создавать ненужные объекты StorageFile или StorageFolder. Даже при использовании индексатора открытие StorageFile требует от системы создать описатель файла, собрать какие-то данные и выполнить их маршалинг в процесс приложения. Это взаимодействие между процессами (IPC) вызывает неизбежные задержки, которых во многих случаях можно избежать, попросту не создавая такие объекты.

Важно отметить, что объект StorageFileQueryResult, поддерживаемый индексатором, не создает на внутреннем уровне никаких объектов StorageFile. Они создаются по запросу от GetFilesAsync. А до тех пор система поддерживает лишь список файлов в памяти, который занимает сравнительно малый объем.

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

Рис. 1. GetFilesAsync

uint index = 0, stepSize = 10;
IReadOnlyList<StorageFile> files =
  await queryResult.GetFilesAsync(index, stepSize);
index += 10;       
while (files.Count != 0)
{
  var fileTask = queryResult.GetFilesAsync(
    index, stepSize).AsTask();
  foreach (StorageFile file in files)
  {
    // Здесь выполняем какую-то фоновую обработку
  }
  files = await fileTask;
  index += 10;
}

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

Объекты StorageFile и StorageFolder разрабатывались в расчете на индексатор.

Упреждающая выборка свойств (property prefetching) — еще один несложный способ ускорить работу вашего приложения. Он позволяет приложению уведомлять систему о своей заинтересованности в заданном наборе файловых свойств. Система будет получать эти свойства от индексатора в то время, как он перечисляет этот набор файлов и кеширует их в объекте StorageFile. Это дает возможность легко увеличить производительность, не собирая значения свойств поэтапно, по мере возврата файлов.

Значения упреждающей выборки свойств указываются в объекте QueryOptions. При использовании PropertyPrefetchOptions поддерживается несколько распространенных сценариев, но в приложениях также можно изменять любые значения, поддерживаемые Windows, для любых запрашиваемых свойств. Код для этого очень прост:

QueryOptions options = new QueryOptions();
options.SetPropertyPrefetch(
  PropertyPrefetchOptions.ImageProperties,
  new String[] { });

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

Одно последнее замечание на эту тему: свойство должно храниться в индексаторе для упреждающей выборки, чтобы обеспечить выигрыш в производительности; иначе системе все равно придется обращаться к файлу для нахождения значения, что выполняется сравнительно медленно. На странице Microsoft Windows Dev Center (bit.ly/1LuovhT) есть вся информация о свойствах, доступных в индексаторе Windows. Просто ищите isColumn = true в описании свойства — это указывает на то, что данное свойство доступно для упреждающей выборки.

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

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

Рис. 2. Наивный код, перечисляющий библиотеку изображений

StorageFolder folder = KnownFolders.PicturesLibrary;
QueryOptions options = new QueryOptions(
  CommonFileQuery.OrderByDate, new String[]
  { ".png", ".jpeg", ".png" });
options.IndexerOption = IndexerOption.OnlyUseIndexer;
StorageFileQueryResult queryResult =
  folder.CreateFileQueryWithOptions(options);

Stopwatch watch = Stopwatch.StartNew();
IReadOnlyList<StorageFile> files =
  await queryResult.GetFilesAsync();

foreach (StorageFile file in files)
{
  IDictionary<string, object> size =
    await file.Properties.RetrievePropertiesAsync(
    new String[] { "System.Image.VerticalSize" });
  var sizeVal = size["System.Image.VerticalSize"];
}
watch.Stop();
Debug.WriteLine("Time to run the slow way: " +
  watch.ElapsedMilliseconds + " ms");

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

StorageFolder folder = KnownFolders.PicturesLibrary;

QueryOptions options = new QueryOptions(
  CommonFileQuery.OrderByDate, new String[]
  { ".png", ".jpeg", ".png" });
// Измените на DoNotUseIndexer для теста 3
options.IndexerOption = IndexerOption.OnlyUseIndexer;

options.SetPropertyPrefetch(PropertyPrefetchOptions.None,
  new String[] { "System.Image.VerticalSize" });
StorageFileQueryResult queryResult =
  folder.CreateFileQueryWithOptions(options);

Stopwatch watch = Stopwatch.StartNew();
uint index = 0, stepSize = 10;
IReadOnlyList<StorageFile> files =
  await queryResult.GetFilesAsync(index, stepSize);
index += 10;

// Заметьте, что я разбиваю файлы по страницам
while (files.Count != 0)
{
  var fileTask = queryResult.GetFilesAsync(
    index, stepSize).AsTask();
  foreach (StorageFile file in files)
  {
    // Помещаем значение в память, чтобы быть уверенным в том,
    // что система использует упреждающую выборку свойства
    IDictionary<string,object> size =
      await file.Properties.RetrievePropertiesAsync(
      new String[] { "System.Image.VerticalSize" });
    var sizeVal = size["System.Image.VerticalSize"];
  }
  files = await fileTask;
  index += 10;
}
watch.Stop();
Debug.WriteLine("Time to run: " +
  watch.ElapsedMilliseconds + " ms");

Глядя на результаты с упреждающей выборкой и без нее, мы видим очевидную разницу в производительности (табл. 1).

Табл. 1. Результаты с упреждающей выборкой и без нее

Тестовый сценарий (2600 изображений на настольном компьютере) Среднее (по более чем 10 замерам) время выполнения
Наивный код + индексатор 9318 мс
Все оптимизации + индексатор 5796 мс
Оптимизации + отсутствие индексатора 20 248 мс (48 420 мс на первом проходе)

Есть шанс почти двойного увеличения производительности наивного кода за счет применения простых оптимизаций, кратко обрисованных здесь. Все шаблоны также проверены «в боевых условиях». Прежде чем выпускать любую версию Windows, мы работаем с прикладными группами, чтобы убедиться, что Photos, Groove Music и прочие программы выполняются максимально быстро. Вот откуда появились эти шаблоны; они взяты прямо из кода первых UWP-приложений и могут применяться непосредственно в ваших приложениях.

Существует два метода отслеживания изменений, применяемых в зависимости от того, работает ваше приложение как активное (на переднем плане) или фоновое.

Отслеживание изменений в файловой системе

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

Существует два метода отслеживания изменений, применяемых в зависимости от того, работает ваше приложение как активное (на переднем плане) или фоновое. Когда приложение активно, оно может использовать событие ContentsChanged от объекта StorageFileQueryResult, чтобы получать уведомления об изменениях в рамках данного запроса. А когда приложение работает в фоне, оно может зарегистрироваться на StorageLibraryContentChangedTrigger для получения уведомлений при каком-то изменении. В обоих вариантах отправляются уведомления, чтобы дать знать приложению, что произошло какое-то изменение, но эти уведомления не включают информацию об измененных файлах.

Для определения того, какие файлы были недавно модифицированы или созданы, система предоставляет свойство System.Search.GatherTime. Это свойство является набором для всех файлов в индексируемом каталоге и отслеживает последнее время, в которое индексатор заметил модификацию файла. Однако оно будет постоянно обновляться на основе системных часов, в том числе из-за переключения часовых поясов, переходов на летнее/зимнее время и возможности у пользователей вручную изменять системное время.

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

StorageFileQueryResult resultSet =
  photos.CreateFileQueryWithOptions(option);
resultSet.ContentsChanged += resultSet_ContentsChanged;

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

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

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

Я описал регистрацию фоновой задачи и дал несколько примеров кода в блоге (bit.ly/1iPUVIo), а здесь давайте разберем пару более интересных операций. Первым делом приложение должно создать фоновый триггер (background trigger):

StorageLibrary library = await StorageLibrary.GetLibraryAsync(
  KnownLibraryId.Pictures);
StorageLibraryContentChangedTrigger trigger =
  StorageLibraryContentChangedTrigger.Create(library);

Триггер также можно создать из набора библиотек, если приложение заинтересовано в отслеживании нескольких каталогов. В данном случае приложение намерено следить только за библиотекой картинок, что является одним из самых распространенных сценариев в приложениях. Вы должны быть уверены, что приложение имеет подходящие возможности для обращения к библиотеке, которую оно пытается отслеживать; иначе система вернет исключение «запрос на доступ отклонен», когда приложение попытается создать объект StorageLibrary.

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

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

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

Для поиска недавно модифицированных или добавленных файлов достаточно отправить один запрос индексатору. Просто запросите все файлы со временем выборки (gather time), попадающим в диапазон, который интересует приложение. При необходимости можно использовать те же средства сортировки и группирования, что и для других запросов. Учтите, что на внутреннем уровне индексатор использует гринвичское время (Zulu time), поэтому перед обращением к нему не забудьте преобразовать все строки со значениями времени в эквивалентные строки со временем по Гринвичу. Вот как можно построить запрос:

QueryOptions options = new QueryOptions();
DateTimeOffset lastSearchTime =
  DateTimeOffset.UtcNow.AddHours(-1);
// Это преобразование в гринвичское время,
// используемое индексатором
string timeFilter = "System.Search.GatherTime:>=" +
  lastSearchTime.ToString("yyyy\\-MM\\-dd\\THH\\:mm\\:ss\\Z")
options.ApplicationSearchFilter += timeFilter;

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

Комбинация двух методов отслеживания изменений со временем выборки позволяет UWP-приложениям легко отслеживать изменения в файловой системе и реагировать на них. Возможно, это сравнительно новые API в истории Windows, но они уже используются в приложениях Photos, Groove Music, OneDrive, Cortana и Movies & TVs, встроенных в Windows 10. Вы можете спокойно включать их в свое приложение, зная, что они эффективно работают в этих программах.

Общие рекомендации

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

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

Индексатор достаточно эффективен в обработке регулярных выражений, но некоторых их виды заведомо замедляют выполнение. Худшее, что можно включить в запрос к индексатору, — поиск по суффиксу (suffix search). Это запрос всех слов, заканчивающихся заданным значением. Примером может послужить запрос вида «*tion», который ищет все документы, содержащие слова, заканчивающиеся на «tion». Поскольку индекс сортируется по первой букве каждой лексемы, быстро найти слова, соответствующие этому запросу, нельзя. Приходится декодировать каждую лексему во всем индексе и сравнивать ее с критерием поиска, что происходит крайне медленно.

Перечисления могут ускорять запросы, но приводят к непредсказуемому поведению в международных сборках (international builds). Любой, кто создавал систему поиска, знает, насколько сравнения на основе перечисления выполняются быстрее, чем сравнения строк. Это относится и к индексатору. Чтобы облегчить работу вашему приложению, система свойств предоставляет ряд перечислений для фильтрации результатов до меньшего количества элементов, прежде чем начинать «дорогостоящее» сравнение строк. Распространенный пример этого — применение фильтра System.Kind для ограничения результатов только теми видами файлов, которые приложение может обрабатывать, скажем, музыку или документы.

При использовании перечислений следует знать об одной распространенной ошибке. Если ваш пользователь собирается искать только музыкальные файлы, то в версии Windows «en-us» добавление System.Kind:=music в запрос будет нормально работать, ограничивая результаты поиска и ускоряя выполнение запроса. Это будет работать и в других языковых версиях (возможно, даже пройдет тесты интернационализации), но закончится неудачей, где система не сможет распознать «music» как английское слово и разберет его, используя локальный язык.

Правильный способ использования такого перечисления, как System.Kind, явно обозначить, что приложение намерено задействовать это значение как перечисление, а не критерий поиска. Это делается по синтаксису enumeration#value. Например, правильный способ фильтрации только музыкальных файлов — написать System.Kind:=System.Kind#Music. Это будет нормально работать во всех языковых версиях Windows.

Корректная изоляция Advanced Query Syntax (AQS) поможет вашим пользователям избежать проблем с запросами. В AQS есть ряд средств, дающих возможность пользователям включать кавычки и скобки для того, чтобы влиять на то, как обрабатывается запрос. То есть приложения должны тщательно изолировать любые критерии запроса, которые могут включать эти символы. Например, поиск Document(8).docx приведет к ошибке разбора, и вы получите неправильные результаты. Вместо этого приложение должно изолировать критерий как Document%288%29.docx. Тогда вы получите элементы из индекса, совпадающие с критерием поиска, и система не станет интерпретировать кавычки как часть запроса.

Глубокое описание всех средств AQS и того, как убедиться в правильности ваших запросов, вы найдете в документации по ссылке bit.ly/1Fhacfl. В ней уйма полезной информации.

Замечание по поводу задержек индексации. Она не мгновенна, а значит, элементы появляются в индексе с небольшой задержкой после записи файла. Это же относится и к уведомлениям от индексатора. При обычной нагрузке на систему задержка составляет порядка 100 мс; это быстрее, чем большинство приложений может запросить файловую систему, поэтому задержка останется незамеченной. Но бывают случаи, где пользователь может переносить тысячи файлов на другой компьютер, и тогда индексатор будет существенно отставать.

В таких ситуациях рекомендуется делать две вещи. Прежде всего приложение должно хранить открытым запрос ко всем каталогам файловой системы, в которых оно заинтересовано больше всего. Как правило, для этого создается объект StorageFileQueryResult, охватывающий все каталоги файловой системы, где приложение будет вести поиск. Когда индексатор видит, что у приложения имеется открытый запрос, он ставит в приоритет индексацию этих областей. Но, пожалуйста, не делайте этого для большей, чем нужно области. Иначе индексатор перестанет соблюдать приоритеты и максимально быстро обрабатывать соответствующие уведомления, из-за чего пользователи могут заметить падение производительности системы.

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

Заключение

Это был краткий экскурс по средствам, доступным для потребителей индексатора и Windows Storage API в Windows 10. Подробнее о том, как использовать запросы для передачи контекста при активации приложения, а также примеры кода для фонового триггера см. в блоге группы (bit.ly/1iPUVIo). Мы постоянно сотрудничаем с разработчиками, чтобы обеспечить корректное и эффективное использование API поиска. Мы были бы рады услышать ваши отзывы о его работе и о том, что еще вы хотели бы увидеть в этом API.


Адам Уилсон (Adam Wilson) — менеджер программ в группе Windows Developer Ecosystem and Platform. Работает над индексатором Windows и push-уведомлениями. Ранее занимался API хранилищ для Windows Phone 8.1. С ним можно связаться по адресу adwilso@microsoft.com.

Выражаю благодарность за рецензирование статьи эксперту Сами Кури (Sami Khoury).