(Swift)iOS Apps with REST APIs(十六) -- 离线处理

这是iOS Apps with REST APIs系列的最后一篇。在整个翻译过程使用swift逐渐开发出了自家APP,还是小有成就的,这个系列的教程也起到很大作用,希望也能够帮到大家。

重要说明: 这是一个系列教程,非本人原创,而是翻译国外的一个教程。本人也在学习Swift,看到这个教程对开发一个实际的APP非常有帮助,所以翻译共享给大家。原教程非常长,我会陆续翻译并发布,欢迎交流与分享。

一个很简单的方法可以让App Store审查的时候拒绝你的APP,就是不处理离线情况。对于离线有很多方式可以处理,但这依赖于你的数据及用户想做些什么。从大的方面来说,可以采用下面几种方式:

  • 提示用户需要网络连接
  • 使用缓存数据并进入只读的状态
  • 允许用户在离线的时候可以进行操作,在恢复连接的时候进行同步

当你的APP在离线的时候也别忘记持续检查连接是否已经恢复,不要以为一旦失去连接就永远失去了连接。

当我们开始编写一个新的APP时,最简单的方式就是告诉用户需要互联网连接(并确保在没有连接的情况下不会崩溃)。然后在深入分析在离线时哪些可以进行优化,以提升用户体验。

如何知道用户离线?

当尝试加载并无法加载到数据时,我们知道用户离线了。这时候应该提示用户,让用户知道发生了什么,用户也就会知道现在看到的数据并不是实时加载的。因此,我们首先会在GitHubAPIManagergetGists中增加相应的处理。修改后的代码如下:

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并尝试把网络关掉,然后刷新看看会有什么错误发生:

{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。所以,应该在调用getGists方法时检查是否有该错误,如果有那么提示用户他们现在离线了。

使用一个非中断式提示,用户交互会更好,因此这里引入一个非常不错的提示库BRYXBanner。使用CocoaPod将该框架的v0.4.1版本引入到你的工程中,并在MasterViewController中引入:

import UIKit
import Alamofire
import PINRemoteImage
import BRYXBanner


class MasterViewController: UITableViewController, LoginViewDelegate { 

  ...
  
}

现在就可以来处理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 {
    ... 
  }
}

当错误发生时显示一个提示栏(Banner)告诉用户现在网络不给力啊。如果现在已经有一个提示栏显示,还需要先把它隐藏,然后再显示新的提示信息。因此,这里我们需要使用一个变量来跟踪之前所显示的提示栏:

...
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
}

显示的结果如下:

下面修改其它API的调用,让它们也能够处理离线情况。首先是创建处理:

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也是可以的。当然,你可以更改为一个提示栏,它也是一个很好的选择。

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)
  }
})

删除的相关处理和创建是非常类似的:中断用户的操作,并告诉他们执行失败。但对于收藏的状态呢?

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)
  }
})

如果没有网络连接,这里会得到一个错误,并在控制台中打印出来,但用户可不知道啊。这里需要让用户知道发生了什么。因此,这里使用一个橙色的提示栏来提示用户,而不是红色,表示该错误没那么重要。所以,我们需要在DetailViewController中引入BRYXBanner并添加相应的变量:

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? 
  
  ...
}

在没有网络连接的时候创建提示栏:

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服务调用收藏和取消收藏。我们选择提示用户当用户进行相应请求出现错误的时候:

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)
      }
    })
   }
}

下面我们就可以运行来检查是否还有其它什么问题了。这时候你可以关闭/开启网络看看是否会发现什么。

啊哈,我找到了两个:第一个如果当前显示了红色提示框,当我选择一个Gist时同时会显示橙色提示栏。但当我们销毁掉橙色的提示栏后,红色的仍然在。因此,我们需要在切换视图的时候销毁提示栏:

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

MasterViewControllerDetailViewController中都需要添加该代码。

第二个错误就是,当没有网络连接的时候,如何切换视图显示,此时显示的列表是错误的。因此,我们需要在用户切换不同的列表的时候清除掉原来的列表:

@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) 
}

还有一个需要网络连接的地方就是:登录。为了测试这个功能,我们需要重置模拟器,并重新安装我们的程序。

当我们测试这个功能的时候,我们会发现,当我们点击登录按钮登录视图控制器只是不断出现。这对用户来说是一个非常糟糕的体验,尤其是当他们第一次运行应用程序。下面我们将修改代码使用SFSafariViewControllerDelegate检查是否有网络连接:

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) 
  }
}

只是让完成处理程序再试一次,不论发生了什么错误:

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)
  }
}

下面让我们使用前面的提示栏来优化用户体验:

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)
  }
}

如果我们现在进行测试,会发现登录视图控制器依然会弹出来,因为主视图控制器在检测到没有登录的时候总是会弹出登录视图。解决的方法也非常简单,就是当检测到没有网络的时候让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将不会再弹出登录视图,直到下拉刷新。

对哪些会调用失败的Web服务进行分析和测试。确保每一个都进行了很好的处理,并根据实际需要添加横幅提示或者弹出对话框,以便让用户知道发生了什么。

我们已经处理了用户无网络的情况,所以Apple不会因为这个拒绝我们的APP上架了。但是如果我们想提供更好的用户体验,比如在无网络的情况下可以让用户查看之前所加载的一些数据,那么我们该如何做呢?不着急,下面我们就来实现这个功能。

本地拷贝

对于一些简单的APP就像这个示例APP一样,当用户离线时以只读的方式显示最后所加载的数据,应该足够了。所以我们需要持久化Gist的列表。当没有网络的时候,用户不能删除、收藏/取消收藏以及查看之前是否已经收藏该Gist。这些功能,在本章前面已经实现。

NSKeyedArchiver可以用来序列化对象,因此也可以很容易写入到磁盘中。对于数组、字符串都是开箱即用的,但对于我们自己所编写的类,需要整明白该如何进行处理。GistFile这两个类是需要进行处理的。

为了能够将我们自定义的类能够被持久化,需要实现NSCoding协议,该协议反过来有需要实现NSObject协议:

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

NSObject协议需要我们改变已存在的init函数:

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找到原来所使用的地方进行更改.

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协议需要实现两个方法,一个是对对象进行序列化,另外一个是进行反序列化:

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

这里我们需要对对象的每一个属性进行序列化和反序列化:

@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 
  }
}

下面我们就可以真正实现Gist的保存了。创建一个PersistenceManager.swift文件来负责数据的保存与加载。这里我们保持简单,只保存和加载Gist数组,而不是其它的类型:

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的时候还有一点,就是数据的保存的路径。这里使用文档路径就可以了:

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

我们还需要为不同的Gist列表指定不同的保存的路径,以防止被覆盖。让我们定义一个枚举对象来负责。后面如果增加了其它类型的可以进行扩展:

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

class PersistenceManager { 
  ...
}

Ok,现在我们就可以真正来实现保存了。这里使用NSKeyedArchiver.archiveRootObject将对象数组保存到指定的路径下:

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]
}

合在一起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]
  }
}

那么何时保存呢?就是当它们被加载的时候。在loadGists中一旦数据被加载成功,我们就需要调用PersistenceManager.saveArray进行保存:

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) 
}

当无网络访问的时候我们就可以进行加载了:

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
  ...
}

再次运行。当加载一些数据后就可以关闭网络,这时候你应该仍然可以看到这些数据。关掉重新运行并保持网络关闭,这时候仍然可以看到这些数据,并且有相应的提示信息。

看看你的APP那个部分可以支持离线只读处理。如果有,使用NSKeyedArchiver保存和加载它们,这样用户就可以在离线的时候可以看到这些数据了。

本章的代码.

数据库

也许你的APP非常复杂,希望能够使用一个真正的数据库来做这件事。有关iOS数据库有专门的书来讲解。随着你将数据保存到数据并进行数据同步。那么你需要处理多个用户之间修改造成的冲突,因为他们在修改的时候可能没有获取到最后一个版本的数据。

当没有互联网连接的时候,数据库并不能真正的解决问题。但数据库可以让你方便的处理复杂对象和数据之间的关系。

接下来你可以了解一下Core Data。它是iOS内置的,并且相对于简单的数据库,它能做的更多。但,你仍需要在网络恢复时进行数据的同步处理。

Realm也越来越流行,也是一个很好的选择。如果你愿意,也可以使用SQLite(Core Data就是构建在它之上)。

如果你打算从头构建整个APP,包括后端,可以考虑使用Parse或[Kinvey](http://devcenter.kinvey.com/ios/guides/caching- offline)。它们所提供的SDK都包含了对离线的处理。

考虑怎么样能够提示离线用户的体验。可以考虑使用数据库,这样用户就可以在离线执行一下处理,并在网络恢复时在后台将它们同步。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容