深入理解iOS设计模式

做 iOS开发也好几年了,记得自己刚入行时,对iOS 开发模式也是一知半解,后面项目做多了,以及看一些优秀书籍之后对他有了进一步的理解;发现自己写的代码中无形之中就用了很多的设计模式,只不过是不知道相应的设计模式术语而已,所以决定把自己做的项目中的一个小模块来写一篇iOS设计模式的文章,一是记录自己的学习;二是让入门者可以作为参考,来理解iOS设计模式。设计模式是软件设计中常见问题的可重复使用的解决方案。 它们是旨在帮助您编写易于理解和重用的代码的模板。 它们还可以帮助您创建松散耦合的代码,以便您可以在代码中更改或替换组件,而不必太麻烦。本文将通过一个小项目的形式来讲解iOS的设计模式。通过跟着我一步一步操作你将学到:什么是设计模式;为什么要使用设计模式;以及怎样在自己的项目中使用合适的设计模式。
设计模式分为三大类:

  1. 构造模式:单例模式(Singleton),抽象工厂模式(Abstract Factory)等等;
  2. 结构模式:MVC,适配器模式(Adapter),外观模式(Facade),装饰模式(Decorator)等等;
  3. 行为模式:观察者(Observer),备忘录(Memento),命令模式(Command)。
    整个项目完成将会是如下效果图:


    image.png

开始鲁代码

github上下载starter project,使用Xcode打开项目,这是一个新建的空项目,我将Main.storyboard给删除了,在appdelegate中使用代码设置window的rootViewController为ViewController;以及将books.json放入到了本地,模拟网络请求来的书籍数据(这部分涉及到公司的机密资源,所以只能这样模拟)。

1、使用快捷键Command+N,选择iOS->Swift File,并且命名为Book,新建一个Book结构体作为模型,并且编写如下代码。(在Objective-C我们一般是新建一个Book类作为模型,但是swift中官方推荐使用结构体,更加轻量级),此处我使用了JSONExport开源工具生成Book结构体,这是一个很强大的将json转化模型的Mac桌面应用。

struct Book {
 
  var bid: Int!
  var bookName: String!
  var isRead: Bool!
  var orientation: Int!
  var pic: String!
  
  
  /**
   * Instantiate the instance using the passed dictionary values to set the properties values
   */
  init(fromDictionary dictionary: [String: Any]){
    bid = dictionary["bid"] as? Int
    bookName = dictionary["book_name"] as? String
    isRead = dictionary["is_read"] as? Bool
    orientation = dictionary["orientation"] as? Int
    pic = dictionary["pic"] as? String
  }
  
  /**
   * Returns all the available property values in the form of [String:Any] object where the key is the approperiate json key and the value is the value of the corresponding property
   */
  func toDictionary() -> [String:Any]
  {
    var dictionary = [String: Any]()
    if bid != nil{
      dictionary["bid"] = bid
    }
    if bookName != nil{
      dictionary["book_name"] = bookName
    }
    if isRead != nil{
      dictionary["is_read"] = isRead
    }
    if orientation != nil{
      dictionary["orientation"] = orientation
    }
    if pic != nil{
      dictionary["pic"] = pic
    }
    return dictionary
  }
        
}

当然在实际项目中swift的json解析我们还可以使用GitHub开源的SwiftyJSON,这个也很好用,使用这个就能够减少model类的创建,这个两种使用方式各有各的好吧。

2、Command + N,选择Cocoa Touch Class,命名为BookView作为UICollectionView的子类,language选择Swift;同样操作新建一个BookCell作为UICollectionViewCell的子类,在选择language上面勾选Also create XIB file。新建完毕之后记得在这两个类前面添加final修饰符(这里涉及到代码规范的内容,我后续将会写一篇这样的文章,苹果官方推荐那些些不会继承的类可以添加final修饰,这么做的好处是提高编译速度,当项目庞大的时候优势就明显了,我也深有体会)

MVC设计模式

Model View Controller(模型视图控制器)是我们所有设计模式中最常用的,他根据程序中的角色对对象进行分类,使代码干净分离,低耦合;
Model:保存程序的数据并且定义如何操作它的对象。本例子的Model是结构体Book
View:负责程序UI视觉元素的表示以及与用户的交互响应,基本上所有的UIView及其子类都属于这一类;本例中的View是BookView
Controller:控制器是View和Model之间的协调者,它访问模型中的数据,并且显示于视图,并且监听用户事件并根据需要操作数据;本例中的Controller是ViewController
下图很好的显示了这三者之间的关系:

MVC

  1. 模型数据的变化会通知通知控制器,同时控制器就会更新视图上的数据;
  2. 当用户有操作交互时,视图也能通知控制器,于是控制器更新模型中的数据信息。
    有些人就会有疑问了,为什么不把所有的都写在一个类里面,直接在将视图和模型都写在控制器里面,这样操作起来很容易。
    我们都知道软件设计有个很重要的原则就是:高内聚,低耦合,所以我们得考虑把代码分离和重用。理想情况下,视图应与模型完全分开。 如果View不依赖于Model的具体实现,则可以使用不同的模型重用其他数据。例如本例中的模型Book,因为不依赖任何视图类,所以可以很简单的重复使用。以及视图类BookView和BookCell,在将来要添加某个功能时完全可以复用,例如我做的真实项目中有个功能模块是书籍的本地下载页面,此时就完全可以复用整个视图类。

