note-swiftui-dynamic-user-notification-on-apple-watch


title: SwiftUI 应用 | 浇水提醒 app
top: false
date: 2020-03-01 17:08:42
tags:

  • watchOS
  • development
  • swiftui
    categories: development
    description:

用 SwiftUI 在 Apple Watch 上构建动态通知

译自 Dynamic user notification on Apple Watch with SwiftUI
源码地址:WaterMyPlants

集成了推送或者本地通知的 app 可以定制 apple watch 上的通知。本文是关于如何在 apple watch 上实现动态通知的笔记。样例工程实现一个提醒给植物浇水的功能。我们会聚焦在添加通知视图,省略从 iOS app 发送通知的步骤。

为 Apple Watch 添加富文本通知添加构建目标

如果工程里没有 App Watch app ,你需要添加它。在 Xcode 中,我们新增一个构建目标,并配置成包含通知场景。打开 New -> Target:

确保 User Interface 选择,并且 “Include Notification Scene” 选中。我们将会把它嵌入当前的 iOS app ,所以 “Embed in Companion App” 要选择当前 app 。值得一提的是,从 iOS 13 和 WatchOS 6 开始,Apple Watch app 已经可以独立存在了。

点击完成,Xcode 会询问激活新的 scheme ,点击激活,它会自动选择新建的目标,所以我们可以直接开始写代码了。先检查工程,会发现 Xcode 加了两个目标:watch app 和 extension。App 包含了 storyboard ,而 extension 包含了所有的代码。 storyboard 是提供基于 WKHostingController 的子类的 HostingController 演示用的场景。这个类负责承载你的 Apple Watch app 的 SwiftUI 视图。另外,还有两个场景,分别是静态和动态通知。我们对动态通知感兴趣,在 storyboard 里可以看见动态视图是由 NotificationController 提供的,它是 WKUserNotificationHostingController 的子类,承载通知的 SwiftUI 视图。这里就是我们给通知提供自定义界面的地方。如果通知的分类和 storyboard 里预先定义的匹配,就会选择动态通知视图。

解析通知的 payload 并设置动态通知视图

NotificationController 的职责是消费用户通知的 payload ,并生成 SwiftUI 视图来展示它们。用户通知是从 didReceive 函数接收的,我们需要释放信息,用于视图。在本地测试的时候,我们可以把测试数据写在 PushNotificationPayload.apns 文件里。因为我们要展示的是关于植物的信息,所有我们添加一个植物对象到文件中。同时,我们还需要把通知分类修改成某个有含义的字符串。确保你设置新的分类时正确更新 storyboard 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"aps": {
"alert": {
"body": "Test message",
"title": "Optional title",
"subtitle": "Optional subtitle"
},
"category": "WATERING_REMINDER",
"thread-id": "plantid123"
},

"plant": {
"id": "plantid123",
"name": "Aloe",
"lastDate": 1579937802,
"nextDate": 1580515200
}
}

当我们访问 UNNotification.request.content.userInfo 拿到植物的信息时,我们可以用 DecodableJSONDecoder 将代表植物的字典转换成值类型。 JSONDecoder 接收 JSON 数据,所以我们先用 JSONSerialization 包装数据,然后把包装的结果传给 JSONDecoder 。 或者我们也可以手动从 userInfo 字典里读取所有的值,然后创建出植物类型。留意,我们需要用 view model 来提供数据给 SwiftUI ,而不是直接使用 Plant 类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Plant: Decodable {
let id: String
let name: String
let lastDate: Date
let nextDate: Date
}

do {
let plantInfo = notification.request.content.userInfo ["plant"] as! [String: Any]
let data = try JSONSerialization.data (withJSONObject: plantInfo, options: [])
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let plant = try decoder.decode (Plant.self, from: data)
viewModel = NotificationViewModel(plant: plant)
}
catch let nsError as NSError {
print(nsError.localizedDescription)
}

另外,我们想要添加三个用户可以执行的动作:标记植物已经浇水,推后提醒,或者安排明天再提醒。这些动作是用 UNNotificationAction 实例表示。当用户点击任意其中一个时,UNUserNotificationCenter 的委托方法会被调用,并且带有该动作的 identifier

1
2
3
4
5
6
7
8
let doneTitle = NSLocalizedString("NotificationAction_Done", comment: "Done button title in notification.")
let laterTitle = NSLocalizedString("NotificationAction_Later", comment: "Later button title in notification.")
let tomorrowTitle = NSLocalizedString("NotificationAction_Tomorrow", comment: "Tomorrow button title in notification.")
notificationActions = [
UNNotificationAction(identifier: "water_done", title: doneTitle, options: []),
UNNotificationAction(identifier: "water_later", title: laterTitle, options: []),
UNNotificationAction(identifier: "water_tomorrow", title: tomorrowTitle, options: [])
]

