Checklists app demo 详解

10 篇文章 0 订阅
本教程包含内容:
  1. Model-View-Controller 工作原理
  2. 大标题(large titles)展示
  3. Segue 类型介绍
  4. 代理(delegate)模式讲解
  5. 可选类型 Optionals 讲解
  6. Weak 弱引用讲解
  7. 沙盒机制讲解
  8. Codable 协议
  9. Plist files 序列化讲解
  10. UserDefaults 讲解
  11. Functional Programming 讲解
  12. 本地通知 (local notifications) 讲解
  13. 类方法 vs 实例方法讲解
  14. 本教程 demo 下载地址
本 demo 最终效果:

checklists app 流程:

最终 storyboard 是这样的:

Navigation controlers

navigation controller 可能是 iOS 第二常用的 UI 组件,另一个更常用的组件是table view:

Navigation controllers

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mocCbZ7D-1619442531719)(https://upload-images.jianshu.io/upload_images/130752-62f6adf827cc0638.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]

Model-View-Controller 工作原理

table view data source

How Model-View-Controller works

gets the data from the model

大标题(large titles)展示

我们可以对导航栏标题(大标题)进行设置。大标题默认情况下未启用,但我们可以通过在 storyboard 中简单设置或写一行代码轻松地启用大标题。

比如在 super.viewDidLoad() 下面一行写下如下代码:

navigationController?.navigationBar.prefersLargeTitles = true

如果我们对大标题效果不满意,可以随时关闭大标题,但注意,当 tableview 有大量的 item 时,需要滚动以查看更多信息,大标题将缩回顶部导航栏,并提供“经典”外观的导航栏。 因此,在决定禁用它之前,不妨尝试一下。

注:Apple 建议不要在所有屏幕上都使用大标题。他们建议,在主屏幕(main screen)以及可能需要醒目标题的时候使用大标题。

Add 按钮
@IBAction func addItem() {
  let newRowIndex = items.count

  let item = ChecklistItem()
  item.text = "I am a new row"
  items.append(item)

  let indexPath = IndexPath(row: newRowIndex, section: 0)
  let indexPaths = [indexPath]
  tableView.insertRows(at: indexPaths, with: .automatic)
}

table views 使用 index-paths 标识行。 因此,我们首先用 newRowIndex 变量中的行号创建一个指向新行的 IndexPath 对象。 现在,该索引路径对象指向第 5 行(section 0 中)。

简单回顾一下:

  1. 创建一个新的 ChecklistItem 对象。
  2. 将其添加到数据模型。
  3. 在表格视图中为其插入新行。
滑动删除行
override func tableView(
  _ tableView: UITableView, 
  commit editingStyle: UITableViewCell.EditingStyle, 
  forRowAt indexPath: IndexPath
) {
  // 1
  items.remove(at: indexPath.row)

  // 2  
  let indexPaths = [indexPath]
  tableView.deleteRows(at: indexPaths, with: .automatic)
}

当我们调用 items.remove(at:) 方法时,这不仅将 ChecklistItem 从数组中取出,而且将其永久销毁。

只要 ChecklistItem 对象位于数组内,该数组就可以对其进行引用。当我们从数组中取出 ChecklistItem 时,引用将消失并且对象将被销毁。

销毁对象意味着什么?

每个对象都占据计算机内存的一小部分。创建对象实例时,计算机将用一块内存来保存对象的数据。如果对象被释放,则该内存将再次变得可用,并最终将被新对象占用。

删除对象后,该对象不再存在于内存中,我们将无法再使用它。

在旧版本的 iOS 上,我们必须手动处理内存管理。幸运的是,现在 Swift 使用一种称为自动引用计数(ARC)的机制来管理应用程序中对象的生存期,从而我们不必再担心它。

Segue 类型

segue 类型简要说明:

** Show**:将新的视图控制器推到导航堆栈上,以便新的视图控制器位于导航堆栈的顶部。它还提供了一个后退按钮以返回到先前的视图控制器。如果视图控制器未嵌入在导航控制器中,则将以模态视图方式显示新的视图控制器。

示例:在 “Mail app” 中浏览文件夹。

Show Detail:用于拆分视图控制器。当处于扩展的两列界面中时,新的视图控制器将替换拆分视图的详细信息视图控制器。否则,如果在单列模式下,它将推入导航控制器。

示例:在“Messages app”中,点击对话将显示对话详细信息-在两列布局中替换右侧的视图控制器,或在单列布局中推送会话。

Present Modally:提供新的视图控制器以覆盖以前的视图控制器-最常用于呈现可覆盖 iPhone 或 iPad 上整个屏幕的视图控制器的情况,通常将其显示为居中的框,以使屏幕变暗展示视图控制器。通常,如果 app 顶部有一个导航栏或在底部有一个标签栏,那么模态视图控制器也将覆盖这些导航栏。

示例:在“设置 app” 中选择 Touch ID 和 密码。

Present as Popover:在 iPad 上运行时,新的视图控制器将出现在Popover中,轻按此 Popover 之外的任何位置将其关闭。在 iPhone上,将以全屏模式呈现新的视图控制器。

示例:点击“日历 app” 中的+按钮

** Custom**:允许我们自定义 segue 并控制其行为。

view controllers 容器

Table View Controller 位于 Navigation Controller 内部。

Navigation Controller 是一种特殊类型的视图控制器,它充当其它视图控制器的容器。它带有导航栏,通过将它们滑动到视线之外,可以轻松地从一个屏幕转到另一个屏幕。

Navigation Controller 只是包含执行实际工作的视图控制器的框架,这些视图控制器被称为“内容”控制器。

比如,这里 ChecklistViewController 提供了第一屏内容。 第二屏的内容来自AddItemViewController。

Static Cells

static cells

three static cells

当我们事先知道表格视图将包含多少节和行时,便可以使用静态单元格(static cells)。

使用静态单元格,可以直接在 storyboard 中设计 rows。 对于具有静态单元格的 table,我们无需提供数据源,并且可以在 outlets 中设置标签等各种属性。

禁止 cell 可选
// MARK: - Table View Delegates
override func tableView(
  _ tableView: UITableView, 
  willSelectRowAt indexPath: IndexPath
) -> IndexPath? {
  return nil
}

Table view cells 具有选择颜色属性。即使我们禁止行可编辑,有时 UIKit 仍然会在我们点击它时短暂地将其绘制为灰色。 因此,最好也禁用此选择颜色。

➤在 storyboard 中,选择 Table view cells ,然后转到“属性”检查器,将选择属性设置为无。

现在,运行该应用程序,则无法选择该行并将其变为灰色。

Return 方法

IndexPath 后面的问号是什么?

它告诉 Swift 编译器,此方法允许返回 nil。注意,仅当返回类型后面有问号(或感叹号)时,才允许从方法返回 nil。 后面带有问号的类型声明称为可选。

Screen A knows all about screen B

Screen A launches  screen B and becomes its delegate

delegate

class AddItemViewController: UITableViewController, . . . {

  // This variable refers to the other view controller
  var checklistViewController: ChecklistViewController

  @IBAction func done() {
    // Create the new checklist item object
    let item = ChecklistItem()
    item.text = textField.text!

    // Directly call a method from ChecklistViewController
    checklistViewController.add(item)
  }
}

在这种情况下,AddItemViewController 有一个引用 ChecklistViewController 的变量,并且 done() 方法内使用新的 ChecklistItem 对象调用其 add() 方法。

这种方式当然可以工作,但它不是 iOS 中好的实现方式。这种方法的最大缺点是将这两个视图控制器对象捆绑在了一起。

通常,如果屏幕 A 启动了屏幕 B,则我们不希望屏幕 B 对调用它的屏幕 A 了解太多。B 对 A 的了解越少越好。

这时,我们就可以使用代理 (delegate) 了。

delegate 模式

委托模式(delegate pattern)最酷的事情是,屏幕 B 并不真正了解屏幕 A。它只知道某个对象是其委托,但并不真正在乎委托是谁。就像 UITableView 并不真正在乎我们的视图控制器一样,它只是在 table view 需要它们时才提供 table view cells 。

在 AddItemViewController.swift 文件的头部添加如下代码:

protocol AddItemViewControllerDelegate: class {
  func addItemViewControllerDidCancel(
    _ controller: AddItemViewController)
  func addItemViewController(
    _ controller: AddItemViewController, 
    didFinishAdding item: ChecklistItem
  )
}

与我们之前看到的方法不同,这些方法中没有实现代码。该协议仅列出方法的名称。

这与协议完全相同,我们可以让一个协议继承于另一个协议,也可以指定可以遵守我们的协议的特定对象类型。

class 关键字标识我们希望 AddItemViewControllerDelegate 协议限于 class 类型。

Protocols

协议通常不会实现其声明的任何方法。它只是说:遵守此协议的任何对象都必须实现方法 X,Y 和 Z。在某些特殊情况下,我们还可能希望为该协议提供默认实现。

调用方法:

@IBAction func cancel() {
  delegate?.addItemViewControllerDidCancel(self)
}

@IBAction func done() {
  let item = ChecklistItem()
  item.text = textField.text!

  delegate?.addItemViewController(self, didFinishAdding: item)
}
可选类型 Optionals

Swift 中的变量和常量必须始终有一个 value。在其他编程语言中,特殊符号 nil 或 NULL 通常用于指示变量没有值。而在Swift 中,普通变量是不允许为空的。

nil 和 NULL 的问题在于它们是导致应用崩溃的常见原因。如果某个 app 在不希望使用 nil 变量时尝试使用 nil 变量,则该 app 将 crash。这是可怕的“空指针取消引用”(null pointer dereference)错误。

Swift 通过阻止我们将 nil 与常规变量一起使用来阻止这种情况。

但是,有时变量确实需要 “no value”。这种情况下,我们可以将其设为可选。我们使用问号(?) 或感叹号(!)在 Swift中将某些内容标记为可选内容。

只有设为可选的变量的值才能为 nil。

delegate?.addItemViewControllerDidCancel(self)

这里 ? 告诉 Swift 如果代理为 nil 则不发送消息。我们可以将其读为:“有 delegate 吗? 然后发送消息。” 这种做法称为“可选链接”(optional chaining ),在 Swift 中有着广泛使用。

可选选项在其他编程语言中并不常见,因此我们可能需要慢慢习惯。我们可以发现可选选项确实使程序更清晰 – 大多数变量都不必为 nil,因此最好防止它们变为nil,避免这些潜在的 bug 来源。

Weak 弱引用

strong

weak

使用弱引用可以避免循环引用(ownership cycle)。

当对象 A 强引用对象 B,同时对象 B 也强引用 A 时,则这两个对象造成循环引用:ownership cycle。

通常,当一个对象不再有其它对象的引用时,该对象将被销毁或释放。但是,由于 A 和 B 相互之间有强关联性,所以它们使彼此保持存活。结果是造成潜在的内存泄漏。

在这种情况下,应该销毁的对象不会被销毁,并且其数据的内存也永远不会被回收。

如果有足够多的此类泄漏,iOS 将耗尽可用内存,从而导致我们的 app crash。这很危险!

delegates 应该总是使用软引用才对。
(还有另一种类型 unowned,它与 weak类似,也可以用于委托。区别在于,weak变量可以再次变为 nil)

@IBOutlets 通常也用 weak 关键字声明。这样做不是为了避免循环引用,而是要明确说明 view controller 实际上并不是views 的所有者。

编辑 Items :新增 checkmark

The Emoji & Symbols palette

在 ChecklistViewController.swift 中,修改 configureCheckmark(for:with:) 方法:

func configureCheckmark(
  for cell: UITableViewCell, 
  with item: ChecklistItem
) {
  let label = cell.viewWithTag(1001) as! UILabel

  if item.checked {
    label.text = "√"
  } else {
    label.text = ""
  }
}    

在 AddItemViewController.swift 中:

override func viewDidLoad() {
  . . .
  if let item = itemToEdit {
    title = "Edit Item"
    textField.text = item.text
  }
}

为了使用它,我们首先需要 unwrap the optional。可以使用以下特殊语法进行操作:

if let temporaryConstant = optionalVariable {
  // temporaryConstant now contains the unwrapped value of the 
  // optional variable. temporayConstant is only available from
  // within this if block
}
if let itemToEdit = itemToEdit {
  title = "Edit Item"
  textField.text = itemToEdit.text
}

看起来有点奇怪是不是?

为什么我们又将 itemToEdit 中的值重新分配给itemToEdit?

我们这样编写代码,为什么编译器现在不会警告 optional unwrapping ?

上面的做法称为 variable shadowing - 仅在 if 条件下创建 itemToEdit 变量的“shadow”实例,并且该“shadow”实例是原始可选 itemToEdit 变量的 unwrapped 实例。

因此,在将 text 分配给 text field 时,引用 itemToEdit,实际上是在引用变量的未包装实例(unwrapped instance),而不是原始的可选实例(original optional instance)。

如果你不熟悉 Swift 和 Optionals,这可能会使您感到困惑。因此,是否使用 variable shadowing 完全取决于您。

我更喜欢 shadowing,因为那样代码就始终清楚所引用的变量,因为 optional 和 unwrapped 都使用相同的变量名称。

view controllers 之间传递参数

视图控制器之间的数据传输有两种方式:

  1. 从 A 到 B。当屏幕 A 打开屏幕 B 时,A 可以向 B提供所需的数据。我们只需在 B 的视图控制器(view controller)中创建一个新的实例变量,然后,屏幕 A 通常在prepare(for:sender:) ,在屏幕 B 可见之前将一个对象放到 B 的属性中。

  2. 从 B 到 A,使用 delegate 将 B 数据传回 A。

在 delegate protocol 中处理编辑事件

AddItemViewController.swift 中,现在 protocol 方法是这样:

protocol AddItemViewControllerDelegate: class {
  func addItemViewControllerDidCancel(
    _ controller: AddItemViewController)
  func addItemViewController(
    _ controller: AddItemViewController,
    didFinishAdding item: ChecklistItem
  )
  func addItemViewController(
    _ controller: AddItemViewController,
    didFinishEditing item: ChecklistItem
  )
}

当用户点击“取消”时,一方法被调用;当用户点击“完成”时,有两个方法被调用。

添加新项目后,调用 didFinishAdding,但是在编辑现有项目时,现在应改为调用新的didFinishEditing 方法。

通过使用不同的方法,委托(ChecklistViewController)可以区分这两种情况。

在 AddItemViewController.swift中,将 done() 方法更改为:

@IBAction func done() {
  if let item = itemToEdit {
    item.text = textField.text!
    delegate?.addItemViewController(
      self, 
      didFinishEditing: item)
  } else {
    let item = ChecklistItem()
    item.text = textField.text!
    delegate?.addItemViewController(self, didFinishAdding: item)
  }
}
Iterative development 迭代开发

不要试图一开始就可以把软件设计的尽善尽美,这是不可能的。刚开始的时候,有比完美重要。

迭代开发,没有设计可以一开始就完美覆盖所有问题,遇到问题,再回头进行优化,一次次迭代,让软件越来越好。你看,市场上所有软件都有版本更新,这就是迭代更新了。

我们要用迭代开发的方法让我们的 app 越来越好。

Saving & Loading

由于 iOS 的多任务处理特性,当我们关闭某个 app 并返回到主屏幕或切换到另一个 app 时,该 app 会存留在内存中。该 app 进入暂停状态,在此状态下,它完全不执行任何操作,但该 app 的内存中的数据还在。

在正常使用期间,用户永远不会真正终止应用程序,而只是将其挂起。但是,当 iOS 的可用内存用尽时,该 app 将被终止,因为 iOS 会终止所有已暂停的应用,以便在必要时释放内存。

documents 文件夹

persist data

iOS app 使用沙盒机制。每个应用程序都有自己的文件夹来存储文件,但无法访问其他 app 的目录或文件。

这是一种安全措施,旨在防止恶意软件(如病毒)造成损害。

我们的 app 可以将文件存储在应用程序沙盒的“Documents” 文件夹中。

当用户将其设备与 iTunes 或 iCloud 同步时,将备份 Documents 文件夹中的内容。

当我们发布 app 的新版本并且用户安装更新时,Documents文件夹将保持不变。app 更新后,该 app 之前保存到此文件夹中的所有数据仍然存在。

也就是说,Documents 文件夹是存储用户数据的理想场所。

获取文件保存路径

在 ChecklistViewController.swift 文件添加如下代码:

func documentsDirectory() -> URL {
  let paths = FileManager.default.urls(
    for: .documentDirectory, 
    in: .userDomainMask)
  return paths[0]
}

func dataFilePath() -> URL {
  return documentsDirectory().appendingPathComponent("Checklists.plist")
}

documentsDirectory() 方法返回 Documents 文件夹完整路径。

dataFilePath() 方法调用 documentsDirectory() 获取 documentDirectory 目录,并在该目录下拼接文件路径 /documentDirectory/Checklists.plist。

网站使用 http:// 或 https:// 表示 URLs。iOS 则使用 file:// URL 这样的 URL 来引用其文件系统中的文件。

在 ChecklistViewController.swift 的 viewDidLoad 的底部添加如下两个打印语句:

override func viewDidLoad() {
  . . . 
  items.append(item5)
  // Add the following
  print("Documents folder is \(documentsDirectory())")
  print("Data file path is \(dataFilePath())")
}

运行 app 。Xcode 的控制台现在将打印出 app 的 Documents 文件夹的所在路径。
如果我从模拟器运行该应用程序,则在我的系统上它将显示以下内容:

如果在 iPhone 真机上运行 app ,则路径看起来会有所不同:

Documents folder is file:///var/mobile/Applications/FDD50B54-9383-4DCC-9C19-C3DEBC1A96FE/Documents

Data file path is file:///var/mobile/Applications/FDD50B54-9383-4DCC-9C19-C3DEBC1A96FE/Documents/Checklists.plist

我们注意到,文件夹名是一个随机的 32 个字符的ID。Xcode 在将 app 安装在 Simulator 或设备上时会记住此ID。

Browse the documents folder

Documents folder and data file locations

我们在模拟器运行 app,在 mac Finder 我们可以轻松访问到 Documents folder:

通过在桌面上单击并键入⌘+ N,打开一个新的Finder 窗口。或者通过单击 Finder 图标,然后按⌘+ Shift + G-或从菜单中选择“前往文件夹…”,从Xcode Console 复制 Documents 文件夹路径,然后将完整路径粘贴到对话框中的Documents文件夹。 (不包括 file:// 位。路径以/Users//… 开头)

效果如下:

The app’s directory structure in the Simulator.png

我们还可以查看真机设备上 app “ Documents” 文件夹的概述(overview):

在真机上,转到“设置”,然后单击“iPhone存储空间”,点击我们 app 名称,就可以看到 Documents 文件夹中内容的大小,但看不到它的存储内容:

4 Documents folder info on the device.png

保存 checklist items

添加新项目或编辑现有项目时,将待办事项列表保存到名为 Checklists.plist 的文件中。

Plist files 序列化

Info.plist 包含几个配置选项,这些选项可为 iOS 提供有关该 app 的一些信息,例如在主屏幕上该 app 图标下显示的名称。

“ plist”代表“属性列表”(Property Lis),它是一种 XML 文件格式,用于存储结构化数据,通常用来配置“设置”所需的信息。属性列表文件在 iOS 中非常常见,因为它们易于使用,它们适用于多种类型的数据存储。

要保存 checklist items,我们需要使用 Swift 的 Codable协议,该协议允许支持 Codable 协议的对象以结构化文件格式存储在磁盘。

在 Objective-C 中,Xcode 使用 NSCoder 将对象写入文件,即编码,当 app 启动时,它再次使用 NSCoder 从 storyboard 文件中读取对象,即解码。

可编码协议的工作原理与此类似。

将对象转换为文件然后再次转换的过程也称为序列化。

The process of freezing (saving) and unfreezing (loading) objects

保存数据到文件

ChecklistViewController.swift:

func saveChecklistItems() {

  let encoder = PropertyListEncoder()

  do {
    let data = try encoder.encode(items)
    try data.write(
      to: dataFilePath(), 
      options: Data.WritingOptions.atomic)
  } catch {
     print("Error encoding item array: \(error.localizedDescription)")
  }
}
Codable 协议

如果要序列化数组,数组中包含的对象也应支持 Codable。这就是为什么我们需要 ChecklistItem 类兼容 Codable。

在 ChecklistItem.swift ,修改类:

class ChecklistItem: NSObject, Codable {

我们告诉编译器(compiler),ChecklistItem 遵守 Codable protocol 。

校验已保存文件

运行 app ,deleting/adding an item ,在 Finder 路径可看到保存的文件:

Checklists.plist file

现在在 Documents 文件夹中有一个 Checklists.plist 文件。
我们可以查看此文件的内容,但是这样阅读内容并没有多大意义。即使是 XML,我们也不希望人直接阅读文件,更好的是由 PropertyListDecoder 读取。

最好对框架中可用的对象有一个大致的了解,但没人能记住所有的细节。因此,养成在文档中查找遇到的任何新对象和方法的习惯。它可以帮助您更快地学习 iOS 框架。

加载文件

从文件读取数据。

打开 ChecklistViewController.swift ,添加如下代码:

func loadChecklistItems() {
  let path = dataFilePath()
  if let data = try? Data(contentsOf: path) {
    let decoder = PropertyListDecoder()
    do {
      items = try decoder.decode(
        [ChecklistItem].self, 
        from: data)
    } catch {
      print("Error decoding item array: \(error.localizedDescription)")
    }
  }
}
The All Lists view controller

The steps involved in performing a segue

AllListsViewController.swift:

// MARK: - List Detail View Controller Delegates
func listDetailViewControllerDidCancel(
  _ controller: ListDetailViewController
) {
  navigationController?.popViewController(animated: true)
}

func listDetailViewController(
  _ controller: ListDetailViewController, 
  didFinishAdding checklist: Checklist
) {
  let newRowIndex = lists.count
  lists.append(checklist)

  let indexPath = IndexPath(row: newRowIndex, section: 0)
  let indexPaths = [indexPath]
  tableView.insertRows(at: indexPaths, with: .automatic)

  navigationController?.popViewController(animated: true)
}

func listDetailViewController(
  _ controller: ListDetailViewController, 
  didFinishEditing checklist: Checklist
) {
  if let index = lists.firstIndex(of: checklist) {
    let indexPath = IndexPath(row: index, section: 0)
    if let cell = tableView.cellForRow(at: indexPath) {
      cell.textLabel!.text = checklist.name
    }
  }
  navigationController?.popViewController(animated: true)
}

当用户点击 Cancel 或者 Done,这些方法被执行。

删除事件:

override func tableView(
  _ tableView: UITableView,
  commit editingStyle: UITableViewCell.EditingStyle,
  forRowAt indexPath: IndexPath
) {
  lists.remove(at: indexPath.row)

  let indexPaths = [indexPath]
  tableView.deleteRows(at: indexPaths, with: .automatic)
}
改善 Data Model

新的 data model是这样的:

Each Checklist object has an array of ChecklistItem objects

var items: [ChecklistItem] = [ChecklistItem]()

这种声明变量的方式不好,因为它违反了“ DRY”原则( Don’t Repeat Yourself)。多亏了 Swift 的类型推断,我们可以节省一些代码。

我们可以这样声明变量:

var items: [ChecklistItem] = []
新的 load/save 代码
// MARK: - Data Saving
func documentsDirectory() -> URL {
  let paths = FileManager.default.urls(
    for: .documentDirectory, 
    in: .userDomainMask)
  return paths[0]
}

func dataFilePath() -> URL {
  return documentsDirectory().appendingPathComponent("Checklists.plist")
}

// this method is now called saveChecklists()
func saveChecklists() {
  let encoder = PropertyListEncoder()
  do {
    // You encode lists instead of "items"
    let data = try encoder.encode(lists)
    try data.write(
      to: dataFilePath(), 
      options: Data.WritingOptions.atomic)
  } catch {
    print("Error encoding list array: \(error.localizedDescription)")
  }
}

// this method is now called loadChecklists()
func loadChecklists() {
  let path = dataFilePath()
  if let data = try? Data(contentsOf: path) {
    let decoder = PropertyListDecoder()
    do {
      // You decode to an object of [Checklist] type to lists
      lists = try decoder.decode(
        [Checklist].self, 
        from: data)
    } catch {
      print("Error decoding list array: \(error.localizedDescription)")
    }
  }
}

除了加载并保存的是 lists array 而不是 items array,其它的与之前 ChecklistViewController 中的内容几乎相同。这里注意,解码类型现在是 [Checklist].self,而不是[ChecklistItem].self。另外,方法的名称以及 catch 代码块儿中的错误消息与之前也略有不同。

app 终止时保存数据

每个 iOS 应用程序可以具有一个或多个场景(scenes)。场景委托(scene delegate)是与 app 中场景转换有关的通知的委托对象。

这是收到 “scene will terminate” 和 “scene will be suspended” 通知的地方。

func sceneDidDisconnect(_ scene: UIScene)

func sceneDidEnterBackground(_ scene: UIScene)

在 SceneDelegate.swift 中添加如下方法:

// MARK: - Helper Methods
func saveData() {
  let navigationController = window!.rootViewController as! UINavigationController
  let controller = navigationController.viewControllers[0] as! AllListsViewController
  controller.saveChecklists()
}

UIWindow 是 app 中所有视图的顶级容器。iOS app 中每个场景只有一个 UIWindow 对象。在 iOS 上,如果要有多个窗口,则需要创建其他场景。但这里我们不过多讨论这个。

对于使用 storyboard 的 app(其中很多都使用情节提要),即使 window 是 optional 类型,我们也可以确保 window 永远不会为 nil。 UIKit 承诺在应用启动时会在 window 变量内放置 app’s UIWindow 的有效引用。

那么为什么它是 optional 呢?

因为在启动 app 和加载 storyboard 之间的短暂时间里,window 属性尚未具有有效值。无论时间多么短暂,如果变量可以为nil,那么 Swift 都将其作为可选(optional)变量。

如果可以确定在使用可选(optional)变量时其非nil,则我们可以通过添加感叹号来强制将其拆包(unwrap):

let navigationController = window!.rootViewController

强制拆包(Force unwrapping)是处理 optionals 变量的最简单方法,但是它有一些危险:如果我们不小心将可选选项设置为 nil 了,app 将 crash。所以请谨慎使用!

更改 SceneDelegate.swift 中的sceneDidEnterBackground(_ 😃 和 sceneDidDisconnect(_ 😃 方法以调用 saveData():

func sceneDidDisconnect(_ scene: UIScene) {
  saveData()
}

func sceneDidEnterBackground(_ scene: UIScene) {
  saveData()
}

按模拟器的主屏幕按钮,或按Shift +⌘+ H,或从模拟器的菜单栏中选择“设备主屏幕”,以使 app 进入后台。 这模拟了用户在 iPhone上单击 “Home” 键时发生的情况。

在 Finder 的“Documents”目录下查看,现在那里有一个新的 Checklists.plist 文件。

在 Xcode 中按 “Stop” 以终止该 app 。再次运行该 app,我们的数据依然存在。 Awesome!

创建 DataModel 对象

DataModel 实例赋值的最好的地方是在
scene(_:willConnectTo:connectionOptions:) 方法。app 刚一启动该方法即被执行。

func scene(
  _ scene: UIScene, 
  willConnectTo session: UISceneSession, 
  options connectionOptions: UIScene.ConnectionOptions
) {
  let navigationController = window!.rootViewController as! UINavigationController
  let controller = navigationController.viewControllers[0] as! AllListsViewController
  controller.dataModel = dataModel
}
使用 UserDefaults

UserDefaults 是一种 iOS 机制,可用来存储与 app 相关的少量信息,例如用户首选项,应用程序状态和配置选项等。它是用于存储键值对的集合对象。

key-value pairs

UserDefaults 无法处理可选(optionals)变量。

==,用来检查两个变量是否具有相同的值。
===,用来检查两个变量是否指向完全相同的对象。

想象两个都叫 Joe 的人。
如果我们使用 joe1 === joe2 进行比较,那么结果将为 false,因为他们不是同一个人。但是 joe1.name == joe2.name 是正确的。

Xcode在“导航器”窗口的底部提供了一个过滤器,可用于过滤当前列表中的文件。例如,我们可以键入“Controller”,它将仅显示文件名中带有“Controller”的文件:

Filter file list by name

为了更清晰直观,我们可以将 view controllers 一起放入一个名为“view controllers”的文件夹中,将 Data Models 放入一个“Data Models”文件夹中,如图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g16HVOo0-1619442531761)(https://upload-images.jianshu.io/upload_images/130752-79724ed010ded5a6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]

如果要移动文件和文件夹,请注意,如果将 Info.plist 文件移动到文件夹中,则下次尝试编译项目时会报错。这是因为 Info.plist 文件包含项目的一些配置信息,并且 Xcode 希望它位于根文件夹中。如果确实需要移动Info.plist 文件,则可以在“ Build Settings”下为该文件指定新位置。

Functional Programming

好的面向对象原则是让自定义对象控件做尽可能多的事情。比如在自定义 table view cell 内进行逻辑处理,而不是在 cell 所在 view controller 内进行逻辑处理。

Swift 主要是一种面向对象的语言,但是,近年来有另一种非常流行的编码样式:函数式编程。

“functional” 是指可以纯粹以转换数据的数学函数(mathematical functions)来处理程序。

与 Swift 中的方法和函数不同,这些数学函数不允许具有“副作用”,即对于任何给定的输入,一个函数应始终产生相同的输出。

虽然 Swift 不是纯粹的函数式语言,但它允许在应用程序中使用某些函数式编程技术。函数式编程技术可以使代码显得更简洁。

像如下方法

func countUncheckedItems() -> Int {
  var count = 0
  for item in items where !item.checked {
    count += 1
  }
  return count
}

也可以这样写:

func countUncheckedItems() -> Int {
  return items.reduce(0) { 
    cnt,item in cnt + (item.checked ? 0 : 1) 
  }
}

reduce() 是一种方法,它查看数组中的每个项目并执行{}块中的代码。初始状态,cnt 变量值为 0,但是在每次代码执行之后,它将被增加 0 或 1,具体取决于item.checked 的值。

当 reduce() 结束时,返回未检查项目的总数。

排序算法:根据 name 排序

实现方法如下:

func sortChecklists() {
  lists.sort { list1, list2 in
    return list1.name.localizedStandardCompare(list2.name) == .orderedAscending
  }
}
本地通知 Local Notifications

本地通知允许 app 向用户发送推送提醒,即使该 app 未运行,该提醒也可以被显示。

本地通知(local notifications )与推送通知(也称为远程通知)不同。推送通知使 app 可以接收通知消息,例如您喜欢的商品降价了,等等…

而本地通知更类似于闹钟。它完全在我们的设备上工作,而不需要走远程服务器。

获取显示本地通知的权限

app 仅在请求用户许可后才允许显示本地通知。如果用户拒绝许可,那么我们的 app 的任何本地通知都不会出现。

申请权限,只需要请求一次权限申请即可。

打开 AppDelegate.swift 并在文件顶部添加一个新的导入:

import UserNotifications

这告诉编译器我们将使用 User Notifications 框架。

在方法 return true 之前,将以下内容添加到application(_:didFinishLaunchingWithOptions:):

// Notification authorization
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) {granted, error in
  if granted {
    print("We have permission")
  } else {
    print("Permission denied")
  }
}

应用程序启动时,将调用 app 委托方法application(_:didFinishLaunchingWithOptions:) 。这是 app 的入口,是代码在 app 启动后可以执行某些操作的第一个位置。

由于我们现在只是在处理本地通知,因此这是一个申请权限的好地方。

运行 app,我们立即得到一个请求权限的弹出窗口:

The permission dialog

测试本地通知示例

在 didFinishLaunchingWithOptions 方法中添加如下代码:

let content = UNMutableNotificationContent()
content.title = "Hello!"
content.body = "I am a local notification"
content.sound = UNNotificationSound.default

let trigger = UNTimeIntervalNotificationTrigger(
  timeInterval: 10, 
  repeats: false)
let request = UNNotificationRequest(
  identifier: "MyNotification", 
  content: content, 
  trigger: trigger)
center.add(request)

这将创建一个新的本地通知。因为我们设置 timeInterval: 10,因此它将在应用启动10秒后触发。

UNMutableNotificationContent 可以设置本地通知内容。这里,我们设置一条警告消息,在触发本地通知时显示。通知将包含声音。

最后,我们将通知添加到 UNUserNotificationCenter。该对象负责跟踪所有本地通知并在计划的时间触发它们。

运行app,启动后立即退出主屏幕。等待 10 秒钟,app 将弹出一条消息:

local notification message

点击屏幕上的通知,它将带我们启动 app。

为什么要退出主屏幕呢?

如果 app 当前处于未激活状态,iOS 只会显示notification alert。

终止 app,然后再次运行。这次不要按 Home 键,只需等待即可。

不要等待太久哦!因为不会发生任何事情。

本地通知确实会被触发,但不会显示给用户。为了处理这种情况,我们必须以某种方式收听通知。

如何收听?当然,通过 delegate!

处理本地通知事件

在 AppDelegate 类中添加通知代理:

class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {

在 AppDelegate.swift 添加如下方法:

// MARK: - User Notification Delegates
func userNotificationCenter(
  _ center: UNUserNotificationCenter, 
  willPresent notification: UNNotification, 
  withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
  print("Received local notification \(notification)")
}

当发布本地通知且应用仍在运行时,将调用此方法。这里打印日志,测试是否执行此方法。

当 app 处于活动状态(active)且处于前台时,它应该可以处理接受到的通知。根据 app 情况,可以对通知做出不同反应,例如向用户显示消息或刷新屏幕。

最后,告诉 UNUserNotificationCenter,AppDelegate 现在是它的委托。我们可以在 application(_:didFinishLaunchingWithOptions:) 中进行此操作:

center.delegate = self

运行 app ,不要按 Home 键退入后台,10秒后,我们在 Xcode 控制台(Console)将看到日志打印:

Received local notification <UNNotification: 0x600001336790; source: com.razeware.Checklists date: 2020-08-09 19:29:39 +0000, request: <UNNotificationRequest: ... 
identifier: MyNotification, content: <UNNotificationContent: ... 
title: <redacted>, subtitle: (null), body: <redacted>, 
. . .

注:由于 iOS 中的隐私限制,有些值显示为,该限制会阻止 app 捕获/记录敏感信息(例如通知消息的内容)。

userNotificationCenter(_:willPresent:withCompletionHandler:) 方法是调试本地通知的好地方。

将待办事项与通知相关联

我们需要以某种方式将 ChecklistItem 对象与其本地通知相关联。这需要我们对数据模型进行一些修改。

安排本地通知时,将创建一个 UNNotificationRequest 对象。将 UNNotificationRequest 对象作为实例变量放在 ChecklistItem 中是可以的,但这不是最好的方法。

好方法是使用标识符(identifier)。创建本地通知时,需要为其指定一个标识符,该字符串中是什么并不重要,只要能唯一标识通知就行。

如果要取消通知,不要使用 UNNotificationRequest 对象,而应使用赋予它的标识符。不错的方法是将此标识符存储在 ChecklistItem 对象中。

在创建数据模型时,将数字ID分配给对象是一种常见的方法,这与为关系数据库中的记录提供数字主键非常相似。

将这些属性添加到 ChecklistItem.swift:

var dueDate = Date()
var shouldRemind = false
var itemID = -1

请注意,这里设置变量名为 itemID,而不是 id。因为 id 是Objective-C 中的特殊关键字,如果我们需要将 Swift 代码与 Objective-C 代码混合使用,使用 id 做变量可能会引起麻烦。

上面的代码中,我们使用 -1 初始化了 itemID。但是在创建新的ChecklistItem 实例时需要将 itemID 设置为唯一的整数值。

向 DataModel 添加新方法以生成唯一的 itemID :

class func nextChecklistItemID() -> Int {
  let userDefaults = UserDefaults.standard
  let itemID = userDefaults.integer(forKey: "ChecklistItemID")
  userDefaults.set(itemID + 1, forKey: "ChecklistItemID")
  return itemID
}

此方法从 UserDefaults 中获取当前的“ ChecklistItemID”值,将其值加 1,然后写回到 UserDefaults,并将加 1 前的值返回给调用方。

我们可以在 registerDefaults() 方法中添加“ ChecklistItemID”的默认值,以自定义 item ID 的初始值,但是也可以不加。因为从 UserDefaults 中调用时,如果没有值,则默认返回 0。

第一次调用 nextChecklistItemID() 方法,它将返回ID 0,第二次调用时,它将返回ID 1,第三次它将返回ID 2,依此类推。理论上,我们可以调用数十亿次该方法才能用尽 item ID,所以请放心使用。

类方法 vs. 实例方法

如果你想知道为什么这样写:

class func nextChecklistItemID()

而不是:

func nextChecklistItemID()

在方法声明中包含 class 关键字表示这是一个类方法。

添加 class 关键字意味着我们无需引用对象的实例就可以调用此方法。

调用方法是这样:

itemID = DataModel.nextChecklistItemID()

而不是这样:

itemID = dataModel.nextChecklistItemID()

使用类方法,还是使用实例方法,这要看具体情况。这里我们使用类方法。
回到 ChecklistItem.swift 并添加一个 init() 方法来设置唯一ID(unique ID):

override init() {  
  super.init()
  itemID = DataModel.nextChecklistItemID()
}

每当 app 创建新的 ChecklistItem 对象并将原始值 -1 替换为唯一ID时,这里就会向 DataModel 对象询问新的项目ID。

显示新 IDs

在 ChecklistViewController.swift 中修改 configureText(for:with:) 方法:

func configureText(
  for cell: UITableViewCell, 
  with item: ChecklistItem
) {
  let label = cell.viewWithTag(1000) as! UILabel
  //label.text = item.text
  label.text = "\(item.itemID): \(item.text)"  
}

再次运行 app,添加新 items,显示应如下:

The items with their IDs

设置 date UI

接下来我们要的效果,Add/Edit Item 页面将是这样:

 Due Date fields

注:在 iOS 14 之前,“日期选取器”( Date Picker)控件的高度为 216 points,因此无法在 table view cell 中很好的展示,我们必须做一些设置来优化它,但是 iOS 14 之后我们不再需要这些设置了。

更新 edited values
@IBAction func done() {
  if let item = itemToEdit {
    item.text = textField.text!

    item.shouldRemind = shouldRemindSwitch.isOn  // add this
    item.dueDate = datePicker.date               // add this

    delegate?.itemDetailViewController(
      self, 
      didFinishEditing: item)
  } else {
    let item = ChecklistItem()
    item.text = textField.text!
    item.checked = false

    item.shouldRemind = shouldRemindSwitch.isOn  // add this
    item.dueDate = datePicker.date               // add this

    delegate?.itemDetailViewController(
      self, 
      didFinishAdding: item)
  }
}

这里,当用户按下 “ Done” 按钮时,将 switch control 和 date picker 的值更新到 ChecklistItem 对象中。

运行 app ,现在页面是这样的:

The date picker calendar

我们可以点击左上方的月份和年份以更改日期,也可以点击右上角的两个箭头逐月前进/后退。当然,我们也可以直接在底部的日期字段中输入日期。

在 iOS 14 之前,这需写更多代码才能达到这样页面效果。但现在,已是如此简单!

新增 to-do item

在 ChecklistItem.swift,修改 scheduleNotification() 方法:

func scheduleNotification() {
  if shouldRemind && dueDate > Date() {
     let content = UNMutableNotificationContent()
    content.title = "Reminder:"
    content.body = text
    content.sound = UNNotificationSound.default

    let calendar = Calendar(identifier: .gregorian)
    let components = calendar.dateComponents(
      [.year, .month, .day, .hour, .minute], 
      from: dueDate)

    let trigger = UNCalendarNotificationTrigger(
      dateMatching: components, 
      repeats: false)

    let request = UNNotificationRequest(
      identifier: "\(itemID)", 
      content: content, 
      trigger: trigger)
 
    let center = UNUserNotificationCenter.current()
    center.add(request)

    print("Scheduled: \(request) for itemID: \(itemID)")
  }
}

以上即可测试一下本地通知。

将以下方法添加到 ItemDetailViewController.swift中:

@IBAction func shouldRemindToggled(_ switchControl: UISwitch) {
  textField.resignFirstResponder()

  if switchControl.isOn {
    let center = UNUserNotificationCenter.current()
    center.requestAuthorization(options: [.alert, .sound]) {_, _ in 
      // do nothing
    }  
  }
}

当 “ Remind Me switch” 开关切换到“ON”时,会提示用户获得发送本地通知的权限。用户授予权限后,该应用将不再显示提示。

同时导入UserNotifications,否则上述方法可能无法编译。

打开 storyboard,并将 shouldRemindToggled:方法绑定到 switch 控件。

运行 app 测试一下: 添加一个新的 checklist item,将到期日期设置为一分钟后,然后按 “Done” 并返回主屏幕。

等待一分钟,我们将看到一个通知显示:

The local notification when the app is in the background

在 ChecklistItem.swift 中添加如下放:

func removeNotification() {
  let center = UNUserNotificationCenter.current()
  center.removePendingNotificationRequests(withIdentifiers: ["\(itemID)"])
}

如果存在此清单的通知,它将被删除。

在 scheduleNotification() 方法顶部调用此删除通知方法:

func scheduleNotification() {
  removeNotification()
  . . .
}

让我们测试一下:
➤ 运行该 app ,添加一个待办事项,设置该待办事项的到期时间为两分钟后。按Home键,回到主屏幕,等待通知显示。
➤ 编辑项目并将到期时间更改为三分钟后。旧的通知将被删除,新的通知将使用新的时间。
➤ 添加一个新的待办事项,设置截止时间为两分钟。编辑待办事项,但现在将开关设置为 OFF。旧的通知将被删除,并且不会安排新的通知。
➤ 再次进行编辑,并将时间设置为未来几分钟,但不要更改其他任何内容;由于开关仍处于关闭状态,因此不会安排新的通知。

如果我们在这之间终止 app,这些测试也应该起作用。

最终流程图

好了,终于完工了,这是 Checklists app 的 storyboard 的最终流程图:

The final storyboard

恭祝您又 get 一些新技能!

源代码地址:https://github.com/MFiOSDemos/Checklists.git

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值