基于MVC,我将项目程序分为了三个group,使用快捷键Command+option+N新建三个group,并且命名为Model、View、Controller,同时将相应的源文件拖入相应的分组,如下图:


group分组

单例模式(The Singleton Pattern)

单例模式能够确保指定类只有一个实例,并且全局可以访问到该实例。苹果官方有大量使用这个模式,比如:UserDefaults.standard、UIApplication.shared、UIScreen.main等等,但是,单例也不能够滥用,因为单例一旦创建他的内存将存在于整个应用程序的一生,直到应用程序被关闭,内存才被释放。本例中:BookAPI类是单例用来处理书籍数据

final class BookAPI: NSObject {
  
  private var persistencyManager: PersistencyManager
  private var httpClient: HTTPClient
  
  /// 单例
  static let sharedInstance = BookAPI()
  
  override init() { // 线程安全
    persistencyManager = PersistencyManager()
    httpClient = HTTPClient()
    super.init()
  }
  
  /// 返回书籍数量
  ///
  /// - Returns: book数组
  func getBooks() -> [Book] {
    return persistencyManager.books
  }
  
  /// 保存书籍数据
  func saveBooks() {
    persistencyManager.saveBooks()
  }
  
  
  /// 删除指定位置的绘本
  ///
  /// - Parameter index: index 0
  func deleteBookAt(index: Int) {
    httpClient.postRequest(url: "delete/book", params: ["book_id": ""]) { (res) in
      if res { // 服务器删除成功,删除本地的绘本
        persistencyManager.deleteBookAt(index: index)
      } else {
        print("删除绘本失败,请稍后再试")
      }
    }
  }
}

外观模式(the Facade Pattern)

外观模式的形象例子

外观设计模式为复杂的子系统提供单一的接口,只公开一个简单统一的API,而不是将一组类及其API暴露给用户。

Facade Pattern

API的用户完全不知道下面的复杂性。 这种模式在使用大量类时非常理想,特别是当它们复杂使用或难以理解时。
外观模式将使用系统的代码与您隐藏的类的接口和实现相分离; 它也减少了外部代码对子系统内部工作的依赖性。 如果外观下面的类改变,那么外部类可以保留相同的API。
例如,如果您希望替换后台服务器地址,您将不必更改您的API的代码。
在本例中如何使用外观设计模式:
目前我们有PersistencyManager来获取本地书籍数据,保存书籍数据;HTTPClient来处理网络请求。我们项目中的其他类将不会知道这个底层逻辑。
如下图我们将在BookAPI暴露以下方法来供外部调用:
公共方法

persistencyManager、httpClientBookAPI的私有成员变量,不暴露给外部调用;
私有成员变量

装饰设计模式(The Decorator Design Patte)

装饰模式的形象例子

装饰设计模式可以在不修改其代码的情况下,动态地向对象添加行为和责任。 就扩展功能来说,装饰设计模式相比于生成子类更为灵活。
在Objective-C中,这种模式有两种非常常见的实现:Category(类别)和Delegation(委派)。swift中使用extension为相应的类添加扩展。
以下三种情况考虑使用这一模式:

  1. 想要在不影响其他对象的情况下,移动台、透明的方式给单个对象添加职责;
  2. 想要扩张一个类的行为,却做不到。类定义可能被隐藏,无法进行子类话;或者对类的每个行为的扩展,为支持美中功能组合,将产生大量的子类;
  3. 对类的职责的扩展是可选的。

extension

本例中的book封面,后台返回的字段"pic": "/image/20160826/d51285bb636281dce6974313eaf6f15d.png"只是一个路径,前面的域名https://xxxxxx-aliyun.firstleap.cn需要我们这边统一拼接,同时我们使用的阿里云存储服务,有对图片进行处理(包括图片压缩,减少图片分辨率),且iPhone和iPad的处理还不一样。

我们对String添加一个扩展(oc中为category),File/New/File...,选择Swift File,命名为String+Aliyun,添加以下代码:

// 判断型号
let isPad = ( UI_USER_INTERFACE_IDIOM() == .pad)
let fileHost = "https://xxxxxx-aliyun.firstleap.cn"

extension String {
  func aliyunThumb() -> String {
    if isPad {
      return "\(fileHost)\(self)" + "!iPadThumb"
    } else {
      return "\(fileHost)\(self)" + "!thumb"
    }
  }
}

Delegation

本例中的BookView作为UICollectionView的子类,有两个方法你必须实现,那就是

