向文件进行写入的最佳做法

重要的 API

开发人员在使用 FileIOPathIO 类的 Write 方法执行文件系统 I/O 操作时,偶尔会遇到一系列常见问题。 例如,这些常见问题包括:

  • 不完整地写入某个文件。
  • 应用在调用某个方法时收到异常。
  • 操作留下一些文件名类似于目标文件名的 .TMP 文件。

FileIOPathIO 类的 Write 方法包括:

  • WriteBufferAsync
  • WriteBytesAsync
  • WriteLinesAsync
  • WriteTextAsync

本文将提供有关这些方法的工作原理的详细信息,使开发人员能够更好地了解何时以及如何使用它们。 本文只会提供指导,而不会试图针对所有可能的文件 I/O 问题提供解决方法。

注意

 本文会重点介绍示例和讨论内容中的 FileIO 方法。 但是,PathIO 方法遵循类似的模式,本文中的大部分指导同样适用于这些方法。

便利性与控制度

StorageFile 对象并非本机 Win32 编程模型那样的文件句柄。 StorageFile 是文件的一种表示形式,该文件包含用于操作其内容的方法。

使用 StorageFile 执行 I/O 时,了解这一概念很有好处。 例如,写入文件部分演示了写入文件的三种方式:

前两种方案是应用最常用的方案。 以单个操作写入文件更易于编程和维护,同样,使应用不必要应对文件 I/O 存在的多种复杂性。 但是,获得这种便利性也需要付出一定的代价:损失了整个操作的控制度,并且无法捕获特定时间点发生的错误。

事务模型

FileIOPathIO 类的 Write 方法通过一个附加层整合了上述第三个写入模型的步骤。 此层封装在存储事务中。

如果在写入数据时出错,为了保护原始文件的完整性,Write 方法将通过 OpenTransactedWriteAsync 打开该文件,以使用事务模型。 此过程将创建 StorageStreamTransaction 对象。 创建此事务对象后,API 会按照 StorageStreamTransaction 一文中的文件访问示例或代码示例所述的类似方式写入数据。

下图演示了成功的写入操作中 WriteTextAsync 方法执行的基础任务。 此图提供了操作的简化视图。 例如,它跳过了在不同的线程上执行文本编码和异步完成的步骤。

UWP API call sequence diagram for writing to a file

使用 FileIOPathIO 类的 Write 方法,而不是使用利用流的更复杂四步模型的优势包括:

  • 执行一次 API 调用即可处理所有中间步骤,包括错误处理。
  • 出错时可以保留原始文件。
  • 尽量保留干净的系统状态。

但是,由于存在许多的潜在中间故障点,发生失败的可能性也会增大。 发生错误时,可能难以了解过程在哪个位置失败。 以下部分介绍了在使用 Write 方法时可能会遇到的一些失败,并提供了可能的解决方法。

FileIO 和 PathIO 类的 Write 方法的常见错误代码

下表列出了应用开发人员在使用 Write 方法时可能会遇到的常见错误代码。 表中的步骤对应于上图中的步骤。

错误名称(值) 步骤 原因 解决方案
ERROR_ACCESS_DENIED (0X80070005) 5 原始文件可能已标记为删除(可能是在前一操作中标记的)。 重试该操作。
确保对文件的访问权限已同步。
ERROR_SHARING_VIOLATION (0x80070020) 5 原始文件已由另一个排他写入操作打开。 重试该操作。
确保对文件的访问权限已同步。
ERROR_UNABLE_TO_REMOVE_REPLACED (0x80070497) 19-20 原始文件 (file.txt) 已被使用,因此无法将其替换。 在替换之前,另一个进程或操作已获取了该文件的访问权限。 重试该操作。
确保对文件的访问权限已同步。
ERROR_DISK_FULL (0x80070070) 7、14、16、20 事务处理模型创建了额外的文件,这消耗了额外的存储。
ERROR_OUTOFMEMORY (0x8007000E) 14、16 此错误的原因可能是存在多个未完成的 I/O 操作,或文件很大。 以更精细的方法控制流可能会解决该错误。
E_FAIL (0x80004005) 任意 杂项 请重试操作即可。 如果仍然失败,则可能表示平台出错,应用应该终止,因为它处于不一致状态。

可能导致出错的其他文件状态考虑因素

除了 Write 方法返回的错误以外,下面还提供了有关应用在写入文件时预期可能会遇到的问题的指导。

当且仅当操作完成时,才会将数据写入文件

当操作正在进行时,应用不应该对文件中的数据做出任何假设。 在操作完成之前尝试访问文件可能会导致不一致的数据。 应用应该负责跟踪未完成的 I/O。

读取者

如果写入到的文件同时由某个正常的读取器(即,文件是使用 FileAccessMode.Read 打开的),则后续读取将会失败并出现错误 ERROR_OPLOCK_HANDLE_CLOSED (0x80070323)。 有时,当 Write 操作正在进行时,应用会重试再次打开文件进行读取。 这可能会导致争用状态,使 Write 最终在尝试覆盖原始文件时失败,因为无法替换该文件。

KnownFolders 中的文件

你的应用可能不是唯一一个尝试访问任何 KnownFolders 中的文件的应用。 无法保证当操作成功时,应用写入到该文件的内容在下一次尝试读取该文件时保持不变。 此外,在这种情况下,拒绝共享或访问错误会更常见。

有冲突的 I/O

如果应用对其本地数据中的文件使用 Write 方法,则可以减少出现并发错误的可能性,但仍需注意某些问题。 如果同时将多个 Write 发送到文件,则无法保证该文件中的最终数据是什么。 为了缓解此问题,我们建议让应用将发送到文件的 Write 操作序列化。

~TMP 文件

有时,如果强制取消操作(例如,如果应用已由 OS 挂起或终止),则不会提交或适当关闭事务。 这可能会留下带有 .~TMP 扩展名的文件。 在处理应用激活时,请考虑删除这些临时文件(如果应用的本地数据中存在这些文件)。

文件类型相关的注意事项

根据文件的类型、其访问频率和大小,某些错误可能会变得更加普遍。 通常情况下,应用可以访问三种类别的文件:

  • 由用户在应用的本地数据文件夹中创建和编辑的文件。 只能在使用应用时创建和编辑这些文件,并且它们只会在该应用中存在。
  • 应用元数据。 应用使用这些文件来跟踪自身的状态。
  • 文件系统位置中的其他文件,应用在其中声明了访问功能。 这些文件通常位于某个 KnownFolders 中。

应用对前两个类别的文件拥有完全控制权,因为这些文件是该应用的包文件的一部分,并由该应用独占访问。 对于最后一个类别中的文件,应用必须注意,其他应用和 OS 服务可能同时正在访问这些文件。

根据具体的应用,对文件的访问因频率而异:

  • 极低。 这些文件通常在应用启动时打开,并在应用挂起时保存。
  • 低。 这些文件是用户专门对其执行操作(例如保存或加载)的文件。
  • 中等或高。 应用必须在这些文件中持续更新数据(例如,自动保存功能或常量元数据跟踪)。

在文件大小方面,请考虑 WriteBytesAsync 方法的以下图表中的性能数据。 该图表比较了在受控的环境中,根据平均性能为每个文件大小 10000 次操作,针对不同的文件大小完成某项操作所需的时间。

WriteBytesAsync performance

此图表中有意省略了 Y 轴上的时间值,因为不同的硬件和配置会生成不同的绝对时间值。 但是,我们在测试中观察到了这些趋势的一致性:

  • 对于极小的文件 (<= 1 MB):完成操作所需的时间一贯很短。
  • 对于较大的文件 (> 1 MB):完成操作所需的时间开始呈指数级增长。

应用挂起期间的 I/O

如果你想要保留状态信息或元数据以便在后续会话中使用,则应用必须能够处理挂起。 有关应用挂起的背景信息,请参阅应用生命周期此博客文章

除非 OS 授权长时间执行应用,否则,当应用挂起时,它可以在 5 秒钟内释放其资源并保存其数据。 为获得最佳可靠性和用户体验,请始终假设处理挂起任务的时间是有限的。 在处理挂起任务的 5 秒时限内,请记住以下指导原则:

  • 尽量减少 I/O,以避免刷新和释放操作导致争用状态。
  • 避免写入需要数百毫秒或更长时间才能写入的文件。
  • 如果应用使用 Write 方法,请记住这些方法需要的所有中间步骤。

如果应用在挂起期间处理少量的状态数据,则大多数情况下,你都可以使用 Write 方法来刷新数据。 但是,如果应用使用大量的状态数据,请考虑使用流来直接存储数据。 这可能有助于减小 Write 方法的事务模型造成的延迟。

有关示例,请参阅 BasicSuspension 示例。

其他示例和资源

下面是适用于特定方案的几个示例和其他资源。

有关重试文件 I/O 的代码示例

以下伪代码示例假设在用户选取要保存的文件后执行写入,演示在这种情况下如何重试写入 (C#):

Windows.Storage.Pickers.FileSavePicker savePicker = new Windows.Storage.Pickers.FileSavePicker();
savePicker.FileTypeChoices.Add("Plain Text", new List<string>() { ".txt" });
Windows.Storage.StorageFile file = await savePicker.PickSaveFileAsync();

Int32 retryAttempts = 5;

const Int32 ERROR_ACCESS_DENIED = unchecked((Int32)0x80070005);
const Int32 ERROR_SHARING_VIOLATION = unchecked((Int32)0x80070020);

if (file != null)
{
    // Application now has read/write access to the picked file.
    while (retryAttempts > 0)
    {
        try
        {
            retryAttempts--;
            await Windows.Storage.FileIO.WriteTextAsync(file, "Text to write to file");
            break;
        }
        catch (Exception ex) when ((ex.HResult == ERROR_ACCESS_DENIED) ||
                                   (ex.HResult == ERROR_SHARING_VIOLATION))
        {
            // This might be recovered by retrying, otherwise let the exception be raised.
            // The app can decide to wait before retrying.
        }
    }
}
else
{
    // The operation was cancelled in the picker dialog.
}

同步对文件的访问权限

使用 .NET 的并行编程博客文章是一个不错的资源,其中提供了有关并行编程的指导。 有关 AsyncReaderWriterLock 的文章专门介绍了如何保留对文件的独占写入访问权限,同时允许进行并发读取访问。 请注意,序列化 I/O 会影响性能。

请参阅