数据持久化方案解析(十) —— UIDocument的数据存储(三)

246 阅读7分钟
原文链接: www.jianshu.com

版本记录

版本号 时间
V1.0 2019.08.25 星期日

前言

数据的持久化存储是移动端不可避免的一个问题,很多时候的业务逻辑都需要我们进行本地化存储解决和完成,我们可以采用很多持久化存储方案,比如说plist文件(属性列表)、preference(偏好设置)、NSKeyedArchiver(归档)、SQLite 3CoreData,这里基本上我们都用过。这几种方案各有优缺点,其中,CoreData是苹果极力推荐我们使用的一种方式,我已经将它分离出去一个专题进行说明讲解。这个专题主要就是针对另外几种数据持久化存储方案而设立。
1. 数据持久化方案解析(一) —— 一个简单的基于SQLite持久化方案示例(一)
2. 数据持久化方案解析(二) —— 一个简单的基于SQLite持久化方案示例(二)
3. 数据持久化方案解析(三) —— 基于NSCoding的持久化存储(一)
4. 数据持久化方案解析(四) —— 基于NSCoding的持久化存储(二)
5. 数据持久化方案解析(五) —— 基于Realm的持久化存储(一)
6. 数据持久化方案解析(六) —— 基于Realm的持久化存储(二)
7. 数据持久化方案解析(七) —— 基于Realm的持久化存储(三)
8. 数据持久化方案解析(八) —— UIDocument的数据存储(一)
9. 数据持久化方案解析(九) —— UIDocument的数据存储(二)

源码

1. Swift

首先看下代码组织结构

下面看一下xib中的内容

接着就是看一下代码了

1. DetailViewController.swift
import UIKit
import Photos
import AssetsLibrary

protocol DetailViewControllerDelegate: class {
  func detailViewControllerDidFinish(_ viewController: DetailViewController, with photoEntry: PhotoEntry?, title: String?)
}

class DetailViewController: UIViewController {
  weak var delegate: DetailViewControllerDelegate?
  var document: Document? {
    didSet {
      guard let doc = document else { return }
      title = doc.description
    }
  }
  
  @IBOutlet weak var addPhotoButton: UIButton!
  @IBOutlet weak var titleTextField: UITextField!
  @IBOutlet weak var fullImageView: UIImageView!
  
  private var newImage: UIImage?
  private var newThumbnailImage: UIImage?
  private var hasChanges = false
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    if PHPhotoLibrary.authorizationStatus() == .notDetermined  {
      PHPhotoLibrary.requestAuthorization { _ in }
    }
    
    openDocument()
  }
  
  private func showImagePicker() {
    let imagePicker = UIImagePickerController()
    guard UIImagePickerController.isSourceTypeAvailable(imagePicker.sourceType) else { return }
    
    imagePicker.sourceType = .photoLibrary
    imagePicker.allowsEditing = false
    imagePicker.delegate = self
    
    present(imagePicker, animated: true, completion: nil)
  }
  
  private func openDocument() {
    if document == nil {
      showImagePicker()
    }
    else {
      document?.open() {
        [weak self] _ in
        self?.fullImageView.image = self?.document?.photo?.mainImage
        self?.titleTextField.text = self?.document?.description
      }
    }
  }
  
  @IBAction func editPhoto(_ sender: Any) {
    showImagePicker()
  }
  
  @IBAction func donePressed(_ sender: Any) {
    var photoEntry: PhotoEntry?

    if let newImage = newImage, let newThumb = newThumbnailImage {
      photoEntry = PhotoEntry(mainImage: newImage, thumbnailImage: newThumb)
    }

    let hasDifferentPhoto = !newImage.isSame(photo: document?.photo?.mainImage)
    let hasDifferentTitle = document?.description != titleTextField.text
    hasChanges = hasDifferentPhoto || hasDifferentTitle

    guard let doc = document, hasChanges else {
      delegate?.detailViewControllerDidFinish(
        self,
        with: photoEntry,
        title: titleTextField.text
      )
      dismiss(animated: true, completion: nil)
      return
    }

    doc.photo = photoEntry
    doc.save(to: doc.fileURL, for: .forOverwriting) { [weak self] (success) in
      guard let self = self else { return }
      
      if !success { fatalError("Failed to close doc.") }
      
      self.delegate?.detailViewControllerDidFinish(
        self,
        with: photoEntry,
        title: self.titleTextField.text
      )
      self.dismiss(animated: true, completion: nil)
    }
  }
  
  @IBAction func dismiss(_ sender: Any) {
    dismiss(animated: true, completion: nil)
  }
}

