Swift37/90Days - iOS 中的設計模式 (Swift 版本) 02

(闊別一個多月。。終於完成了。。)html

更新聲明

翻譯自 Introducing iOS Design Patterns in Swift – Part 2/2 ,本教程 objc 版本的做者是 Eli Ganem ,由 Vincent Ngo 更新爲 Swift 版本。ios

再續前緣

歡迎來到教程的第二部分!這是本系列教程的最後一部分,在這一章的學習裏,咱們會更加深刻的學習一些 iOS 開發中常見的設計模式:適配器模式 (Adapter),觀察者模式 (Observer),備忘錄模式 (Memento)。git

開始吧少年們!github

準備工做

你能夠先下載上一章結束時的項目源碼編程

在第一部分的教程裏,咱們完成了這樣一個簡單的應用:swift

咱們的原計劃是在上面的空白處放一個能夠橫滑瀏覽專輯的視圖。其實仔細想一想,這個控件是能夠應用在其餘地方的,咱們不妨把它作成一個可複用的視圖。設計模式

爲了讓這個視圖能夠複用,顯示內容的工做都只能交給另外一個對象來完成:它的委託。這個橫滑頁面應該聲明一些方法讓它的委託去實現,就像是 UITableViewUITableViewDelegate 同樣。咱們將會在下一個設計模式中實現這個功能。數組

適配器模式 - Adapter

適配器把本身封裝起來而後暴露統一的接口給其餘類,這樣即便其餘類的接口各不相同,也能相安無事,一塊兒工做。緩存

若是你熟悉適配器模式,那麼你會發現蘋果在實現適配器模式的方式稍有不一樣:蘋果經過委託實現了適配器模式。委託相信你們都不陌生。舉個例子,若是一個類遵循了 NSCoying 的協議,那麼它必定要實現 copy 方法。網絡

如何使用適配器模式

橫滑的滾動欄理論上應該是這個樣子的:

新建一個 Swift 文件:HorizontalScroller.swift ,做爲咱們的橫滑滾動控件, HorizontalScroller 繼承自 UIView

打開 HorizontalScroller.swift 文件並添加以下代碼:

@objc protocol HorizontalScrollerDelegate {
}

這行代碼定義了一個新的協議: HorizontalScrollerDelegate 。咱們在前面加上了 @objc 的標記,這樣咱們就能夠像在 objc 裏同樣使用 @optional 的委託方法了。

接下來咱們在大括號裏定義全部的委託方法,包括必須的和可選的:

// 在橫滑視圖中有多少頁面須要展現
func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> Int
// 展現在第 index 位置顯示的 UIView
func horizontalScrollerViewAtIndex(scroller: HorizontalScroller, index:Int) -> UIView
// 通知委託第 index 個視圖被點擊了
func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller, index:Int)
// 可選方法,返回初始化時顯示的圖片下標,默認是0
optional func initialViewIndex(scroller: HorizontalScroller) -> Int

其中,沒有 option 標記的方法是必須實現的,通常來講包括那些用來顯示的必須數據,好比如何展現數據,有多少數據須要展現,點擊事件如何處理等等,不可或缺;有 option 標記的方法爲可選實現的,至關因而一些輔助設置和功能,就算沒有實現也有默認值進行處理。

HorizontalScroller 類裏添加一個新的委託對象:

weak var delegate: HorizontalScrollerDelegate?

爲了不循環引用的問題,委託是 weak 類型。若是委託是 strong 類型的,當前對象持有了委託的強引用,委託又持有了當前對象的強引用,這樣誰都沒法釋放就會致使內存泄露。

委託是可選類型,因此頗有可能當前類的使用者並無指定委託。可是若是指定了委託,那麼它必定會遵循 HorizontalScrollerDelegate 里約定的內容。

再添加一些新的屬性:

// 1
private let VIEW_PADDING = 10
private let VIEW_DIMENSIONS = 100
private let VIEWS_OFFSET = 100

// 2
private var scroller : UIScrollView!
// 3
var viewArray = [UIView]()

上面標註的三點分別作了這些事情:

  • 定義一個常量,用來方便的改變佈局。如今默認的是顯示的內容長寬爲100,間隔爲10。
  • 建立一個 UIScrollView 做爲容器。
  • 建立一個數組用來存放須要展現的數據

接下來實現初始化方法:

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

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

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

    //2
    scroller.setTranslatesAutoresizingMaskIntoConstraints(false)
    //3
    self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Leading, relatedBy: .Equal, toItem: self, attribute: .Leading, multiplier: 1.0, constant: 0.0))
    self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Trailing, relatedBy: .Equal, toItem: self, attribute: .Trailing, multiplier: 1.0, constant: 0.0))
    self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Top, relatedBy: .Equal, toItem: self, attribute: .Top, multiplier: 1.0, constant: 0.0))
    self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Bottom, relatedBy: .Equal, toItem: self, attribute: .Bottom, multiplier: 1.0, constant: 0.0))

    //4
    let tapRecognizer = UITapGestureRecognizer(target: self, action:Selector("scrollerTapped:"))
    scroller.addGestureRecognizer(tapRecognizer)
}

