(Swift)iOS Apps with REST APIs(十

作者: CD826 | 来源:发表于2016-09-22 17:38 被阅读203次

    这是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都包含了对离线的处理。

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

    相关文章

      网友评论

        本文标题:(Swift)iOS Apps with REST APIs(十

        本文链接:https://www.haomeiwen.com/subject/ijgmettx.html