[譯] 使用 Swift 的 iOS 設計模式(第二部分)

iWeslie發表於2018-12-17

在這個由兩部分組成的教程中,你將瞭解構建 iOS 應用程式的常見設計模式,以及如何在自己的應用程式中應用這些模式。

更新說明:本教程已由譯者針對 iOS 12,Xcode 10 和 Swift 4.2 進行了更新。原帖由教程團隊成員 Eli Ganem 釋出。

歡迎回到 iOS 設計模式的入門教程第二部分!在 第一部分 中,你已經瞭解了 Cocoa 中的一些基本模式,比如 MVC、單例和裝飾模式。

在最後一部分中,你將瞭解 iOS 和 OS X 開發中出現的其他基本設計模式:介面卡、觀察者和備忘錄。讓我們現在就開始吧!

入門

你可以下載 第一部分最結尾處的專案 來開始。

這是你在第一部分結尾處留下的音樂庫應用程式:

Album app showing populated table view

該應用程式的原計劃包括了螢幕頂部用來在專輯之間切換的 scrollView。但是與其編寫一個只有單個用途的 scrollView,為何不讓它變得可以給其他任何 view 複用呢?

要使此 scrollView 可複用,跟其內容有關的所有決策都應留給其他兩個物件:它的資料來源和代理。為了使用 scrollView,應該給它宣告資料來源和代理實現的方法,這就類似於 UITableView 的代理方法工作方式。當我們接下來一邊討論下一個設計模式時,你也將一邊著手實現它。

介面卡模式

介面卡允許和具有不相容介面的類一起工作,它將自身包裹在一個物件內,並公開一個標準介面來與該物件進行互動。

如果你熟悉介面卡模式,那麼你會注意到 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,如下圖所示:

[譯] 使用 Swift 的 iOS 設計模式(第二部分)

接下來開啟 Assistant Editor 並從灰色矩形 view 拖線到 ViewController.swift 來建立一個 IBOutlet,並命名為 horizontalScrollerView,如下圖所示:

[譯] 使用 Swift 的 iOS 設計模式(第二部分)

接下來開啟 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章