Managing data always was and will be the main concept behind any application for any platform and at any time. But how we can do this? How to manage data between different sessions of app usage?

One of the simplest and still perfect ways to do this is by using files.

“A computer file is a computer resource for recording data in a computer storage device.” - according to wiki.

On the iOS platform luckily for us there is a bunch of ways how can perform various manipulations within the file (different CRUD operations):

  • Foundations framework way
  • FileManager
  • FileHandle
  • Files
  • DispathIO
  • Stream
  • C++ API

CRUD - Create Read Update Delete

Foundations framework way

Foundation is a base framework that we use in every app. If we check API from it, we will find a lot of methods/functions that allow us to perform various manipulations within a file. The most known types are: String, Dictionary, Data, Array, etc.

In most cases concrete Type supports only basic functionality:

  • read
  • write

I will show examples only for Data type, all other has something similar or specific for concrete type.

Data

The Data type - is a basic type that can represent information. It represents a byte buffer in memory. For basic operations like save/read here are a few code snippets.

To save data into file we can:

do {
	try someData?.write(to: filePath)
} catch {
	// handle error
}

To read data from the file:

do {
    let data = try? Data(contentsOf: fileURL, options: [<options>])
    // use data
} catch {
    print(error)
}

FileManager

FileManager is one of the simplest ways to work within a file. Using FileManager we can do all most common operations:

  • create
  • read
  • update
  • move
  • delete

we can also use this class for work with directories, not just for files.

To demonstrate all possibilities related to files, we first should obtain an instance of FileManager. To do this, we can use either shared instance either create or own:

let fileManager = FileManager.default
// or
let fileManager = FileManager()

This method always represents the same file manager object. If you plan to use a delegate with the file manager to receive notifications about the completion of file-based operations, you should create a new instance of FileManager (using the init method) rather than using the shared object. [source].

Create

To create file we can use simple snipet:

let someData = "hello world!".data(using: .utf8)
let isCreated = fileManager.createFile(atPath: filePath.path, contents: someData, attributes: nil)

Read

Reading is also trivial operation - all u need, its just a path:

let data = fileManager.contents(atPath: filePath.path)

Update

Update operation is a bit tricky - using any approach, its require at least 2 operations - find what to update and perform update.

I will asume in my samples, that we would like simply append some information to the end of the file (in real-world scenarious, u probably needs to find concrete postion for replacement or appending content):

if let data = fileManager.contents(atPath: filePath.path) {
	if let stringInFile = String(data: data, encoding: .utf8) {
		let newContent = "content"
	 	let updatedContent = stringInFile + newContent // <-- update
	 	let isCreated = fileManager.createFile(atPath: filePath.path, contents: updatedContent.data(using: .utf8), attributes: nil) // <-- recreate with updated content as a workaround
	 	print(isCreated)
	} else {
 		print("Nothing in file")
	}
}

Update is not supported by FileManager, that why we can use a workaround - recreate the file instead.

From the official documentation - “You use it to locate, create, copy, and move files and directories. You also use it to get information about a file or directory or change some of its attributes.”

Move

To change position of a file, we can simply calling only one method:

do {
  try fileManager.moveItem(atPath: filePath.path, toPath: newURL.path)
  print("Moved successfully")
} catch {
  print("Error: \(error.localizedDescription)")
}

Delete

To remove unnecessary element fom file system:

do {
  try fileManager.removeItem(atPath: filePath.path)
} catch {
  print(error)
}

FileHandle

FileHandle is a less known, but yet powerful class for working with files. The list of operations is quite different from that one used in FileManager, but usage is much simpler.

Also, this class provides additional possibilities - it allows us to work not only within files, but with sockets, pipes, and devices.

As we are talking about a files, we can:

  • read
  • write
  • seek
  • update
  • truncate

It’s good to know, that FileHandle can create asynchronous background I/O operations, so u can use it in systems, where u would like to save some resources for I/O operations.

To use the possibilities of FileHandle, we should create an instance of this class by using one of the existing initializers. The key-point here - we should use init based on our needs. I will show u different initializations of this object below in each examples.

Read

Read is essential part of data management, and FileHandle has a perfect method for this:

let fileHandler = FileHandle(forReadingAtPath: filePath.path)
let buffer = fileHandler?.readDataToEndOfFile()

