[譯] 使用 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索