(Swift)iOS Apps with REST APIs(十六) -- 離線處理

這是iOS Apps with REST APIs系列的最后一篇砌庄。在整個翻譯過程使用swift逐漸開發(fā)出了自家APP羹唠,還是小有成就的,這個系列的教程也起到很大作用鹤耍,希望也能夠幫到大家。

重要說明: 這是一個系列教程验辞,非本人原創(chuàng)稿黄,而是翻譯國外的一個教程。本人也在學(xué)習(xí)Swift跌造,看到這個教程對開發(fā)一個實際的APP非常有幫助杆怕,所以翻譯共享給大家族购。原教程非常長,我會陸續(xù)翻譯并發(fā)布陵珍,歡迎交流與分享寝杖。

一個很簡單的方法可以讓App Store審查的時候拒絕你的APP,就是不處理離線情況互纯。對于離線有很多方式可以處理瑟幕,但這依賴于你的數(shù)據(jù)及用戶想做些什么。從大的方面來說留潦,可以采用下面幾種方式:

  • 提示用戶需要網(wǎng)絡(luò)連接
  • 使用緩存數(shù)據(jù)并進(jìn)入只讀的狀態(tài)
  • 允許用戶在離線的時候可以進(jìn)行操作只盹,在恢復(fù)連接的時候進(jìn)行同步

當(dāng)你的APP在離線的時候也別忘記持續(xù)檢查連接是否已經(jīng)恢復(fù),不要以為一旦失去連接就永遠(yuǎn)失去了連接兔院。

當(dāng)我們開始編寫一個新的APP時殖卑,最簡單的方式就是告訴用戶需要互聯(lián)網(wǎng)連接(并確保在沒有連接的情況下不會崩潰)。然后在深入分析在離線時哪些可以進(jìn)行優(yōu)化坊萝,以提升用戶體驗孵稽。

如何知道用戶離線?

當(dāng)嘗試加載并無法加載到數(shù)據(jù)時十偶,我們知道用戶離線了菩鲜。這時候應(yīng)該提示用戶,讓用戶知道發(fā)生了什么扯键,用戶也就會知道現(xiàn)在看到的數(shù)據(jù)并不是實時加載的睦袖。因此,我們首先會在GitHubAPIManagergetGists中增加相應(yīng)的處理荣刑。修改后的代碼如下:

func getGists(urlRequest: URLRequestConvertible, completionHandler: 
  (Result<[Gist], NSError>, String?) -> Void) { 
  alamofireManager.request(urlRequest)
    .validate()
    .responseArray { (response:Response<[Gist], NSError>) in
      if let urlResponse = response.response,
      authError = self.checkUnauthorized(urlResponse) { 
      completionHandler(.Failure(authError), nil) 
      return
    }
    guard response.result.error == nil,
      let gists = response.result.value else { 
        print(response.result.error) 
        completionHandler(response.result, nil) 
        return
      }
      
      // need to figure out if this is the last page
      // check the link header, if present
      let next = self.getNextPageFromHeaders(response.response) 
      completionHandler(.Success(gists), next)
  }
}

啟動APP并嘗試把網(wǎng)絡(luò)關(guān)掉馅笙,然后刷新看看會有什么錯誤發(fā)生:

{Error Domain=NSURLErrorDomain Code=-1009 "The Internet connection appears to be offline."
UserInfo={NSUnderlyingError=0x7fc8fb403940
{Error Domain=kCFErrorDomainCFNetwork Code=-1009 "(null)"
UserInfo={_kCFStreamErrorCodeKey=8, _kCFStreamErrorDomainKey=12}},
NSErrorFailingURLStringKey=https://api.github.com/gists/public,
NSErrorFailingURLKey=https://api.github.com/gists/public,
_kCFStreamErrorDomainKey=12, _kCFStreamErrorCodeKey=8,
NSLocalizedDescription=The Internet connection appears to be offline.}

可以看到這里會得到了一個NSURLErrorDomain錯誤,錯誤代碼為-1009厉亏,及錯誤NSURLErrorNotConnectedToInternet董习。所以,應(yīng)該在調(diào)用getGists方法時檢查是否有該錯誤爱只,如果有那么提示用戶他們現(xiàn)在離線了皿淋。

使用一個非中斷式提示,用戶交互會更好恬试,因此這里引入一個非常不錯的提示庫BRYXBanner窝趣。使用CocoaPod將該框架的v0.4.1版本引入到你的工程中,并在MasterViewController中引入:

import UIKit
import Alamofire
import PINRemoteImage
import BRYXBanner


class MasterViewController: UITableViewController, LoginViewDelegate { 

  ...
  
}

現(xiàn)在就可以來處理loadGists的錯誤了:

if let error = result.error {
  if error.domain == NSURLErrorDomain &&
    error.code == NSURLErrorUserAuthenticationRequired {
    self.showOAuthLoginView() 
  }
}

這里我們可以像處理NSURLErrorUserAuthenticationRequired錯誤一樣來處理NSURLErrorNotConnectedToInternet錯誤:

if error.domain == NSURLErrorDomain {
  if error.code == NSURLErrorUserAuthenticationRequired {
    self.showOAuthLoginView()
  } else if error.code == NSURLErrorNotConnectedToInternet {
    ... 
  }
}

當(dāng)錯誤發(fā)生時顯示一個提示欄(Banner)告訴用戶現(xiàn)在網(wǎng)絡(luò)不給力啊。如果現(xiàn)在已經(jīng)有一個提示欄顯示,還需要先把它隱藏吊洼,然后再顯示新的提示信息蓖捶。因此,這里我們需要使用一個變量來跟蹤之前所顯示的提示欄:

...
import BRYXBanner

class MasterViewController: UITableViewController, LoginViewDelegate {

  var detailViewController: DetailViewController? = nil 
  var gists = [Gist]()
  var nextPageURLString: String?
  var isLoading = false
  var dateFormatter = NSDateFormatter() 
  var notConnectedBanner: Banner?
  
  ... 
}

顯示提示欄:

guard result.error == nil else { 
  print(result.error) 
  self.nextPageURLString = nil
  
  self.isLoading = false
  if let error = result.error {
    if error.domain == NSURLErrorDomain {
      if error.code == NSURLErrorUserAuthenticationRequired {
        self.showOAuthLoginView()
      } else if error.code == NSURLErrorNotConnectedToInternet {
        // show not connected error & tell em to try again when they do have a connection 
        // check for existing banner
        if let existingBanner = self.notConnectedBanner {
          existingBanner.dismiss()
        }
        self.notConnectedBanner = Banner(title: "No Internet Connection", 
          subtitle: "Could not load gists." +
            " Try again when you're connected to the internet", 
          image: nil,
          backgroundColor: UIColor.redColor())
      }
      self.notConnectedBanner?.dismissesOnSwipe = true 
      self.notConnectedBanner?.show(duration: nil)
    }
  }
  return
}

顯示的結(jié)果如下:

下面修改其它API的調(diào)用军援,讓它們也能夠處理離線情況绍些。首先是創(chuàng)建處理:

GitHubAPIManager.sharedInstance.createNewGist(description, isPublic: isPublic, 
  files: files, completionHandler: {
  result in
  guard result.error == nil, let successValue = result.value
    where successValue == true else { 
    if let error = result.error {
      print(error)
    }

    let alertController = UIAlertController(title: "Could not create gist", 
      message: "Sorry, your gist couldn't be deleted. " +
      "Maybe GitHub is down or you don't have an internet connection.", 
      preferredStyle: .Alert)
    // add ok button
    let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) 
    alertController.addAction(okAction) 
    self.presentViewController(alertController, animated:true, completion: nil) 
    return
  }
  self.navigationController?.popViewControllerAnimated(true) 
})

如果我們想也可以檢查錯誤的域和代碼可训。但膘滨,這里不這么做甘凭,因為這里不會為不同的域和代碼顯示不同的錯誤。在這里只需要提示用戶刪除失敗即可火邓,所以使用UIAlertController也是可以的丹弱。當(dāng)然,你可以更改為一個提示欄贡翘,它也是一個很好的選擇蹈矮。

GitHubAPIManager.sharedInstance.deleteGist(id, completionHandler: { 
  (error) in
  print(error)
  if let _ = error {
    // Put it back
    self.gists.insert(gistToDelete, atIndex: indexPath.row) 
    tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Right) 
    // tell them it didn't work
    let alertController = UIAlertController(title: "Could not delete gist",
      message: "Sorry, your gist couldn't be deleted. " +
      "Maybe GitHub is down or you don't have an internet connection.",
      preferredStyle: .Alert)
    // add ok button
    let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) 
    alertController.addAction(okAction)
    // show the alert
    self.presentViewController(alertController, animated:true, completion: nil)
  }
})

刪除的相關(guān)處理和創(chuàng)建是非常類似的:中斷用戶的操作,并告訴他們執(zhí)行失敗鸣驱。但對于收藏的狀態(tài)呢泛鸟?

GitHubAPIManager.sharedInstance.isGistStarred(gistId, completionHandler: { 
  result in
  if let error = result.error {
    print(error)
    if error.domain == NSURLErrorDomain &&
      error.code == NSURLErrorUserAuthenticationRequired {
      self.alertController = UIAlertController(title: "Could load starred status",
        message: error.description,
        preferredStyle: .Alert)
      // add ok button
      let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) 
      self.alertController?.addAction(okAction) 
      self.presentViewController(self.alertController!, animated:true, completion: nil)
    }
  }
  
  if let status = result.value where self.isStarred == nil { // just got it 
    self.isStarred = status
    self.tableView?.insertRowsAtIndexPaths( 
      [NSIndexPath(forRow: 2, inSection: 0)], 
      withRowAnimation: .Automatic)
  }
})

如果沒有網(wǎng)絡(luò)連接,這里會得到一個錯誤踊东,并在控制臺中打印出來北滥,但用戶可不知道啊。這里需要讓用戶知道發(fā)生了什么闸翅。因此再芋,這里使用一個橙色的提示欄來提示用戶,而不是紅色坚冀,表示該錯誤沒那么重要济赎。所以,我們需要在DetailViewController中引入BRYXBanner并添加相應(yīng)的變量:

import UIKit
import WebKit
import BRYXBanner


class DetailViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { 
  @IBOutlet weak var tableView: UITableView!
  var isStarred: Bool?
  var alertController: UIAlertController?
  var notConnectedBanner: Banner? 
  
  ...
}

在沒有網(wǎng)絡(luò)連接的時候創(chuàng)建提示欄:

func fetchStarredStatus() { 
  if let gistId = gist?.id {
    GitHubAPIManager.sharedInstance.isGistStarred(gistId, completionHandler: { 
      result in
      if let error = result.error {
        print(error)
        if error.domain == NSURLErrorDomain {
          if error.code == NSURLErrorUserAuthenticationRequired { 
            self.alertController = UIAlertController(title:
              "Could not get starred status", message: error.description,
              preferredStyle: .Alert)
            // add ok button
            let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) 
            self.alertController?.addAction(okAction) 
            self.presentViewController(self.alertController!, animated:true,
                completion: nil)
          } else if error.code == NSURLErrorNotConnectedToInternet {
            // show not connected error & tell em to try again when they do have a conne\
            // check for existing banner
            if let existingBanner = self.notConnectedBanner { 
              existingBanner.dismiss()
            }
            self.notConnectedBanner = Banner(title: "No Internet Connection",
              subtitle: "Can not display starred status. " + 
              "Try again when you're connected to the internet", 
              image: nil,
              backgroundColor: UIColor.orangeColor())
            self.notConnectedBanner?.dismissesOnSwipe = true
            self.notConnectedBanner?.show(duration: nil) 
        }
      }
    }
  
    if let status = result.value where self.isStarred == nil { // just got it 
      self.isStarred = status
      self.tableView?.insertRowsAtIndexPaths(
        [NSIndexPath(forRow: 2, inSection: 0)],
        withRowAnimation: .Automatic)
      }
    })
  }
}

這里我們有兩個Web服務(wù)調(diào)用收藏和取消收藏记某。我們選擇提示用戶當(dāng)用戶進(jìn)行相應(yīng)請求出現(xiàn)錯誤的時候:

func starThisGist() {
  if let gistId = gist?.id {
    GitHubAPIManager.sharedInstance.starGist(gistId, completionHandler: { 
      (error) in
      if let error = error {
        print(error)
        if error.domain == NSURLErrorDomain &&
          error.code == NSURLErrorUserAuthenticationRequired { 
          self.alertController = UIAlertController(title: "Could not star gist",
            message: error.description, preferredStyle: .Alert) 
        } else {
          self.alertController = UIAlertController(title: "Could not star gist", 
            message: "Sorry, your gist couldn't be starred. " +
            "Maybe GitHub is down or you don't have an internet connection.", 
            preferredStyle: .Alert)
        }
        // add ok button
        let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) 
        self.alertController?.addAction(okAction) 
        self.presentViewController(self.alertController!, animated:true, completion: nil)
      } else {
        self.isStarred = true 
        self.tableView.reloadRowsAtIndexPaths(
          [NSIndexPath(forRow: 2, inSection: 0)],
          withRowAnimation: .Automatic)
      }
    }) 
  }
}

