[译] 使用 Swift 的 iOS 设计模式(第二部分)

在这个由两部分组成的教程中,你将了解构建 iOS 应用程序的常见设计模式,以及如何在本身的应用程序中应用这些模式。html

更新说明:本教程已由译者针对 iOS 12,Xcode 10 和 Swift 4.2 进行了更新。原帖由教程团队成员 Eli Ganem 发布。前端

欢迎回到 iOS 设计模式的入门教程第二部分!在 第一部分 中,你已经了解了 Cocoa 中的一些基本模式,好比 MVC、单例和装饰模式。android

在最后一部分中,你将了解 iOS 和 OS X 开发中出现的其余基本设计模式:适配器、观察者和备忘录。让咱们如今就开始吧!ios

入门

你能够下载 第一部分最结尾处的项目 来开始。git

这是你在第一部分结尾处留下的音乐库应用程序:github

Album app showing populated table view

该应用程序的原计划包括了屏幕顶部用来在专辑之间切换的 scrollView。可是与其编写一个只有单个用途的 scrollView,为什么不让它变得能够给其余任何 view 复用呢?编程

要使此 scrollView 可复用,跟其内容有关的全部决策都应留给其余两个对象:它的数据源和代理。为了使用 scrollView,应该给它声明数据源和代理实现的方法,这就相似于 UITableView 的代理方法工做方式。当咱们接下来一边讨论下一个设计模式时,你也将一边着手实现它。json

适配器模式

适配器容许和具备不兼容接口的类一块儿工做,它将自身包裹在一个对象内,并公开一个标准接口来与该对象进行交互。swift

若是你熟悉适配器模式,那么你会注意到 Apple 以一种稍微不一样的方式实现它,那就是协议。你可能熟悉 UITableViewDelegateUIScrollViewDelegateNSCodingNSCopying 等协议。例如使用 NSCopying 协议,任何类均可以提供一个标准的 copy 方法。后端

如何使用适配器模式

以前提到的 scrollView 以下图所示:

swiftDesignPattern7

咱们如今来实现它吧,右击项目导航栏中的 View 组,选择 New File > iOS > Cocoa Touch Class,而后单击 Next,将类名设置为 HorizontalScrollerView 并继承自 UIView

打开 HorizontalScrollerView.swift 并在 HorizontalScroller 类声明的 上方 插入如下代码:

protocol HorizontalScrollerViewDataSource: class {
  // 询问数据源它想要在 scrollView 中显示多少个 view
  func numberOfViews(in horizontalScrollerView: HorizontalScrollerView) -> Int
  // 请求数据源返回应该出如今第 index 个的 view
  func horizontalScrollerView(_ horizontalScrollerView: HorizontalScrollerView, viewAt index: Int) -> UIView
}
复制代码

这定义了一个名为 HorizontalScrollerViewDataSource 的协议,它执行两个操做:请求在 scrollView 内显示 view 的个数以及应为特定索引显示的 view。

在此协议定义的下方再添加另外一个名为 HorizontalScrollerViewDelegate 的协议。

protocol HorizontalScrollerViewDelegate: class {
  // 通知代理第 index 个 view 已经被选择
  func horizontalScrollerView(_ horizontalScrollerView: HorizontalScrollerView, didSelectViewAt index: Int)
}
复制代码

这将使 scrollView 通知某个其余对象它内部的一个 view 已经被选中。

**注意:**将关注区域划分为不一样的协议会使代码看起来更加清晰。经过这种方式你能够决定遵循特定的协议,并避免使用 @objc 来声明可选的协议方法。

HorizontalScrollerView.swift 中,将如下代码添加到 HorizontalScrollerView 类的定义里:

weak var dataSource: HorizontalScrollerViewDataSource?
weak var delegate: HorizontalScrollerViewDelegate?
复制代码

代理和数据源都是可选项,所以你不必定要给他们赋值,但你在此处设置的任何对象都必须遵循相应的协议。