NotificationController 的完整实现如下:

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
final class NotificationController: WKUserNotificationHostingController<NotificationView> {
private var viewModel: NotificationViewModel?

override var body: NotificationView {
return NotificationView(viewModel: viewModel!)
}

override func didReceive(_ notification: UNNotification) {
do {
let plantInfo = notification.request.content.userInfo ["plant"] as! [String: Any]
let data = try JSONSerialization.data (withJSONObject: plantInfo, options: [])
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let plant = try decoder.decode (Plant.self, from: data)
viewModel = NotificationViewModel(plant: plant)
}
catch let nsError as NSError {
print(nsError.localizedDescription)
}

let doneTitle = NSLocalizedString("NotificationAction_Done", comment: "Done button title in notification.")
let laterTitle = NSLocalizedString("NotificationAction_Later", comment: "Later button title in notification.")
let tomorrowTitle = NSLocalizedString("NotificationAction_Tomorrow", comment: "Tomorrow button title in notification.")
notificationActions = [
UNNotificationAction(identifier: "water_done", title: doneTitle, options: []),
UNNotificationAction(identifier: "water_later", title: laterTitle, options: []),
UNNotificationAction(identifier: "water_tomorrow", title: tomorrowTitle, options: [])
]
}
}

呈现通知的 NotificationView

上面提到 view model NotificationViewModel 为 NotificationView 提供文本,它主要处理日期的格式化字符。

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
struct NotificationViewModel {
private let plant: Plant

init(plant: Plant) {
self.plant = plant
}

var title: String {
return plant.name
}

var subtitle: String {
return NSLocalizedString("NotificationView_Subtitle", comment: "Notification suggestion text")
}

private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = DateFormatter.dateFormat (fromTemplate: "dMMMM", options: 0, locale: .current)
return formatter
}()

var lastWatering: String {
let format = NSLocalizedString("NotificationView_LastWatering", comment: "Last watering date.")
return String(format: format, dateFormatter.string (from: plant.lastDate))
}

var nextWatering: String {
let format = NSLocalizedString("NotificationView_NextWatering", comment: "Next watering date.")
return String(format: format, dateFormatter.string (from: plant.nextDate))
}
}

SwiftUI 视图很简单,4 个文本。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct NotificationView: View {
let viewModel: NotificationViewModel

var body: some View {
VStack {
Text(viewModel.title).font (.title)
Text(viewModel.subtitle).font (.subheadline)
Divider()
Text(viewModel.lastWatering).font (.body).multilineTextAlignment (.center)
Text(viewModel.nextWatering).font (.body).multilineTextAlignment (.center)
}
}
}

小结

我们往一个 iOS app 中添加了 watch app ,实现一个通知分类的动态通知视图。我们学习了如何解析通知数据,添加动作按钮。下一步是在 companion iOS app 里基于按钮的 identifier 处理对应通知动作。


拉取和显示数据

这一节的主题是从 compasion iOS app 的 CoreData 存储中获取数据,需要借助 WatchConnectivity framework 。

iOS 和 WatchOS app 之间的 session

iOS app 用 CoreData 来存储植物列表,记录了每株植物上一次和下一次浇水的日期。在这里,没有 web 服务,所有的东西都存在设备上。那么如何把持久化存储中的数据拿给 WatchOS app 使用呢?

我们会用到 WatchConnectivity framework 来做 iOS 和 WatchOS app 之间的交互。连接是在 iOS 和 WatchOS app 上都激活 WCSession 来实现的。因此,第一步是添加一个管理 WCSession 的类到 iOS 工程,我们不妨称它为 WatchConnectivityProvider (稍后也会添加一个相似的类到 WatchOS app)。它的主要职能是建立 WCSession ,处理 WCSessionDelegate ,其中包含从 CoreData 存储拉取数据。因此,有一个叫 NSPersistentContainer 的参数会提供对 CoreData 栈的访问 (借由访问 performBackgroundTask 函数)。