// 有多少个item
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
// 每个item需要现实的信息内容
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell```

这两个方法都是有BookView的代理ViewController中实现,苹果的UIKit中有很多都是代理委托模式(UITableView, UITextView, UITextField, UIWebView, UIAlert, UIActionSheet, UICollectionView, UIPickerView, UIGestureRecognizer, UIScrollView

ViewController中实现BookView的代理方法如下:

extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
      return books.count
  }
  
  func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "BookCell", for: indexPath) as! BookCell
    if indexPath.item > books.count - 1  {
      cell.coverName.isHidden = true
      cell.bookCover.isHidden = true
      cell.isReadImageView.isHidden = true
    } else {
      cell.coverName.isHidden = false
      cell.bookCover.isHidden = false
      cell.isReadImageView.isHidden = false
      cell.book = books[indexPath.item]
    }
    return cell
  }
  
  func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    let alertVc = UIAlertController(title: "要删除这本绘本么", message: "", preferredStyle: .alert)
    let sureAction = UIAlertAction(title: "确定删除", style: .default, handler: { (_) in

    })
    alertVc.addAction(sureAction)
    let cancelAction = UIAlertAction(title: "取消", style: .default, handler: nil)
    alertVc.addAction(cancelAction)
    present(alertVc, animated: true, completion: nil)
  }

}

备忘录模式(The Memento Pattern)

顾名思义,备忘录模式用来保存当前程序退出时,当前上下文的文档的数据;
在不破坏封装的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,这样以后就可以将该对象恢复到原先的保存状态。
当满足以下两个条件时需要考虑使用这一模式:

  1. 需要保存一个对象在某一时刻的状态,这样以后就可以恢复到先前的状态;
  2. 用于获取状态的接口会暴露细节,需要将其隐藏起来。

在本例中,我在cell的点击事件添加了删除绘本事件,用来删除被点击的书籍,具体代码如下:

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    let alertVc = UIAlertController(title: "要删除这本绘本么", message: "", preferredStyle: .alert)
    let sureAction = UIAlertAction(title: "确定删除", style: .default, handler: { (_) in
      BookAPI.sharedInstance.deleteBookAt(index: indexPath.item) // 发送删除绘本的请求
      self.books = BookAPI.sharedInstance.getBooks() // 更新数据源
      self.bookView?.deleteItems(at: [indexPath]) 
      BookAPI.sharedInstance.saveBooks() // 保存最新数据,到本地,下次加载app,删除绘本不再显示
    })
    alertVc.addAction(sureAction)
    let cancelAction = UIAlertAction(title: "取消", style: .default, handler: nil)
    alertVc.addAction(cancelAction)
    present(alertVc, animated: true, completion: nil)
  }

内部的实现,封装在PersistencyManager对象中,真个思路就是,第一次启动加载budle中的资源(实际项目中需要加在服务器的数据),然后保存到沙河中,但数据有更新变动时也实时保存数据,整个代码如下:

let LibraryCacheDirectory = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first!

final class PersistencyManager: NSObject {
  
  var books = [Book]()
  
  override init() {
    super.init()
    
    let fileName = "\(LibraryCacheDirectory)/albums.bin"
    let fileurl = URL(fileURLWithPath: fileName)
    let bookdata = try? Data(contentsOf: fileurl)
    
    guard let data = bookdata else { // 本地沙河没有数据,所以需要加在bundle中的数据
      setupBundleData()
      return
    }
    let json = try! JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [Any]
    for b in json! {
      let book = Book(fromDictionary: b as! [String : Any])
      books.append(book)
    }
  }

  private func setupBundleData() {
    let path = Bundle.main.path(forResource: "books", ofType: "json")
    let url = URL(fileURLWithPath: path!)
    
    do {
      let data = try Data(contentsOf: url)
      let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [Any]
      print(data)
      //      print(json)
      for b in json! {
        //        print(b)
        let book = Book(fromDictionary: b as! [String : Any])
        books.append(book)
      }
      print(books)
      saveBooks()
    } catch {
      print(error)
    }

  }
  
  func getBooks() -> [Book]{
    return books
  }
  
  func saveBooks() {
    let fileName = "\(LibraryCacheDirectory)/albums.bin"
    let url = URL(fileURLWithPath: fileName)
    let bookDicts = books.map { (book) -> [String:Any] in
      book.toDictionary()
    }
    let data = try? JSONSerialization.data(withJSONObject: bookDicts, options: .prettyPrinted)
    try? data?.write(to: url)
  }
  
  func deleteBookAt(index: Int) {
    if index >= books.count {
      print("数组越界,没有此绘本书")
      return
    }
    books.remove(at: index)
  }
}

感兴趣的可以在github上下载完整代码大家相互交流,整个项目只是我从实际项目中摘取出来的一个页面来讲解,而整个iOS设计模式的内容肯定址这么点内容,后续有时间我应该会更新代码讲解其他的设计模式。
我的参考内容有:

  1. 图灵程序设计丛书Objective-C编程之道 iOS设计模式解析
  2. raywenderlich官网文章iOS Design Patterns

如果你觉得本文对你有帮助,就请你点亮底部的❤️吧,你的鼓励是我前进的动力!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容