extension DetailViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
  @objc func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
    guard let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else {
      return
    }

    let options = PHImageRequestOptions()
    options.resizeMode = .exact
    options.isSynchronous = true

    if let imageAsset = info[UIImagePickerController.InfoKey.phAsset] as? PHAsset {
      let imageManager = PHImageManager.default()

      imageManager.requestImage(
        for: imageAsset,
        targetSize: CGSize(width: 150, height: 150),
        contentMode: .aspectFill,
        options: options
      ) { (result, _) in
          self.newThumbnailImage = result
      }
    }

    fullImageView.image = image
    let mainSize = fullImageView.bounds.size
    newImage = image.imageByBestFit(for: mainSize)

    picker.dismiss(animated: true, completion: nil)
  }
  
  @objc func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
    picker.dismiss(animated: true, completion: nil)
  }
}

extension DetailViewController: UITextFieldDelegate {
  func textFieldShouldReturn(_ textField: UITextField) -> Bool {
    textField.resignFirstResponder()
    return true
  }
}
2. ViewController.swift
import UIKit

private extension String {
  static let cellID = "PhotoKeeperCell"
}

class ViewController: UIViewController {
  private var selectedEntry: Entry?
  private var entries: [Entry] = []
  private lazy var localRoot: URL? = FileManager.default.urls(
    for: .documentDirectory,
    in: .userDomainMask).first
  private var selectedDocument: Document?

  @IBOutlet weak var tableView: UITableView!
  @IBOutlet weak var leftBarButtonItem: UIBarButtonItem!
  
  override func viewDidLoad() {
    super.viewDidLoad()
    refresh()
  }

  private func loadDoc(at fileURL: URL) {
    let doc = Document(fileURL: fileURL)
    doc.open { [weak self] success in
      guard success else {
        fatalError("Failed to open doc.")
      }

      let metadata = doc.metadata
      let fileURL = doc.fileURL
      let version = NSFileVersion.currentVersionOfItem(at: fileURL)
      
      doc.close() { success in
        guard success else {
          fatalError("Failed to close doc.")
        }
        
        if let version = version {
          self?.addOrUpdateEntry(for: fileURL, metadata: metadata, version: version)
        }
      }
    }
  }

  private func loadLocal() {
    guard let root = localRoot else { return }
    do {
      let localDocs = try FileManager.default.contentsOfDirectory(
        at: root,
        includingPropertiesForKeys: nil,
        options: [])

      for localDoc in localDocs where localDoc.pathExtension == .appExtension {
        loadDoc(at: localDoc)
      }
    } catch let error {
      fatalError("Couldn't load local content. \(error.localizedDescription)")
    }
  }

  private func refresh() {
    loadLocal()
    tableView.reloadData()
  }

  private func getDocumentURL(for filename: String) -> URL? {
    return localRoot?.appendingPathComponent(filename, isDirectory: false)
  }

  private func docNameExists(for docName: String) -> Bool {
    return !entries.filter{ $0.fileURL.lastPathComponent == docName }.isEmpty
  }

  private func indexOfEntry(for fileURL: URL) -> Int? {
    return entries.firstIndex(where: { $0.fileURL == fileURL })
  }

  private func addOrUpdateEntry(
    for fileURL: URL,
    metadata: PhotoMetadata?,
    version: NSFileVersion) {

    if let index = indexOfEntry(for: fileURL) {
      let entry = entries[index]
      entry.metadata = metadata
      entry.version = version
    } else {
      let entry = Entry(fileURL: fileURL, metadata: metadata, version: version)
      entries.append(entry)
    }

    entries = entries.sorted(by: >)
    tableView.reloadData()
  }

  private func insertNewDocument(
    with photoEntry: PhotoEntry? = nil,
    title: String? = nil) {

    guard let fileURL = getDocumentURL(
      for: getDocFilename(for: title ?? .photoKey)
      ) else { return }


    let doc = Document(fileURL: fileURL)
    doc.photo = photoEntry

    doc.save(to: fileURL, for: .forCreating) {
      [weak self] success in
      guard success else {
        fatalError("Failed to create file.")
      }

      print("File created at: \(fileURL)")

      let metadata = doc.metadata
      let URL = doc.fileURL
      if let version = NSFileVersion.currentVersionOfItem(at: fileURL) {
        self?.addOrUpdateEntry(for: URL, metadata: metadata, version: version)
      }
    }
  }

