• 原文地址:Design Patterns on iOS using Swift – Part 2/2
  • 原文作者:Lorenzo Boaro
  • 譯文出自:掘金翻譯計劃
  • 本文永久鏈接:github.com/xitu/gold-mi
  • 譯者:iWeslie
  • 校對者:swants

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

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

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

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

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

入門

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

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

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

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

適配器模式

適配器允許和具有不兼容介面的類一起工作,它將自身包裹在一個對象內,並公開一個標準介面來與該對象進行交互。

如果你熟悉適配器模式,那麼你會注意到 Apple 以一種稍微不同的方式實現它,那就是協議。你可能熟悉 UITableViewDelegateUIScrollViewDelegateNSCodingNSCopying 等協議。例如使用 NSCopying 協議,任何類都可以提供一個標準的 copy 方法。

如何使用適配器模式

之前提到的 scrollView 如下圖所示:

我們現在來實現它吧,右擊項目導航欄中的 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()

編譯並運行你的項目,就可以看到漂亮的水平滾動視圖:

呃,等一下!水平滾動視圖已就位,但專輯的封面在哪裡呢?

噢,對了,你還沒有實現下載封面的代碼。為此,你需要添加下載圖像的方法,而且你對伺服器的全部訪問請求都要通過一個所有新方法必經的一層 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 中漂亮的封面:

停止你的應用並再次運行它。請注意載入封面沒有延遲,這是因為它們已在本地保存了。你甚至可以斷開與互聯網的連接,應用程序仍將完美運行。然而這裡有一個奇怪的地方,旋轉載入的動畫永遠不會停止!這是怎麼回事?

你在下載圖像時開始了旋轉動畫,但是在下載圖像後,你並沒有實現停止載入動畫的邏輯。你 本應該 在每次下載圖像時發送通知,但是下面你將使用鍵值監聽(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 屬性更改時,你要停止載入的旋轉動畫。這樣做了之後,當圖片載入完成,旋轉動畫就會停止。

編譯並運行你的項目,載入中的旋轉動畫將會消失:

注意: 要始終記得在它們被銷毀時刪除你的觀察者,否則當對象試圖向這些不存在的觀察者發送消息時,你的應用程序將崩潰!在這種情況下,當專輯視圖被移除,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 上停止運行你的應用程序並重新啟動,看一下之前選擇的專輯是否到了中間的位置:

請看一下 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 鏈接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。

推薦閱讀:

相关文章