上面的代碼作了以下工做:

  • 建立一個 UIScrollView 對象而且把它加到父視圖中。
  • 關閉 autoresizing masks ,從而可使用 AutoLayout 進行佈局。
  • scrollview 添加約束。咱們但願 scrollview 能填滿 HorizontalScroller
  • 建立一個點擊事件,檢測是否點擊到了專輯封面,若是確實點擊到了專輯封面,咱們須要通知 HorizontalScroller 的委託。

添加委託方法:

func scrollerTapped(gesture: UITapGestureRecognizer) {
  let location = gesture.locationInView(gesture.view)
  if let delegate = self.delegate {
    for index in 0..<delegate.numberOfViewsForHorizontalScroller(self) {
      let view = scroller.subviews[index] as UIView
      if CGRectContainsPoint(view.frame, location) {
        delegate.horizontalScrollerClickedViewAtIndex(self, index: index)
        scroller.setContentOffset(CGPointMake(view.frame.origin.x - self.frame.size.width/2 + view.frame.size.width/2, 0), animated:true)
        break
      }
    }
  }
}

咱們把 gesture 做爲一個參數傳了進來,這樣就能夠獲取點擊的具體座標了。

接下來咱們調用了 numberOfViewsForHorizontalScroller 方法,HorizontalScroller 不知道本身的 delegate 具體是誰,可是知道它必定實現了 HorizontalScrollerDelegate 協議,因此能夠放心的調用。

對於 scroll view 中的 view ,經過 CGRectContainsPoint 進行點擊檢測,從而獲知是哪個 view 被點擊了。當找到了點擊的 view 的時候,則會調用委託方法裏的 horizontalScrollerClickedViewAtIndex 方法通知委託。在跳出 for 循環以前,先把點擊到的 view 居中。

接下來咱們再加個方法獲取數組裏的 view

func viewAtIndex(index :Int) -> UIView {
  return viewArray[index]
}

這個方法很簡單,只是用來更方便獲取數組裏的 view 而已。在後面實現高亮選中專輯的時候會用到這個方法。

添加以下代碼用來從新加載 scroller

func reload() {
  // 1 - Check if there is a delegate, if not there is nothing to load.
  if let delegate = self.delegate {
    //2 - Will keep adding new album views on reload, need to reset.
    viewArray = []
    let views: NSArray = scroller.subviews

    // 3 - remove all subviews
    views.enumerateObjectsUsingBlock {
    (object: AnyObject!, idx: Int, stop: UnsafeMutablePointer<ObjCBool>) -> Void in
      object.removeFromSuperview()
    }
    // 4 - xValue is the starting point of the views inside the scroller            
    var xValue = VIEWS_OFFSET
    for index in 0..<delegate.numberOfViewsForHorizontalScroller(self) {
      // 5 - add a view at the right position
      xValue += VIEW_PADDING
      let view = delegate.horizontalScrollerViewAtIndex(self, index: index)
      view.frame = CGRectMake(CGFloat(xValue), CGFloat(VIEW_PADDING), CGFloat(VIEW_DIMENSIONS), CGFloat(VIEW_DIMENSIONS))
      scroller.addSubview(view)
      xValue += VIEW_DIMENSIONS + VIEW_PADDING
      // 6 - Store the view so we can reference it later
     viewArray.append(view)
    }
    // 7
    scroller.contentSize = CGSizeMake(CGFloat(xValue + VIEWS_OFFSET), frame.size.height)

    // 8 - If an initial view is defined, center the scroller on it
    if let initialView = delegate.initialViewIndex?(self) {
      scroller.setContentOffset(CGPointMake(CGFloat(initialView)*CGFloat((VIEW_DIMENSIONS + (2 * VIEW_PADDING))), 0), animated: true)
    }
  }
}

這個 reload 方法有點像是 UITableView 裏面的 reloadData 方法,它會從新加載全部數據。

一段一段的看下上面的代碼:

  • 在調用 reload 以前,先檢查一下是否有委託。
  • 既然要清除專輯封面,那麼也須要從新設置 viewArray ,要否則之前的數據會累加進來。
  • 移除先前加入到 scrollview 的子視圖。
  • 全部的 view 都有一個偏移量,目前默認是100,咱們能夠修改 VIEW_OFFSET 這個常量輕鬆的修改它。
  • HorizontalScroller 經過委託獲取對應位置的 view 而且把它們放在對應的位置上。
  • view 存進 viewArray 以便後面的操做。
  • 當全部 view 都安放好了,再設置一下 content size 這樣才能夠進行滑動。
  • HorizontalScroller 檢查一下委託是否實現了 initialViewIndex() 這個可選方法,這種檢查十分必要,由於這個委託方法是可選的,若是委託沒有實現這個方法則用0做爲默認值。最終設置 scroll view 將初始的 view 放置到居中的位置。

