Файловый ввод/вывод в WinRT

151

Программисты, работающие с .NET, хорошо знакомы с использованием пространства имен System.IO для выполнения файловых операций ввода/вывода. Отчасти эти навыки пригодятся и в Windows 8, но как нетрудно убедиться, версия System.IO в Windows 8 заметно «похудела». Большая часть поддержки файлового ввода/вывода Windows Runtime находится в нескольких пространствах имен, начинающихся с префикса Windows.Storage. Приготовьтесь к изучению множества новых классов и концепций. Весь интерфейс файлов и потоков ввода/вывода был переработан, а все методы, обращающиеся к диску, работают асинхронно.

Приложение Windows 8 может выбрать одно из трех основных способов файлового ввода/вывода. Я опишу их в трех следующих разделах в порядке предпочтения.

Локальное хранение данных

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

Для получения доступа к локальному хранилищу используется класс ApplicationData из пространства имен Windows.Storage. Объект ApplicationData для текущего приложения можно получить из статического свойства Current:

ApplicationData appData = ApplicationData.Current;

Класс ApplicationData определяет несколько свойств, которые могут использоваться с этим объектом. Свойства LocalSettings и RoamingSettings предоставляют доступ к объекту ApplicationDataContainer, содержащему словарь для хранения параметров конфигурации приложения. Настройки ограничиваются базовыми типами Windows Runtime types (числа и строки).

Свойства LocalFolder, RoamingFolder и TemporaryFolder возвращают объекты типа StorageFolder - важного класса, также определенного в пространстве имен Windows.Storage. Класс StorageFolder представляет каталог - в данном случае каталог, предназначенный для закрытого использования приложением. Класс StorageFolder содержит методы для создания вложенных папок, для создания и обращения к файлам, представленным объектами типа StorageFile. Далее объект StorageFile можно открыть и получить объект потока ввода/вывода для чтения и записи.

Выбор файлов

В пространстве Windows.Storage.Pickers собраны классы FileOpenPicker, FileSavePicker и FolderPicker - стандартных диалоговых окон, которые используются программами Windows 8 для открытия и сохранения файлов в стандартных папках данных (таких, как библиотеки документов, изображений и музыки - Documents Library, Music Library и Pictures Library).

Как и MessageDialog, классы FileOpenPicker и FileSavePicker содержат асинхронные методы для отображения диалоговых окон; они возвращают информацию типа StorageFile, a FolderPicker возвращает объект типа StorageFolder.

Используя один из этих классов, пользователь фактически разрешает приложению работать с файловой системой, а затем управляет перемещением средства выбора по файловой системе, классы Picker обладают значительной гибкостью. Однако приложение должно указать конкретные типы файлов, которые его интересуют. Например, при использовании FileOpenPicker приложение должно указать как минимум один тип файла (допустим, «.txt») в свойстве FileTypeFilter. Типы файлов не могут содержать подстановочные символы.

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

Работа с файловой системой

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

Но поскольку этот процесс не управляется пользователем, приложение должно объявить свои намерения. Приложение, использующее этот механизм, должно содержать файл package.appxmanifest с описанием областей хранения информации, которые этому приложению разрешено просматривать. В Visual Studio вы можете отредактировать файл package.appxmanifest из диалогового окна. Чтобы получить доступ к разделам Documents Library, Music Library, Pictures Library или Videos Library, выберите их в разделе Capabilities этого окна.

Выбранные возможности определяют ограничения приложения. Для раздела Documents Library раздел Declarations должен включать пункт File Type Associations, а все типы файлов, нужные приложению, должны быть перечислены явно; все запросы информации о файлах ограничиваются этими типами.

Выбор файлов и файловый ввод/вывод

Чтобы поближе познакомиться с классами FileOpenPicker и FileSavePicker, мы напишем простую программу, похожую на классическое Windows-приложение Блокнот. По сути программа представляет собой большое поле TextBox с парой команд. Обычно эти команды реализуются на панели приложения, но я отложу эту тему.

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

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

<Page ...>

    <Page.Resources>
        <Style x:Key="buttonStyle" TargetType="ButtonBase">
            <Setter Property="Margin" Value="0 12" />
            <Setter Property="HorizontalAlignment" Value="Center" />
            <Setter Property="FontSize" Value="26" />
        </Style>
    </Page.Resources>

    <Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>

        <Button Content="Открыть..."
                Grid.Row="0"
                Grid.Column="0"
                Style="{StaticResource buttonStyle}"
                Click="OnFileOpenButtonClick" />

        <Button Content="Сохранить как..."
                Grid.Row="0"
                Grid.Column="1"
                Style="{StaticResource buttonStyle}"
                Click="OnFileSaveAsButtonClick" />

        <ToggleButton Name="wrapButton"
                      Content="Без переноса"
                      Grid.Row="0"
                      Grid.Column="2"
                      Style="{StaticResource buttonStyle}"
                      Checked="OnWrapButtonChecked"
                      Unchecked="OnWrapButtonChecked" />

        <TextBox Name="txtbox"
                 Grid.Row="1"
                 Grid.Column="0"
                 Grid.ColumnSpan="3"
                 FontSize="24"
                 AcceptsReturn="True" />
    </Grid>