func unstarThisGist() {
  if let gistId = gist?.id {
    GitHubAPIManager.sharedInstance.unstarGist(gistId, completionHandler: { 
      (error) in
      if let error = error {
        print(error)
        if error.domain == NSURLErrorDomain &&
          error.code == NSURLErrorUserAuthenticationRequired { 
          self.alertController = UIAlertController(title: "Could not unstar gist",
            message: error.description, preferredStyle: .Alert) 
        } else {
          self.alertController = UIAlertController(title: "Could not unstar gist", 
            message: "Sorry, your gist couldn't be unstarred. " +
            "Maybe GitHub is down or you don't have an internet connection.", 
            preferredStyle: .Alert)
        }
        // add ok button
        let okAction = UIAlertAction(title: "OK", style: .Default, handler: nil) 
        self.alertController?.addAction(okAction) 
        self.presentViewController(self.alertController!, animated:true, completion: nil)
      } else {
         self.isStarred = false self.tableView.reloadRowsAtIndexPaths(
           [NSIndexPath(forRow: 2, inSection: 0)],
           withRowAnimation: .Automatic)
      }
    })
   }
}

下面我們就可以運行來檢查是否還有其它什么問題了司训。這時候你可以關(guān)閉/開啟網(wǎng)絡(luò)看看是否會發(fā)現(xiàn)什么。

啊哈液南,我找到了兩個:第一個如果當(dāng)前顯示了紅色提示框壳猜,當(dāng)我選擇一個Gist時同時會顯示橙色提示欄。但當(dāng)我們銷毀掉橙色的提示欄后滑凉,紅色的仍然在统扳。因此,我們需要在切換視圖的時候銷毀提示欄:

override func viewWillDisappear(animated: Bool) { 
  if let existingBanner = self.notConnectedBanner {
    existingBanner.dismiss()
  }
  super.viewWillDisappear(animated) 
}

MasterViewControllerDetailViewController中都需要添加該代碼畅姊。

第二個錯誤就是咒钟,當(dāng)沒有網(wǎng)絡(luò)連接的時候,如何切換視圖顯示若未,此時顯示的列表是錯誤的朱嘴。因此,我們需要在用戶切換不同的列表的時候清除掉原來的列表:

@IBAction func segmentedControlValueChanged(sender: UISegmentedControl) { 
  // only show add/edit buttons for my gists
  ...
  

  // clear gists so they can't get shown for the wrong list
  self.gists = [Gist]() 
  self.tableView.reloadData()
  
  loadGists(nil) 
}

還有一個需要網(wǎng)絡(luò)連接的地方就是:登錄陨瘩。為了測試這個功能腕够,我們需要重置模擬器,并重新安裝我們的程序舌劳。

當(dāng)我們測試這個功能的時候帚湘,我們會發(fā)現(xiàn),當(dāng)我們點擊登錄按鈕登錄視圖控制器只是不斷出現(xiàn)甚淡。這對用戶來說是一個非常糟糕的體驗大诸,尤其是當(dāng)他們第一次運行應(yīng)用程序。下面我們將修改代碼使用SFSafariViewControllerDelegate檢查是否有網(wǎng)絡(luò)連接:

func safariViewController(controller: SFSafariViewController, didCompleteInitialLoad 
  didLoadSuccessfully: Bool) {
  // Detect not being able to load the OAuth URL
  if (!didLoadSuccessfully) {
    let defaults = NSUserDefaults.standardUserDefaults() 
    defaults.setBool(false, forKey: "loadingOAuthToken") 
    if let completionHandler =
      GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler { 
      let error = NSError(domain: NSURLErrorDomain, code:
        NSURLErrorNotConnectedToInternet,
        userInfo: [NSLocalizedDescriptionKey: "No Internet Connection",
        NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"])
      completionHandler(error)
    }
    controller.dismissViewControllerAnimated(true, completion: nil) 
  }
}

只是讓完成處理程序再試一次贯卦,不論發(fā)生了什么錯誤:

GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = { (error) -> Void in
  self.safariViewController?.dismissViewControllerAnimated(true, completion: nil) 
  if let error = error {
    print(error)
    self.isLoading = false
    // TODO: handle error
    // Something went wrong, try again 
    self.showOAuthLoginView()
  } else { 
    self.loadGists(nil)
  }
}

下面讓我們使用前面的提示欄來優(yōu)化用戶體驗:

GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = { (error) -> Void in 
  self.safariViewController?.dismissViewControllerAnimated(true, completion: nil) 
  if let error = error {
    print(error)
    self.isLoading = false
    if error.domain == NSURLErrorDomain && error.code == NSURLErrorNotConnectedToInternet {
      // show not connected error & tell em to try again when they do have a connection 
      // check for existing banner
      if let existingBanner = self.notConnectedBanner {
        existingBanner.dismiss()
      }
      self.notConnectedBanner = Banner(title: "No Internet Connection",
        subtitle: "Could not load gists. Try again when you're connected to the internet", 
        image: nil,
        backgroundColor: UIColor.redColor())
      self.notConnectedBanner?.dismissesOnSwipe = true
      self.notConnectedBanner?.show(duration: nil) 
    } else {
      // Something went wrong, try again
      self.showOAuthLoginView() 
    }
  } else { 
    self.loadGists(nil)
  }
}

如果我們現(xiàn)在進(jìn)行測試资柔,會發(fā)現(xiàn)登錄視圖控制器依然會彈出來,因為主視圖控制器在檢測到?jīng)]有登錄的時候總是會彈出登錄視圖撵割。解決的方法也非常簡單贿堰,就是當(dāng)檢測到?jīng)]有網(wǎng)絡(luò)的時候讓APP不再加載OAuth令牌:

func safariViewController(controller: SFSafariViewController, didCompleteInitialLoad
  didLoadSuccessfully: Bool) {
  // Detect not being able to load the OAuth URL
  if (!didLoadSuccessfully) {
    // let defaults = NSUserDefaults.standardUserDefaults()
    // defaults.setBool(false, forKey: "loadingOAuthToken")
    if let completionHandler =
      GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler {
      let error = NSError(domain: NSURLErrorDomain,
      code: NSURLErrorNotConnectedToInternet, userInfo: [
      NSLocalizedDescriptionKey: "No Internet Connection",
      NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"])
      completionHandler(error)
    }
    controller.dismissViewControllerAnimated(true, completion: nil)
  }
}

這樣APP將不會再彈出登錄視圖,直到下拉刷新啡彬。

對哪些會調(diào)用失敗的Web服務(wù)進(jìn)行分析和測試羹与。確保每一個都進(jìn)行了很好的處理,并根據(jù)實際需要添加橫幅提示或者彈出對話框庶灿,以便讓用戶知道發(fā)生了什么纵搁。

我們已經(jīng)處理了用戶無網(wǎng)絡(luò)的情況,所以Apple不會因為這個拒絕我們的APP上架了往踢。但是如果我們想提供更好的用戶體驗腾誉,比如在無網(wǎng)絡(luò)的情況下可以讓用戶查看之前所加載的一些數(shù)據(jù),那么我們該如何做呢峻呕?不著急利职,下面我們就來實現(xiàn)這個功能。

本地拷貝

對于一些簡單的APP就像這個示例APP一樣山上,當(dāng)用戶離線時以只讀的方式顯示最后所加載的數(shù)據(jù)眼耀,應(yīng)該足夠了。所以我們需要持久化Gist的列表佩憾。當(dāng)沒有網(wǎng)絡(luò)的時候哮伟,用戶不能刪除、收藏/取消收藏以及查看之前是否已經(jīng)收藏該Gist妄帘。這些功能楞黄,在本章前面已經(jīng)實現(xiàn)。

NSKeyedArchiver可以用來序列化對象抡驼,因此也可以很容易寫入到磁盤中鬼廓。對于數(shù)組、字符串都是開箱即用的致盟,但對于我們自己所編寫的類碎税,需要整明白該如何進(jìn)行處理尤慰。GistFile這兩個類是需要進(jìn)行處理的。

為了能夠?qū)⑽覀冏远x的類能夠被持久化雷蹂,需要實現(xiàn)NSCoding協(xié)議伟端,該協(xié)議反過來有需要實現(xiàn)NSObject協(xié)議:

class Gist: NSObject, NSCoding, ResponseJSONObjectSerializable { 
  ...
}

NSObject協(xié)議需要我們改變已存在的init函數(shù):

required override init() { 
}

還需要包含一個description屬性,因此我們這里需要修改getDescription方法:

class Gist: NSObject, NSCoding, ResponseJSONObjectSerializable { 
  var id: String?
  var gistDescription: String?
  ...
  
  required init(json: JSON) {
    self.gistDescription = json["description"].string 
    ...
  }
  
  ...
}

而且我們還需要修改視圖控制器中所使用的地方匪煌。下面我們通過搜索.description找到原來所使用的地方進(jìn)行更改.

MasterViewController中:

cell.textLabel!.text = gist.gistDescription

DetailViewController中:

func tableView(tableView: UITableView, cellForRowAtIndexPath
  indexPath: NSIndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath)
  if indexPath.section == 0 { 
    if indexPath.row == 0 {
      cell.textLabel?.text = gist?.gistDescription

NSCoding協(xié)議需要實現(xiàn)兩個方法责蝠,一個是對對象進(jìn)行序列化,另外一個是進(jìn)行反序列化:

class Gist: NSObject, NSCoding, ResponseJSONObjectSerializable { 
  ...
  
  // MARK: NSCoding
  @objc func encodeWithCoder(aCoder: NSCoder) { 
    ...
  }
  
  @objc required convenience init?(coder aDecoder: NSCoder) { 
    self.init()
    ... 
  }
}      

這里我們需要對對象的每一個屬性進(jìn)行序列化和反序列化:

@objc func encodeWithCoder(aCoder: NSCoder) { 
  aCoder.encodeObject(self.id, forKey: "id")   
  aCoder.encodeObject(self.gistDescription, forKey: "gistDescription") 
  aCoder.encodeObject(self.ownerLogin, forKey: "ownerLogin") 
  aCoder.encodeObject(self.ownerAvatarURL, forKey: "ownerAvatarURL") 
  aCoder.encodeObject(self.url, forKey: "url") 
  aCoder.encodeObject(self.createdAt, forKey: "createdAt") 
  aCoder.encodeObject(self.updatedAt, forKey: "updatedAt")
  if let files = self.files { 
    aCoder.encodeObject(files, forKey: "files")
  }
}

@objc required convenience init?(coder aDecoder: NSCoder) { 
  self.init()
  self.id = aDecoder.decodeObjectForKey("id") as? String
  self.gistDescription = aDecoder.decodeObjectForKey("gistDescription") as? String 
  self.ownerLogin = aDecoder.decodeObjectForKey("ownerLogin") as? String 
  self.ownerAvatarURL = aDecoder.decodeObjectForKey("ownerAvatarURL") as? String 
  self.createdAt = aDecoder.decodeObjectForKey("createdAt") as? NSDate 
  self.updatedAt = aDecoder.decodeObjectForKey("updatedAt") as? NSDate
  if let files = aDecoder.decodeObjectForKey("files") as? [File] {
    self.files = files 
  }
}

對于File

class File: NSObject, NSCoding, ResponseJSONObjectSerializable { 
  var filename: String?
  var raw_url: String?
  var content: String?
  
  ...
  
  // MARK: NSCoding
  @objc func encodeWithCoder(aCoder: NSCoder) { 
    aCoder.encodeObject(self.filename, forKey: "filename") 
    aCoder.encodeObject(self.raw_url, forKey: "raw_url") 
    aCoder.encodeObject(self.content, forKey: "content")
  }
  
  @objc required convenience init?(coder aDecoder: NSCoder) {
    let filename = aDecoder.decodeObjectForKey("filename") as? String 
    let content = aDecoder.decodeObjectForKey("content") as? String
    
    // use the existing init function
    self.init(aName: filename, aContent: content)
    self.raw_url = aDecoder.decodeObjectForKey("raw_url") as? String 
  }
}

下面我們就可以真正實現(xiàn)Gist的保存了萎庭。創(chuàng)建一個PersistenceManager.swift文件來負(fù)責(zé)數(shù)據(jù)的保存與加載霜医。這里我們保持簡單,只保存和加載Gist數(shù)組驳规,而不是其它的類型:

import Foundation

class PersistenceManager {
  class func saveArray<T: NSCoding>(arrayToSave: [T], path: Path) {
    // TODO: implement
  }
  
  class func loadArray<T: NSCoding>(path: Path) -> [T]? { 
    // TODO: implement
  }
}

在保存Gists的時候還有一點肴敛,就是數(shù)據(jù)的保存的路徑。這里使用文檔路徑就可以了:

class PersistenceManager {
  class private func documentsDirectory() -> NSString {
    let paths = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, 
      .UserDomainMask, true)
    let documentDirectory = paths[0] as String
    return documentDirectory 
  }
  
  ...
}

我們還需要為不同的Gist列表指定不同的保存的路徑吗购,以防止被覆蓋值朋。讓我們定義一個枚舉對象來負(fù)責(zé)。后面如果增加了其它類型的可以進(jìn)行擴展:

enum Path: String {
  case Public = "Public" 
  case Starred = "Starred" 
  case MyGists = "MyGists"
}

class PersistenceManager { 
  ...
}

Ok巩搏,現(xiàn)在我們就可以真正來實現(xiàn)保存了昨登。這里使用NSKeyedArchiver.archiveRootObject將對象數(shù)組保存到指定的路徑下:

class func saveArray<T: NSCoding>(arrayToSave: [T], path: Path) {
  let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue) 
  NSKeyedArchiver.archiveRootObject(arrayToSave, toFile: file)
}

加載對象數(shù)組的方法類似:

class func loadArray<T: NSCoding>(path: Path) -> [T]? {
  let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue) 
  let result = NSKeyedUnarchiver.unarchiveObjectWithFile(file)
  return result as? [T]
}

