Swift37/90Days - iOS 中的设计模式 (Swift 版本) 02
(阔别一个多月。。终于完成了。。) 更新声明翻译自 Introducing iOS Design Patterns in Swift – Part 2/2 ,本教程 objc 版本的作者是 Eli Ganem ,由 Vincent Ngo 更新为 Swift 版本。 再续前缘欢迎来到教程的第二部分!这是本系列教程的最后一部分,在这一章的学习里,我们会更加深入的学习一些 iOS 开发中常见的设计模式:适配器模式 (Adapter),观察者模式 (Observer),备忘录模式 (Memento)。 开始吧少年们! 准备工作你可以先下载上一章结束时的项目源码 。 在第一部分的教程里,我们完成了这样一个简单的应用:
我们的原计划是在上面的空白处放一个可以横滑浏览专辑的视图。其实仔细想想,这个控件是可以应用在其他地方的,我们不妨把它做成一个可复用的视图。 为了让这个视图可以复用,显示内容的工作都只能交给另一个对象来完成:它的委托。这个横滑页面应该声明一些方法让它的委托去实现,就像是 适配器模式 - Adapter适配器把自己封装起来然后暴露统一的接口给其他类,这样即使其他类的接口各不相同,也能相安无事,一起工作。 如果你熟悉适配器模式,那么你会发现苹果在实现适配器模式的方式稍有不同:苹果通过委托实现了适配器模式。委托相信大家都不陌生。举个例子,如果一个类遵循了 如何使用适配器模式横滑的滚动栏理论上应该是这个样子的:
新建一个 Swift 文件: 打开 @objc protocol HorizontalScrollerDelegate { } 这行代码定义了一个新的协议: 接下来我们在大括号里定义所有的委托方法,包括必须的和可选的: // 在横滑视图中有多少页面需要展示 func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> Int // 展示在第 index 位置显示的 UIView func horizontalScrollerViewAtIndex(scroller: HorizontalScroller,index:Int) -> UIView // 通知委托第 index 个视图被点击了 func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller,index:Int) // 可选方法,返回初始化时显示的图片下标,默认是0 optional func initialViewIndex(scroller: HorizontalScroller) -> Int 其中,没有 在 weak var delegate: HorizontalScrollerDelegate? 为了避免循环引用的问题,委托是 委托是可选类型,所以很有可能当前类的使用者并没有指定委托。但是如果指定了委托,那么它一定会遵循 再添加一些新的属性: // 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]() 上面标注的三点分别做了这些事情:
接下来实现初始化方法: 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.setTranslatesAutoresizingMaskIntoConstraints(false) //3 self.addConstraint(NSLayoutConstraint(item: scroller,attribute: .Leading,relatedBy: .Equal,toItem: self,multiplier: 1.0,constant: 0.0)) self.addConstraint(NSLayoutConstraint(item: scroller,attribute: .Trailing,attribute: .Top,attribute: .Bottom,constant: 0.0)) //4 let tapRecognizer = UITapGestureRecognizer(target: self,action:Selector("scrollerTapped:")) scroller.addGestureRecognizer(tapRecognizer) } 上面的代码做了如下工作:
添加委托方法: func scrollerTapped(gesture: UITapGestureRecognizer) { let location = gesture.locationInView(gesture.view) if let delegate = self.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(CGPointMake(view.frame.origin.x - self.frame.size.width/2 + view.frame.size.width/2,0),animated:true) break } } } } 我们把 接下来我们调用了 对于 接下来我们再加个方法获取数组里的 func viewAtIndex(index :Int) -> UIView { return viewArray[index] } 这个方法很简单,只是用来更方便获取数组里的 添加如下代码用来重新加载 func reload() { // 1 - Check if there is a delegate,if not there is nothing to load. if let delegate = self.delegate { //2 - Will keep adding new album views on reload,need to reset. viewArray = [] let views: NSArray = scroller.subviews // 3 - remove all subviews views.enumerateObjectsUsingBlock { (object: AnyObject!,idx: Int,stop: UnsafeMutablePointer<ObjCBool>) -> Void in object.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(CGPointMake(CGFloat(initialView)*CGFloat((VIEW_DIMENSIONS + (2 * VIEW_PADDING))),animated: true) } } } 这个 一段一段的看下上面的代码:
当数据发生改变的时候,我们需要调用 override func didMoveToSuperview() { reload() } 在当前
添加下面这个方法: func centerCurrentView() { var xFinal = scroller.contentOffset.x + CGFloat((VIEWS_OFFSET/2) + VIEW_PADDING) let viewIndex = xFinal / CGFloat((VIEW_DIMENSIONS + (2*VIEW_PADDING))) xFinal = viewIndex * CGFloat(VIEW_DIMENSIONS + (2*VIEW_PADDING)) scroller.setContentOffset(CGPointMake(xFinal,animated: true) if let delegate = self.delegate { delegate.horizontalScrollerClickedViewAtIndex(self,index: Int(viewIndex)) } } 上面的代码计算了当前视图里中心位置距离多少,然后算出正确的居中坐标并滑动到那个位置。最后一行是通知委托所选视图已经发生了改变。 为了检测到用户滑动的结束时间,我们还需要实现 extension HorizontalScroller: UIScrollViewDelegate { func scrollViewDidEndDragging(scrollView: UIScrollView,willDecelerate decelerate: Bool) { if !decelerate { centerCurrentView() } } func scrollViewDidEndDecelerating(scrollView: UIScrollView) { centerCurrentView() } } 当用户停止滑动的时候, 你的 运行一下你的项目,确保编译通过。 这样,我们的
接下来,在
接下来打开 添加如下扩展: extension ViewController: HorizontalScrollerDelegate { func horizontalScrollerClickedViewAtIndex(scroller: HorizontalScroller,index: Int) { //1 let previousAlbumView = scroller.viewAtIndex(currentAlbumIndex) as AlbumView previousAlbumView.highlightAlbum(didHighlightView: false) //2 currentAlbumIndex = index //3 let albumView = scroller.viewAtIndex(index) as AlbumView albumView.highlightAlbum(didHighlightView: true) //4 showDataForAlbum(index) } } 让我们一行一行的看下这个委托的实现:
接下来在扩展里添加如下方法: func numberOfViewsForHorizontalScroller(scroller: HorizontalScroller) -> (Int) { return allAlbums.count } 这个委托方法返回 然后添加如下代码: func horizontalScrollerViewAtIndex(scroller: HorizontalScroller,index: Int) -> (UIView) { let album = allAlbums[index] let albumView = AlbumView(frame: CGRectMake(0,100,100),albumCover: album.coverUrl) if currentAlbumIndex == index { albumView.highlightAlbum(didHighlightView: true) } else { albumView.highlightAlbum(didHighlightView: false) } return albumView } 我们创建了一个新的 是的就是这么简单!三个方法,完成了一个横向滚动的浏览视图。 我们还需要创建这个滚动视图并把它加到主视图里,但是在这之前,先添加如下方法: func reloadScroller() { allAlbums = LibraryAPI.sharedInstance.getAlbums() if currentAlbumIndex < 0 { currentAlbumIndex = 0 } else if currentAlbumIndex >= allAlbums.count { currentAlbumIndex = allAlbums.count - 1 } scroller.reload() showDataForAlbum(currentAlbumIndex) } 这个方法通过 接下来只需要指定委托就可以了,在 scroller.delegate = self reloadScroller() 因为 标注:如果协议里的方法过多,可以考虑把它分解成几个更小的协议。 运行一下当前项目,看一下我们的新页面:
等下,滚动视图显示出来了,但是专辑的封面怎么不见了? 啊哈,是的。我们还没完成下载部分的代码,我们需要添加下载图片的方法。因为我们所有的访问都是通过
看起来好像很难的样子?别绝望,接下来我们会用观察者模式 ( 观察者模式 - Observer在观察者模式里,一个对象在状态变化的时候会通知另一个对象。参与者并不需要知道其他对象的具体是干什么的 - 这是一种降低耦合度的设计。这个设计模式常用于在某个属性改变的时候通知关注该属性的对象。 常见的使用方法是观察者注册监听,然后再状态改变的时候,所有观察者们都会收到通知。 在 MVC 里,观察者模式意味着需要允许
通知 - Notification不要把这里的通知和推送通知或者本地通知搞混了,这里的通知是基于订阅-发布模型的,即一个对象 (发布者) 向其他对象 (订阅者) 发送消息。发布者永远不需要知道订阅者的任何数据。
注意:打开 如何使用通知打开 NSNotificationCenter.defaultCenter().postNotificationName("BLDownloadImageNotification",object: self,userInfo: ["imageView":coverImage,"coverUrl" : albumCover]) 这行代码通过 然后在 NSNotificationCenter.defaultCenter().addObserver(self,selector:"downloadImage:",name: "BLDownloadImageNotification",object: nil) 这是等号的另一边:观察者。每当 但是,在实现 在 deinit { NSNotificationCenter.defaultCenter().removeObserver(self) } 当对象销毁的时候,把它从所有消息的订阅列表里去除。 这里还要做一件事情:我们最好把图片存储到本地,这样可以避免一次又一次下载相同的封面。 打开 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)") let data = NSData(contentsOfFile: path,options: .UncachedRead,error: &error) if let unwrappedError = error { return nil } else { return UIImage(data: data!) } } 代码很简单直接,下载的图片会存储在 然后在 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 = persistencyManager.getImage(coverUrl.lastPathComponent) if imageViewUnWrapped.image == nil { //3 dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT,{ () -> Void in let downloadedImage = self.httpClient.downloadImage(coverUrl) //4 dispatch_sync(dispatch_get_main_queue(),{ () -> Void in imageViewUnWrapped.image = downloadedImage self.persistencyManager.saveImage(downloadedImage,filename: coverUrl.lastPathComponent) }) }) } } } 拆解一下上面的代码:
再回顾一下,我们使用外观模式隐藏了下载图片的复杂程度。通知的发送者并不在乎图片是如何从网上下载到本地的。 运行一下项目,可以看到专辑封面已经显示出来了:
关了应用再重新运行,注意这次没有任何延时就显示了所有的图片,因为我们已经有了本地缓存。我们甚至可以在没有网络的情况下正常使用我们的应用。不过出了问题:这个用来提示加载网络请求的小菊花怎么一直在显示! 我们在下载图片的时候开启了这个白色小菊花,但是在图片下载完毕的时候我们并没有停掉它。我们可以在每次下载成功的时候发送一个通知,但是我们不这样做,这次我们来用用另一个观察者模式: KVO 。 键值观察 - KVO在 KVO 里,对象可以注册监听任何属性的变化,不管它是否持有。如果感兴趣的话,可以读一读苹果 KVO 编程指南。 如何使用 KVO正如前面所提及的, 对象可以关注任何属性的变化。在我们的例子里,我们可以用 KVO 关注 打开 coverImage.addObserver(self,forKeyPath: "image",options: nil,context: nil) 这行代码把 在销毁的时候,我们也需要取消观察。还是在 deinit { coverImage.removeObserver(self,forKeyPath: "image") } 最终添加如下方法: override func observeValueForKeyPath(keyPath: String,ofObject object: AnyObject,change: [NSObject : AnyObject],context: UnsafeMutablePointer<Void>) { if keyPath == "image" { indicator.stopAnimating() } } 必须在所有的观察者里实现上面的代码。在检测到属性变化的时候,系统会自动调用这个方法。在上面的代码里,我们在图片加载完成的时候把那个提示加载的小菊花去掉了。 再次运行项目,你会发现一切正常了:
注意:一定要记得移除观察者,否则如果对象已经销毁了还给它发送消息会导致应用崩溃。 此时你可以把玩一下当前的应用然后再关掉它,你会发现你的应用的状态并没有存储下来。最后看见的专辑并不会再下次打开应用的时候出现。 为了解决这个问题,我们可以使用下一种模式:备忘录模式。 备忘录模式 - Memento备忘录模式捕捉并且具象化一个对象的内在状态。换句话说,它把你的对象存在了某个地方,然后在以后的某个时间再把它恢复出来,而不会打破它本身的封装性,私有数据依旧是私有数据。 如何使用备忘录模式在 //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) }
接下来在 loadPreviousState() 这样在刚初始化的时候就加载了上次存储的状态。但是什么时候存储当前状态呢?这个时候我们可以用通知来做。在应用进入到后台的时候, iOS 会发送一个 在 NSNotificationCenter.defaultCenter().addObserver(self,selector:"saveCurrentState",name: UIApplicationDidEnterBackgroundNotification,object: nil) 现在,当应用即将进入后台的时候, 当然也别忘了取消监听通知,添加如下代码: deinit { NSNotificationCenter.defaultCenter().removeObserver(self) } 这样就确保在 这时再运行程序,随意移到某个专辑上,然后按下 Home 键把应用切换到后台,再在 Xcode 上把 App 关闭。重新启动,会看见上次记录的专辑已经存了下来并成功还原了:
看起来专辑数据好像是对了,但是上面的滚动条似乎出了问题,没有居中啊! 这时 为了修复这个问题,我们可以在 func initialViewIndex(scroller: HorizontalScroller) -> Int { return currentAlbumIndex } 现在 再次重复上次的步骤,切到后台,关闭应用,重启,一切顺利:
回头看看 一种方案是遍历 况且你也无法存储这些对象的私有属性,因为其他类是没有访问权限的。这也就是为什么 Apple 提供了 归档 的机制。 归档 - Archiving苹果通过归档的方法来实现备忘录模式。它把对象转化成了流然后在不暴露内部属性的情况下存储数据。你可以读一读 《iOS 6 by Tutorials》 这本书的第 16 章,或者看下苹果的归档和序列化文档。 如何使用归档首先,我们需要让 class Album: NSObject,NSCoding { 然后添加如下的两个方法: 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") }
现在 在 func saveAlbums() { var filename = NSHomeDirectory().stringByAppendingString("/Documents/albums.bin") let data = NSKeyedArchiver.archivedDataWithRootObject(albums) data.writeToFile(filename,atomically: true) } 这个方法可以用来存储专辑。 当我们归档一个包含子对象的对象时,系统会自动递归的归档子对象,然后是子对象的子对象,这样一层层递归下去。在我们的例子里,我们归档的是 用下面的代码取代 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: "http://www.coversproject.com/static/thumbs/album/album_david%20bowie_best%20of%20bowie.png",year: "1992") let album2 = Album(title: "It's My Life",artist: "No Doubt",coverUrl: "http://www.coversproject.com/static/thumbs/album/album_no%20doubt_its%20my%20life%20%20bathwater.png",year: "2003") let album3 = Album(title: "Nothing Like The Sun",artist: "Sting",coverUrl: "http://www.coversproject.com/static/thumbs/album/album_sting_nothing%20like%20the%20sun.png",year: "1999") let album4 = Album(title: "Staring at the Sun",artist: "U2",coverUrl: "http://www.coversproject.com/static/thumbs/album/album_u2_staring%20at%20the%20sun.png",year: "2000") let album5 = Album(title: "American Pie",artist: "Madonna",coverUrl: "http://www.coversproject.com/static/thumbs/album/album_madonna_american%20pie.png",year: "2000") albums = [album1,album2,album3,album4,album5] saveAlbums() } 我们把创建专辑数据的方法放到了 我们还想在每次程序进入后台的时候存储专辑数据。看起来现在这个功能并不是必须的,但是如果以后我们加了编辑功能,这样做还是很有必要的,那时我们肯定希望确保新的数据会同步到本地的归档文件。 因为我们的程序通过 在 func saveAlbums() { persistencyManager.saveAlbums() } 这个方法很简单,就是把 然后在 LibraryAPI.sharedInstance.saveAlbums() 在 运行一下程序,检查一下没有编译错误。 不幸的是似乎没什么简单的方法来检查归档是否正确完成。你可以检查一下 不过和编辑数据相比,似乎加个删除专辑的功能更好一点,如果不想要这张专辑直接删除即可。再进一步,万一误删了话,是不是还可以再加个撤销按钮? 最后的润色现在我们将添加最后一个功能:允许用户删除专辑,以及撤销上次的删除操作。 在 // 为了实现撤销功能,我们用数组作为一个栈来 push 和 pop 用户的操作 var undoStack: [(Album,Int)] = [] 然后在 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) 上面的代码创建了一个 我们需要在 先写添加的方法: func addAlbumAtIndex(album: Album,index: Int) { LibraryAPI.sharedInstance.addAlbum(album,index: index) currentAlbumIndex = index reloadScroller() } 做了三件事:添加专辑,设为当前的索引,重新加载滚动条。 接下来是删除方法: 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 as [UIBarButtonItem] var undoButton : UIBarButtonItem = barButtonItems[0] undoButton.enabled = true //5 if (allAlbums.count == 0) { var trashButton : UIBarButtonItem = barButtonItems[2] trashButton.enabled = false } } 挨个看一下各个部分:
最后添加撤销按钮: func undoAction() { let barButtonItems = toolbar.items as [UIBarButtonItem] //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 } 照着备注的三个步骤再看一下撤销方法里的代码:
这时再运行应用,试试删除和插销功能,似乎一切正常了:
我们也可以趁机测试一下,看看是否及时存储了专辑数据的变化。比如删除一个专辑,然后切到后台,强关应用,再重新开启,看看是不是删除操作成功保存了。 如果想要恢复所有数据,删除应用然后重新安装即可。 小结最终项目的源代码可以在 BlueLibrarySwift-Final 下载。 通过这两篇设计模式的学习,我们接触到了一些基础的设计模式和概念:Singleton、MVC、Delegation、Protocols、Facade、Observer、Memento 。 这篇文章的目的,并不是推崇每行代码都要用设计模式,而是希望大家在考虑一些问题的时候,可以参考设计模式提出一些合理的解决方案,尤其是应用开发的起始阶段,思考和设计尤为重要。 如果想继续深入学习设计模式,推荐设计模式的经典书籍:Design Patterns: Elements of Reusable Object-Oriented Software。 如果想看更多的设计模式相关的代码,推荐这个神奇的项目: Swift 实现的种种设计模式。 接下来你可以看看这篇:Swift 设计模式中级指南,学习更多的设计模式。 玩的开心。 :] 原文链接:
(编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |