Tải bản đầy đủ
Chapter 5. Working with Documents on OS X

Chapter 5. Working with Documents on OS X

Tải bản đầy đủ

The NSDocument class and its many related classes form a framework that allows you
to focus on the specifics of how your application needs to work. You don’t, for exam‐
ple, need to re-implement common features like a filepicker to let the user choose
what file to open. By using NSDocument, you also automatically gain access to
advanced features like autosaving and versions.
An instance of NSDocument, or one of its subclasses, represents a single document and
its contents. When the application wants to create a new document, it creates a new
instance of the class; when that document needs to be saved, the system calls a
method that returns an encoded version of the document’s contents, which the sys‐
tem then writes to disk. When an existing document needs to be loaded, an instance
is created and then given an encoded representation to use.
This means that your NSDocument subclasses never directly work with the filesystem,
which allows OS X to do several behind-the-scenes tricks, like automatically saving
backup copies or saving snapshots over time.
NSDocument is not part of the Swift programming language, but is

instead part of the AppKit framework. AppKit is the framework
Apple provides to build graphical applications for OS X, and it con‐
tains windows, buttons, menus, text fields, and so on. You can learn
more about AppKit in Apple’s documentation.

We’re going to work with NSDocument, using Swift, through the rest of this chapter,
and we’ll explain how it fits together as we go.

Storing Data in the Document
In Chapter 4 we created a new project and asked it to set up a “document-based appli‐
cation” for us. Because of this, our project already has a Document class file in place,
with some method stubs, as shown here (with comments removed). You’ll find this in
the Document.swift file, which you can open by clicking on it in the Project Navigator,
located on the left side of the screen. As ever, if you need a refresher on the structure
of Xcode’s user interface, check back to “The Xcode Interface” on page 12.
When you open Document.swift in the editor, you’ll see a number of stub functions
that the template gave us. Here are the two we are interested in:
override func dataOfType(typeName: String) throws -> NSData {
throw NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: nil)
}
override func readFromData(data: NSData, ofType typeName: String) throws {
throw NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: nil)
}

106

| Chapter 5: Working with Documents on OS X

The method stubs provided to us don’t really do much at the moment. All we have
right now is some methods that get called when a document of whatever type (in our
case, a note) gets written to, read from, or displayed. We’re going to need to make
sure this Document class is actually useful to us.
The throw keyword, in this case, causes an NSError object to be
relayed back to the document system, indicating that there was a
problem in saving or opening the document. In this case, the prob‐
lem is that the methods are unimplemented.

The first thing we need to do is customize our Document class to support storing the
data that our documents contain. There are two main items that the documents keep:
the text and the attachments. The first item is very straightforward; the second is
quite a bit trickier.

Storing Text
The Notes application is primarily about storing written text. In just about every pro‐
gramming language under the sun, text is stored in strings, which means that we’ll
need to add a string property in which to store the document’s text.
Strings in Swift are really powerful. In Swift, a string is stored as a
series of Unicode characters. If you’re an old Objective-C program‐
mer, you might (or might not, if you disliked NSString!) be pleased
to know that Swift String class is bridged to Foundation’s
NSString class, which means you can do anything to a Swift
String that you could do to an NSString. If you don’t know what
this means, then you don’t need to worry about it!

However, the Notes application should be slightly fancier than a plain-text editor. We
want the user to be able to bold and italicize the text, or maybe both, and regular
strings can’t store this information. To do so, we’ll need to use a different type than
String: we’ll need to use NSAttributedString.
NSAttributedString is from Foundation, the base layer of classes

that were created to support Objective-C, Apple’s other program‐
ming language. Because of this, NSAttributedString has a few dif‐
ferences around its use than the native Swift String class but in
practice, this often doesn’t matter.

An attributed string is a type of string that contains attributes that apply to ranges of
characters. These attributes include things like bold, color, and font.
Storing Text

|

107

Attributed text is also referred to as rich text.

1. Open the Document.swift class file containing stubs created by Xcode.
2. Add the following property to the Document class above the init method:
// Main text content
var text : NSAttributedString = NSAttributedString()

Although in theory you can put your property declarations pretty
much anywhere you want, it is standard practice to add them to the
top of the class declaration. We talked more about properties in
Swift back in Chapter 2.

This NSAttributedString property has a default value of the empty attributed string
and will be used to store the text for a note file. NSAttributedString is all you need
in order to store and work with formatted text (that is, text with attributes, such as a
font and a size) almost anywhere within your apps. User interface elements provided
by Apple support NSAttributedString and know how to display it. It’s that easy!

Package File Formats
In addition to storing plain text, the Document class also needs to store attachments.
These attachments can be any file that the user chooses, which means that we need to
think carefully about how we approach this.
On most operating systems, documents are represented as single file. This makes a lot
of intuitive sense in most situations, since a “document” can be thought of as a single
“thing” on the disk. However, if you have a complex document format that contains
lots of different information, it can cause a lot of work: the document system needs to
read through the file, determine what information is where, and parse it into a usable
in-memory representation. If the file is large, this can mean that the system needs to
make sure that it doesn’t run out of memory while reading the file.
OS X deals with this kind of problem in a simpler, more elegant way. Instead of
requiring all documents to be individual files, documents can also be packages: fold‐
ers that contain multiple files. The NSDocument class is capable of working with both
flat files and packages in the same way.
A file package allows you to use the filesystem to work with different parts of your
document. For example, Apple’s presentation tool Keynote uses a package file format

108

|

Chapter 5: Working with Documents on OS X

to store the content of the slides separately from the images that appear on those
slides. Additionally, all applications on OS X and iOS are themselves packages: when
you build an application using Xcode, what’s produced is a folder that contains,
among much else, the compiled binary, all images and resources, and files containing
information that describes the capabilities of the application to the operating system.
Package file formats have a lot of advantages, but they have a single,
large disadvantage: you can’t directly email a folder to someone.
Instead, you have to store the folder in an archive, such as a ZIP
file. Additionally, users of other operating systems won’t see a pack‐
age as a single document, but rather as a folder.

To work with package file formats, you use the NSFileWrapper class. An NSFile
Wrapper is an object that represents either a single file, or a directory of multiple files
(each of which can itself be a file wrapper representing a directory).
The Document class will contain at least two file wrappers:
• One for the .rtf file containing the text, called Text.rtf
• One for a folder called Attachments, which will store the attachments.
We need two file wrappers in order to store both parts of a note. As we described
back in Chapter 4, a note is composed of formatted text, plus any number of attach‐
ments, which can be arbitrary files. To store the text to disk—which is represented
and manipulated within our Swift code as an NSAttributedString—we use one file
wrapper to store it, saving it using the pre-existing rich-text format (RTF). To store
the attachments, we’ll use a folder, called Attachments, which will live inside the pack‐
age that represents an individual .note file.
We need to implement methods that load from and save to a file wrapper, as well as
the necessary machinery for accessing the contents of the files. We’ll implement the
first file wrapper, for the text of a note, in this chapter, and the second file wrapper,
for attachments, in the next chapter.
However, before we start implementing the Document class, we need to do a little bit
of preparation. First, we’ll define the names of the important files and folders that are
kept inside the documents; next, we’ll lay out the types of errors that can be generated
while working with the documents.
These will be kept inside an enum, which we talked about in “Enumerations” on page
47; by using this approach, we’ll avoid annoying bugs caused by typos. This enumera‐
tion is one that we’ll be adding to as we extend the functionality of the Document class.

Storing Text

|

109

1. Add the following enumeration to the top of Document.swift (that is, before the
Document class):
// Names of files/directories in the package
enum NoteDocumentFileNames : String {
case TextFile = "Text.rtf"
case AttachmentsDirectory = "Attachments"
}

Note that the type associated with this enumeration is String.
This allows us to associate each value of the enumeration with
a corresponding string.