在类里继续添加如下代码:

// 1
private enum ViewConstants {
  static let Padding: CGFloat = 10
  static let Dimensions: CGFloat = 100
  static let Offset: CGFloat = 100
}

// 2
private let scroller = UIScrollView()

// 3
private var contentViews = [UIView]()
复制代码

每条注释的详解以下:

  1. 定义一个私有的 enum 来使代码布局在设计时更易修改。scrollView 的内的 view 尺寸为 100 x 100,padding 为 10
  2. 建立包含多个 view 的 scrollView
  3. 建立一个包含全部专辑封面的数组

接下来你须要实现初始化器。添加如下方法:

override init(frame: CGRect) {
  super.init(frame: frame)
  initializeScrollView()
}

required init?(coder aDecoder: NSCoder) {
  super.init(coder: aDecoder)
  initializeScrollView()
}

func initializeScrollView() {
  // 1
  addSubview(scroller)

  // 2
  scroller.translatesAutoresizingMaskIntoConstraints = false

  // 3
  NSLayoutConstraint.activate([
    scroller.leadingAnchor.constraint(equalTo: self.leadingAnchor),
    scroller.trailingAnchor.constraint(equalTo: self.trailingAnchor),
    scroller.topAnchor.constraint(equalTo: self.topAnchor),
    scroller.bottomAnchor.constraint(equalTo: self.bottomAnchor)
  ])

  // 4
  let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(scrollerTapped(gesture:)))
  scroller.addGestureRecognizer(tapRecognizer)
}
复制代码

这项工做是在 initializeScrollView() 中完成的。如下是详细分析:

  1. 添加子视图 UIScrollView 实例
  2. 关闭 autoresizingMask,这样你就可使用自定义约束了
  3. 将约束应用于 scrollView,你但愿 scrollView 彻底填充 HorizontalScrollerView
  4. 建立 tap 手势。它会检测 scrollView 上的触摸事件并检查是否已经点击了专辑封面。若是是,它将通知 HorizontalScrollerView 的代理。在这里会有一个编译错误,由于 scrollerTapped(gesture:) 方法还没有实现,你接下来就要实现它了。

如今添加下面的方法:

func scrollToView(at index: Int, animated: Bool = true) {
  let centralView = contentViews[index]
  let targetCenter = centralView.center
  let targetOffsetX = targetCenter.x - (scroller.bounds.width / 2)
  scroller.setContentOffset(CGPoint(x: targetOffsetX, y: 0), animated: animated)
}
复制代码

此方法检索特定索引的 view 并使其居中。它将由如下方法调用(你也须要将此方法添加到类中):

@objc func scrollerTapped(gesture: UITapGestureRecognizer) {
  let location = gesture.location(in: scroller)
  guard
    let index = contentViews.index(where: { $0.frame.contains(location)})
    else { return }

  delegate?.horizontalScrollerView(self, didSelectViewAt: index)
  scrollToView(at: index)
}
复制代码

此方法在 scrollView 中寻找点击的位置,若是存在的话它会查找包含该位置的第一个 contentView 的索引。

若是点击了 contentView,则通知代理并将此 view 滚动到中心位置。

接下来添加如下内容以从滚动器访问专辑封面:

func view(at index :Int) -> UIView {
  return contentViews[index]
}
复制代码

view(at:) 只返回特定索引处的 view,稍后你将使用此方法突出显示你已点击的专辑封面。

如今添加如下代码来刷新 scrollView:

func reload() {
  // 1. 检查是否有数据源,若是没有则返回。
  guard let dataSource = dataSource else {
    return
  }

  // 2. 删除全部旧的 contentView
  contentViews.forEach { $0.removeFromSuperview() }

  // 3. xValue 是 scrollView 内每一个 view 的起点 x 坐标
  var xValue = ViewConstants.Offset
  // 4. 获取并添加新的 View
  contentViews = (0..<dataSource.numberOfViews(in: self)).map {
    index in
    // 5. 在正确的位置添加 View
    xValue += ViewConstants.Padding
    let view = dataSource.horizontalScrollerView(self, viewAt: index)
    view.frame = CGRect(x: xValue, y: ViewConstants.Padding, width: ViewConstants.Dimensions, height: ViewConstants.Dimensions)
    scroller.addSubview(view)
    xValue += ViewConstants.Dimensions + ViewConstants.Padding
    return view
  }
  // 6
  scroller.contentSize = CGSize(width: xValue + ViewConstants.Offset, height: frame.size.height)
}
复制代码

UITableView 中的 reload 方法会在 reloadData 以后建模,它将从新加载用于构造 scrollView 的全部数据。

每条注释对应的详解以下:

  1. 在执行任何 reload 以前检查数据源是否存在。
  2. 因为你要清除专辑封面,所以你还须要移除全部存在的 view。
  3. 全部 view 都从给定的偏移量开始定位。目前它是 100,但能够经过更改文件顶部的常量 ViewConstants.Offset 来轻松地作出调整。
  4. 向数据源请求 view 的个数,而后使用它来建立新的 contentView 数组。
  5. HorizontalScrollerView 一次向一个 view 请求其数据源,并使用先前定义的填充将它们水平依次布局。
  6. 全部 view 布局好以后,设置 scrollView 的偏移量来容许用户滚动浏览全部专辑封面。

当你的数据发生改变时调用 reload 方法。

HorizontalScrollerView 须要实现的最后一个功能是确保你正在查看的专辑始终位于 scrollView 的中心。为此,当用户用手指拖动 scrollView 时,你须要执行一些计算。

下面添加如下方法:

private func centerCurrentView() {
  let centerRect = CGRect(
    origin: CGPoint(x: scroller.bounds.midX - ViewConstants.Padding, y: 0),
    size: CGSize(width: ViewConstants.Padding, height: bounds.height)
  )

  guard let selectedIndex = contentViews.index(where: { $0.frame.intersects(centerRect) })
    else { return }
  let centralView = contentViews[selectedIndex]
  let targetCenter = centralView.center
  let targetOffsetX = targetCenter.x - (scroller.bounds.width / 2)

  scroller.setContentOffset(CGPoint(x: targetOffsetX, y: 0), animated: true)
  delegate?.horizontalScrollerView(self, didSelectViewAt: selectedIndex)
}
复制代码

上面的代码考虑了 scrollView 的当前偏移量以及 view 的尺寸和填充以便计算当前view 与中心的距离。最后一行很重要:一旦 view 居中,就通知代理所选的 view 已变动。

要检测用户是否在 scrollView 内完成了拖动,你须要实现一些 UIScrollViewDelegate 的方法,将如下类扩展添加到文件的底部。记住必定要在主类声明的花括号 下面 添加!

extension HorizontalScrollerView: UIScrollViewDelegate {
  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
      centerCurrentView()
    }
  }

  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    centerCurrentView()
  }
}
复制代码

scrollViewDidEndDragging(_:willDecelerate:) 在用户完成拖拽时通知代理,若是 scrollView 还没有彻底中止,则 decelerate 为 true。当滚动结束时,系统调用scrollViewDidEndDecelerating(_:)。在这两种状况下,你都应该调用新方法使当前视图居中,由于当用户拖动滚动视图后当前视图可能已更改。

最后不要忘记设置代理,将如下代码添加到 initializeScrollView() 的最开头:

scroller.delegate = self
复制代码

你的 HorizontalScrollerView 已准备就绪!看一下你刚刚编写的代码,你会看到没有任何地方有出现 AlbumAlbumView 类。这很是棒,由于这意味着新的 scrollView 真正实现了解耦而且可复用。

编译项目确保能够正常经过编译。