合在一起PersistenceManager的代碼如下:

import Foundation

enum Path: String {
  case Public = "Public" 
  case Starred = "Starred" 
  case MyGists = "MyGists"
}


class PersistenceManager {
  class private func documentsDirectory() -> NSString {
    let paths = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, 
      .UserDomainMask, true)
    let documentDirectory = paths[0] as String
    return documentDirectory 
  }
  
  class func saveArray<T: NSCoding>(arrayToSave: [T], path: Path) {
    let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue) 
    NSKeyedArchiver.archiveRootObject(arrayToSave, toFile: file)
  }
  
  class func loadArray<T: NSCoding>(path: Path) -> [T]? {
    let file = documentsDirectory().stringByAppendingPathComponent(path.rawValue) 
    let result = NSKeyedUnarchiver.unarchiveObjectWithFile(file)
    return result as? [T]
  }
}

那么何時保存呢?就是當(dāng)它們被加載的時候贯底。在loadGists中一旦數(shù)據(jù)被加載成功丰辣,我們就需要調(diào)用PersistenceManager.saveArray進(jìn)行保存:

if let fetchedGists = result.value { 
  if let _ = urlToLoad {
    self.gists += fetchedGists 
  } else {
    self.gists = fetchedGists 
  }
  let path:Path
  if self.gistSegmentedControl.selectedSegmentIndex == 0 {
    path = .Public
  } else if self.gistSegmentedControl.selectedSegmentIndex == 1 {
    path = .Starred 
  } else {
    path = .MyGists
  }
  PersistenceManager.saveArray(self.gists, path: path) 
}

當(dāng)無網(wǎng)絡(luò)訪問的時候我們就可以進(jìn)行加載了:

if error.code == NSURLErrorUserAuthenticationRequired { 
  self.showOAuthLoginView()
} else if error.code == NSURLErrorNotConnectedToInternet { 
  let path:Path
  if self.gistSegmentedControl.selectedSegmentIndex == 0 {
    path = .Public
  } else if self.gistSegmentedControl.selectedSegmentIndex == 1 {
    path = .Starred 
  } else {
    path = .MyGists
  }
  if let archived:[Gist] = PersistenceManager.loadArray(path) { 
    self.gists = archived
  } else {
    self.gists = [] // don't have any saved gists
  }
  
  // show not connected error & tell em to try again when they do have a connection
  ...
}

再次運行。當(dāng)加載一些數(shù)據(jù)后就可以關(guān)閉網(wǎng)絡(luò)禽捆,這時候你應(yīng)該仍然可以看到這些數(shù)據(jù)笙什。關(guān)掉重新運行并保持網(wǎng)絡(luò)關(guān)閉,這時候仍然可以看到這些數(shù)據(jù)胚想,并且有相應(yīng)的提示信息琐凭。

看看你的APP那個部分可以支持離線只讀處理。如果有浊服,使用NSKeyedArchiver保存和加載它們统屈,這樣用戶就可以在離線的時候可以看到這些數(shù)據(jù)了。

本章的代碼.

數(shù)據(jù)庫