Opening and saving a document can fail. To diagnose why it failed, it’s useful to
build a list of error codes, which will help us figure out the precise causes of fail‐
ures. The list of errors we’ve chosen here is derived from Apple’s list of possible
NSError types.
In the NSError system (which we discussed back in “Error Handling” on page
77), each possible error is represented by an error code: a number that represents
the error in question. Rather than having to manually specify the error codes for
each thing that could go wrong, we’ll use an enumeration; this allows us to focus
on the errors themselves instead of having to be reminded that we’re really work‐
ing with numbers.
2. Add the following list of possible errors, which is also an enumeration. This enu‐
merator is an Int type, since that’s what NSError requires for its error codes. As
with the NoteDocumentFileNames enumeration, we want to add this one above
the class definition, at the top of the Document.swift file:
enum ErrorCode : Int {
/// We couldn't find the document at all.
case CannotAccessDocument
/// We couldn't access any file wrappers inside this document.
case CannotLoadFileWrappers
/// We couldn't load the Text.rtf file.
case CannotLoadText
/// We couldn't access the Attachments folder.
case CannotAccessAttachments

110

| Chapter 5: Working with Documents on OS X

/// We couldn't save the Text.rtf file.
case CannotSaveText
/// We couldn't save an attachment.
case CannotSaveAttachment
}