如今 HorizontalScrollerView 已经完成,是时候在你的应用程序中使用它了。首先打开 Main.storyboard。单击顶部的灰色矩形视图,而后单击 Identity Inspector。将类名更改成 HorizontalScrollerView,以下图所示:

接下来打开 Assistant Editor 并从灰色矩形 view 拖线到 ViewController.swift 来建立一个 IBOutlet,并命名为 horizontalScrollerView,以下图所示:

接下来打开 ViewController.swift,是时候开始实现一些 HorizontalScrollerViewDelegate 方法了!

把下面的拓展添加到该文件的最底部:

extension ViewController: HorizontalScrollerViewDelegate {
  func horizontalScrollerView(** horizontalScrollerView: HorizontalScrollerView, didSelectViewAt index: Int) {
    // 1
    let previousAlbumView = horizontalScrollerView.view(at: currentAlbumIndex) as! AlbumView
    previousAlbumView.highlightAlbum(false)
    // 2
    currentAlbumIndex = index
    // 3
    let albumView = horizontalScrollerView.view(at: currentAlbumIndex) as! AlbumView
    albumView.highlightAlbum(true)
    // 4
    showDataForAlbum(at: index)
  }
}
复制代码

这是在调用此代理方法时发生的事情:

  1. 首先你取到以前选择的专辑,而后取消选择专辑封面
  2. 存储刚刚点击的当前专辑封面的索引
  3. 取得当前所选的专辑封面并显示高亮状态
  4. 在 tableView 中显示新专辑的数据

接下来,是时候实现 HorizontalScrollerViewDataSource 了。在当前文件末尾添加如下代码:

extension ViewController: HorizontalScrollerViewDataSource {
  func numberOfViews(in horizontalScrollerView: HorizontalScrollerView) -> Int {
    return allAlbums.count
  }

  func horizontalScrollerView(_ horizontalScrollerView: HorizontalScrollerView, viewAt index: Int) -> UIView {
    let album = allAlbums[index]
    let albumView = AlbumView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), coverUrl: album.coverUrl)
    if currentAlbumIndex == index {
      albumView.highlightAlbum(true)
    } else {
      albumView.highlightAlbum(false)
    }
    return albumView
  }
}
复制代码

正如你所看到的,numberOfViews(in:) 是返回 scrollView 中 view 的个数的协议方法。因为 scrollView 将显示全部专辑数据的封面,所以 count 就是专辑记录的数量。在 horizontalScrollerView(_:viewAt:) 里你建立一个新的 AlbumView,若是它是所选的专辑,则高亮显示它,再将它传递给 HorizontalScrollerView

基本完成了!只用三个简短的方法就能显示出一个漂亮的 scrollView。你如今须要设置数据源和代理。在 viewDidLoad 中的 showDataForAlbum(at:) 以前添加如下代码:

horizontalScrollerView.dataSource = self
horizontalScrollerView.delegate = self
horizontalScrollerView.reload()
复制代码

编译并运行你的项目,就能够看到漂亮的水平滚动视图:

Album cover scroller

呃,等一下!水平滚动视图已就位,但专辑的封面在哪里呢?

噢,对了,你尚未实现下载封面的代码。为此,你须要添加下载图像的方法,并且你对服务器的所有访问请求都要经过一个全部新方法必经的一层 LibraryAPI。可是,首先要考虑如下几点:

  1. AlbumView 不该直接与 LibraryAPI 产生联系,你不会但愿将 view 里的逻辑与网络请求混合在一块儿的。
  2. 出于一样的缘由,LibraryAPI 也不该该知道 AlbumView 的存在。
  3. 当封面被下载完成,LibraryAPI 须要通知 AlbumView 来显示专辑。

是否是感受听起来好像很难的样子?不要绝望,你将学习如何使用 观察者 模式来作到这点!

观察者模式