Write

To write something in a file we can use next snipet:

if let fileHandler = FileHandle(forWritingAtPath: filePath.path),
  let someData = "hello world!".data(using: .utf8) {
	fileHandler?.write(someData)
}

With FileHandle we can’t write data to file if the file does not exist.

Seek

let file: FileHandle? = FileHandle(forUpdatingAtPath: filePath.path)
if file != nil {
  let data = " and goodbuy".data(using: .utf8)

  // option 1 - set offset
  let offset = 100
  file?.seek(toFileOffset: UInt64(offset))
  // option 2 - seek the end of file
  file?.seekToEndOfFile()
  // do any other operations with content and file
}

Update

let file: FileHandle? = FileHandle(forUpdatingAtPath: filePath.path)
if file != nil {
  let data = «content".data(using: .utf8)
  file?.seekToEndOfFile()
  file?.write(data!)
  file?.closeFile()
}

Truncate

Remove is not available as an option for FileHandle. We can, instead, truncate the content:

let file: FileHandle? = FileHandle(forUpdatingAtPath: filePath.path)
if file == nil {
  print("File open failed")
} else {
  file?.truncateFile(atOffset: 0)
  file?.closeFile()
}

Files

“The Files app brings all of your files together in iOS 11 or later. You can easily browse, search, and organize all your files in one place. Not just the ones on the device you’re using, but also those in apps, on your other iOS devices, in iCloud Drive, and across other cloud services.” more.

We also can use UIDocument that also can represent data from files. Using this class we can create and read information from files in our apps. To do this, we should describe this feature in metadata of the app - using plist:

files


Then, define UIDocument subclass that will represent the content of a file and provide some UI for manipulating with files on our file system.

The simplest code for doing this can be as next:

Document description:

import UIKit

final class MyDocument: UIDocument {

  private (set) var stringValue: String?

  override func contents(forType typeName: String) throws -> Any {
    return Data()
  }

  override func load(fromContents contents: Any, ofType typeName: String?) throws {
    if let contentData = contents as? Data {
      stringValue = String(data: contentData, encoding: .utf8)
    }
  }
}

Document preview:

import UIKit

final class DocumentViewController: UIViewController {

  @IBOutlet private weak var documentNameLabel: UILabel!
  @IBOutlet private weak var textView: UITextView!

  var document: UIDocument?

  // MARK: - LifeCycle

  override func viewDidLoad() {
    super.viewDidLoad()

    document?.open(completionHandler: { (success) in
      if success {
        self.documentNameLabel.text = self.document?.fileURL.lastPathComponent
        self.textView.text = (self.document as? MyDocument)?.stringValue
      }
    })
  }

  @IBAction private func dismissDocumentViewController() {
    dismiss(animated: true) {
      self.document?.close(completionHandler: nil)
    }
  }
}

Document picker:

import UIKit

final class DocumentPreviewViewController: UIDocumentBrowserViewController, UIDocumentBrowserViewControllerDelegate {

  override func viewDidLoad() {
    super.viewDidLoad()

    delegate = self
    allowsDocumentCreation = true
    allowsPickingMultipleItems = false
  }

  // MARK: UIDocumentBrowserViewControllerDelegate

  func documentBrowser(_ controller: UIDocumentBrowserViewController, didRequestDocumentCreationWithHandler importHandler: @escaping (URL?, UIDocumentBrowserViewController.ImportMode) -> Void) {
      importHandler(nil, .none)
  }

  func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentURLs documentURLs: [URL]) {
    guard let sourceURL = documentURLs.first else { return }
    presentDocument(at: sourceURL)
  }

  func documentBrowser(_ controller: UIDocumentBrowserViewController, didImportDocumentAt sourceURL: URL, toDestinationURL destinationURL: URL) {
    presentDocument(at: destinationURL)
  }

  func documentBrowser(_ controller: UIDocumentBrowserViewController, failedToImportDocumentAt documentURL: URL, error: Error?) {
  }

  // MARK: Document Presentation

  func presentDocument(at documentURL: URL) {
    let storyBoard = UIStoryboard(name: "Main", bundle: nil)
    let documentViewController = storyBoard.instantiateViewController(withIdentifier: "DocumentViewController") as! DocumentViewController
    documentViewController.document = MyDocument(fileURL: documentURL)
    present(documentViewController, animated: true, completion: nil)
  }
}