當數據發生改變的時候,咱們須要調用 reload 方法。當 HorizontalScroller 被加到其餘頁面的時候也須要調用這個方法,咱們在 HorizontalScroller.swift 裏面加入以下代碼:

override func didMoveToSuperview() {
    reload()
}

在當前 view 添加到其餘 view 裏的時候就會自動調用 didMoveToSuperview 方法,這樣能夠在正確的時間從新加載數據。

HorizontalScroller 的最後一部分是用來確保當前瀏覽的內容時刻位於正中心的位置,爲了實現這個功能咱們須要在用戶滑動結束的時候作一些額外的計算和修正。

添加下面這個方法:

func centerCurrentView() {
    var xFinal = scroller.contentOffset.x + CGFloat((VIEWS_OFFSET/2) + VIEW_PADDING)
    let viewIndex = xFinal / CGFloat((VIEW_DIMENSIONS + (2*VIEW_PADDING)))
    xFinal = viewIndex * CGFloat(VIEW_DIMENSIONS + (2*VIEW_PADDING))
    scroller.setContentOffset(CGPointMake(xFinal, 0), animated: true)
    if let delegate = self.delegate {
        delegate.horizontalScrollerClickedViewAtIndex(self, index: Int(viewIndex))
    }  
}

上面的代碼計算了當前視圖裏中心位置距離多少,而後算出正確的居中座標並滑動到那個位置。最後一行是通知委託所選視圖已經發生了改變。

爲了檢測到用戶滑動的結束時間,咱們還須要實現 UIScrollViewDelegate 的方法。在文件結尾加上下面這個擴展:

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

    func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
        centerCurrentView()
    }
}

當用戶中止滑動的時候,scrollViewDidEndDragging(_:willDecelerate:) 這個方法會通知委託。若是滑動尚未中止,decelerate 的值爲 true 。當滑動徹底結束的時候,則會調用 scrollViewDidEndDecelerating 這個方法。在這兩種狀況下,你都應該把當前的視圖居中,由於用戶的操做可能會改變當前視圖。

你的 HorizontalScroller 已經可使用了!回頭看看前面寫的代碼,你會看到咱們並無涉及什麼 Album 或者 AlbumView 的代碼。這是極好的,由於這樣意味着這個 scroller 是徹底獨立的,能夠複用。

運行一下你的項目,確保編譯經過。

這樣,咱們的 HorizontalScroller 就完成了,接下來咱們就要把它應用到咱們的項目裏了。首先,打開 Main.Sstoryboard 文件,點擊上面的灰色矩形,設置 ClassHorizontalScroller

接下來,在 assistant editor 模式下向 ViewController.swift 拖拽生成 outlet ,命名爲 scroller

接下來打開 ViewController.swift 文件,是時候實現 HorizontalScrollerDelegate 委託裏的方法啦!

添加以下擴展:

extension ViewController: HorizontalScrollerDelegate {
    func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller, index: Int) {
        //1
        let previousAlbumView = scroller.viewAtIndex(currentAlbumIndex) as AlbumView
        previousAlbumView.highlightAlbum(didHighlightView: false)
        //2
        currentAlbumIndex = index
        //3
        let albumView = scroller.viewAtIndex(index) as AlbumView
        albumView.highlightAlbum(didHighlightView: true)
        //4
        showDataForAlbum(index)
    }
}

讓咱們一行一行的看下這個委託的實現:

  • 獲取上一個選中的相冊,而後取消高亮
  • 存儲當前點擊的相冊封面
  • 獲取當前選中的相冊,設置爲高亮
  • table view 裏面展現新數據

接下來在擴展裏添加以下方法:

func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> (Int) {
    return allAlbums.count
}

這個委託方法返回 scroll vew 裏面的視圖數量,由於是用來展現全部的專輯的封面,因此數目也就是專輯數目。

而後添加以下代碼:

func horizontalScrollerViewAtIndex(scroller: HorizontalScroller, index: Int) -> (UIView) {
    let album = allAlbums[index]
    let albumView = AlbumView(frame: CGRectMake(0, 0, 100, 100), albumCover: album.coverUrl)
    if currentAlbumIndex == index {
        albumView.highlightAlbum(didHighlightView: true)
    } else {
        albumView.highlightAlbum(didHighlightView: false)
    }
    return albumView
}

咱們建立了一個新的 AlbumView ,而後檢查一下是否是當前選中的專輯,若是是則設爲高亮,最後返回結果。

是的就是這麼簡單!三個方法,完成了一個橫向滾動的瀏覽視圖。

咱們還須要建立這個滾動視圖並把它加到主視圖裏,可是在這以前,先添加以下方法:

func reloadScroller() {
    allAlbums = LibraryAPI.sharedInstance.getAlbums()
    if currentAlbumIndex < 0 {
        currentAlbumIndex = 0
    } else if currentAlbumIndex >= allAlbums.count {
        currentAlbumIndex = allAlbums.count - 1
    } 
    scroller.reload() 
    showDataForAlbum(currentAlbumIndex)
}

這個方法經過 LibraryAPI 加載專輯數據,而後根據 currentAlbumIndex 的值設置當前視圖。在設置以前先進行了校訂,若是小於0則設置第一個專輯爲展現的視圖,若是超出了範圍則設置最後一個專輯爲展現的視圖。

接下來只須要指定委託就能夠了,在 viewDidLoad 最後加入一下代碼:

scroller.delegate = self
reloadScroller()

由於 HorizontalScroller 是在 StoryBoard 裏初始化的,因此咱們須要作的只是指定委託,而後調用 reloadScroller() 方法,從而加載全部的子視圖而且展現專輯數據。

標註:若是協議裏的方法過多,能夠考慮把它分解成幾個更小的協議。UITableViewDelegateUITableViewDataSource 就是很好的例子,它們都是 UITableView 的協議。嘗試去設計你本身的協議,讓每一個協議都單獨負責一部分功能。

運行一下當前項目,看一下咱們的新頁面:

等下,滾動視圖顯示出來了,可是專輯的封面怎麼不見了?

啊哈,是的。咱們還沒完成下載部分的代碼,咱們須要添加下載圖片的方法。由於咱們全部的訪問都是經過 LibraryAPI 實現的,因此很顯然咱們下一步應該去完善這個類了。不過在這以前,咱們還須要考慮一些問題:

  • AlbumView 不該該直接和 LibraryAPI 交互,咱們不該該把視圖的邏輯和業務邏輯混在一塊兒。
  • 一樣, LibraryAPI 也不該該知道 AlbumView 這個類。
  • 若是 AlbumView 要展現封面,LibraryAPI 須要告訴 AlbumView 圖片下載完成。

看起來好像很難的樣子?別絕望,接下來咱們會用觀察者模式 (Observer Pattern) 解決這個問題!:]

觀察者模式 - Observer

在觀察者模式裏,一個對象在狀態變化的時候會通知另外一個對象。參與者並不須要知道其餘對象的具體是幹什麼的 - 這是一種下降耦合度的設計。這個設計模式經常使用於在某個屬性改變的時候通知關注該屬性的對象。

常見的使用方法是觀察者註冊監聽,而後再狀態改變的時候,全部觀察者們都會收到通知。

在 MVC 裏,觀察者模式意味着須要容許 Model 對象和 View 對象進行交流,而不能有直接的關聯。

Cocoa 使用兩種方式實現了觀察者模式: NotificationKey-Value Observing (KVO)

通知 - Notification

不要把這裏的通知和推送通知或者本地通知搞混了,這裏的通知是基於訂閱-發佈模型的,即一個對象 (發佈者) 向其餘對象 (訂閱者) 發送消息。發佈者永遠不須要知道訂閱者的任何數據。

Apple 對於通知的使用很頻繁,好比當鍵盤彈出或者收起的時候,系統會分別發送 UIKeyboardWillShowNotification/UIKeyboardWillHideNotification 的通知。當你的應用切到後臺的時候,又會發送 UIApplicationDidEnterBackgroundNotification 的通知。

注意:打開 UIApplication.swift 文件,在文件結尾你會看到二十多種系統發送的通知。

如何使用通知

打開 AlbumView.swift 而後在 init 的最後插入以下代碼:

NSNotificationCenter.defaultCenter().postNotificationName("BLDownloadImageNotification", object: self, userInfo: ["imageView":coverImage, "coverUrl" : albumCover])

這行代碼經過 NSNotificationCenter 發送了一個通知,通知信息包含了 UIImageView 和圖片的下載地址。這是下載圖像須要的全部數據。

而後在 LibraryAPI.swiftinit 方法的 super.init() 後面加上以下代碼:

NSNotificationCenter.defaultCenter().addObserver(self, selector:"downloadImage:", name: "BLDownloadImageNotification", object: nil)

這是等號的另外一邊:觀察者。每當 AlbumView 發出一個 BLDownloadImageNotification 通知的時候,因爲 LibraryAPI 已經註冊了成爲觀察者,因此係統會調用 downloadImage() 方法。

可是,在實現 downloadImage() 以前,咱們必須先在 dealloc 裏取消監聽。若是沒有取消監聽消息,消息會發送給一個已經銷燬的對象,致使程序崩潰。

LibaratyAPI.swift 里加上取消訂閱的代碼:

deinit {
    NSNotificationCenter.defaultCenter().removeObserver(self)
}

當對象銷燬的時候,把它從全部消息的訂閱列表裏去除。

這裏還要作一件事情:咱們最好把圖片存儲到本地,這樣能夠避免一次又一次下載相同的封面。

打開 PersistencyManager.swift 添加以下代碼:

func saveImage(image: UIImage, filename: String) {
    let path = NSHomeDirectory().stringByAppendingString("/Documents/\(filename)")
    let data = UIImagePNGRepresentation(image)
    data.writeToFile(path, atomically: true)
}