在观察者模式中,一个对象通知其余对象任何状态的更改,可是通知的涉及对象不须要相互关联,咱们鼓励这种解耦的设计方式。这种模式最经常使用于在一个对象的属性发生更改时通知其余相关对象。

一般的实现是须要观察者监听另外一个对象的状态。当状态发生改变时,全部观察对象都会被通知这次更改。

若是你坚持 MVC 的概念(也确实须要坚持),你须要容许 Model 对象与 View 对象进行通讯,可是它们之间没有直接引用,这就是观察者模式的用武之地。

Cocoa 以两种方式实现了观察者模式:通知键值监听(KVO)

通知

不要与推送通知或本地通知混淆,观察者模式的通知基于订阅和发布模型,该模型容许对象(发布者)将消息发送到其余对象(订阅者或监听者),并且发布者永远不须要了解有关订阅者的任何信息。

Apple 会大量使用通知。例如,当显示或隐藏键盘时,系统分别发送 UIKeyboardWillShowUIKeyboardWillHide 通知。当你的应用程序转入后台运行时,系统会发送一个 UIApplicationDidEnterBackground 通知。

如何使用通知

右击 RWBlueLibrary 并选择 New Group,而后命名为 Extension。再次右击该组,而后选择New File > iOS > Swift File,并将文件名设置为 NotificationExtension.swift

把下面的代码拷贝到该文件中:

extension Notification.Name {
  static let BLDownloadImage = Notification.Name("BLDownloadImageNotification")
}
复制代码

你正在使用自定义通知扩展的 Notification.Name,从如今开始,新的通知能够像系统通知同样用 .BLDownloadImage 访问。

打开 AlbumView.swift 并将如下代码插入到 init(frame:coverUrl:) 方法的最后:

NotificationCenter.default.post(name: .BLDownloadImage, object: self, userInfo: ["imageView": coverImageView, "coverUrl" : coverUrl])
复制代码

该行代码经过 NotificationCenter 的单例发送通知,通知信息包含要填充的 UIImageView 和要下载的专辑图像的 URL,这些是执行封面下载任务所需的全部信息。

将如下代码添加到 LibraryAPI.swift中的 init 方法来做为当前为空的初始化方法的实现:

NotificationCenter.default.addObserver(self, selector: #selector(downloadImage(with:)), name: .BLDownloadImage, object: nil)
复制代码

这是通知这个等式的另外一边--观察者,每次 AlbumView 发送 BLDownloadImage 通知时,因为 LibraryAPI 已注册成为该通知的观察者,系统会通知 LibraryAPI,而后 LibraryAPI 响应并调用 downloadImage(with:)

在实现 downloadImage(with:) 以前,还有一件事要作。在本地保存下载的封面多是个好主意,这样应用程序就不须要一遍又一遍地下载相同的封面了。

打开 PersistencyManager.swift,把 import Foundation 换成下面的代码:

import UIKit
复制代码

这次 import 很重要,由于你将处理 UI 对象,好比 UIImage

把这个计算属性添加到该类的最后:

private var cache: URL {
  return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}
复制代码

此变量返回缓存目录的 URL,它是一个存储了你能够随时从新下载的文件的好地方。

如今添加如下两个方法:

func saveImage(_ image: UIImage, filename: String) {
  let url = cache.appendingPathComponent(filename)
  guard let data = UIImagePNGRepresentation(image) else {
    return
  }
  try? data.write(to: url)
}

func getImage(with filename: String) -> UIImage? {
  let url = cache.appendingPathComponent(filename)
  guard let data = try? Data(contentsOf: url) else {
    return nil
  }
  return UIImage(data: data)
}
复制代码

这段代码很是简单,下载的图像将保存在 Cache 目录中,若是在 Cache 目录中找不到匹配的文件,getImage(with:) 将返回 nil

如今打开 LibraryAPI.swift 而且将 import Foundation 改成 import UIKit

在类的最后添加如下方法:

@objc func downloadImage(with notification: Notification) {
  guard let userInfo = notification.userInfo,
    let imageView = userInfo["imageView"] as? UIImageView,
    let coverUrl = userInfo["coverUrl"] as? String,
    let filename = URL(string: coverUrl)?.lastPathComponent else {
      return
  }

  if let savedImage = persistencyManager.getImage(with: filename) {
    imageView.image = savedImage
    return
  }

  DispatchQueue.global().async {
    let downloadedImage = self.httpClient.downloadImage(coverUrl) ?? UIImage()
    DispatchQueue.main.async {
      imageView.image = downloadedImage
      self.persistencyManager.saveImage(downloadedImage, filename: filename)
    }
  }
}
复制代码

如下是上面两个方法的详解:

  1. downloadImage 是经过通知触发调用的,所以该方法接收通知对象做为参数。从通知传递来的对象取出 UIImageView 和 image 的 URL。
  2. 若是先前已下载过,则从 PersistencyManager 中检索 image。
  3. 若是还没有下载图像,则使用 HTTPClient 检索。
  4. 下载完成后,在 imageView 中显示图像,并使用 PersistencyManager 将其保存在本地。

再一次的,你使用外观模式隐藏了从其余类下载图像这一复杂的过程。通知发送者并不关心图像是来自网络下载仍是来自本地的存储。

编译并运行你的应用程序,如今能看到 collectionView 中漂亮的封面:

Album app showing cover art but still with spinners

中止你的应用并再次运行它。请注意加载封面没有延迟,这是由于它们已在本地保存了。你甚至能够断开与互联网的链接,应用程序仍将完美运行。然而这里有一个奇怪的地方,旋转加载的动画永远不会中止!这是怎么回事?

你在下载图像时开始了旋转动画,可是在下载图像后,你并无实现中止加载动画的逻辑。你 本应该 在每次下载图像时发送通知,可是下面你将使用键值监听(KVO)来执行此操做。

键值监听(KVO)

在 KVO 中,对象能够监听一个特定属性的任何更改,要么是本身的属性,要么就是另外一个对象的。若是你有兴趣,能够阅读 KVO 开发文档 中的更多关信息。

如何使用键值监听

如上所述,键值监听机制容许对象观察属性的变化。在你的案例中,你可使用键值监听来监听显示图片的 UIImageViewimage 属性的更改。

打开 AlbumView.swift 并在 private var indicatorView: UIActivityIndicatorView! 的声明下面添加如下属性:

private var valueObservation: NSKeyValueObservation!
复制代码

在添加封面的 imageView 作为子视图以前,将如下代码添加到commonInit

valueObservation = coverImageView.observe(\.image, options: [.new]) { [unowned self] observed, change in
  if change.newValue is UIImage {
      self.indicatorView.stopAnimating()
  }
}
复制代码

这段代码将 imageView 作为封面图片的 image 属性的观察者。\.image 是一个启用此功能的 keyPath 表达式。

在 Swift 4 中,keyPath 表达式具备如下形式:

\<type>.<property>.<subproperty>
复制代码

type 一般能够由编译器推断,但至少须要提供一个 property。在某些状况下,使用属性的属性多是有意义的。在你如今的状况下,咱们已指定属性名称 image,而省略了类型名称 UIImageView

尾随闭包指定了在每次观察到的属性更改时执行的闭包。在上面的代码中,当 image 属性更改时,你要中止加载的旋转动画。这样作了以后,当图片加载完成,旋转动画就会中止。

编译并运行你的项目,加载中的旋转动画将会消失:

How the album app will look when the design patterns tutorial is complete

注意: 要始终记得在它们被销毁时删除你的观察者,不然当对象试图向这些不存在的观察者发送消息时,你的应用程序将崩溃!在这种状况下,当专辑视图被移除,valueObservation 将被销毁,所以监听将会中止。

若是你稍微使用一下你的应用而后就终止它,你会注意到你的应用状态并未保存。应用程序启动时,你查看的最后一张专辑将不是默认专辑。

要更正此问题,你可使用以前列表中接下来的一个模式:备忘录

备忘录模式

备忘录模式捕获并使对象的内部状态暴露出来。换句话讲,它能够在某处保存你的东西,稍后在不违反封装的原则下恢复此对外暴露的状态。也就是说,私有数据仍然是私有的。

如何使用备忘录模式

iOS 使用备忘录模式做为 状态恢复 的一部分。你能够经过阅读咱们的 教程 来了解更多信息,但实质上它会存储并从新应用你的应用程序状态,以便用户回到上次操做的状态。

要在应用程序中激活状态恢复,请打开 Main.storyboard,选择 Navigation Controller,而后在 Identity Inspector 中找到 Restoration ID 字段并输入 NavigationController

选择 Pop Music scene 并在刚才的位置输入 ViewController。这些 ID 会告诉系统,当应用从新启动时,你想要恢复这些 viewController 的状态。

AppDelegate.swift 中添加如下代码:

func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
  return true
}