We’re using a triple-slash (///) for our comments in the preceding code for a rea‐
son. The triple-slash tells Xcode to treat that comment as documentation. Put
triple-slash comments above method names and entries in enums to define what
they mean, and Option-click those names to see this documentation.
To save typing, we’ll also create a method that prepares an NSError object for us
based on the types of errors that can occur while a user is opening or saving a
document.
3. Above the Document class definition, implement the err function:
let ErrorDomain = "NotesErrorDomain"
func err(code: ErrorCode, _ userInfo:[NSObject:AnyObject]? = nil)
-> NSError {
// Generate an NSError object, using ErrorDomain and whatever
// value we were passed.
return NSError(domain: ErrorDomain, code: code.rawValue,
userInfo: userInfo)
}

The userInfo parameter is a little complex, so let’s break it
down a bit. The underscore before the parameter’s name (user
Info) indicates to Swift that calls to this function don’t need to
label the parameter—they can just call it as err(A, B) instead
of err(A, userInfo: B). The type of the parameter is an
optional dictionary that maps NSObjects to any object. If this
parameter is omitted, this parameter’s value defaults to nil.

This function takes our enumeration from before, as well as the object that
caused the error, and returns an NSError object.
The NSError class represents something—anything—that can go wrong. In order
to properly deal with the specific things that can go wrong while someone is
working with the document, it’s useful to have an NSError that describes what
happened. However, the NSError class’s initializer is complicated and verbose.
It’s easier to instead create a simple little function that you can just pass a value
from the ErrorCode enumeration in, as in this example (which is part of the code
we’ll be writing later), instead of having to pass in the ErrorDomain variable and

Storing Text

|

111

an Int version of the error code. It saves typing and reduces the chance of acci‐
dentally introducing a bug.
You’ll be using the err method later in this chapter, when we start making the
loading and saving system. Here’s what it looks like:
// Load the text data as RTF
guard let documentText = NSAttributedString(RTF: documentTextData,
documentAttributes: nil) else {
throw err(.CannotLoadText)
}

The guard Keyword, and Why It’s Great
You’ll notice we’re using the guard keyword in the previous example. The guard key‐
word was introduced in Swift 2 and helps you to avoid writing two kinds of painful
code: if-pyramids (sometimes called “pyramids of doom”), and early returns.
An if-pyramid looks something like this:
if let someObjectA = optionalA {
if let someObjectB = optionalB {
if let someObjectC = optionalC {
// do something that relies on all three of these optionals
// having a value
}
}
}

And an early return looks something like this:
if conditionA == "thing" { return }
if conditionB == "thing" { return }
if conditionC == "thing" { return }
// Do something that relies on conditionA, conditionB, and
// conditionC all NOT being equal to "thing".
// Don't forget to include the 'return' statements, or you're in trouble!

We suspect that at least one of these looks familiar! The guard keyword lets you avoid
this pain. It embodies Swift’s philosophy of encouraging, or even forcing, you to write
safe code. You tell guard what you want to be the case, rather than what you don’t
want to be the case; this makes it easier to read the code and understand what’s going
on.
When you use guard, you provide a condition to test and a chunk of code. If the con‐
dition evaluates to false, then the chunk of code is executed. So far, this might seem
similar to the if statement, but it has an interesting extra requirement: at the end of

112

| Chapter 5: Working with Documents on OS X

the code, you’re required to exit from the current scope. This means, for example,
you’re have to return from the function you’re in. For example:
guard someText.characters.count > 0 else {
throw err(.TextIsEmpty)
}

Here, we guard on the premise that a variable called someText has more than zero
characters. If it doesn’t, we throw an error. Again, while guard might not look that
different from a bunch of if statements right now, it’s a lot easier to read and under‐
stand what the code is going to do.
Getting back to the app, there is one more task we need to do before we can start sav‐
ing and loading files. We’ll add a property to the Document class: an NSFileWrapper
that represents the file on disk. We’ll be using this later to access the attachments that
are stored in the document.
Add the following property to the Document class (this goes inside the class defini‐
tion):
var documentFileWrapper = NSFileWrapper(directoryWithFileWrappers: [:])

The documentFileWrapper will represent the contents of the document folder, and
we’ll use it to add files to the package. Defining the variable with the default value
NSFileWrapper(directoryWithFileWrappers: [:]) ensures that the variable will
always contain a valid file wrapper to work with.

Saving Files
With the groundwork in place, we can now implement the guts of loading and saving.
We’ll start by implementing the method that saves the content, and then we’ll imple‐
ment the loading method.
The saving method, fileWrapperOfType, an NSDocument method we are going to
override, is required to return an NSFileWrapper that represents a file or directory to
be saved to disk. It’s important to note that you don’t actually write a file yourself;
instead, the NSFileWrapper object merely represents a file and its contents, and it’s up
to OS X to actually commit that object to disk. The advantage of doing it like this is
that you can construct whatever organization you need for your package file format
without actually having to have files written to disk. You simply create “imaginary”
files and folders out of NSFileWrapper objects and return them from this method,
and the system takes care of actually writing them to disk.
Inside the Document class implement the fileWrapperOfType method, which pre‐
pares and returns a file wrapper to the system, which then saves it to disk:
override func fileWrapperOfType(typeName: String) throws -> NSFileWrapper {

Storing Text

|

113

let textRTFData = try self.text.dataFromRange(
NSRange(0..documentAttributes: [
NSDocumentTypeDocumentAttribute: NSRTFTextDocumentType
]
)
// If the current document file wrapper already contains a
// text file, remove it - we'll replace it with a new one
if let oldTextFileWrapper = self.documentFileWrapper
.fileWrappers?[NoteDocumentFileNames.TextFile.rawValue] {
self.documentFileWrapper.removeFileWrapper(oldTextFileWrapper)
}
// Save the text data into the file
self.documentFileWrapper.addRegularFileWithContents(
textRTFData,
preferredFilename: NoteDocumentFileNames.TextFile.rawValue
)
// Return the main document's file wrapper - this is what will
// be saved on disk
return self.documentFileWrapper
}

This function takes a single parameter: a string, which contains a UTI that describes
the kind of file that the system would like returned to it.
In this application, which only works with one type of file, we can
safely ignore this parameter. In an app that can open and save mul‐
tiple types of documents, you’d need to check the contents of this
parameter and tailor your behavior accordingly. For example, in an
image-editing application that can work with both PNG and JPEG
images, if the user wants to save her image as a PNG, the typeName
parameter would be public.png, and you’d need to ensure that you
produce a PNG image.

The method creates a new variable, called textRTFData, which contains the text of
the document encoded as an RTF document. The line in which this happens is com‐
plex, so let’s take a closer look at it:
let textRTFData = try self.text.dataFromRange(
NSRange(0..documentAttributes: [
NSDocumentTypeDocumentAttribute: NSRTFTextDocumentType
]
)

This line of code does a lot of work, all at once. It first accesses the self.text prop‐
erty, and accesses the NSAttributedString that contains the document’s text. It then
114

|

Chapter 5: Working with Documents on OS X

calls the dataFromRange method to convert this attributed string into a collection of
bytes that can be written to disk. This method first requires an NSRange, which repre‐
sents a chunk of the text; in this case, we want the entire text, so we ask for the range
starting at zero (the start of the text) and ending at the last character in the text.
The dataFromRange method also needs to know how the data should be formatted,
because there are multiple ways to represent formatted text; we indicate that we want
RTF text by passing in a dictionary that contains the NSDocumentTypeDocumentAttri
bute key, which is associated with the value NSRTFTextDocumentType.
This whole line is prefixed with the try keyword, which is required because dataFrom
Range is capable of failing. However, we don’t need to actually deal with this error,
because the fileWrapperOfType method itself is marked as capable of failing (this is
the throws keyword at the top of the function). In other words, if there is a problem
in getting the formatted data, the entire function will immediately return, and the
calling function will need to deal with the error object that is generated. (The calling
function, in this case, is part of the OS X document system; we don’t need to worry
about the specifics. But if you’re curious, it displays an alert box to users to tell them
that there was a problem saving their file.)
At this point, the method is taking advantage of an especially useful combination of
Swift’s features. Remember that, in Swift, nonoptional variables are required to be
non-nil—that is, they have a value. The only way for the dataFromRange method to
fail to provide a value is to completely fail in its task. This is indicated from the way
that dataFromRange’s method is declared:
func dataFromRange(
range: NSRange,
documentAttributes dict: [String : AnyObject]) throws -> NSData

Notice how the return type of this method is NSData, not NSData? (with a question
mark at the end). This indicates that the method will either succeed and give you a
value, or completely fail. If it fails, the fact that this method throws and that any
errors from dataFromRange are not specifically caught means that the method will
immediately return. This means that you don’t have to do any nil checking or
optional unwrapping on the value you get back from dataFromRange.
Once the textRTFData variable has been created, the method then needs to deter‐
mine whether or not it needs to replace any existing text file. The reason for this is
that an NSFileWrapper can have multiple file wrappers inside it with the same name.
We can’t simply say “add a new file wrapper called Text.rtf,” because if one already
existed, it would be added as "Text 2.rtf,” or something similar. As a result, the docu‐
ment asks itself if it already has a file wrapper for the text file; if one exists, it is
removed.

Storing Text

|

115

After that, a new file wrapper is created that contains the textRTFData that we pre‐
pared earlier. This is added to the document’s documentFileWrapper.
Remember, documentFileWrapper is guaranteed to always exist
and be ready to use, because it was defined with a default value.

Finally, the documentFileWrapper is returned to the system. At this point, it’s now in
the hands of the operating system; it will be saved, as needed, by OS X.

Loading Files
Next, we’ll implement the function that loads the data from the various files into
memory. This is basically the reverse of the fileWrapperOfType method: it receives
an NSFileWrapper and uses its contents to get the useful information out.
Implement the readFromFileWrapper method, which loads the document from the
file wrapper:
override func readFromFileWrapper(fileWrapper: NSFileWrapper,
ofType typeName: String) throws {
// Ensure that we have additional file wrappers in this file wrapper
guard let fileWrappers = fileWrapper.fileWrappers else {
throw err(.CannotLoadFileWrappers)
}
// Ensure that we can access the document text
guard let documentTextData =
fileWrappers[NoteDocumentFileNames.TextFile.rawValue]?
.regularFileContents else {
throw err(.CannotLoadText)
}
// Load the text data as RTF
guard let documentText = NSAttributedString(RTF: documentTextData,
documentAttributes: nil) else {
throw err(.CannotLoadText)
}
// Keep the text in memory
self.documentFileWrapper = fileWrapper
self.text = documentText
}

116

|

Chapter 5: Working with Documents on OS X