1
2
3
4
5
6
7
8
9
10
final class WatchConnectivityProvider: NSObject, WCSessionDelegate {
private let persistentContainer: NSPersistentContainer
private let session: WCSession

init(session: WCSession = .default, persistentContainer: NSPersistentContainer) {
self.persistentContainer = persistentContainer
self.session = session
super.init()
session.delegate = self
}

WCSession 是通过调用 activate () 来激活,激活过程是异步的。激活的响应通过 session (_:activationDidCompleteWith:error:) 委托访问返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func connect() {
guard WCSession.isSupported () else {
os_log (.debug, log: .watch, "watch session is not supported")
return
}
os_log (.debug, log: .watch, "activating watch session")
session.activate ()
}
func session(_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?) {
os_log (.debug,
log: .watch,
"did finish activating session % lu (error: % s)",
activationState == .activated,
error?.localizedDescription ?? "none")
}

在 watchOS extension target 那边,我们会添加相似的代码,不过名字不一样,叫 “PhoneConnectivityProvider” 。当两个类都创建完成后,我们需要初始化并调用 connect ,分别在 SceneDelegate (iOS) 和 ExtensionDelegate (watchOS) 中完成。注意,在 iOS app 这边,我们需要实现两个委托方面,不过目前我们简单打印就可以了。

1
2
3
4
5
6
7
func sessionDidBecomeInactive(_ session: WCSession) {
os_log (.debug, log: .watch, "session became inactive")
}

func sessionDidDeactivate(_ session: WCSession) {
os_log (.debug, log: .watch, "session deactivated")
}

为了测试 session ,我们需要先编译并运行,然后在编译运行 watchOS app 。如果一切工作正常, Xcode 调试窗口会打印出消息: “did finish activating session 1 (error: none)”. 这表明 session 已经建立并且正在运行,我们可以两个 app 间发送消息了。

Fetching plants from iOS app

因为 iOS 和 watchOS app 之间的通信依赖字典,所以第一步是定义一组两个 app 共享使用的 key 。这样可以减少误拼写的风险,所以我们可以添加新文件,并且同时包含到 iOS app target 和 watchOS extension target 中去。

1
2
3
4
5
6
7
8
struct WatchCommunication {
static let requestKey = "request"
static let responseKey = "response"

enum Content: String {
case allPlants
}
}

第二步是在 PhoneConnectivityProvider (watchOS app extension target) 中实现一个 refreshAllPlants (completionHandler) 函数,用来发送消息给 iOS app ,并且等待植物数据的数组返回。 WCSession 有一个叫 sendMessage (_:replyHandler:errorHandler:) 的函数,我们可以用它来发送一个字典给 iOS app ,然后等待 reply handler 。我们会用 WatchCommunication.requestKey 和 WatchCommunication.Content.allPlants 枚举的 rawValue 来构建消息。这种模式便于后续扩展,你只要添加更到 case 到枚举就可以了。在 reply handler 中,我们期望得到一个字典的数组,描述所有的植物。让我们先看一眼完整的实现,然后再讨论字典是如何被转换成 Plant 值类型的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func refreshAllPlants(withCompletionHandler completionHandler: @escaping ([Plant]?) -> Void) {
guard session.activationState == .activated else {
os_log (.debug, log: .phone, "session is not active")
completionHandler (nil)
return
}
let message = [WatchCommunication.requestKey: WatchCommunication.Content.allPlants.rawValue]
session.sendMessage (message, replyHandler: { (payload) in
let plantDictionaries = payload [WatchCommunication.requestKey] as? [[String: Any]]
os_log (.debug, log: .phone, "received % lu plants", plantDictionaries?.count ?? 0)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let plants = plantDictionaries?.compactMap ({ Plant(dictionary: $0, decoder: decoder) })
DispatchQueue.main.async {
completionHandler (plants)
}
}, errorHandler: { error in
os_log (.debug, log: .phone, "sending message failed: % s", error.localizedDescription)
})
}

iOS app 上处理 CoreData 和 Plant 类型的是一个 NSManagedObject 子类的对象。watchOS app extension 定义了它自己的 Plant 值类型,因为它并没有 CoreData 栈。为了将字典转换成值类型,我们可以使用 “Storing struct in UserDefault” 中描述的方法,只需要额外配置 JSONDecoder 使用的 dateDecodingStrategysecondsSince1970 。理由是我们希望以自 1970 年之后的秒数来存储日期。转换字典到值类型的过程用到了 JSONSerialization ,它只支持 NSStringNSNumberNSArrayNSDictionary , 或者 NSNull

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Plant value type in WatchOS app extension
struct Plant: Identifiable, Decodable, DictionaryDecodable {
let id: String
let name: String
let lastWateringDate: Date
let nextWateringDate: Date
}
// Plant class in iOS app
final class Plant: NSManagedObject, Identifiable {
@NSManaged var id: String
@NSManaged var name: String

@NSManaged var lastWateringDate: Date
@NSManaged var nextWateringDate: Date
}

第三步是在 iOS app 端处理消息,并且提供数据给 watchOS app 。我们需要做的是实现 session 的委托,从 CoreData 栈中获取字典数据。 先看下完整实现,然后逐一拆解。

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
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
os_log (.debug, log: .watch, "did receive message: % s", message [WatchCommunication.requestKey] as? String ?? "unknown")
guard let contentString = message [WatchCommunication.requestKey] as? String , let content = WatchCommunication.Content(rawValue: contentString) else {
replyHandler ([:])
return
}
switch content {
case .allPlants:
persistentContainer.performBackgroundTask { (managedObjectContext) in
let all = Plant.allPlantsDictionaryRepresentation () as! [[String: Any]]
// Replace Date with Double
let converted = all.map { (plantDictionary) -> [String: Any] in
plantDictionary.mapValues { (value) -> Any in
if let date = value as? Date {
return date.timeIntervalSince1970
}
else {
return value
}
}
}
let response = [WatchCommunication.responseKey: converted]
replyHandler (response)
}
}
}