  private func showDetailVC() {
    guard let detailVC = detailVC else { return }

    detailVC.delegate = self
    detailVC.document = selectedDocument

    mode = .viewing
    present(detailVC.navigationController!, animated: true, completion: nil)
  }

  private func getDocFilename(for prefix: String) -> String {
    var newDocName = String(format: "%@.%@", prefix, String.appExtension)
    var docCount = 1

    while docNameExists(for: newDocName) {
      newDocName = String(format: "%@ %d.%@", prefix, docCount, String.appExtension)
      docCount += 1
    }

    return newDocName
  }
  
  private func indexOfEntry(for name: String) -> Int? {
    return entries.firstIndex(where: { $0.description == name})
  }

  
  @IBAction func addEntry(_ sender: Any) {
    selectedEntry = nil
    selectedDocument = nil
    showDetailVC()
  }
  
  @IBAction func editEntries(_ sender: Any) {
    mode = mode.otherMode
  }
  
  private func delete(entry: Entry) {
    let fileURL = entry.fileURL
    guard let entryIndex = indexOfEntry(for: fileURL) else { return }
    
    do {
      try FileManager.default.removeItem(at: fileURL)
      entries.remove(at: entryIndex)
      tableView.reloadData()
    } catch {
      fatalError("Couldn't remove file.")
    }
  }
  
  private func rename(_ entry: Entry, with name: String) {
    guard entry.description != name else { return }

    let newDocFilename = "\(name).\(String.appExtension)"

    if docNameExists(for: newDocFilename) {
      fatalError("Name already taken.")
    }

    guard let newDocURL = getDocumentURL(for: newDocFilename) else { return }

    do {
      try FileManager.default.moveItem(at: entry.fileURL, to: newDocURL)
    } catch {
      fatalError("Couldn't move to new URL.")
    }

    entry.fileURL = newDocURL
    entry.version = NSFileVersion.currentVersionOfItem(at: entry.fileURL) ?? entry.version

    tableView.reloadData()
  }
  
  private var mode: Mode = .viewing {
    didSet {
      switch mode {
      case .editing:
        tableView.setEditing(true, animated: true)
        leftBarButtonItem.title = "Done"
      case .viewing:
        tableView.setEditing(false, animated: true)
        leftBarButtonItem.title = "Edit"
      }
    }
  }
}

//MARK: DetailViewControllerDelegate
extension ViewController: DetailViewControllerDelegate {
  func detailViewControllerDidFinish(_ viewController: DetailViewController, with photoEntry: PhotoEntry?, title: String?) {
    guard
      let doc = viewController.document,
      let version = NSFileVersion.currentVersionOfItem(at: doc.fileURL)
      else {
        if let docData = photoEntry {
          insertNewDocument(with: docData, title: title)
        }
        return
    }


    if let docData = photoEntry {
      doc.photo = docData
    }

    addOrUpdateEntry(for: doc.fileURL, metadata: doc.metadata, version: version)
    if let title = title, let entry = selectedEntry, title != entry.description {
      rename(entry, with: title)
    }
  }
}

//MARK: UITableViewDelegate
extension ViewController: UITableViewDelegate {
  func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let entry = entries[indexPath.row]
    selectedEntry = entry
    selectedDocument = Document(fileURL: entry.fileURL)

    showDetailVC()

    tableView.deselectRow(at: indexPath, animated: false)
  }

  func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
    return true
  }
  
  func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
    
    if editingStyle == .delete {
      let entry = entries[indexPath.row]
      delete(entry: entry)
    }
  }
}

//MARK: UITableViewDataSource
extension ViewController: UITableViewDataSource {
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return entries.count
  }
  
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: .cellID, for: indexPath) as? PhotoKeeperCell else { return UITableViewCell() }
    
    let entry = entries[indexPath.row]
    
    cell.photoImageView?.image = entry.metadata?.image
    cell.titleTextField?.text = entry.description
    cell.subtitleLabel?.text = entry.version.modificationDate?.mediumString
    
    return cell
  }
}