Off cause document representation and preview is non-real-world and used just for simplification of the approach presentation.

DispathIO

DispatchIO - An object that manages operations on a file descriptor using either stream-based or random-access semantics. source.

DispatchIO can perform only a few operations within the file descriptor:

  • read
  • write

U should use this class when u have a large file and u need to efficiently work within it.

Read

import Foundation

final class DispatchIOReader {

  typealias IsReadingCompleted = Bool

  enum Failure: Error {
    case cantFindFile
    case cantFindChannel
    case readingIssue(Int32)
  }

  var channel: DispatchIO?

  init(_ path: String) throws {
    let inputPath = [Int8](path.utf8.map { Int8($0) })
    channel = DispatchIO(
      type: .random,
      path: inputPath,
      oflag: 0,
      mode: 0,
      queue: .main,
      cleanupHandler: { (errCode) in

      })

    if channel == nil {
      throw Failure.cantFindFile
    }

    channel?.setLimit(lowWater: .max)
  }

  deinit {
    channel?.close()
    channel = nil
  }

  // MARK: - Public

  func read(
    byteRange: CountableRange<Int>,
    queue: DispatchQueue = .main,
    completion: @escaping (Result<(DispatchData?, IsReadingCompleted), Error>) -> Void
  ) throws {
    if let channel = channel {
      channel.read(
        offset: off_t(byteRange.startIndex),
        length: byteRange.count,
        queue: queue,
        ioHandler: { done, data, errorCode in
          if errorCode != 0 {
            completion(.failure(Failure.readingIssue(errorCode)))
          } else {
            completion(.success((data, done)))
          }
      })
    } else {
      throw Failure.cantFindChannel
    }
  }
}

usage:

let path = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first
let file = path?.appendingPathComponent("CreatedFile.txt")

if let dispatchReader = try? DispatchIOReader(file!.path) {
  try? dispatchReader.read(byteRange: 0..<300) { (result) in
    switch result {
      case .failure(let error):
        print(error)
      case .success(let resultTuple):
        let data = resultTuple.0?.compactMap({ UInt8($0) }) ?? []
        let text = String(data: Data(data), encoding: .utf8)
        print("Read - ", text)
    }
  }
}

off_t - This is a data type defined in the sys/types.h header file (of fundamental type unsigned long) and is used to measure the file offset in bytes from the beginning of the file. It is defined as a signed, 32-bit integer, but if the programming environment enables large files off_t is defined to be a signed, 64-bit integer.

Write

Write with DispatchIO is a similar operation - simply open channel and execute write(offset:data:queue:ioHandler:).

Dispatch framework is very powerful, but most of the developers didn’t use its power - instead, only Dispatch.main.async was used. If u would like to find more - u can start by checking Bruno Rocha’s article about DispatchSouce.

Stream

Stream - it’s and abstractions, that allow us to manipulate the data over. In Foundation framework we have InputStream and OutputStreamfor reading and writing.

In the context of a file, using streams, we can:

  • read
  • write

Read

let path = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first
let file = path?.appendingPathComponent("CreatedFile.txt")

let inputStr = InputStream(url: file!)
inputStr?.open()
while inputStr?.hasBytesAvailable == true {
  let buff = UnsafeMutablePointer<UInt8>.allocate(capacity: 8)
  let readLen = inputStr?.read(buff, maxLength: 5)

  let bytesArray = UnsafeBufferPointer(start: buff, count: 8).map { $0 }
  print("read \(readLen) bytes ", String(data: Data(bytesArray), encoding: .utf8))
}
inputStr?.close()

output:

read Optional(5) bytes  Optional("Hello\0\0 ")
read Optional(5) bytes  Optional(" worl\0\0 ")
read Optional(1) bytes  Optional("d\0\0\0\0\0\0 ")
read Optional(0) bytes  Optional("\0\0\0\0\0\0\0 ")

Write

let outputStream = OutputStream(url: file!, append: true)
outputStream?.open()