</Page>

Три кнопки отображаются в верхней части приложения, а в большом поле TextBox вводится текст:

Простой текстовый редактор

Классы FileOpenPicker и FileSavePicker вызывают диалоговые окна, которые занимают место на экране и не возвращают управление до тех пор, пока не будут закрыты пользователем. Если вас это не устраивает, используйте решение BulkAccess с самостоятельным перемещением по каталогам.

Оба класса возвращают приложению объект типа StorageFile. (FileOpenPicker поддерживает возможность множественного выделения, при котором возвращаются несколько объектов StorageFile.) Объект StorageFile определяется в пространстве имен Windows.Storage и представляет неоткрытый файл.

В результате вызова одного из методов Open() для объекта StorageFile вы получаете объект потока ввода/вывода в виде интерфейса (например, IInputStream или IRandomAccessStream), определенного в пространстве имен Windows.Storage.Streams. Далее к потоку ввода/вывода можно присоединить объект DataReader или DataWriter для чтения и записи. Методы расширения, определенные в System.IO, также позволяют создать объект .NET Stream на базе объектов потоков ввода/вывода Windows Runtime, после чего использовать знакомые объекты .NET (такие, как StreamReader или StreamWriter) для работы с файлами. Возможно, это позволит вам повторно использовать существующий код, работающий с потоками .NET; кроме того, объекты потоков .NET также пригодятся для чтения и записи файлов XML.

Единственным обязательным условием для использования FileOpenPicker является включение хотя бы одной строки в коллекцию FileTypeFilter (например, «.txt»). Далее вызывается метод PickSingleFileAsync(). На экране появляется стандартное диалоговое окно выбора файла, пользователь выбирает существующий файл и нажимает кнопку Open или Cancel. Если вы используете await с вызовом этого метода, ваша программа получает объект StorageFile с обозначением файла, выбранного пользователем. Файл отделенного кода выглядит так:

using System;
using System.Collections.Generic;
using Windows.Storage;
using Windows.Storage.Pickers;
using Windows.Storage.Streams;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        ApplicationDataContainer appData = ApplicationData.Current.LocalSettings;

        public MainPage()
        {
            this.InitializeComponent();

            Loaded += (sender, args) =>
            {
                if (appData.Values.ContainsKey("TextWrapping"))
                    txtbox.TextWrapping = (TextWrapping)appData.Values["TextWrapping"];

                wrapButton.IsChecked = txtbox.TextWrapping == TextWrapping.Wrap;
                wrapButton.Content = (bool)wrapButton.IsChecked ? "С переносом" : "Без переноса";

                txtbox.Focus(FocusState.Programmatic);
            };
        }

        private async void OnFileOpenButtonClick(object sender, RoutedEventArgs args)
        {
            FileOpenPicker picker = new FileOpenPicker();
            picker.FileTypeFilter.Add(".txt");
            StorageFile storageFile = await picker.PickSingleFileAsync();

            // Если пользователь нажимает кнопку отмены, возвращается null
            if (storageFile == null)
                return;

            using (IRandomAccessStream stream = await storageFile.OpenReadAsync())
            {
                using (DataReader dataReader = new DataReader(stream))
                {
                    uint length = (uint)stream.Size;
                    await dataReader.LoadAsync(length);
                    txtbox.Text = dataReader.ReadString(length);
                }
            }
        }

        private async void OnFileSaveAsButtonClick(object sender, RoutedEventArgs args)
        {
            FileSavePicker picker = new FileSavePicker();
            picker.DefaultFileExtension = ".txt";
            picker.FileTypeChoices.Add("Text", new List<string> { ".txt" });
            StorageFile storageFile = await picker.PickSaveFileAsync();

            // Если пользователь нажимает кнопку отмены, возвращается null
            if (storageFile == null)
                return;

            using (IRandomAccessStream stream = await storageFile.OpenAsync(FileAccessMode.ReadWrite))
            {
                using (DataWriter dataWriter = new DataWriter(stream))
                {
                    dataWriter.WriteString(txtbox.Text);
                    await dataWriter.StoreAsync();
                }
            }
        }

        private void OnWrapButtonChecked(object sender, RoutedEventArgs args)
        {
            txtbox.TextWrapping = (bool)wrapButton.IsChecked ? TextWrapping.Wrap :
                                                               TextWrapping.NoWrap;
            wrapButton.Content = (bool)wrapButton.IsChecked ? "С переносом" : "Без переноса";
            appData.Values["TextWrapping"] = (int)txtbox.TextWrapping;
        }
    }
}