func getImage(filename: String) -> UIImage? {
    var error: NSError?
    let path = NSHomeDirectory().stringByAppendingString("/Documents/\(filename)")
    let data = NSData(contentsOfFile: path, options: .UncachedRead, error: &error)
    if let unwrappedError = error {
        return nil
    } else {
        return UIImage(data: data!)
    }
}

代碼很簡單直接,下載的圖片會存儲在 Documents 目錄下,若是沒有檢查到緩存文件, getImage() 方法則會返回 nil

而後在 LibraryAPI.swift 添加以下代碼:

func downloadImage(notification: NSNotification) {
    //1
    let userInfo = notification.userInfo as [String: AnyObject]
    var imageView = userInfo["imageView"] as UIImageView?
    let coverUrl = userInfo["coverUrl"] as NSString

    //2
    if let imageViewUnWrapped = imageView {
        imageViewUnWrapped.image = persistencyManager.getImage(coverUrl.lastPathComponent)
        if imageViewUnWrapped.image == nil {
            //3
            dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { () -> Void in
                let downloadedImage = self.httpClient.downloadImage(coverUrl)
                //4
                dispatch_sync(dispatch_get_main_queue(), { () -> Void in
                    imageViewUnWrapped.image = downloadedImage
                    self.persistencyManager.saveImage(downloadedImage, filename: coverUrl.lastPathComponent)
                })
            })
        }
    }
}

拆解一下上面的代碼:

  • downloadImage 經過通知調用,因此這個方法的參數就是 NSNotification 自己。 UIImageViewURL 均可以從其中獲取到。
  • 若是之前下載過,從 PersistencyManager 裏獲取緩存。
  • 若是圖片沒有緩存,則經過 HTTPClient 獲取。
  • 若是下載完成,展現圖片並用 PersistencyManager 存儲到本地。

再回顧一下,咱們使用外觀模式隱藏了下載圖片的複雜程度。通知的發送者並不在意圖片是如何從網上下載到本地的。

運行一下項目,能夠看到專輯封面已經顯示出來了:

關了應用再從新運行,注意此次沒有任何延時就顯示了全部的圖片,由於咱們已經有了本地緩存。咱們甚至能夠在沒有網絡的狀況下正常使用咱們的應用。不過出了問題:這個用來提示加載網絡請求的小菊花怎麼一直在顯示!

咱們在下載圖片的時候開啓了這個白色小菊花,可是在圖片下載完畢的時候咱們並無停掉它。咱們能夠在每次下載成功的時候發送一個通知,可是咱們不這樣作,此次咱們來用用另外一個觀察者模式: KVO 。

鍵值觀察 - KVO

在 KVO 裏,對象能夠註冊監放任何屬性的變化,無論它是否持有。若是感興趣的話,能夠讀一讀蘋果 KVO 編程指南

如何使用 KVO

正如前面所說起的, 對象能夠關注任何屬性的變化。在咱們的例子裏,咱們能夠用 KVO 關注 UIImageViewimage 屬性變化。

打開 AlbumView.swift 文件,找到 init(frame:albumCover:) 方法,在把 coverImage 添加到 subView 的代碼後面添加以下代碼:

coverImage.addObserver(self, forKeyPath: "image", options: nil, context: nil)

這行代碼把 self (也就是當前類) 添加到了 coverImageimage 屬性的觀察者裏。

在銷燬的時候,咱們也須要取消觀察。仍是在 AlbumView.swift 文件裏,添加以下代碼:

deinit {
    coverImage.removeObserver(self, forKeyPath: "image")
}

最終添加以下方法:

override func observeValueForKeyPath(keyPath: String, ofObject object: AnyObject, change: [NSObject : AnyObject], context: UnsafeMutablePointer<Void>) {
    if keyPath == "image" {
        indicator.stopAnimating()
    }
}

必須在全部的觀察者裏實現上面的代碼。在檢測到屬性變化的時候,系統會自動調用這個方法。在上面的代碼裏,咱們在圖片加載完成的時候把那個提示加載的小菊花去掉了。

再次運行項目,你會發現一切正常了:

注意:必定要記得移除觀察者,不然若是對象已經銷燬了還給它發送消息會致使應用崩潰。

此時你能夠把玩一下當前的應用而後再關掉它,你會發現你的應用的狀態並無存儲下來。最後看見的專輯並不會再下次打開應用的時候出現。

爲了解決這個問題,咱們可使用下一種模式:備忘錄模式。

備忘錄模式 - Memento

備忘錄模式捕捉而且具象化一個對象的內在狀態。換句話說,它把你的對象存在了某個地方,而後在之後的某個時間再把它恢復出來,而不會打破它自己的封裝性,私有數據依舊是私有數據。

如何使用備忘錄模式

ViewController.swift 里加上下面兩個方法:

//MARK: Memento Pattern
func saveCurrentState() {
    // When the user leaves the app and then comes back again, he wants it to be in the exact same state
    // he left it. In order to do this we need to save the currently displayed album.
    // Since it's only one piece of information we can use NSUserDefaults.
    NSUserDefaults.standardUserDefaults().setInteger(currentAlbumIndex, forKey: "currentAlbumIndex")
}

func loadPreviousState() {
    currentAlbumIndex = NSUserDefaults.standardUserDefaults().integerForKey("currentAlbumIndex")
    showDataForAlbum(currentAlbumIndex)
}

saveCurrentState 把當前相冊的索引值存到 NSUserDefaults 裏。NSUserDefaults 是 iOS 提供的一個標準存儲方案,用於保存應用的配置信息和數據。

loadPreviousState 方法加載上次存儲的索引值。這並非備忘錄模式的完整實現,可是已經離目標不遠了。

接下來在 viewDidLoadscroller.delegate = self 前面調用:

loadPreviousState()

這樣在剛初始化的時候就加載了上次存儲的狀態。可是何時存儲當前狀態呢?這個時候咱們能夠用通知來作。在應用進入到後臺的時候, iOS 會發送一個 UIApplicationDidEnterBackgroundNotification 的通知,咱們能夠在這個通知裏調用 saveCurrentState 這個方法。是否是很方便?

viewDidLoad 的最後加上以下代碼:

NSNotificationCenter.defaultCenter().addObserver(self, selector:"saveCurrentState", name: UIApplicationDidEnterBackgroundNotification, object: nil)

如今,當應用即將進入後臺的時候,ViewController 會調用 saveCurrentState 方法自動存儲當前狀態。

固然也別忘了取消監聽通知,添加以下代碼:

deinit {
    NSNotificationCenter.defaultCenter().removeObserver(self)
}

這樣就確保在 ViewController 銷燬的時候取消監聽通知。

這時再運行程序,隨意移到某個專輯上,而後按下 Home 鍵把應用切換到後臺,再在 Xcode 上把 App 關閉。從新啓動,會看見上次記錄的專輯已經存了下來併成功還原了:

看起來專輯數據好像是對了,可是上面的滾動條彷佛出了問題,沒有居中啊!

這時 initialViewIndex 方法就派上用場了。因爲在委託裏 (也就是 ViewController ) 還沒實現這個方法,因此初始化的結果老是第一張專輯。

爲了修復這個問題,咱們能夠在 ViewController.swift 裏添加以下代碼:

func initialViewIndex(scroller: HorizontalScroller) -> Int {
    return currentAlbumIndex
}

如今 HorizontalScroller 能夠根據 currentAlbumIndex 自動滑到相應的索引位置了。

再次重複上次的步驟,切到後臺,關閉應用,重啓,一切順利:

回頭看看 PersistencyManagerinit 方法,你會發現專輯數據是咱們硬編碼寫進去的,並且每次建立 PersistencyManager 的時候都會再建立一次專輯數據。而實際上一個比較好的方案是隻建立一次,而後把專輯數據存到本地文件裏。咱們如何把專輯數據存到文件裏呢?

一種方案是遍歷 Album 的屬性而後把它們寫到一個 plist 文件裏,而後若是須要的時候再從新建立 Album 對象。這並非最好的選擇,由於數據和屬性不一樣,你的代碼也就要相應的產生變化。舉個例子,若是咱們之後想添加 Movie 對象,它有着徹底不一樣的屬性,那麼存儲和讀取數據又須要重寫新的代碼。

何況你也沒法存儲這些對象的私有屬性,由於其餘類是沒有訪問權限的。這也就是爲何 Apple 提供了 歸檔 的機制。

歸檔 - Archiving

蘋果經過歸檔的方法來實現備忘錄模式。它把對象轉化成了流而後在不暴露內部屬性的狀況下存儲數據。你能夠讀一讀 《iOS 6 by Tutorials》 這本書的第 16 章,或者看下蘋果的歸檔和序列化文檔

如何使用歸檔

首先,咱們須要讓 Album 實現 NSCoding 協議,聲明這個類是可被歸檔的。打開 Album.swiftclass 那行後面加上 NSCoding

class Album: NSObject, NSCoding {

而後添加以下的兩個方法:

required init(coder decoder: NSCoder) {
    super.init()
    self.title = decoder.decodeObjectForKey("title") as String?
    self.artist = decoder.decodeObjectForKey("artist") as String?
    self.genre = decoder.decodeObjectForKey("genre") as String?
    self.coverUrl = decoder.decodeObjectForKey("cover_url") as String?
    self.year = decoder.decodeObjectForKey("year") as String?
}

func encodeWithCoder(aCoder: NSCoder) {
    aCoder.encodeObject(title, forKey: "title")
    aCoder.encodeObject(artist, forKey: "artist")
    aCoder.encodeObject(genre, forKey: "genre")
    aCoder.encodeObject(coverUrl, forKey: "cover_url")
    aCoder.encodeObject(year, forKey: "year")
}

encodeWithCoder 方法是 NSCoding 的一部分,在被歸檔的時候調用。相對的, init(coder:) 方法則是用來解檔的。很簡單,很強大。

如今 Album 對象能夠被歸檔了,添加一些代碼來存儲和加載 Album 數據。

PersistencyManager.swift 裏添加以下代碼:

func saveAlbums() {
    var filename = NSHomeDirectory().stringByAppendingString("/Documents/albums.bin")
    let data = NSKeyedArchiver.archivedDataWithRootObject(albums)
    data.writeToFile(filename, atomically: true)
}

這個方法能夠用來存儲專輯。 NSKeyedArchiver 把專輯數組歸檔到了 albums.bin 這個文件裏。

當咱們歸檔一個包含子對象的對象時,系統會自動遞歸的歸檔子對象,而後是子對象的子對象,這樣一層層遞歸下去。在咱們的例子裏,咱們歸檔的是 albums 由於 ArrayAlbum 都是實現 NSCopying 接口的,因此數組裏的對象均可以自動歸檔。

用下面的代碼取代 PersistencyManager 中的 init 方法:

override init() {
    super.init()
    if let data = NSData(contentsOfFile: NSHomeDirectory().stringByAppendingString("/Documents/albums.bin")) {
        let unarchiveAlbums = NSKeyedUnarchiver.unarchiveObjectWithData(data) as [Album]?
        if let unwrappedAlbum = unarchiveAlbums {
            albums = unwrappedAlbum
        }
    } else {
        createPlaceholderAlbum()
    }
}

func createPlaceholderAlbum() {
    //Dummy list of albums
    let album1 = Album(title: "Best of Bowie",
             artist: "David Bowie",
             genre: "Pop",
             coverUrl: "http://www.coversproject.com/static/thumbs/album/album_david%20bowie_best%20of%20bowie.png",
             year: "1992")

    let album2 = Album(title: "It's My Life",
           artist: "No Doubt",
           genre: "Pop",
           coverUrl: "http://www.coversproject.com/static/thumbs/album/album_no%20doubt_its%20my%20life%20%20bathwater.png",
           year: "2003")

    let album3 = Album(title: "Nothing Like The Sun",
               artist: "Sting",
           genre: "Pop",
           coverUrl: "http://www.coversproject.com/static/thumbs/album/album_sting_nothing%20like%20the%20sun.png",
           year: "1999")

    let album4 = Album(title: "Staring at the Sun",
           artist: "U2",
           genre: "Pop",
           coverUrl: "http://www.coversproject.com/static/thumbs/album/album_u2_staring%20at%20the%20sun.png",
           year: "2000")

    let album5 = Album(title: "American Pie",
           artist: "Madonna",
           genre: "Pop",
           coverUrl: "http://www.coversproject.com/static/thumbs/album/album_madonna_american%20pie.png",
           year: "2000")
    albums = [album1, album2, album3, album4, album5]
    saveAlbums()
}

咱們把建立專輯數據的方法放到了 createPlaceholderAlbum 裏,這樣代碼可讀性更高。在新的代碼裏,若是存在歸檔文件, NSKeyedUnarchiver 從歸檔文件加載數據;不然就建立歸檔文件,這樣下次程序啓動的時候能夠讀取本地文件加載數據。

咱們還想在每次程序進入後臺的時候存儲專輯數據。看起來如今這個功能並非必須的,可是若是之後咱們加了編輯功能,這樣作仍是頗有必要的,那時咱們確定但願確保新的數據會同步到本地的歸檔文件。

由於咱們的程序經過 LibraryAPI 來訪問全部服務,因此咱們須要經過 LibraryAPI 來通知 PersistencyManager 存儲專輯數據。

LibraryAPI 裏添加存儲專輯數據的方法:

func saveAlbums() {
    persistencyManager.saveAlbums()
}

這個方法很簡單,就是把 LibraryAPIsaveAlbums 方法傳遞給了 persistencyManagersaveAlbums 方法。

而後在 ViewController.swiftsaveCurrentState 方法的最後加上:

LibraryAPI.sharedInstance.saveAlbums()

ViewController 須要存儲狀態的時候,上面的代碼經過 LibraryAPI 歸檔當前的專輯數據。

運行一下程序,檢查一下沒有編譯錯誤。

不幸的是彷佛沒什麼簡單的方法來檢查歸檔是否正確完成。你能夠檢查一下 Documents 目錄,看下是否存在歸檔文件。若是要查看其餘數據變化的話,還須要添加編輯專輯數據的功能。

不過和編輯數據相比,彷佛加個刪除專輯的功能更好一點,若是不想要這張專輯直接刪除便可。再進一步,萬一誤刪了話,是否是還能夠再加個撤銷按鈕?

最後的潤色

如今咱們將添加最後一個功能:容許用戶刪除專輯,以及撤銷上次的刪除操做。

ViewController 裏添加以下屬性:

// 爲了實現撤銷功能,咱們用數組做爲一個棧來 push 和 pop 用戶的操做
var undoStack: [(Album, Int)] = []

而後在 viewDidLoadreloadScroller() 後面添加以下代碼:

let undoButton = UIBarButtonItem(barButtonSystemItem: .Undo, target: self, action:"undoAction")
undoButton.enabled = false;
let space = UIBarButtonItem(barButtonSystemItem: .FlexibleSpace, target:nil, action:nil)
let trashButton = UIBarButtonItem(barButtonSystemItem: .Trash, target:self, action:"deleteAlbum")
let toolbarButtonItems = [undoButton, space, trashButton]
toolbar.setItems(toolbarButtonItems, animated: true)

上面的代碼建立了一個 toolbar ,上面有兩個按鈕,在 undoStack 爲空的狀況下, undo 的按鈕是不可用的。注意 toolbar 已經在 storyboard 裏了,咱們須要作的只是配置上面的按鈕。

咱們須要在 ViewController.swift 裏添加三個方法,用來處理專輯的編輯事件:增長,刪除,撤銷。

先寫添加的方法:

func addAlbumAtIndex(album: Album,index: Int) {
    LibraryAPI.sharedInstance.addAlbum(album, index: index)
    currentAlbumIndex = index
    reloadScroller()
}

作了三件事:添加專輯,設爲當前的索引,從新加載滾動條。

接下來是刪除方法:

func deleteAlbum() {
    //1
    var deletedAlbum : Album = allAlbums[currentAlbumIndex]
    //2
    var undoAction = (deletedAlbum, currentAlbumIndex)
    undoStack.insert(undoAction, atIndex: 0)
    //3
    LibraryAPI.sharedInstance.deleteAlbum(currentAlbumIndex)
    reloadScroller()
    //4
    let barButtonItems = toolbar.items as [UIBarButtonItem]
    var undoButton : UIBarButtonItem = barButtonItems[0]
    undoButton.enabled = true
    //5
    if (allAlbums.count == 0) {
        var trashButton : UIBarButtonItem = barButtonItems[2]
        trashButton.enabled = false
    }
}

挨個看一下各個部分:

  • 獲取要刪除的專輯。
  • 建立一個 undoAction 對象,用元組存儲 Album 對象和它的索引值。而後把這個元組加到了棧裏。
  • 使用 LibraryAPI 刪除專輯數據,而後從新加載滾動條。
  • 既然撤銷棧裏已經有了數據,那麼咱們須要設置撤銷按鈕爲可用。
  • 檢查一下是否是還剩專輯,若是沒有專輯了那就設置刪除按鈕爲不可用。

最後添加撤銷按鈕:

func undoAction() {
    let barButtonItems = toolbar.items as [UIBarButtonItem]
    //1       
    if undoStack.count > 0 {
        let (deletedAlbum, index) = undoStack.removeAtIndex(0)
        addAlbumAtIndex(deletedAlbum, index: index)
    }
    //2       
    if undoStack.count == 0 {
        var undoButton : UIBarButtonItem = barButtonItems[0]
        undoButton.enabled = false
    }
    //3       
    let trashButton : UIBarButtonItem = barButtonItems[2]
    trashButton.enabled = true
}

照着備註的三個步驟再看一下撤銷方法裏的代碼:

  • 首先從棧裏 pop 出一個對象,這個對象就是咱們當初塞進去的元祖,存有刪除的 Album 對象和它的索引位置。而後咱們把取出來的對象放回了數據源裏。
  • 由於咱們從棧裏刪除了一個對象,因此須要檢查一下看看棧是否是空了。若是空了那就設置撤銷按鈕不可用。
  • 既然咱們已經撤消了一個專輯,那刪除按鈕確定是可用的。因此把它設置爲 enabled

這時再運行應用,試試刪除和插銷功能,彷佛一切正常了:

咱們也能夠趁機測試一下,看看是否及時存儲了專輯數據的變化。好比刪除一個專輯,而後切到後臺,強關應用,再從新開啓,看看是否是刪除操做成功保存了。

若是想要恢復全部數據,刪除應用而後從新安裝便可。

小結

最終項目的源代碼能夠在 BlueLibrarySwift-Final 下載。

經過這兩篇設計模式的學習,咱們接觸到了一些基礎的設計模式和概念:Singleton、MVC、Delegation、Protocols、Facade、Observer、Memento 。

這篇文章的目的,並非推崇每行代碼都要用設計模式,而是但願你們在考慮一些問題的時候,能夠參考設計模式提出一些合理的解決方案,尤爲是應用開發的起始階段,思考和設計尤其重要。

若是想繼續深刻學習設計模式,推薦設計模式的經典書籍:Design Patterns: Elements of Reusable Object-Oriented Software

若是想看更多的設計模式相關的代碼,推薦這個神奇的項目: Swift 實現的種種設計模式

接下來你能夠看看這篇:Swift 設計模式中級指南,學習更多的設計模式。

玩的開心。 :]


原文連接:

相關文章
相關標籤/搜索