let data = "Aloha".data(using: .utf8)
var buffer = Array(data!)
let result = outputStream?.write(&buffer, maxLength: 5)
print("write \(result) bytes ", String(data: try! Data(contentsOf: file!), encoding: .utf8))
outputStream?.close()

output:

write Optional(5) bytes  Optional("Hello worldAloha")

There is a lot of discussions about Stream usage, for example this one.

C++ API

Another option for file manipulation use C++ API like fopen and support functions.

We can perform all basic operations within files like:

  • write/create
  • read
  • seek
  • update
  • delete

We can use different variants of accessMode and perform various operations, for example - “rb”, “wb”, “ab”, “r+b”, “w+b”, “a+b”, etc.

Write/Create

import Foundation

final class FileWriter {

  enum Failure: Error {
    case cantCreateFile(String)
    case writeError
  }

  private var file: UnsafeMutablePointer<FILE>?

  init(_ path: String) throws {
    file = fopen(path, "w")
    if file == nil {
      throw Failure.cantCreateFile(path)
    }
  }

  deinit {
    fclose(file)
  }

  func write(_ text: String) throws {
    let buffer = [UInt8](text.utf8)
    let writtenCharCount = fwrite(buffer, MemoryLayout<UInt8>.size, buffer.count, file);

    // chronize a file's in-core state with storage device
    // map a stream pointer to a file descriptor
    fsync(fileno(file));

    if writtenCharCount != buffer.count {
      throw Failure.writeError
    }
  }
}

usage:

let path = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first
let file = path?.appendingPathComponent("CreatedFile.txt")

if let writer = try? FileWriter(file!.path) {
  let text = "Hello world"
  try? writer.write(text)
}

Read

final class FileReader {

  enum Failure: Error {
    case cantReadFile(String)
    case cantReadLine
  }

  private var file: UnsafeMutablePointer<FILE>?

  // MARK: - Lifecycle

  init(_ path: String) throws {
    file = fopen(path, "r")
    if file == nil {
      throw Failure.cantReadFile(path)
    }
  }

  deinit {
    fclose(file)
  }

  // MARK: - Public

  func readNextLine() throws -> String? {
    var line: String
    repeat {
      // create buffer
      var buffer = [CChar](repeating: 0, count: 1024)

      // Reads characters from stream and stores them as a C string into str until
      // (num-1) characters have been read or either a newline or the end-of-file
      // is reached, whichever happens first.
      if fgets(&buffer, Int32(buffer.count), file) == nil {

        // Checks if the error indicator associated with stream is set,
        // returning a value different from zero if it is.
        if ferror(file) != 0 {
          throw Failure.cantReadLine
        }

        return nil
      }
      // append line
      line = String(cString: buffer)
    } while line.lastIndex(of: "\n") == nil
    return line
  }
}

usage:

if let path = Bundle.main.path(forResource: "Text", ofType: "txt"),
   let file = try? FileReader(path){
  while let line = try? file.readNextLine() {
    // do some operations
  }
}

discussion about read approach using fopen here.

Seek

Seek concrete position in file also can be performed using c++ api and fseek:

FILE* fp;
// if file is 50 bytes long:
fseek(fp, /* from the end */ 23, SEEK_END); // <- at 50 - 23 so 27
fseek(fp, /* from the start */ 23, SEEK_SET); // 23
fseek(fp, /* from the the current (see ftell) */ 10, SEEK_CUR); // 33

U can combine fseek usage within rewind. More about file related api from c++ here

Update

Update operation can be done using specifc flag a while perform fopen:

f = fopen("filename", "a"); // or a+

a for append.

write operation can be done as before.

Delete

final class FileRemover {

  enum Failure: Error {
    case cantFindFile(String)
    case cantRemove
  }

  private var path: String?

  init(_ path: String) throws {
    let file = fopen(path, "w")
    if file == nil {
      throw Failure.cantFindFile(path)
    } else {
      fclose(file)
      self.path = path
    }
  }

  func remove() throws {
    let name = [Int8](path!.utf8.map { Int8($0) })
    let err = Darwin.remove(name)
    if err != 0 {
      throw Failure.cantRemove
    }
  }
}

usage:

let path = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first
let file = path?.appendingPathComponent("CreatedFile.txt")

if let remover = try? FileRemover(file!.path) {
  try? remover.remove()
}

download source code

Resources