//MARK: UITextFieldDelegate
extension ViewController: UITextFieldDelegate {
  func textFieldShouldReturn(_ textField: UITextField) -> Bool {
    textField.resignFirstResponder()
    mode = .viewing
    
    guard
      let entry = selectedEntry,
      let newName = textField.text
      else {
        return true
    }
    
    rename(entry, with: newName)
    return true
  }
  
  func textFieldDidBeginEditing(_ textField: UITextField) {
    let filteredEntries = entries.filter { (entry) -> Bool in
      return entry.description == textField.text
    }
    
    guard let entry = filteredEntries.first else { return }
    
    selectedEntry = entry
  }
  
  func textFieldDidEndEditing(_ textField: UITextField) {
    textField.resignFirstResponder()
  }
}

//MARK: Additional Conveniences
extension ViewController {
  private var detailVC: DetailViewController? {
    let storyboard = UIStoryboard(name: "Main", bundle: nil)
    let detailNavVC = storyboard.instantiateViewController(withIdentifier: "DetailNavigationController")
    
    guard
      let navVC = detailNavVC as? UINavigationController,
      let detailVC = navVC.topViewController as? DetailViewController
      else {
        return nil
    }

    return detailVC
  }
}

private enum Mode {
  case editing
  case viewing
  
  var otherMode: Mode {
    switch self {
    case .editing:
      return .viewing
    case .viewing:
      return .editing
    }
  }
}
3. PhotoKeeperCell.swift
import UIKit

class PhotoKeeperCell: UITableViewCell {
  @IBOutlet weak var photoImageView: UIImageView!
  @IBOutlet weak var titleTextField: UITextField!
  @IBOutlet weak var subtitleLabel: UILabel!
  
  override func setSelected(_ selected: Bool, animated: Bool) {
    super.setSelected(selected, animated: animated)
  }
  
  override func setEditing(_ editing: Bool, animated: Bool) {
    super.setEditing(editing, animated: animated)
    
    UIView.animate(withDuration: 0.1) {
      self.titleTextField.isEnabled = editing
      self.titleTextField.borderStyle = editing ? UITextField.BorderStyle.roundedRect : .none
    }
  }
}
4. Date+Extensions.swift
import Foundation

private var mediumFormatter: DateFormatter = {
    let dateFormatter =  DateFormatter()
    dateFormatter.doesRelativeDateFormatting = true
    dateFormatter.timeStyle = .medium
    dateFormatter.dateStyle = .medium
    return dateFormatter
}()

extension Date {
  var mediumString: String {
    return mediumFormatter.string(from: self)
  }
}
5. UIImage+Extensions.swift
import UIKit

extension UIImage {
  static var `default`: UIImage {
    return #imageLiteral(resourceName: "default")
  }
  
  func imageWith(newSize: CGSize) -> UIImage {
    let renderer = UIGraphicsImageRenderer(size: newSize)
    let image = renderer.image {_ in
      draw(in: CGRect(origin: .zero, size: newSize))
    }
    
    return image
  }
  
  func imageByBestFit(for targetSize: CGSize) -> UIImage {
    let aspectRatio = size.width / size.height
    let targetHeight = targetSize.height
    let scaledWidth = targetSize.height * aspectRatio
    
    let bestSize = CGSize(width: scaledWidth, height: targetHeight)
    return imageWith(newSize: bestSize)
  }
}

extension Optional where Wrapped == UIImage {
  func isSame(photo: UIImage?) -> Bool {
    switch (self, photo) {
    case (nil, nil):
      return true
    case (nil, _), (_, nil):
      return false
    case (let p1, let p2):
      return p1!.isEqual(p2)
    }
  }
}
6. PhotoData.swift
import Foundation
import UIKit

class PhotoData: NSObject, NSCoding {
  var image: UIImage?

  init(image: UIImage? = nil) {
    self.image = image
  }

  func encode(with aCoder: NSCoder) {
    aCoder.encode(1, forKey: .versionKey)
    guard let photoData = image?.pngData() else { return }
    aCoder.encode(photoData, forKey: .photoKey)
  }

  required init?(coder aDecoder: NSCoder) {
    aDecoder.decodeInteger(forKey: .versionKey)
    guard let photoData = aDecoder.decodeObject(forKey: .photoKey) as? Data else { return nil }
    self.image = UIImage(data: photoData)
  }
}
7. PhotoMetadata.swift
import Foundation
import UIKit