func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
  return true
}
复制代码

如下的代码会为你的应用程序打开状态做为一个总体来还原。如今,将如下代码添加到 ViewController.swift 中的 Constants 枚举中:

static let IndexRestorationKey = "currentAlbumIndex"
复制代码

这个静态常量将用于保存和恢复当前专辑的索引,如今添加如下代码:

override func encodeRestorableState(with coder: NSCoder) {
  coder.encode(currentAlbumIndex, forKey: Constants.IndexRestorationKey)
  super.encodeRestorableState(with: coder)
}

override func decodeRestorableState(with coder: NSCoder) {
  super.decodeRestorableState(with: coder)
  currentAlbumIndex = coder.decodeInteger(forKey: Constants.IndexRestorationKey)
  showDataForAlbum(at: currentAlbumIndex)
  horizontalScrollerView.reload()
}
复制代码

你将在这里保存索引(该操做在应用程序进入后台时进行)并恢复它(该操做在应用程序启动时加载完成 controller 中的 view 后进行)。还原索引后,更新 tableView 和 scrollView 以显示更新以后的选中状态。还有一件事要作,那就是你须要将 scrollView 滚动到正确的位置。若是你在此处滚动 scrollView,这样是行不通的,由于 view 还没有布局完毕。下面请在正确的地方添加代码让 scrollView 滚动到对应的 view:

override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)
  horizontalScrollerView.scrollToView(at: currentAlbumIndex, animated: false)
}
复制代码

编译并运行你的应用程序,点击其中一个专辑,而后按一下 Home 键使应用程序进入后台(若是你在模拟器上运行,则也能够按下 Command+Shift+H),再从 Xcode 上中止运行你的应用程序并从新启动,看一下以前选择的专辑是否到了中间的位置:

How the album app will look when the design patterns tutorial is complete

请看一下 PersistencyManager 中的 init 方法,你会注意到每次建立 PersistencyManager 时都会对专辑数据进行硬编码并从新建立。但其实更好的解决方案是一次性建立好专辑列表并将其存储在文件中。那你该如何将 Album 的数据保存到文件中呢?

方案之一是遍历 Album 的属性并将它们保存到 plist 文件,而后在须要时从新建立 Album 实例,但这并非最佳的,由于它要求你根据每一个类中的数据或属性编写特定代码,若是你之后建立了具备不一样属性的 Movie 类,则保存和加载该数据都将须要重写新的代码。

此外,你将没法为每一个类实例保存私有变量,由于外部类并不难访问它们,这就是为何 Apple 要建立 归档和序列化 机制。

归档和序列化