На самом деле метод PickSingleFileAsync() возвращает объект IAsyncOperation<StorageFile>, но это один из нескольких асинхронных вызовов, у которых объект, представленный обобщенным аргументом, может принимать значение null. Значение null встречается тогда, когда пользователь нажимает кнопку Cancel в окне выбора файла. В этом случае ничего больше делать не нужно.

Чтобы открыть объект StorageFile для чтения, можно вызвать для него метод OpenReadAsync(). Эта операция также выполняется в асинхронном режиме, что вполне логично, поскольку вызов должен обратиться к диску. OpenReadAsync() возвращает объект типа IAsyncOperation<IRandomAccessStreamWithContentType>, но интерфейс IRandomAccessStreamWithContentType реализует IRandomAccessStream, поэтому я использовал более короткий вариант. IRandomAccessStream реализует IDisposable, поэтому объект потока стоит разместить в блоке using для автоматического уничтожения.

DataReader также реализует IDisposable. Этот класс предоставляет много методов Read для примитивных типов Windows Runtime, например ReadString(). Эти методы Read не являются асинхронными, потому что они не связаны с доступом к диску. Они просто читают байты из внутреннего буфера (типа IBuffer), хранящегося в памяти, и преобразуют их в конкретные типы данных. Фактическое обращение к файлу на диске осуществляется методом LoadAsync(), который загружает заданное количество байтов из файла в буфер, что должно быть сделано до вызова Read. Для очень больших файлов загрузку данных можно разбить на несколько частей. Класс DataReader содержит свойство UnconsumedBufferLength, упрощающее процесс разбиения.

Без оператора await трем асинхронным методам потребуются собственные методы обратного вызова, а четвертый метод обратного вызова потребуется для выполнения кода, задающего свойство Text объекта TextBox в потоке пользовательского интерфейса.

Логика сохранения файлов выглядит аналогично. Метод StoreAsync() класса DataWriter возвращает объект DataWriteStoreOperation, реализующий IAsyncOperation<uint>. Параметр uint обозначает количество байтов, хранимых в файле. В данном примере значение ни для чего не используется, a StoreAsync() является последней командой в методе; так стоит ли использовать оператор await для этого вызова? Как правило, асинхронные методы, не возвращающие значений, можно вызывать без оператора await, однако следует учитывать, что метод, содержащий вызов, продолжит выполнение во время выполнения асинхронного метода. Это может создать проблемы, если метод, содержащий вызов, неявно предполагает, что асинхронный метод завершит работу до продолжения своего выполнения. В данном конкретном случае я бы не стал опускать await, потому что блоки using неявно закрывают объекты DataWriter и IRandomAccessStream, а до завершения StoreAsync это было бы нежелательно.

Наша программа также позволяет включать и выключать перенос текста при помощи переключателя ToggleButton. В поле appData сохраняется объект ApplicationDataContainer для этого приложения. Свойство Values объекта содержит словарь, который может использоваться приложением да сохранения параметров конфигурации - по крайней мере тех, которые могут быть выражены примитивными типами. В обработчике Loaded проверяется содержимое словаря; если он содержит элемент «TextWrapping», то этот элемент используется для задания свойства элемента TextBox, а переключатель ToggleButton инициализируется соответствующим образом.

Каждый раз, когда ToggleButton устанавливается или сбрасывается, обработчик задает свойство TextWrapping элемента TextBox и сохраняет новое значение в словаре.

Это один из возможных способов сохранения настроек приложения. Если вам когда-либо потребуются эти данные (и другие данные локального хранилища) на жестком диске, сначала в Visual Studio проверьте значение Package Name на вкладке Packaging файла Package.appxmanifest (или проверьте атрибут Name элемента Identity в файле). Значение представляет собой код GUID, однозначно ректифицирующий приложение. Настройки и локальные данные хранятся в каталоге:

C:\Users\[имя пользователя]\AppData\Local\Packages\[guid-приложения]

Обработка исключений

Если немного постараться, можно вызвать сбой в тестовой программе. Например нажмите кнопку "Открыть", выберите файл, но перед нажатием кнопки Open используйте Проводник Windows (или другую программу) для удаления выбранного файла. Когда программа попытается открыть несуществующий файл, инициируется исключение.

