Swift 2.2
Мы не останавливаемся и продолжаем готовить вас к будущему собеседованию по iOS разработке. Это вторая часть практической темы — паттерны. Первая честь здесь. Тема эта самая тяжелая в разделе и самая ключевая. А так же она требует некоторых навыков создания iOS приложений. Если вы только начинаете свой путь iOS разработчика — пропустите эту статью и пока что ознакомьтесь только с теорией по ссылкам в конце. Спустя пару разделов вы сможете вернуться и пройти практическую часть этой статьи. Все написанное является переводом статьи Ray Wenderlich. Итак, продолжим.
1. Общая информация:
1.3 Паттерны — часть 1 — часть 2
1.5 Использование third-party решений в iOS разработке
В этой части на примере оставшихся паттернов мы научим приложение сохранять данные в файл, качать данные из сети и много чего еще!
Паттерны: Шаблон проектирования Adapter
Адаптер позволяет классам с несовместимыми интерфейсами работать вместе. Адаптер — это обёртка вокруг объекта, которая предоставляет унифицированный интерфейс для взаимодействия с этим объектом.
Если вы знакомы с паттерном «Адаптер» по другим языкам, то вы заметите, что Apple реализует его своеобразно (примерно как и требование Еврокомиссии перейти на MicroUSB). Они это делают с помощью протоколов. Вы, можете быть, знакомы с протоколами UITableViewDelegate, UIScrollViewDelegate, NSCoding, NSCopying. К примеру, протокол NSCopying предоставляет любому классу стандартный метод copy.
Паттерны: Как использовать Adapter
Горизонтальный скрол для листания альбомов будет выглядеть следующим образом.
Чтобы приступить к его реализации, создайте новый класс из шаблона Cocoa-Touch class (ещё не забыли, как?), назовите его HorizontalScroller и наследуйте его от UIView.
Откройте HorizontalScroller.swift и добавьте следующий код выше объявления класса:
1 2 | @objc protocol HorizontalScrollerDelegate { } |
Этим мы определяем протокол HorizontalScrollerDelegate — наследник от протокола NSObject (таким же образом, как наследуются классы). Общепринятая практика — чтобы класс соответствовал протоколу NSObject или другому протоколу, который наследуется/соответствует протоколу NSObject.
Объявляем обязательные и необязательные методы, которые делегат должен реализовать (объявляем в теле протокола):
1 2 3 4 5 6 7 8 9 | // ask the delegate how many views he wants to present inside the horizontal scroller func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> Int // ask the delegate to return the view that should appear at <index> func horizontalScrollerViewAtIndex(scroller: HorizontalScroller, index:Int) -> UIView // inform the delegate what the view at <index> has been clicked func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller, index:Int) // ask the delegate for the index of the initial view to display. this method is optional // and defaults to 0 if it's not implemented by the delegate optional func initialViewIndex(scroller: HorizontalScroller) -> Int |
Здесь у нас есть обязательные и необязательные методы. Обязательные — должны быть реализованы делегатом, они обычно содержат те данные, которые абсолютно необходимы классу. Здесь нам необходимы данные по количеству элементов (представлений), элемент по данному индексу, а также действие по нажатию на элемент.
В HorizontalScroller.swift добавьте следующий код после определения класса HorizontalScroller:
1 | weak var delegate: HorizontalScrollerDelegate? |
Заметьте, мы добавили атрибут weak («слабый») к свойству (указателю на) delegate. Это необходимо, чтобы предотвратить «retain-зацикливание». Если объект хранит сильный указатель на делегата, а делегат хранит сильный указатель обратно на тот объект, то вы гарантированно получите утечку памяти. Ни один из двух объектов никогда не освободит память.
Добавьте в клас еще свойства:
1 2 3 4 5 6 7 8 9 | // 1 private let VIEW_PADDING = 10 private let VIEW_DIMENSIONS = 100 private let VIEWS_OFFSET = 100 // 2 private var scroller : UIScrollView! // 3 var viewArray = [UIView]() |
По пунктам, что мы делаем:
- Определяем константы для удобной модификации внешнего вида на этапе разработки. Размеры представлений внутри скролла будут 100х100 пунктов с отступом 10 пунктов от прямоугольника, который их обрамляет.
- Создаем ScrollView, содержащий альбомы
- Создаем массив обложек.
Далее добавляем инициализаторы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | override init(frame: CGRect) { super.init(frame: frame) initializeScrollView() } required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder)! initializeScrollView() } func initializeScrollView() { //1 scroller = UIScrollView() addSubview(scroller) //2 scroller.translatesAutoresizingMaskIntoConstraints = false //3 self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Leading, relatedBy: .Equal, toItem: self, attribute: .Leading, multiplier: 1.0, constant: 0.0)) self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Trailing, relatedBy: .Equal, toItem: self, attribute: .Trailing, multiplier: 1.0, constant: 0.0)) self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Top, relatedBy: .Equal, toItem: self, attribute: .Top, multiplier: 1.0, constant: 0.0)) self.addConstraint(NSLayoutConstraint(item: scroller, attribute: .Bottom, relatedBy: .Equal, toItem: self, attribute: .Bottom, multiplier: 1.0, constant: 0.0)) //4 let tapRecognizer = UITapGestureRecognizer(target: self, action:Selector("scrollerTapped:")) scroller.addGestureRecognizer(tapRecognizer) } |
Инициализаторы делегируют большую часть работы к initializeScrollView(). Вот что происходит в этом методе:
- Создаем новый экземпляр UIScrollView и добавляет его к родительскому представлению.
- Выключаем маски автоизменения размеров.
- Применение ограничения к scrollview. Мы хотим, чтобы ScrollView полностью заполнило HorizontalScroller.
- Создаем tap gesture recognizer. Tap gesture recognizer распознает жест касанияк ScrollView и проверяет, коснулись ли мы обложки.
Добавьте еще один метод:
1 2 3 4 5 6 7 8 9 10 11 12 13 | func scrollerTapped(gesture: UITapGestureRecognizer) { let location = gesture.locationInView(gesture.view) if let delegate = delegate { for index in 0..<delegate.numberOfViewsForHorizontalScroller(self) { let view = scroller.subviews[index] as! UIView if CGRectContainsPoint(view.frame, location) { delegate.horizontalScrollerClickedViewAtIndex(self, index: index) scroller.setContentOffset(CGPoint(x: view.frame.origin.x - self.frame.size.width/2 + view.frame.size.width/2, y: 0), animated:true) break } } } } |
Жест передается в качестве параметра и позволяет нам извлечь расположение с locationInView().
Затем мы вызываем numberOfViewsForHorizontalScroller() в делегате. У экземпляра HorizontalScroller нет информации о делегате кроме знания, что он может безопасно отправить это сообщение, так как делегат должен соответствовать протоколу HorizontalScrollerDelegate.
Добавьте следующий, чтобы получить доступ к обложке альбома от скроллера:
1 2 3 | func viewAtIndex(index :Int) -> UIView { return viewArray[index] } |
viewAtIndex просто возвращает представление по определенному индексу. Вы будете использовать этот метод позже, чтобы выделить обложку альбома, которой вы коснулись.
Для перезагрузки скроллера добавьте следующий код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | func reload() { // 1 - Check if there is a delegate, if not there is nothing to load. if let delegate = delegate { //2 - Will keep adding new album views on reload, need to reset. viewArray = [] let views: NSArray = scroller.subviews // 3 - remove all subviews for view in views { view.removeFromSuperview() } // 4 - xValue is the starting point of the views inside the scroller var xValue = VIEWS_OFFSET for index in 0..<delegate.numberOfViewsForHorizontalScroller(self) { // 5 - add a view at the right position xValue += VIEW_PADDING let view = delegate.horizontalScrollerViewAtIndex(self, index: index) view.frame = CGRectMake(CGFloat(xValue), CGFloat(VIEW_PADDING), CGFloat(VIEW_DIMENSIONS), CGFloat(VIEW_DIMENSIONS)) scroller.addSubview(view) xValue += VIEW_DIMENSIONS + VIEW_PADDING // 6 - Store the view so we can reference it later viewArray.append(view) } // 7 scroller.contentSize = CGSizeMake(CGFloat(xValue + VIEWS_OFFSET), frame.size.height) // 8 - If an initial view is defined, center the scroller on it if let initialView = delegate.initialViewIndex?(self) { scroller.setContentOffset(CGPoint(x: CGFloat(initialView)*CGFloat((VIEW_DIMENSIONS + (2 * VIEW_PADDING))), y: 0), animated: true) } } } |
Метод перезагружает все используемые данные, чтобы создать горизонтальный скроллер.
- Проверки на делегат, прежде чем мы выполним любую перезагрузку.
- Так как вы очищаете обложки альбомов, вы также должны сбросить viewArray.
- Удалите все подпредставления, ранее добавленные к ScrollView.
- Все представления запускаются с заданным смещением. В настоящее время это 100, но это можно легко настроить, изменив постоянный VIEW_OFFSET в начале.
- HorizontalScroller спрашивает свой делегат о всех view по одному, и он кладет их рядом горизонтально с ранее определенным отступом.
- Сохранение представлений в viewArray.
- Как только все представления существуют, установите смещение содержания для HorizontalScroller позволяет пользователю просматривать все альбомы путем прокрутки.
- HorizontalScroller проверяет, реализует ли его делегат initialViewIndex(). Эта проверка необходима, потому что этот метод протокола дополнительный. Если делегат не реализует этот метод, 0 используется в качестве значения по умолчанию.
Вы выполняете перезагрузку, когда ваши данные изменились. Вы также должны вызвать этот метод, когда Вы добавляете HorizontalScroller к другому представлению. Добавьте следующий код к HorizontalScroller.swift, чтобы реализовать последний сценарий:
1 2 3 | override func didMoveToSuperview() { reload() } |
didMoveToSuperview вызывается, когда HorizontalScroller добавлен к другому представлению как подпредставление. Это — правильное время, чтобы перезагрузить содержание скроллера.
Последняя часть проблемы — HorizontalScroller должty удостовериться, что альбом, который вы просматриваете, всегда центрируется в scrollView. Чтобы сделать это, вы должны будете выполнить некоторые вычисления.
1 2 3 4 5 6 7 8 9 | func centerCurrentView() { var xFinal = Int(scroller.contentOffset.x) + (VIEWS_OFFSET/2) + VIEW_PADDING let viewIndex = xFinal / (VIEW_DIMENSIONS + (2*VIEW_PADDING)) xFinal = viewIndex * (VIEW_DIMENSIONS + (2*VIEW_PADDING)) scroller.setContentOffset(CGPoint(x: xFinal, y: 0), animated: true) if let delegate = delegate { delegate.horizontalScrollerClickedViewAtIndex(self, index: Int(viewIndex)) } } |
Этот код помогает центрировать альбом. Как только представление центрируется, мы сообщаем делегату, что выбранное представление изменилось.
Для обнаружения того, что пользователь перестал перетаскивать элементы, необходимо реализовать некоторые методы UIScrollViewDelegate. Добавьте следующий код в самом конце файла после закрывающей фигурной скобки:
1 2 3 4 5 6 7 8 9 10 11 | extension HorizontalScroller: UIScrollViewDelegate { func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { centerCurrentView() } } func scrollViewDidEndDecelerating(scrollView: UIScrollView) { centerCurrentView() } } |
scrollViewDidEndDragging (_: willDecelerate:) сообщает делегату, когда пользователь заканчивает перетаскивать. Параметр decelerate равен true, если представление не остановилось. Вы должны вызвать новый метод, чтобы центрировать текущее представление, так как текущее представление, вероятно, изменилось после того, как пользователь перетащил ScrollView.
В initializeScrollView() добавляют следующий код после scroller = UIScrollView():
1 | scroller.delegate = self; |
HorizontalScroller готов к использованию. Откройте Storyboard и войдите в identity inspector. Установите для скрола класс HorizontalScroller.
Далее свяжите этот скролл с Outlet в ViewController.swift и назовите его scroller:
Далее в файле ViewController.swift мы должны реализовать некоторые методы HorizontalScrollerDelegate. В конце файла добавьте следующее расширение.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | extension ViewController: HorizontalScrollerDelegate { func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller, index: Int) { //1 let previousAlbumView = scroller.viewAtIndex(currentAlbumIndex) as! AlbumView previousAlbumView.highlightAlbum(false) //2 currentAlbumIndex = index //3 let albumView = scroller.viewAtIndex(index) as! AlbumView albumView.highlightAlbum(true) //4 showDataForAlbum(index) } } |
- Берем раннее выбранную обложку альбома и «отменяем» выбор обложки.
- Сохраняем текущий индекс обложки.
- «Захват» выбранной обложки.
- Выводим на экран данные в Table View.
Добавьте следующий метод в расширение:
1 2 3 | func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> (Int) { return allAlbums.count } |
Этот код возвращает количество View для ScrollView. Так как ScrollView выведет на экран обложки для всех альбомов, количество — число записей.
Напоследок, добавьте в расширение следующий код:
1 2 3 4 5 6 7 8 9 10 | func horizontalScrollerViewAtIndex(scroller: HorizontalScroller, index: Int) -> (UIView) { let album = allAlbums[index] let albumView = AlbumView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), albumCover: album.coverUrl) if currentAlbumIndex == index { albumView.highlightAlbum(true) } else { albumView.highlightAlbum(false) } return albumView } |
Здесь мы создаем новый AlbumView, затем проверяем, выбрал ли пользователь этот альбом. Наконец, мы передаете его к HorizontalScroller.
Вот именно! Только три сокращенных метода вывести на экран хорошо выглядящий горизонтальный скроллер.
Добавить следующий метод в класс:
1 2 3 4 5 6 7 8 9 10 | func reloadScroller() { allAlbums = LibraryAPI.sharedInstance.getAlbums() if currentAlbumIndex < 0 { currentAlbumIndex = 0 } else if currentAlbumIndex >= allAlbums.count { currentAlbumIndex = allAlbums.count - 1 } scroller.reload() showDataForAlbum(currentAlbumIndex) } |
Этот метод загружает данные альбома через LibraryAPI и затем устанавливает выведенное на экран представление на основе индекса текущего представления. Если индекс текущего представления — меньше чем 0, это значит что никакое представление не было в настоящее время выбрано.
Добавьте следующие строки во viewDidLoad():
1 2 | scroller.delegate = self reloadScroller() |
Так как HorizontalScroller создавался в Storyboard, все, что мы должны сделать — установить делегат, и вызовать reloadScroller(), который загрузит Subview для скроллера, чтобы вывести на экран данные альбома.
Запустите программу и узрите ваш горизонтальный скроллер =)
Круто, скроллер работает. Но где же обложки?
Мы еще не реализовали код для добавления обложек. Мы должны добавить способ загрузки изображений. Так как весь наш доступ к службам осуществляется через LibraryAPI, то именно к нему мы и обратимся за помощью. Но для начала рассмотрим пару пунктов:
- AlbumView не должен работать непосредственно с LibraryAPI. Вы не должны смешивать логику представления с коммуникационной логикой.
- По той же причине LibraryAPI не должен знать о AlbumView.
- LibraryAPI должен уведомить AlbumView, как только абложки загружены, так как AlbumView должен вывести их на экран.
Ребус? Ни сколько! В этой простейшей задачке нам поможет Observer.
Паттерны: Шаблон проектирования Observer
В паттерне Наблюдатель один объект уведомляет другие объекты о любых изменениях состояния. Объекты не должны знать о друг друге. Этот паттерн чаще всего используется для уведомления нужных объектов об изменении каких либо свойств.
Обычная реализация требует, чтобы наблюдатель зарегистрировал интерес к состоянию другого объекта. Когда изменится состояния, все объекты наблюдения будут уведомлены относительно изменения.
Если вы хотите придерживаться паттерны MVC (что мы и делаем в этом проекте), вы должны позволить объектам Модели связаться с объектами Представления, но без прямых ссылок между ними. И это то, где можно применить Observer.
Cocoa реализует шаблон наблюдатель двумя примечательными способами: Notifications и Key-Value Observing (KVO).
Notifications
Не путайте понятие с Push или Local уведомлениями. Нотификации работают по принципу Лектор-Слушатели. Объект-Лектор отправляет сообщения объектам-Слушателям. Лектор ничего не должен знать о Слушателях.
Перейдите к AlbumView.swift и введите следующий код в конце инициализатора init(frame: CGRect, albumCover: String):
1 | NSNotificationCenter.defaultCenter().postNotificationName("BLDownloadImageNotification", object: self, userInfo: ["imageView":coverImage, "coverUrl" : albumCover]) |
Этот код отправляет нотификацию через синглтон NSNotificationCenter. Уведомление содержит информацию о UIImageView для заполнения и URL обложки для альбома, которая будет загружена. Это вся необходимая для загрузки информация.
Добавьте следующик код в LibraryAPI.swift в init сразу после super.init():
1 | NSNotificationCenter.defaultCenter().addObserver(self, selector:"downloadImage:", name: "BLDownloadImageNotification", object: nil) |
Это другая сторона управления Observer. Каждый раз, когда AlbumView отправит уведомление BLDownloadImageNotification, система будет уведомлять об этом LibraryAPI. В ответ на уведомление LibraryAPI вызывает downloadImage(). Прежде чем реализовывать функцию загрузки, давайте сделаем необходимое действие — отпишемся от нотификации, когда объект деинициализирован. Ведь если послать уведомление в несуществующий объект, это может плохо кончится для приложения. Добавьте к LibraryAPI метод:
1 2 3 | deinit { NSNotificationCenter.defaultCenter().removeObserver(self) } |
Теперь давайте реализуем одну хорошую задумку — локальное сохранение загруженных фото. Это избавит от необходимости загружать их много раз. Откройте PersistencyManager.swift и добавьте методы:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | func saveImage(image: UIImage, filename: String) { let path = NSHomeDirectory().stringByAppendingString("/Documents/\(filename)") let data = UIImagePNGRepresentation(image) data!.writeToFile(path, atomically: true) } func getImage(filename: String) -> UIImage? { var error: NSError? let path = NSHomeDirectory().stringByAppendingString("/Documents/\(filename)") do { let data = try NSData(contentsOfFile: path, options: .UncachedRead) return UIImage(data: data) } catch { print(error) return nil } } |
Это простой код. Загружаемые фото будут сохранены в папке Documents и getImage() вернет nil, если фото не будет найдено в этой папке.
Теперь добавьте метод в LibrariAPI.swift:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | func downloadImage(notification: NSNotification) { //1 let userInfo = notification.userInfo as! [String: AnyObject] var imageView = userInfo["imageView"] as! UIImageView? let coverUrl = userInfo["coverUrl"] as! NSString //2 if let imageViewUnWrapped = imageView { imageViewUnWrapped.image = persistentManager.getImage(coverUrl.lastPathComponent) if imageViewUnWrapped.image == nil { //3 dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { () -> Void in let downloadedImage = self.httpClient.downloadImage(coverUrl as String) //4 dispatch_sync(dispatch_get_main_queue(), { () -> Void in imageViewUnWrapped.image = downloadedImage self.persistentManager.saveImage(downloadedImage, filename: coverUrl.lastPathComponent) }) }) } } } |
- downloadImage выполняется с помощью нотификации, получая объект нотификации как параметр. UIImage и URL получены из нотификации.
- Берем изображение из PersistentManager, если оно было загружено ранее.
- Если оно загружено не было, то скачиваем его через HTTPClient.
- Когда изображение загружено, выводим его на экран и используем PersistentManager для сохранения локально.
Перед тем как запускать приложение, давайте позволим приложению работать через незащищенное соединение. Для этого перейдите в info.plist и добавьте строку, нажав на + в шапке документа: NSAppTransportSecurity. Раскройте его и добавьте Allow Arbitrary Loads со значением YES.
Запускаем программу. Все работает!
Обратите внимание на крутящийся кружочек на альбомах — Spinner. Мы не реализовали логику его остановки по окончанию загрузки. В этом нам поможет иная сторона Observer — KVO.
Key-Value Observing (KVO)
В KVO объект может попросить быть уведомленным относительно любых изменений в определенном свойстве: его собственном или тот из другого объекта. Если Вам интересно, Вы можете читать больше об этом в руководстве по программированию Apple KVO.
Как упомянуто выше, механизм KVO позволяет объекту наблюдать за изменениями в свойстве. В нашем случае мы можем использовать KVO, чтобы наблюдать изменения в свойстве изображения UIImageView, которое содержит изображение.
Откройте AlbumView.swift и добавьте следующий код в init(frame:albumCover:) сразу после commonInit():
1 | coverImage.addObserver(self, forKeyPath: "image", options: [], context: nil) |
Этот код добавляет текущий класс как наблюдатель для свойства изображения coverImage. Добавьте следующий метод для того что бы отписаться при деинициализации.
1 2 3 | deinit { coverImage.removeObserver(self, forKeyPath: "image") } |
И наконец, добавьте следующий метод:
1 2 3 4 5 | override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) { if keyPath == "image" { indicator.stopAnimating() } } |
Вы должны реализовать этот метод в каждом классе, работающем как наблюдатель. Система выполняет этот метод каждый раз, когда наблюдаемое свойство изменяется. В вышеупомянутом коде вы останавливаете индикатор, когда свойство image изменяется. Таким образом, когда изображение загружено, спиннер прекратит вращаться.
Создайте и выполните свой проект. Спиннер должен исчезнуть.
На данный момент приложение не сохраняет свое состояние после перезагрузки. Каждый раз выбран первый альбом. Нам нужно заставить сохранять последнюю выбранную позицию. И в этом нам поможет Memento.
Паттерны: Шаблон проектирования Memento
Патерн Memento получает и воплощает внутреннее состояние объекта. Другими словами, он сохраняет ваш материал где-нибудь. Позже, это состояние может быть восстановлено, не нарушая инкапсуляцию, т.е. приватные данные остаются приватными.
Добавьте слежующие методы в ViewController.swift:
1 2 3 4 5 6 7 8 9 10 11 12 | //MARK: Memento Pattern func saveCurrentState() { // When the user leaves the app and then comes back again, he wants it to be in the exact same state // he left it. In order to do this we need to save the currently displayed album. // Since it's only one piece of information we can use NSUserDefaults. NSUserDefaults.standardUserDefaults().setInteger(currentAlbumIndex, forKey: "currentAlbumIndex") } func loadPreviousState() { currentAlbumIndex = NSUserDefaults.standardUserDefaults().integerForKey("currentAlbumIndex") showDataForAlbum(currentAlbumIndex) } |
saveCurrentState сохраняет текущий индекс альбома к NSUserDefaults. NSUserDefaults — стандартное хранилище данных, обеспеченное iOS для того, чтобы сохранить некоторые настройки и данные.
loadPreviousState загружает ранее сохраненный индекс. Это не полное осуществление паттерна memento, пока что.
Теперь, Добавьте следующую строку к viewDidLoad в ViewController.swift перед scroller.delegate:
1 | loadPreviousState() |
Это загружает ранее сохраненное состояние, когда приложение запускается. Но где мы сохраним текущее состояние приложения для загрузки? Мы будем использовать нотификации, чтобы сделать это. iOS отправляет уведомление UIApplicationDidEnterBackgroundNotification, когда приложение уходит в фон. Вы можете использовать это уведомление, чтобы вызвать saveCurrentState. Удобно же?
Добавьте следующую строку в конце viewDidLoad:
1 | NSNotificationCenter.defaultCenter().addObserver(self, selector:"saveCurrentState", name: UIApplicationDidEnterBackgroundNotification, object: nil) |
Теперь, когда приложение уйдет в фон, ViewController автоматически сохранит текущее состояние, вызывая saveCurrentState.
Но не забываем вызывать отписываться от нотификаций при деинициализации объекта:
1 2 3 | deinit { NSNotificationCenter.defaultCenter().removeObserver(self) } |
Запустите приложение, выберите какой либо альбом, и с помощью комбинации в симуляторе Command+Shift+H сверните приложение в фон. В этот момент сработает метод сохранения состояния. Что бы убедится что оно сохранено, перезапустите приложение в Xcode. Вы увидите, что все работает правильно, однако скроллер при старте не переместился за сохраненным альбомом. Вот для этого нам и нужен был опциональным метод initialViewIndexForHorizontalScroller. Добавьте его во ViewController.swift:
1 2 3 | func initialViewIndex(scroller: HorizontalScroller) -> Int { return currentAlbumIndex } |
Теперь перезапустите приложение и увидите, что все работает правильно!
Если вы посмотрите на init в PersistentManager, то вы заметите, что альбомы создаются каждый раз, когда PersistentManager создается. Но лучше создать список альбомов один раз и сохранить их в файле. Как вы сохранили бы данные Album в файле?
Используем созданный Apple механизм архивации!
Архивация
Одна из функций паттерна Memento — архивация. Она позволяет преобразовать объект в «поток», который может быть сохранен и позже восстановлен.
Сначала вы должны объявить, что Album может быть заархивирован, то есть соответствует протоколу NSCoding. Откройте Album.swift и измените строку объявления класса:
1 | class Album: NSObject, NSCoding { |
После этого добавьте два метода в сам класс:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | required init(coder decoder: NSCoder) { super.init() self.title = decoder.decodeObjectForKey("title") as! String self.artist = decoder.decodeObjectForKey("artist") as! String self.genre = decoder.decodeObjectForKey("genre") as! String self.coverUrl = decoder.decodeObjectForKey("cover_url") as! String self.year = decoder.decodeObjectForKey("year") as! String } func encodeWithCoder(aCoder: NSCoder) { aCoder.encodeObject(title, forKey: "title") aCoder.encodeObject(artist, forKey: "artist") aCoder.encodeObject(genre, forKey: "genre") aCoder.encodeObject(coverUrl, forKey: "cover_url") aCoder.encodeObject(year, forKey: "year") } |
Как часть протокола NSCoding, encodeWithCoder будет вызван, когда мы попросим экземпляр Album быть заархивированными. С другой стороны,инициализатор init(coder:) будет использоваться, чтобы восстановить или разархивировать сохраненный экземпляр. Это просто, но все же мощно.
Теперь, когда класс Album может быть заархивирован, добавьте код, который фактически сохраняет и загружает список альбомов.
Добавьте следующий метод в PersistencyManager.swift:
1 2 3 4 5 | func saveAlbums() { var filename = NSHomeDirectory().stringByAppendingString("/Documents/albums.bin") let data = NSKeyedArchiver.archivedDataWithRootObject(albums) data.writeToFile(filename, atomically: true) } |
Это метод, который вызывают, чтобы сохранить альбомы. NSKeyedArchiver архивирует массив альбома в файл, названный albums.bin.
Когда Вы архивируете объект, который содержит другие объекты, archiver автоматически пытается рекурсивно заархивировать дочерние объекты и любые дочерние объекты дочерних объектов и т.д. В этом экземпляре архивация запускается с альбомов, которые являются массивом экземпляров Album. Так как Array и Album оба поддерживают интерфейс NSCopying, все в массиве автоматически архивируется.
Сейчас замените init в PersistentManager.swift на этот код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | override init() { super.init() if let data = NSData(contentsOfFile: NSHomeDirectory().stringByAppendingString("/Documents/albums.bin")) { let unarchiveAlbums = NSKeyedUnarchiver.unarchiveObjectWithData(data) as! [Album]? if let unwrappedAlbum = unarchiveAlbums { albums = unwrappedAlbum } } else { createPlaceholderAlbum() } } func createPlaceholderAlbum() { //Dummy list of albums let album1 = Album(title: "Best of Bowie", artist: "David Bowie", genre: "Pop", coverUrl: "https://blog.justdev.org/wp-content/uploads/2016/05/Best_of_bowie.jpg", year: "1992") let album2 = Album(title: "It's My Life", artist: "No Doubt", genre: "Pop", coverUrl: "https://blog.justdev.org/wp-content/uploads/2016/05/200x200.jpeg", year: "2003") let album3 = Album(title: "Nothing Like The Sun", artist: "Sting", genre: "Pop", coverUrl: "https://blog.justdev.org/wp-content/uploads/2016/05/220px-NLTS.jpg", year: "1999") let album4 = Album(title: "Staring at the Sun", artist: "U2", genre: "Pop", coverUrl: "https://blog.justdev.org/wp-content/uploads/2016/05/220px-Sats.jpg", year: "2000") let album5 = Album(title: "American Pie", artist: "Madonna", genre: "Pop", coverUrl: "https://blog.justdev.org/wp-content/uploads/2016/05/American_Pie_Madonna.png", year: "2000") albums = [album1, album2, album3, album4, album5] saveAlbums() } |
Мы переместили код создания альбомов в отдельный метод createPlaceholderAlbum(). В новом коде NSKeyedUnarchiver загружает данные альбома из файла, если он существует. Если не существует, то создает данные альбома и сразу сохраняет его для следующего запуска приложения.
Нам также нужно сохранить данные альбома каждый раз, когда приложение входит в фон. Добавьте следующий код в LibraryAPI.swift:
1 2 3 | func saveAlbums() { persistentManager.saveAlbums() } |
Этот код просто передает сохранение альбомов в LibraryAPI. Добавьте теперь строку кода в конце saveCurrentState во ViewController.swift:
1 | LibraryAPI.sharedInstance.saveAlbums() |
Вышеупомянутый код использует LibraryAPI, чтобы инициировать сохранение данных альбома каждый раз, когда ViewController сохраняет свое состояние.
К сожалению, нет никакого простого способа, чтобы проверить, корректна ли персистентность данных. Вы можете проверить папку Documents в папке приложения в Finder, чтобы видеть, что файл данных альбома создается, но чтобы видеть любые другие изменения, вы должны будете добавить возможность изменить данные альбома.
Но вместо того, чтобы изменить данные, что если мы добавим опцию удаления альбомов?, которые больше не хотим держать в своей библиотеке? Кроме того, не было бы лишним иметь опцию отмены, если удаляен альбом по ошибке.
Паттерны: Финальные штрихи
Мы собираемся добавить последние штрихи к музыкальному приложению, позволяющие пользователю выполнить действие удаления, чтобы удалить альбом или действия отмены в случае, если он передумал. Добавьте следующее свойство во ViewController:
1 2 | // We will use this array as a stack to push and pop operation for the undo option var undoStack: [(Album, Int)] = [] |
Этот код создает пустой стек отмены. undoStack будет содержать кортеж двух параметров. Первым является Album, и вторым является индекс альбома.
Добавьте следующий код после reloadScroller() в viewDidLoad:
1 2 3 4 5 6 | let undoButton = UIBarButtonItem(barButtonSystemItem: .Undo, target: self, action:"undoAction") undoButton.enabled = false; let space = UIBarButtonItem(barButtonSystemItem: .FlexibleSpace, target:nil, action:nil) let trashButton = UIBarButtonItem(barButtonSystemItem: .Trash, target:self, action:"deleteAlbum") let toolbarButtonItems = [undoButton, space, trashButton] toolbar.setItems(toolbarButtonItems, animated: true) |
Этот код создает панель инструментов с двумя кнопками и гибким пространством между ними. Кнопка отмены отключена, потому что стек отмены еще пустой. Обратите внимание на то, что панель инструментов уже находится в Storyboard, таким образом все что мы должны сделать — установить элементы панели инструментов.
Вы добавите три метода к ViewController.swift для обработки действий управления альбомом: добавление, удаление, и отмена.
Для начала добавим метод добавления альбома:
1 2 3 4 5 | func addAlbumAtIndex(album: Album,index: Int) { LibraryAPI.sharedInstance.addAlbum(album, index: index) currentAlbumIndex = index reloadScroller() } |
Здесь мы добавляем альбом, устанавливаете его как текущий индекс альбома и перезагружаете скроллер.
Далее идет метод удаления, но в нем все по сложнее:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | func deleteAlbum() { //1 var deletedAlbum : Album = allAlbums[currentAlbumIndex] //2 var undoAction = (deletedAlbum, currentAlbumIndex) undoStack.insert(undoAction, atIndex: 0) //3 LibraryAPI.sharedInstance.deleteAlbum(currentAlbumIndex) reloadScroller() //4 let barButtonItems = toolbar.items var undoButton : UIBarButtonItem = barButtonItems![0] undoButton.enabled = true //5 if (allAlbums.count == 0) { var trashButton : UIBarButtonItem = barButtonItems![2] trashButton.enabled = false } } |
- Удаляем альбом.
- Создаем переменную, названную undoAction, которая хранит кортеж Альбома и его индекса. Затем добавляем кортеж в стек.
- Используйте LibraryAPI, чтобы удалить альбом из структуры данных и перезагрузить скроллер.
- Мы должны включить кнопку отмены.
- Проверяем, есть ли еще какие либо альбомы. Если нет — выключаем кнопку удаления.
Наконец то, добавьте метод отмены:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | func undoAction() { let barButtonItems = toolbar.items //1 if undoStack.count > 0 { let (deletedAlbum, index) = undoStack.removeAtIndex(0) addAlbumAtIndex(deletedAlbum, index: index) } //2 if undoStack.count == 0 { var undoButton : UIBarButtonItem = barButtonItems![0] undoButton.enabled = false } //3 let trashButton : UIBarButtonItem = barButtonItems![2] trashButton.enabled = true } |
- Метод “выталкивает” объект из стека, давая кортеж, содержащий Альбом и его индекс. Их вы используете для возвращения альбома.
- Так как мы также удалили последний объект в стеке, когда “выталкивали” его, мы теперь должны проверить, пуст ли стек. Если да, то это означает что больше нет действий для отмены. Таким образом, мы отключаете кнопку Undo.
- Мы также знаем, что так как мы отменили действие, должна быть по крайней мере одна обложка альбома. Следовательно мы включаем кнопку удаления.
Запустите приложение и поиграйтесь с ним. Вы увидите, что удаление/возвращение работают отлично.
Теперь, если вы удаляете альбом, отправьте приложение в фон, и затем завершите приложение, в следующий раз, когда вы запускаете приложение, выведенный на экран список альбомов должен отразить удаление.
Если Вы хотите вернуть все альбомы, просто удалить приложение и запустите его снова в Xcode, чтобы установить начальные данные.
Финальное приложение вы можете скачать по этой ссылке!
Паттерны: Полезные ссылки для углубления в тему
- Хрестоматия паттернов для Objective-C
- Общая информация на Википедии
- Наглядная общая информация на Хабре
- Крутой репозиторий на Github с демонстрацией всех паттернов на Swift 2
- MVC от Apple