Apple 的备忘录模式的一个专门实现方法是经过归档和序列化。在 Swift 4 以前,为了序列化和保存你的自定义类型,你必须通过许多步骤。对于 来讲,你须要继承自 NSObject 并遵行 NSCoding 协议。

可是像 结构体枚举 这样的值类型就须要一个能够扩展 NSObject 并遵行 NSCoding 的子对象了。

Swift 4 为 结构体枚举 这三种类型解决了这个问题:[SE-0166]

如何使用归档和序列化

打开 Album.swift 并让 Album 遵行 Codable。这个协议可让 Swift 中的类同时遵行 EncodableDecodable。若是全部属性都是可 Codable 的,则协议的实现由编译器自动生成。

你的代码如今看起来会像这样:

struct Album: Codable {
  let title : String
  let artist : String
  let genre : String
  let coverUrl : String
  let year : String
}
复制代码

要对对象进行编码,你须要使用 encoder。打开 PersistencyManager.swift 并添加如下代码:

private var documents: URL {
  return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}

private enum Filenames {
  static let Albums = "albums.json"
}

func saveAlbums() {
  let url = documents.appendingPathComponent(Filenames.Albums)
  let encoder = JSONEncoder()
  guard let encodedData = try? encoder.encode(albums) else {
    return
  }
  try? encodedData.write(to: url)
}
复制代码

就像使用 caches 同样,你将在此定义一个 URL 用来保存文件目录,它是一个存储文件名路径的常量,而后就是将你的专辑数据写入文件的方法,事实上你并不用编写不少的代码!

该方案的另外一部分是将数据解码回具体对象。你如今须要替换掉建立专辑并从文件中加载它们的很长一段的那个方法。下载并解压 此JSON文件 并将其添加到你的项目中。

如今用如下代码替换 PersistencyManager.swift 中的 init 方法体:

let savedURL = documents.appendingPathComponent(Filenames.Albums)
var data = try? Data(contentsOf: savedURL)
if data == nil, let bundleURL = Bundle.main.url(forResource: Filenames.Albums, withExtension: nil) {
  data = try? Data(contentsOf: bundleURL)
}

if let albumData = data,
  let decodedAlbums = try? JSONDecoder().decode([Album].self, from: albumData) {
  albums = decodedAlbums
  saveAlbums()
}
复制代码

如今你正在从 documents 目录下的文件中加载专辑数据(若是存在的话)。若是它不存在,则从先前添加的启动文件中加载它,而后就当即保存,那么下次启动时它将会位于文档目录中。JSONDecoder 很是智能,你只需告诉它你但愿文件包含的类型,它就会为你完成剩下的全部工做!

你可能还但愿每次应用进入后台时保存专辑数据,我将把这一部分做为一个挑战让你亲自弄明白其中的原理,你在这两个教程中学到的一些模式还有技术将会派上用场!

接下来该干吗?

你能够 在此 下载最终项目。

在本教程中你了解了如何利用 iOS 设计模式的强大功能来以很直接的方式执行复杂的任务。你已经学习了不少 iOS 设计模式和概念:单例,MVC,代理,协议,外观,观察者和备忘录。

你的最终代码将会是耦合度低、可重用而且易读的。若是其余开发者阅读你的代码,他们将可以很轻松地了解每行代码的功能以及每一个类在你的应用中的做用。

其中的关键点是不要为你了使用设计模式而使用它。然而在考虑如何解决特定问题时,请留意设计模式,尤为是在设计应用程序的早期阶段。它们将使做为开发者的你生活变得更加轻松,代码同时也会更好!

关于该文章主题的一本经典书籍是 Design Patterns: Elements of Reusable Object-Oriented Software。有关代码示例,请查看 GitHub 上一个很是棒的项目 Design Patterns: Elements of Reusable Object-Oriented Software 来取更多在 Swift 中编程中的设计模式。

最后请务必查看 Swift 设计模式进阶 和咱们的视频课程 iOS Design Patterns 来了解更多设计模式!

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索