Для перехвата подобных ошибок весь блок кода после проверки StorageFile на null можно поместить в блок try. Но будьте внимательны: в блоке catch нельзя отобразить окно MessageDialog, чтобы сообщить пользователю о возникшей проблеме, потому что операторы await запрещены в блоках catch. Правильный способ обработки исключений должен выглядеть примерно так:

private async void OnFileOpenButtonClick(object sender, RoutedEventArgs args)
{
     // ...

    Exception exception = null;

    try
    {
        using (IRandomAccessStream stream = await storageFile.OpenReadAsync())
        {
            using (DataReader dataReader = new DataReader(stream))
            {
                uint length = (uint)stream.Size;
                await dataReader.LoadAsync(length);
                txtbox.Text = dataReader.ReadString(length);
            }
        }
    }
    catch (Exception ex)
    {
        exception = ex;
    }

    if (exception != null)
    {
        MessageDialog msg = new MessageDialog(exception.Message,
            "Ошибка при чтении файла");
        await msg.ShowAsync();
    }
}

Последняя команда if определяет, произошло ли исключение, проверяя значение переменной exception. Если оно отлично от null, в этой точке можно использовать MessageDialog для вывода сообщения об ошибке.

Консолидация асинхронных вызовов

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

Допустим, метод LoadFile() выводит FileOpenPicker, читает все содержимое текстового файла и возвращает строку. Метод OnFileOpenButtonClick() в этом случае может быть совсем простым:

private void OnFileOpenButtonClick(object sender, RoutedEventArgs args)
{
    txtbox.Text = LoadFile();
}

Вообще-то настолько простым он быть не может. Метод LoadFile() не может возвращать строку, потому что строка недоступна до завершения асинхронных операций. Следует помнить, что оператор await приводит к созданию методов обратного вызова так, как если бы вы записали в явном виде. Попробуйте написать метод LoadFile() с явными методами обратного вызова, а затем вернуть строку из LoadFile(). У вас ничего не получится.

Нужно каким-то образом применить оператор await непосредственно к методу LoadFile(). Вероятно, при этом метод правильнее назвать LoadFileAsync(), а вызываться он должен так:

private async void OnFileOpenButtonClick(object sender, RoutedEventArgs args)
{
    txtbox.Text = await LoadFileAsync();
}

Такое решение выглядит приемлемым - это означает, что метод LoadFileAsync() можно написать без обработки исключений, разместив обработку на стороне вызова (в данном случае в обработчике OnFileOpenButtonClick).

Но какой тип значения должен возвращать метод LoadFileAsync? Судя по асинхронным методам, реализованным в Windows Runtime, можно предположить IAsyncOperation<string>, но это не так. Проблема в том, что Windows Runtime не определяет открытый класс, реализующий этот интерфейс.

Вместо этого программисту C# приходится использовать классы .NET, поддерживающие асинхронные операции. Лучшим типом возвращаемого значения LoadFileAsync() будет Task<string>, а сам метод выглядит так:

private async Task<string> LoadFileAsync()
{
    FileOpenPicker picker = new FileOpenPicker();
    picker.FileTypeFilter.Add(".txt");
    StorageFile storageFile = await picker.PickSingleFileAsync();

    // Если пользователь нажимает кнопку отмены, возвращается null
    if (storageFile == null)
        return null;

    using (IRandomAccessStream stream = await storageFile.OpenReadAsync())
    {
        using (DataReader dataReader = new DataReader(stream))
        {
            uint length = (uint)stream.Size;
            await dataReader.LoadAsync(length);
            return dataReader.ReadString(length);
        }
    }
}

Несмотря на имя LoadFileAsync(), весь код метода выполняется в потоке пользовательского интерфейса. Но метод считается асинхронным, потому что отдельные его части выполняются во вторичных потоках. Следует помнить, что при нажатии пользователем кнопки отмены метод возвращает null. Присвоить null свойству Text поля TextBox невозможно, поэтому обработчик Click должен учитывать такую возможность:

private async void OnFileOpenButtonClick(object sender, RoutedEventArgs args)
{
    string text = await LoadFileAsync();

    if (text != null)
        txtbox.Text = text;
}

Что такое Task? Это класс, определенный в пространстве имен System.Threading.Tasks и занимающий центральное место в поддержке асинхронных и параллельных операций в .NET. Он существует как в обобщенной (generic), так и в необобщенной версиях. Для метода, который не возвращает ничего, используется необобщенная версия - как в следующем методе, консолидирующем логику сохранения:

private async void OnFileSaveAsButtonClick(object sender, RoutedEventArgs args)
{
    await SaveFileAsync(txtbox.Text);
}

private async Task SaveFileAsync(string text)
{
    FileSavePicker picker = new FileSavePicker();
    picker.DefaultFileExtension = ".txt";
    picker.FileTypeChoices.Add("Text", new List<string> { ".txt" });
    StorageFile storageFile = await picker.PickSaveFileAsync();

    // Если пользователь нажимает кнопку отмены, возвращается null
    if (storageFile == null)
        return;

    using (IRandomAccessStream stream = await storageFile.OpenAsync(FileAccessMode.ReadWrite))
    {
        using (DataWriter dataWriter = new DataWriter(stream))
        {
            dataWriter.WriteString(text);
            await dataWriter.StoreAsync();
        }
    }
}

Поддержка асинхронных операций в .NET и Windows Runtime имеет достаточно много общего, чтобы эти типы можно было преобразовывать друг в друга. Класс Task содержит метод расширения AsAsyncAction(), который возвращает IAsyncAction, a Task<T> содержит метод расширения AsAsyncOperation<T>, который возвращает IAsyncOperation<T>. С другой стороны, IAsyncAction содержит метод AsTask(), возвращающий Task, a IAsyncOperation<T> - метод AsTask<T>, возвращающий Task<T>.

При этом класс Task по своим возможностям существенно превосходит асинхронную поддержку Windows Runtime. В частности, он предоставляет средства управления параллельным выполнением и ожидания по группам задач.

Вспомогательные средства файлового ввода/вывода

Хотя программисту полезно уметь работать с классами DataReader и DataWriter, вероятно, большая часть ввода/вывода может быть выполнена с использованием вспомогательных методов статических классов FileIO и PathIO пространства имен Windows.Storage. Методы этих классов читают и записывают целые файлы за один асинхронный вызов.

Для текстовых файлов метод FileIO.ReadLinesAsync() читает текстовый файл и возвращает объект IList с объектами string (по одному на строку), а метод FileIO.ReadTextAsync() возвращает содержимое файла в одном объекте string. В нашем приложении блок из двух вложенных команд using в OnFileOpenButtonClick можно заменить следующей конструкцией:

txtbox.Text = await FileIO.ReadTextAsync(storageFile);

Аналогичным образом вся логика сохранения файла может быть заменена одним вызовом:

await FileIO.WriteTextAsync(storageFile, txtbox.Text, UnicodeEncoding.Utf8);

Для двоичных файлов можно использовать методы ReadBufferAsync() и WriteBufferAsync(), работающие с объектом типа IBuffer. Объект IBuffer фактически представляет собой массив байтов, существующих в системной памяти. Отслеживание ссылок на объект IBuffer позволяет системе удалить его из памяти, когда он перестанет использоваться.

Напрямую работать с объектом IBuffer из программы C# невозможно, но можно получить доступ к данным косвенно. Чтобы создать двоичный файл, создайте объект DataWriter, запишите в него данные и сохраните внутренний объект IBuffer, созданный объектом DataWriter:

DataWriter writer = new DataWriter();

// ... запись данных в DataWriter

await FileIO.WriteBufferAsync(storageFile, writer.DetachBuffer());

При чтении двоичного файла сначала получите объект IBuffer, а затем создайте для него объект DataReader:

IBuffer buffer = await FileIO.ReadBufferAsync(storageFile);
DataReader reader = DataReader.FromBuffer(buffer);

// ... чтение из DataReader

Включив в свою программу пространство имен System.Runtime.InteropServices.WindowsRuntime, вы сможете преобразовать объект IBuffer в объект .NET Stream и использовать его для создания других классов, определенных в пространстве имен System.IO: BinaryReader, BinaryWriter, StreamReader и StreamWriter. Также IBuffer можно преобразовать в массив байтов.

Класс PathIO похож на FileIO, но вместо передачи статическим методам объекта StorageFile передается строковый URI-адрес. Как будет показано далее, обычно он начинается с префикса «ms-appx:///» для обращения к файлам, хранимым в контенте программы, или «ms-appdata:///» для обращения к файлам в хранилище приложения.

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

Uri uri = new Uri("http://professorweb.ru");
RandomAccessStreamReference streamRfc = RandomAccessStreamReference.CreateFromUri(uri);

using (IRandomAccessStream stream = await streamRfc.OpenReadAsync())
{
     // ...
}

Далее можно вызвать ReadAsync() для объекта IRandomAccessStream, чтобы прочитать содержимое файла в IBuffer, после чего передать IBuffer статическому методу DataReader.FromBuffer().

Пройди тесты
Лучший чат для C# программистов