第一步是查看接收到的字典,看看 watchOS app 请求的是哪些内容。然后我们访问持久化存储,获取表示 Plant 的字典,把其他的日期转换成 1970 年后秒数的形式 (以便 watchOS app 能够在字典上使用 JSONSerialization),然后把数据发送回 watchOS app 。注意,从 CoreData 中获取字典形式的 Plant 很容易:我们首先是请求 NSDictionary 类型的数据,并且将结果类型属性设置为 .dictionaryResultType 。对于各庞大的模型,我们可能还会用到属性集合 (propertiesToFetch) 。不过目前,所有的属性都被添加到字典中了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
extension Plant {
static let entityName = "Plant"

static func makeDictionaryRequest() -> NSFetchRequest<NSDictionary> {
return NSFetchRequest<NSDictionary>(entityName: entityName)
}
static func allPlantsDictionaryRepresentation() -> [NSDictionary] {
let request = makeDictionaryRequest ()
request.resultType = .dictionaryResultType
do {
return try request.execute ()
}
catch let nsError as NSError {
os_log (.debug, log: .plants, "failed fetching all plants with error % s % s", nsError, nsError.userInfo)
return []
}
}
}

用 SwiftUI 在 watchOS app 中构建 UI

Xcode 中 watchOS app 的模板是借助 storyboard 初始化 HostingController, 这个控制器负责提供初始的 SwiftUI 视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
class HostingController: WKHostingController<PlantListView> {
lazy private(set) var connectivityProvider: PhoneConnectivityProvider = {
let provider = PhoneConnectivityProvider()
provider.connect ()
return provider
}()

private lazy var listViewModel = PlantListViewModel(connectivityProvider: connectivityProvider)

override var body: PlantListView {
return PlantListView(viewModel: listViewModel)
}
}

PlantListView 是一个显示植物列表的简单视图,它用 PhoneConnectivityProviderrefreshAllPlants (withCompletionHandler:) 来处理刷新植物的逻辑。 SwiftUI 视图会在 view model 改变时自动更新。这是因为 view model 的 plants 属性使用了 @Published 属性包装器,而 view model 本身是 ObservableObject ,这是 SwiftUI 视图中为 view model 采用的属性包装器 (更多信息可以阅读 refreshing SwiftUI view in MVVM in SwiftUI) 。注意,这里的 view model 是 SwiftUI 视图显现时刷新内容的。

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
final class PlantListViewModel: ObservableObject {
private let connectivityProvider: PhoneConnectivityProvider

init(plants: [Plant] = [], connectivityProvider: PhoneConnectivityProvider) {
self.plants = plants
self.connectivityProvider = connectivityProvider
refresh ()
}
@Published private(set) var plants: [Plant]

func refresh() {
connectivityProvider.refreshAllPlants { [weak self] (plants) in
guard let plants = plants else { return }
self?.plants = plants
}
}
}
struct PlantListView: View {
@ObservedObject var viewModel: PlantListViewModel

var body: some View {
VStack {
List(self.viewModel.plants) { plant in
PlantCell(viewModel: PlantCellViewModel(plant: plant))
}
}.onAppear {
self.viewModel.refresh ()
}
}
}

PlantListViewPlantCell 来显示独立的视图。

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
struct PlantCell: View {
let viewModel: PlantCellViewModel

var body: some View {
VStack(spacing: 4) {
Text(viewModel.title).font (.headline).multilineTextAlignment (.center)
Text(viewModel.subtitle).font (.footnote).multilineTextAlignment (.center)
}.padding (8)
.frame (minWidth: 0, maxWidth: .greatestFiniteMagnitude)
}
}
struct PlantCellViewModel {
let plant: Plant

var title: String {
return plant.name
}

private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = DateFormatter.dateFormat (fromTemplate: "dMMMM", options: 0, locale: .current)
return formatter
}()

var subtitle: String {
let format = NSLocalizedString("PlantCellView_NextWatering", comment: "Next watering date.")
return String(format: format, Self.dateFormatter.string (from: plant.nextWateringDate))
}
}

小结

我们在 iOS 和 watchOS app 上都添加 WCSessions ,实现相关的委托方法以处理 session 和接收到的消息。然后,我们定义一个简单的通信模式,并在 watchOS app 端实现刷新植物的方法,在 iOS 端实现 CoreData 集成。当数据访问创建完成后,我们在 watchOS app 上用 SwiftUI 视图显示植物的列表。