Alamofire: Часть 2 (Xcode 6.3)

08 сентября 2015

Этот туториал обновлен до версии Xcode 6.3 и Swift версии 1.2.

С возвращением во вторую и последнюю часть нашего туториала посвященного Alamofire!

В первой части вы освоили основные случаи использования библиотеки Alamofire, а именно: запросы GET, отправка параметров, создание роутера для запроса и даже создание пользовательского сериализатора ответа.

В этом туториале вы создадите приложение, которое будет представлять из себя галерею, а так же узнаете, как добавить следующую функциональность к вашему уже существующему приложению Photomania:

  • Просмотр фотографий
  • Возможность просматривать комментарии и другие детали
  • Возможность скачивать фотографии с симпатичным прогресс баром
  • Оптимизированные сетевые вызовы и кэширование изображений
  • И возможность обновления

Поехали!

Вы можете продолжить свою работу с прошлым проектом, но если вы создаете все с нуля, то скачайте конечный вариант прошлого туториала тут.

Если вы не работали с нами в первой части, то не забудьте получить conusmer key на сайте 500px.com и заменить его в Five100px.swift. Инструкции, как получить этот ключ и, как его заменить упакованы в первой части.

Запустите ваш проект и освежите память:] Вы можете просматривать фотографии в галерее, но при нажатии на конкретную фотографию она не будет отображаться. Это и есть наша первая проблема, которую мы вскоре решим! ;]

Создаем просмотр фотографий (Photo Viewer)

Давайте будем честны - универсальные шаблоны (или джинерики) - одна из самых мощных особенностей любого современного языка, включая Swift. По этому, будет неправильно, если мы не используем ее в нашем проекте.

Откройте Five100px.swift и добавьте следующий код, рядом с верхней строкой, сразу после import Alamofire:


@objc public protocol ResponseObjectSerializable {
  init?(response: NSHTTPURLResponse, representation: AnyObject)
}
 
extension Alamofire.Request {
  public func responseObject(completionHandler: (NSURLRequest, NSHTTPURLResponse?, T?, NSError?) -> Void) -> Self {
    let responseSerializer = GenericResponseSerializer { request, response, data in
      let JSONResponseSerializer = Request.JSONResponseSerializer(options: .AllowFragments)
      let (JSON: AnyObject?, serializationError) = JSONResponseSerializer.serializeResponse(request, response, data)
 
      if let response = response, JSON: AnyObject = JSON {
        return (T(response: response, representation: JSON), nil)
      } else {
        return (nil, serializationError)
      }
    }
 
    return response(responseSerializer: responseSerializer, completionHandler: completionHandler)
  }
}

В коде выше вы снова расширяете Alamofire для того, чтобы добавить новый сериализатор ответа. В этот раз вы добавили функцию .responseObject() в качестве универсальной функции. Она может сериализовать любые объекты и данные, которые подписаны под ResponseObjectSerializable (тот, что вы определили ранее).

Это значит, что если вы определили новый класс, который имеет форму инициализатора init(response:representation:), Alamofire может автоматически возвращать объекты этого типа от сервера. Вы инкапсулировали логику сериализации прямо внутри самого пользовательского класса, и это самая настоящая элегантность объектно-ориентированного проектирования!

Просмотр фотографий (photo viewer) использует класс PhotoInfo, который уже соответствует протоколу ResponseObjectSerializable(он реализует необходимый метод). Но вам нужно сделать это официально, указав, что класс подписан на протокол ResponseObjectSerializable.

Откройте Five100px.swift и укажите в объявлении класса PhotoInfo, что он соответствует протоколу ResponseObjectSerializable:

class PhotoInfo: NSObject, ResponseObjectSerializable {

Заметка

Если вам интересно, как параметр representation сериализован в объект PhotoInfo, то вы можете посмотреть required init(response:representation:).

Откройте PhotoViewerViewController.swift (но не PhotoBrowserCollectionViewController.swift!!!), и добавьте библиотечный реквизин вверх файла:


import Alamofire

Затем добавьте следующий код в viewDidLoad():


loadPhoto()

Вы получили ошибку, говорящую о том, что данного метода нет? Ничего страшного, сейчас мы его опишем!

Добавьте следующий код перед setupView() в том же файле:


func loadPhoto() {
  Alamofire.request(Five100px.Router.PhotoInfo(self.photoID, .Large)).validate().responseObject() {
    (_, _, photoInfo: PhotoInfo?, error) in
 
    if error == nil {
      self.photoInfo = photoInfo
 
      dispatch_async(dispatch_get_main_queue()) {
        self.addButtomBar()
        self.title = photoInfo!.name
      }
 
      Alamofire.request(.GET, photoInfo!.url).validate().responseImage() {
        (_, _, image, error) in
 
        if error == nil && image != nil {
          self.imageView.image = image
          self.imageView.frame = self.centerFrameFromImage(image)
 
          self.spinner.stopAnimating()
 
          self.centerScrollViewContents()
        }
      }
    }
  }
}

В этот раз вы создали Alamofire - запрос внутри другого обработчика результата запроса Alamofire. Первый запрос получает ответ JSON и использует новый универсальный сериализатор ответа, для создания экземпляра PhotoInfo из этого ответа. 

(_, _, photoInfo: PhotoInfo?, error) in - отображает параметры обработчика завершения (complition hanlder parameters). Первые два знака подчеркивания ("_") означают, что нам не нужны первые два параметра, и нам не к чему явно указывать их имена, например, как request и response.

Третий параметр явно указан как экземпляр PhotoInfo, так что универсальный сериализатор автоматически инициализирует его, и возвращает объект этого типа, который содержит URL фотографии. Второй запрос Alamofire использует сериализатор изображения, который вы создали ранее, для конвертирования NSData в UIImage, который вы отобразите в image view.

Вы не используете роутер, потому что у вас есть прямой URL, и вам не приходится собирать его по кускам самостоятельно.

Вызов функции .validate() до запроса объекта ответа - другая простая для использования особенность Alamofire. Сцепление его между запросом и ответом подтверждает, что ответ имеет код состояния по умолчанию в допустимом диапазоне от 200 до 299. Если проверка не пройдена, обработчик ответа будет иметь соответствующую ошибку, которую вы можете обработать в завершающем обработчике.

Даже если у вас ошибка, ваш обработчик ошибки все равно будет вызван. Четвертый параметр error является экземпляром NSError. Он содержит значение, которое позволяет вам реагировать на ваши ошибки совершенно любым образом.

Запустите ваш проект и нажмите на одну из картинок. Вы увидите, как эта картинка заполнит весь экран:

Ура! Мы реализовали просмотр фотографий! Вы можете выполнить двойное нажатие на картинке, а затем перемещаться по ней.

Когда ваш типо-безопасный универсальный сериализатор ответа инициализирует PhotoInfo, вы устанавливаете не только id или url свойства. На самом деле, есть еще несколько свойств, которые вы пока что не видели.

Щелкните по кнопке Menu в левом нижнем углу приложения и вы увидите некоторые подробные детали фотографии:

Нажмите в любую точку экрана, чтобы выйти из просмотра деталей.

Если вы знакомы с 500px.com, то вы знаете, что пользователи любят комментировать самые интересные фотографии. Теперь вы можете просматривать фотографии по отдельности. Давайте теперь реализуем сериализатор для комментариев.

Создаем сериализатор для коллекции комментариев

Во время просмотра фотографий, которые имеют комментарии, будет отображаться кнопка с их колличеством. Нажав на эту кнопку, вы получите все комментарии к фотографии.

Откройте Five100px.swift и добавьте следующий код, под строкой import Alamofire:


@objc public protocol ResponseCollectionSerializable {
  static func collection(#response: NSHTTPURLResponse, representation: AnyObject) -> [Self]
}
 
extension Alamofire.Request {
  public func responseCollection(completionHandler: (NSURLRequest, NSHTTPURLResponse?, [T]?, NSError?) -> Void) -> Self {
    let responseSerializer = GenericResponseSerializer { request, response, data in
      let JSONResponseSerializer = Request.JSONResponseSerializer(options: .AllowFragments)
      let (JSON: AnyObject?, serializationError) = JSONResponseSerializer.serializeResponse(request, response, data)
      if let response = response, JSON: AnyObject = JSON {
        return (T.collection(response: response, representation: JSON), nil)
      } else {
        return (nil, serializationError)
      }
    }
 
    return response(responseSerializer: responseSerializer, completionHandler: completionHandler)
  }
}

Этот код должен показаться вам знакомым, так как он очень похож на код универсального сериализатора ответа, который вы создали ранее.

Единственное отличие в том, что этот протокол определяет функцию класса, которая возвращает коллекцию (а не один элемент), в нашем случае [Self]. Завершающий обработчик имеет коллекцию в качестве его третьего параметра - [T] и вызывает collection на тип, вместо инициализатора.

Продолжаем работать в нашем файле, замените класс Comment следующим кодом:


final class Comment: ResponseCollectionSerializable {
  @objc static func collection(#response: NSHTTPURLResponse, representation: AnyObject) -> [Comment] {
    var comments = [Comment]()
 
    for comment in representation.valueForKeyPath("comments") as! [NSDictionary] {
      comments.append(Comment(JSON: comment))
    }
 
    return comments
  }
 
  let userFullname: String
  let userPictureURL: String
  let commentBody: String
 
  init(JSON: AnyObject) {
    userFullname = JSON.valueForKeyPath("user.fullname") as! String
    userPictureURL = JSON.valueForKeyPath("user.userpic_url") as! String
    commentBody = JSON.valueForKeyPath("body") as! String
  }
}

Это заставляет Comment соответствовать протоколу ResponseCollectionSerializable так, что он работает с сериализатором ответа.

Теперь, все что вам нужно сделать - это использовать его. Откройте PhotoCommentsViewController.swift и добавьте вверх файла импорт:


import Alamofire

Теперь, добавьте следующий код в конец viewDidLoad():


Alamofire.request(Five100px.Router.Comments(photoID, 1)).validate().responseCollection() {
  (_, _, comments: [Comment]?, error) in
 
  if error == nil {
    self.comments = comments
 
    self.tableView.reloadData()
  }
}

Это использует ваш новый сериализатор ответа для десериализации ответа NSData в коллекцию Comments, сохраняет комментарии в свойство и перезагружает table view.

Затем, добавьте следующий код в tableView(_:cellForRowAtIndexPath), прямо над строкой return cell:


cell.userFullnameLabel.text = comments![indexPath.row].userFullname
cell.commentLabel.text = comments![indexPath.row].commentBody
 
cell.userImageView.image = nil
 
let imageURL = comments![indexPath.row].userPictureURL
 
Alamofire.request(.GET, imageURL).validate().responseImage() {
  (request, _, image, error) in
 
  if error == nil {
    if request.URLString.isEqual(imageURL) {
      cell.userImageView.image = image
    }
  }
}

Это позволяет отображать информацию из комментария в ячейке table view, а так же выстреливает второй запрос Alamofire, для загрузки изображения(аналогично тому, что вы делали в первой части).

Запустите ваше приложение, просмотрите фотографии и найдите ту, что имеет комментарии. Вы узнаете ее по цифре, отображаемой рядом с иконкой комменатриев. Нажмите на Comments и вы увидите, что комментарии к этой фотографии отображаются примерно так:

Отображение процесса загрузки

Ваше окно для просмотра фотографии имеет активную кнопку, по середине нижней планки. Она отображает UIActionSheet, который должен позволить вам скачать фотографию, но сейчас ничего не происходит. До этого момента вы могли загружать фотографии с 500px.com в вашу память, но как насчет того, чтобы у вас была возможность скачивать фотографии на ваше устройство?

Откройте PhotoViewerViewController.swift и замените пустой downloadPhoto() на следующий код:


func downloadPhoto() {
  // 1
  Alamofire.request(Five100px.Router.PhotoInfo(photoInfo!.id, .XLarge)).validate().responseJSON() {
    (_, _, JSON, error) in
 
    if error == nil {
      let jsonDictionary = (JSON as! NSDictionary)
      let imageURL = jsonDictionary.valueForKeyPath("photo.image_url") as! String
 
      // 2
      let destination = Alamofire.Request.suggestedDownloadDestination(directory: .DocumentDirectory, domain: .UserDomainMask)
 
      // 3
      Alamofire.download(.GET, imageURL, destination: destination)
 
    }
  }
}

Давайте разберем его по кусочкам и узнаем, что там внутри:

  • Сначала вы запрашиваете новый PhotoInfo, только на этот раз, вы спрашиваете размер XLarge этого изображения.
  • Получаете дефолтное расположение для сохранения файлов, что в нашем случае, будет поддерикторией Documents данного приложения. Имя файла будет таким же, как нам и предлагет сервер. destination является замыканием, но подробнее об этом поговорим чуть позже.
  • Alamofire.download(_:_:_) немного отличается от Alamofire.request(_:_), который не требует обработчика ответа или сериализатора, для определения действий с объектом данных. А все потому, что он уже знает как с ним поступить - конечно же сохранить на диск! Замыкание destination возвращает местоположение сохраненного изображения.

Запустите ваше приложение, найдите понравившееся фото, нажмите на него, а затем нажмите на кнопку Save. Не видите никакого результата? Просто пройдите на главный экран приложения и нажмите на вкладку Downloads.

Вы можете спросить: "Почему просто не передать постоянное положение для файла?". А потому, что вы можете и не знать имени файла на момент его загрузки. В случае с 500px.com сервер всегда предлагает варианты 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, основываясь на размере файла. Вы не сможете сохранить фотографии с одним и тем же именем.

Вместо того, чтобы передавать постоянное положение файла в качестве строкового значения, вы передаете замыкание в качестве третьего параметра Alamofire.download. Затем Alamofire вызывает это замыкание в подходящее время, передавая temporaryURL и NSHTTPURLResponse, и ожидает экземпляр NSURL, который укажет на точное положение места хранения, куда есть доступ для записи.

Сохранили несколько фотографий, но посмотрели в Downloads, а там находится всего одна. Что же происходит?

Получается, что файлы не имеют уникальных имен, так что нам нужно реализовать свою логику именования файлов. Замените строку, которая идет сразу после "//2" в downloadPhoto() следующим кодом:


let destination: (NSURL, NSHTTPURLResponse) -> (NSURL) = {
  (temporaryURL, response) in
 
  if let directoryURL = NSFileManager.defaultManager().URLsForDirectory(.DocumentDirectory, inDomains: .UserDomainMask)[0] as? NSURL {
    return directoryURL.URLByAppendingPathComponent("\(self.photoInfo!.id).\(response.suggestedFilename)")
  }
 
  return temporaryURL
}

И снова, let destination является замыканием. Но в этот раз вы снова реализовали свою логику именования файлов. Теперь вы используете id фотографии, которое захватывает замыкание из окружающего скопа и соединяете его с предложенным сервером-именем, разделяя их "." между ними.

Запустите приложение: теперь вы можете хранить несколько фоток:

Загрузка фотографий на диск функционирует нормально, но будет еще удобнее, если во время загрузки у нас будет отображаться полоса прогресса. Alamofire позволяет очень просто реализовать нашу задумку.

Замените строку под комментарием "//3" в downloadPhoto() следующим кодом:


// 4
let progressIndicatorView = UIProgressView(frame: CGRect(x: 0.0, y: 80.0, width: self.view.bounds.width, height: 10.0))
progressIndicatorView.tintColor = UIColor.blueColor()
self.view.addSubview(progressIndicatorView)
 
// 5
Alamofire.download(.GET, imageURL, destination: destination).progress {
  (_, totalBytesRead, totalBytesExpectedToRead) in
 
  dispatch_async(dispatch_get_main_queue()) {
    // 6
    progressIndicatorView.setProgress(Float(totalBytesRead) / Float(totalBytesExpectedToRead), animated: true)
 
    // 7
    if totalBytesRead == totalBytesExpectedToRead {
      progressIndicatorView.removeFromSuperview()
    }
  }
}

Давайте рассмотрим каждый пронумерованный комментарий по отдельности:

  • Вы используете стандартный UIProgressView для отображения прогресса загрузки фото. Настройте его и добавьте в свою схему документа.
  • С Alamofire вы можете соединить .progress(), который принимает замыкание, которое в свою очередь, периодически вызывается с тремя параметрами bytesRead, totalBytesRead и totalBytesExpectedToRead.
  • Разделите totalBytesRead на totalBytesExpectedToRead, и вы получите значение между 0 и 1, которое будет отображать ваш прогресс загрузки. Замыкание может выполняться множество раз. Если ваше время загрузки не слишком короткое, то вы получите отображаемый обновляемый процесс загрузки на экране.
  • После того, как весь процесс прошел, просто уберите полосу загрузки с экрана.

Запустите ваше приложение, найдите другое фото для сохранения и попробуйте его сохранить. У вас должно получиться вот так:

Полоса загрузки исчезает, когда завершается загрузка фотографии, но вы можете ее и не увидеть, если ваша скорость слишком большая.

Обратите внимание, что downloadPhoto все еще используется .responseJSON в секции №1. Вот вам такой челендж, чтобы понять на сколько вы хорошо усвоили работу сериализаторов ответа: усовершенствуйте код выше, чтобы в нем использовался универсальный сериализатор, вместо responseObject(). Если вы хотите проверить ваше решение, то вы можете посмотреть на код ниже. Первые строки downloadPhoto() должны быть заменены на следующие:


Alamofire.request(Five100px.Router.PhotoInfo(photoInfo!.id, .XLarge)).validate().responseObject() {
  (_, _, photoInfo: PhotoInfo?, error) in
 
  if error == nil && photoInfo != nil {
    let imageURL = photoInfo!.url
.
.
.

Таким образом вы переключили код на использование вашего пользовательского сериализатора ответа, вместо того, чтобы вручную парсить JSON, что делает ваш код чище.

Оптимизация и обновление

Хорошо! Самое время реализовать возможность обновления.

Откройте PhotoBrowserCollectionViewController.swift и замените func handleRefresh() следующим кодом:


func handleRefresh() {
  refreshControl.beginRefreshing()
 
  self.photos.removeAllObjects()
  self.currentPage = 1
 
  self.collectionView!.reloadData()
 
  refreshControl.endRefreshing()
 
  populatePhotos()
}

Этот код просто освобождает вашу модель self.photos и сбрасывает currentPage, а так же обновляет ваш UI.

Запустите приложение. Теперь потяните на себя пальцем и ваш UI обновится, таким образом вы увидите самые новые картинки подгруженные на 500px.com:

Когда вы быстро прокручиваете главный экран, вы наверное заметили, что вы можете отправлять еще незаполненные ячейки за экран. На самом деле, эти фотографии еще продолжают ожидать свою загрузку, но загруженные фотографии и связанные с ними данные просто скидываются.

В дополнение ко всему, когда вы возвращаетесь к ранее просмотренным ячейкам, то они заново создают сетевой запрос для фотографии, даже если вы скачали ее себе на диск всего-лишь секунду назад. Вы определенно можете улучшить пропускную способность данного приложения!

Вы можете сделать это, кэшируя полученные изображения, так что и мне не придется грузиться множество раз. Так же, вы отмените любой сетевой запрос, если ячейка вышла за пределы экрана, еще до того, как он успел завершиться.

Откройте PhotoBrowserCollectionViewController.swift и добавьте следующий код прямо перед let refreshControl:


let imageCache = NSCache()

Это создает объект NSCache, который вы будете использовать для кэширования ваших изображений.

Затем, замените collectionView(_:cellForItemAtIndexPath:) следующим кодом:


override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
  let cell = collectionView.dequeueReusableCellWithReuseIdentifier(PhotoBrowserCellIdentifier, forIndexPath: indexPath) as! PhotoBrowserCollectionViewCell
 
  let imageURL = (photos.objectAtIndex(indexPath.row) as! PhotoInfo).url
 
  // 1
  cell.request?.cancel()
 
  // 2
  if let image = self.imageCache.objectForKey(imageURL) as? UIImage {
    cell.imageView.image = image
  } else {
    // 3
    cell.imageView.image = nil
 
    // 4
    cell.request = Alamofire.request(.GET, imageURL).validate(contentType: ["image/*"]).responseImage() {
      (request, _, image, error) in
      if error == nil && image != nil {
        // 5
        self.imageCache.setObject(image!, forKey: request.URLString)
 
        // 6
        cell.imageView.image = image
      } else {
        /*
        If the cell went off-screen before the image was downloaded, we cancel it and
        an NSURLErrorDomain (-999: cancelled) is returned. This is a normal behavior.
        */
      }
    }
  }
 
  return cell
}

Давайте пройдемся по коду от комментария к комментарию:

  • Ячейка, которая уже вышла из очереди, вполне возможно, уже имеет какой-либо request от Alamofire. Вы можете просто отменить его, так как он больше уже не валиден для новой созданной ею ячейки.
  • Опциональная привязка нужна для проверки наличия фотографии в кэше, перед тем, как просто закачать ее снова.
  • Если у вас нет версии фотографии в кэше, то вы просто ее скачиваете. Однако, если ваша ячейка, вышедшая из очереди, имеет некоторое изображение, то просто присвойте ей nil, чтобы она стала пустой, в то время как необходимое изображение будет загружаться.
  • Скачайте фотографию с сервера, но на этот раз проверьте content-type вернувшегося ответа. Если ответ не является изображением, то error будет содержать значение, и таким образом вы ничего не будете делать с потенциально невалидными ответом изображения. Смысл в том, что вы храните объект Alamofire request в ячейке для того, чтобы использовать его, когда ваш асинхронный вызов вернется.
  • Если вы не получили ошибки и загрузили нужное вам фото, то сохраните его в кэше.
  • Установите изображение ячеек соответственно.

Запустите ваше приложение. Вы увидите, что когда вы прогручиваете ваш главный экран назад, то фотографии грузятся значительно быстрее, из-за того, что вы оптимизировали ваши запросы, убрав лишние из них и сохранив в кэше изображения для повторного отображения. Такое использование ресурсов всегда оставит ваших пользователей счастливыми! ;]

Некоторые ячейки могут оставаться пустыми, однако, когда вы будете нажимать на них, то вы будете видеть полное изображение. Знайте, это не ваша вина, просто не все фотографии на 500px.com имеют свои миниатюры.

Вот конечный вариант второй части туториала по Alamofire. В нем вы найдете другие особенности, которые не были раскрыты в этом туториале.

Если вы прошли с нами обе части обучения работе с Alamofire, то у вас должно было сложиться хорошее понимание основ работы с ним. Вы разобрались как создавать змеевидные методы запросов/ответов, ваши собственные сериализаторы ответа, роутеры и шифрованные URL-адреса с кодировкой параметра URL, скачивание файлов на диск, и как использовать замыкание прогресса, и проверенные ответы. Это уже достаточно приличный список ваших достижений! ;]

Так же, Alamofire может проверять подлинность серверов, используя различные схемы. Закачивать файлы и потоки, чего не было продемонстрировано в этих туториалах. Но с вашими новыми знаниями по Alamofire вы можете самостоятельно изучить эти аспекты.

На данный момент Alamofire не такой продвинутый как AFNetworking, но если вы создаете новый проект, используя Alamofire, то это только доставит вам удовольствие, потому что его можно использовать во множестве типичных случаях. Расширение UIKit - является одним из самых популярных расширений AFNetworking, которого так не хватает в Alamofire. Но никто вам не говорил, что эти две библиотеки не уживаются мирно в одном проекте!

Если вы использовали AFNetworking до этого и не можете жить без набора setImageWithURL методов категории для UIKit, то вы можете продолжать использовать AFNetworking в ваших проектах. Например, вы можете использовать Alamofire для вызовов серверных API, а затем использовать AFNetworking для асинхронного отображения изображений. AFNetworking имеет общий кэш, так что вам не нужно вручную кэшировать или отменять ваши запросы.

Что дальше?

Дальше, вы можете продолжить изучать наши туториалы по мере их появления, а также, параллельно читать перевод официальной книги по языку программирования Swift. И, для более подробного изучения языка, вы можете пройти наши курсы!

Урок подготовил: Иван Акулов

Источник урока: http://www.raywenderlich.com/87595/intermediate-alamofire-tutorial

Содержание