class PhotoMetadata: NSObject, NSCoding {
  var image: UIImage?

  init(image: UIImage? = nil) {
    self.image = image
  }

  func encode(with aCoder: NSCoder) {
    aCoder.encode(1, forKey: .versionKey)

    guard let photoData = image?.pngData() else { return }
    aCoder.encode(photoData, forKey: .thumbnailKey)
  }

  required init?(coder aDecoder: NSCoder) {
    aDecoder.decodeInteger(forKey: .versionKey)

    guard let photoData = aDecoder.decodeObject(forKey: .thumbnailKey) as? Data else { return }
    image = UIImage(data: photoData)
  }
}
8. Entry.swift
import Foundation
import UIKit

class Entry: NSObject {
  var fileURL: URL
  var metadata: PhotoMetadata?
  var version: NSFileVersion

  private var editDate: Date {
    return version.modificationDate ?? .distantPast
  }

  override var description: String {
    return fileURL.deletingPathExtension().lastPathComponent
  }

  init(fileURL: URL, metadata: PhotoMetadata?, version: NSFileVersion) {
    self.fileURL = fileURL
    self.metadata = metadata
    self.version = version
  }
}

extension Entry: Comparable {
  static func < (lhs: Entry, rhs: Entry) -> Bool {
    return lhs.editDate < rhs.editDate
  }
}
9. Document.swift
import UIKit

extension String {
  static let appExtension: String = "ptk"
  static let versionKey: String = "Version"
  static let photoKey: String = "Photo"
  static let thumbnailKey: String = "Thumbnail"
}

typealias PhotoEntry = (mainImage: UIImage?, thumbnailImage: UIImage?)
private extension String {
  static let dataKey: String = "Data"
  static let metadataFilename: String = "photo.metadata"
  static let dataFilename: String = "photo.data"
}

class Document: UIDocument {
  override var description: String {
    return fileURL.deletingPathExtension().lastPathComponent
  }

  var fileWrapper: FileWrapper?

  lazy var photoData: PhotoData = {
    guard
      fileWrapper != nil,
      let data = decodeFromWrapper(for: .dataFilename) as? PhotoData
      else {
        return PhotoData()
    }
    
    return data
  }()
  
  lazy var metadata: PhotoMetadata = {
    guard
      fileWrapper != nil,
      let data = decodeFromWrapper(for: .metadataFilename) as? PhotoMetadata
      else {
        return PhotoMetadata()
    }
    
    return data
    
  }()
  
  // 4
  var photo: PhotoEntry? {
    get {
      return PhotoEntry(mainImage: photoData.image, thumbnailImage: metadata.image)
    }

    set {
      photoData.image = newValue?.mainImage
      metadata.image = newValue?.thumbnailImage
    }
  }

  private func encodeToWrapper(object: NSCoding) -> FileWrapper {
    let archiver = NSKeyedArchiver(requiringSecureCoding: false)
    archiver.encode(object, forKey: .dataKey)
    archiver.finishEncoding()

    return FileWrapper(regularFileWithContents: archiver.encodedData)
  }

  override func contents(forType typeName: String) throws -> Any {
    
    let metaDataWrapper = encodeToWrapper(object: metadata)
    let photoDataWrapper = encodeToWrapper(object: photoData)
    let wrappers: [String: FileWrapper] = [.metadataFilename: metaDataWrapper,
                                           .dataFilename: photoDataWrapper]

    return FileWrapper(directoryWithFileWrappers: wrappers)
  }

  override func load(fromContents contents: Any, ofType typeName: String?) throws {
    guard let contents = contents as? FileWrapper else { return }

    fileWrapper = contents
  }

  func decodeFromWrapper(for name: String) -> Any? {
    guard let allWrappers = fileWrapper,
      let wrapper = allWrappers.fileWrappers?[name],
      let data = wrapper.regularFileContents else { return nil }

    do {
      let unarchiver = try NSKeyedUnarchiver.init(forReadingFrom: data)
      unarchiver.requiresSecureCoding = false
      return unarchiver.decodeObject(forKey: .dataKey)
    } catch let error {
      fatalError("Unarchiving failed. \(error.localizedDescription)")
    }
  }
}

后记

本篇主要讲述了UIDocument的数据存储,感兴趣的给个赞或者关注~~~