也許你的APP非常復(fù)雜牙躺,希望能夠使用一個真正的數(shù)據(jù)庫來做這件事愁憔。有關(guān)iOS數(shù)據(jù)庫有專門的書來講解。隨著你將數(shù)據(jù)保存到數(shù)據(jù)并進(jìn)行數(shù)據(jù)同步孽拷。那么你需要處理多個用戶之間修改造成的沖突吨掌,因為他們在修改的時候可能沒有獲取到最后一個版本的數(shù)據(jù)。

當(dāng)沒有互聯(lián)網(wǎng)連接的時候,數(shù)據(jù)庫并不能真正的解決問題膜宋。但數(shù)據(jù)庫可以讓你方便的處理復(fù)雜對象和數(shù)據(jù)之間的關(guān)系窿侈。

接下來你可以了解一下Core Data。它是iOS內(nèi)置的秋茫,并且相對于簡單的數(shù)據(jù)庫棉磨,它能做的更多。但学辱,你仍需要在網(wǎng)絡(luò)恢復(fù)時進(jìn)行數(shù)據(jù)的同步處理。

Realm也越來越流行环形,也是一個很好的選擇策泣。如果你愿意,也可以使用SQLite(Core Data就是構(gòu)建在它之上)抬吟。

如果你打算從頭構(gòu)建整個APP萨咕,包括后端,可以考慮使用Parse或[Kinvey](http://devcenter.kinvey.com/ios/guides/caching- offline)火本。它們所提供的SDK都包含了對離線的處理危队。

考慮怎么樣能夠提示離線用戶的體驗「婆希可以考慮使用數(shù)據(jù)庫茫陆,這樣用戶就可以在離線執(zhí)行一下處理,并在網(wǎng)絡(luò)恢復(fù)時在后臺將它們同步擎析。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末簿盅,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子揍魂,更是在濱河造成了極大的恐慌桨醋,老刑警劉巖,帶你破解...
    沈念sama閱讀 212,718評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件现斋,死亡現(xiàn)場離奇詭異喜最,居然都是意外死亡,警方通過查閱死者的電腦和手機庄蹋,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,683評論 3 385
  • 文/潘曉璐 我一進(jìn)店門瞬内,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人限书,你說我怎么就攤上這事遂鹊。” “怎么了蔗包?”我有些...
    開封第一講書人閱讀 158,207評論 0 348
  • 文/不壞的土叔 我叫張陵秉扑,是天一觀的道長。 經(jīng)常有香客問我,道長舟陆,這世上最難降的妖魔是什么误澳? 我笑而不...
    開封第一講書人閱讀 56,755評論 1 284
  • 正文 為了忘掉前任,我火速辦了婚禮秦躯,結(jié)果婚禮上忆谓,老公的妹妹穿的比我還像新娘。我一直安慰自己踱承,他們只是感情好倡缠,可當(dāng)我...
    茶點故事閱讀 65,862評論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著茎活,像睡著了一般昙沦。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上载荔,一...
    開封第一講書人閱讀 50,050評論 1 291
  • 那天盾饮,我揣著相機與錄音,去河邊找鬼懒熙。 笑死丘损,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的工扎。 我是一名探鬼主播徘钥,決...
    沈念sama閱讀 39,136評論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼肢娘!你這毒婦竟也來了吏饿?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,882評論 0 268
  • 序言:老撾萬榮一對情侶失蹤蔬浙,失蹤者是張志新(化名)和其女友劉穎猪落,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體畴博,經(jīng)...
    沈念sama閱讀 44,330評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡笨忌,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,651評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了俱病。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片官疲。...
    茶點故事閱讀 38,789評論 1 341
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖亮隙,靈堂內(nèi)的尸體忽然破棺而出途凫,到底是詐尸還是另有隱情,我是刑警寧澤溢吻,帶...
    沈念sama閱讀 34,477評論 4 333
  • 正文 年R本政府宣布维费,位于F島的核電站果元,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏犀盟。R本人自食惡果不足惜而晒,卻給世界環(huán)境...
    茶點故事閱讀 40,135評論 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望阅畴。 院中可真熱鬧倡怎,春花似錦、人聲如沸贱枣。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,864評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽纽哥。三九已至钠乏,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間昵仅,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,099評論 1 267
  • 我被黑心中介騙來泰國打工累魔, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留摔笤,地道東北人。 一個月前我還...
    沈念sama閱讀 46,598評論 2 362
  • 正文 我出身青樓垦写,卻偏偏與公主長得像吕世,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子梯投,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,697評論 2 351

推薦閱讀更多精彩內(nèi)容