使用 WatchConnectivity 进行数据交互
Apple Watch 体验的优势之一就在于 watchOS App 与 iOS App 之间的无缝交互。
WatchConnectivity
WatchConnectivity 是 Apple 提供的框架,可让 iOS 应用程序及其对应的 watchOS 应用程序传输数据和文件。如果两个应用程序都处于活动状态,则通信主要是实时进行的。否则通信会在后台进行,一旦接收侧应用程序启动,数据就可用。
在设备之间传递数据时,操作系统会考虑许多因素。虽然传输经常很快完成,但不排除滞后的情况。在设备之间传输数据,必须打开多个系统资源,例如蓝牙等。 这可能导致大量电量的使用。因此尽可能将消息捆绑在一起以限制电池消耗。
设备间通信
WatchConnectivity 框架提供了五种在设备之间传输数据的方式。其中四种方法发送任意数据,而第五种方法在设备之间发送文件。这些方法都是 WCSession 的一部分。
大多数数据传输方法都接受类型为 [String: Any] 的字典,但这并不意味着我们可以发送任何内容。 字典只能接受原始类型。
支持类型请参考 About Property Lists
这五种方式可以进一步细分为两类:「交互式消息传递」和「后台传输」。
-
交互式消息传递最适合我们需要立即传输信息的情况。例如,如果 watchOS 应用需要触发 iOS 应用来检查用户的当前位置,则交互式消息传递 API 可以将请求从 Apple Watch 传输到 iPhone。
-
如果只有一个应用程序处于活动状态,它可以使用后台传输方法向其对应应用程序发送数据。
交互式消息传递
交互式消息传递最适合你需要立即传输信息的情况。但不能保证实际会传递交互式消息。它们会尽快发送,并按照先进先出FIFO的顺序异步交付。
如果我们从 watchOS App 发送交互式消息,相应的 iOS App 将在后台唤醒并变为可访问。
当我们从 iOS App 发送交互式消息但 watchOS App 未激活时,watchOS App 将不会唤醒。
如果我们有一个数据字典,以字符串为 key,可以使用 sendMessage(_:replyHandler:errorHandler:)。如果我们有一个 Data 对象,则使用 sendMessageData(_:replyHandler:errorHandler:)。
replyHandler
发送交互式消息时,我们可能期望来自对等设备的回复。我们可以传递一个类型为 ([String: Any]) -> Void 的闭包作为 replyHandler,它将接收对等设备返回的消息。例如我们要求 iPhone 生成某种类型的数据,消息将返回并回调 replyHandler。
errorHandler
当我们想知道消息传输过程中何时出现问题时,我们可以使用 errorHandler 并传递 (Error) -> Void 闭包。例如,如果网络出现故障,我们将调用 errorHandler。
后台传输
如果只有一个 App 处于活动状态,它仍然可以使用后台传输方法向对等设备发送数据。后台传输让 iOS 和 watchOS 根据电池使用情况和等待传输的其他数据量等特征,选择合适的时机进行传输。
后台传输有三种类型:
-
Guaranteed user information
-
Application context
-
Files
Guaranteed user information
transferUserInfo(_:) 进行后台传输。 调用此方法时,我们指定数据是关键的,必须尽快交付。设备将持续尝试发送数据,直到对等设备接收到数据为止。 一旦数据传输开始,即使 App 被挂起,操作也会继续直到完成。
transferUserInfo(_:) 也是以 FIFO 方式传递我们发送的每个数据包。
Application context
通过 updateApplicationContext(_:) 传递的高优先级消息,类似于 Guaranteed user information,但有两个重要区别:
-
操作系统会在适合发送数据时发送数据。
-
它只发送最新消息,旧的未发送的消息会被覆盖。
如果我们有频繁更新的数据,并且我们只需要最新的数据,应该使用 updateApplicationContext(_:)。
Files
有时我们需要在设备之间发送实际文件。 例如,iPhone 可能会从网络下载一张图片,然后将该图片发送到 Apple Watch。我们通过 transferFile(_:metadata:) 发送文件。 我们可以通过元数据参数发送以字符串为 key 的任何类型的字典数据。 使用元数据提供文件名、大小和创建时间等信息。
SwiftUI
Flipped 模版构建
我们将搭建一个电影票购票 App 模版 Flipped ,我们后续的数据交互,将基于此模版进行实现。如果你对此部分并不感兴趣,可以直接跳过该部分,并使用来自 github.com/LLLLLayer/A… 的项目文件。
数据源与数据模型
首先创建项目。
调整我们的文件夹,iOS 与 watchOS 公用的文件将存放于 Shared 文件夹中。
首先在 Shared 文件夹中新增文件 Movies.json,并添加以下内容,这将是我们后续使用的数据源:
[
{
"id": 31413,
"title": "Angel On My Shoulder",
"director": "Archie Mayo",
"actors": [
"Paul Muni", "Anne Baxter", "Claude Rains"
],
"synopsis": "The devil (Claude Rains) offers a deceased gangster (Paul Muni) the chance to return in the body of a judge. Black & White.",
"poster": "angel_on_my_shoulder",
"hour": 13
},
{
"id": 23415,
"title": "Baby Face Morgan",
"director": "Arthur Dreifuss",
"actors": [
"Robert Armstrong", "Mary Carlisle", "Richard Cromwell"
],
"synopsis": "Aging mobsters try to bring back the good old days by setting up a naive yokel as the kingpin. Black & White.",
"poster": "baby_face_morgan",
"hour": 14
},
{
"id": 23523,
"title": "Africa Screams",
"director": "Charles Barton",
"actors": [
"Bud Abbott", "Lou Costello"
],
"synopsis": "Basic crazy Abbott and Costello movie that also features a couple of the three stooges. Black & White.",
"poster": "africa_screams",
"hour": 15
},
{
"id": 87934,
"title": "The Flying Deuces",
"director": "A. Edward Sullivan",
"actors": [
"Stan Laurel", "Oliver Hardy"
],
"synopsis": "Oliver's heart is broken when he finds that his love Georgette is already married to Francois, a dashing Foreign Legion officer. He runs off to join the Foreign Legion, taking Stanley with him. Their zany antics get them charged with desertion and sentenced to a firing squad. Black & White.",
"poster": "the_flying_deuces",
"hour": 16
},
{
"id": 34340,
"title": "The General",
"director": "Clyde Bruckman",
"actors": [
"Marion Mack", "Charles Henry Smity", "Richard Allen", "Buster Keaton"
],
"synopsis": "Civil War film. The General is a locomotive, and Buster Keaton is its engineer. Union soliders steal the locomotive, and Buster is on the chase--not knowing that his girl friend is being held captive aboard The General. Black & White. Silent.",
"poster": "the_general",
"hour": 17
},
{
"id": 99900,
"title": "One Body Too Many",
"director": "Frank McDonald",
"actors": [
"Jack Haley", "Jean Parker", "Bela Lugosi"
],
"synopsis": "An insurance saleman is hired to protect a millionaire. Black & White.",
"poster": "one_body_too_many",
"hour": 18
},
{
"id": 77788,
"title": "The Royal Bed",
"director": "Lowell Sherman",
"actors": [
"Lowell Sherman", "Mary Astor"
],
"synopsis": "A comic farce among the royalty. Black & White.",
"poster": "the_royal_bed",
"hour": 19
},
{
"id": 858555,
"title": "Something to Sing About",
"director": "Victor Schertzinger",
"actors": [
"James Cagney", "Evelyn Daw", "William Frawley"
],
"synopsis": "Cagney stars as a New York bandleader who moves to California and butts heads with the Hollywood star making machine. Black & White.",
"poster": "something_to_sing_about",
"hour": 20
},
{
"id": 4443355,
"title": "Speak Easily",
"director": "Eward Sedgwick",
"actors": [
"Buster Keaton", "Jimmy Durante"
],
"synopsis": "A college professor uses inherited to back a Broadway musical in the hopes of winning the love of the star. Black & White.",
"poster": "speak_easily",
"hour": 21
}
]
在 Shared 文件夹中新增文件 Movie.swift,这是将数据源转换后的 Model:
import Foundation
struct Movie: Identifiable {
/// 电影 id
let id: Int
/// 电影时间
let time: String
/// 电影名称
let title: String
/// 电影概要
let synopsis: String
/// 电影海报
let poster: String
/// 电影导演
let director: String
/// 参演演员
let actors: String
static func preview() -> Self {
.init(
id: 23523,
time: "3:00 PM",
title: "Africa Screams",
synopsis: """
Basic crazy Abbott and Costello movie that also features \
a couple of the three stooges. Black & White.
""",
poster: "africa_screams",
director: "Charles Barton",
actors: "Bud Abbot, Lou Costello")
}
}
extension Movie: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
}
extension Movie: Decodable {
private enum CodingKeys: String, CodingKey {
case id, hour, title, synopsis, poster, director, actors
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(Int.self, forKey: .id)
let hour = try values.decode(Int.self, forKey: .hour)
let date = Calendar.current.date(from: DateComponents(hour: hour)) ?? Date()
time = date.formatted(.dateTime.hour().minute())
title = try values.decode(String.self, forKey: .title)
synopsis = try values.decode(String.self, forKey: .synopsis)
poster = try values.decode(String.self, forKey: .poster)
director = try values.decode(String.self, forKey: .director)
let names = try values.decode([String].self, forKey: .actors)
actors = names.joined(separator: ", ")
}
}
数据服务
在 Shared 文件夹中新增文件 TicketOffice.swift,是电影票数据的服务操作中心:
import SwiftUI
import Combine
class TicketOffice: ObservableObject {
/// 电影票服务单例
static let shared = TicketOffice()
/// 上映的电影
var movies: [Movie]
/// 已购的电影票
@Published var purchased: [Movie] = [] {
didSet {
let ids = purchased.map { $0.id }
UserDefaults.standard.setValue(ids, forKey: "purchased")
}
}
init() {
// 加载电影
let decoder = JSONDecoder()
guard let file = Bundle.main.url(forResource: "Movies", withExtension: "json"),
let data = try? Data(contentsOf: file),
let movies = try? decoder.decode([Movie].self, from: data) else {
fatalError("Can't find Movies!")
}
self.movies = movies
// 加载已购的电影票
let purchasedIds = UserDefaults.standard.array(forKey: "purchased") as? [Int] ?? []
purchased = movies.filter { purchasedIds.contains($0.id) }
}
}
extension TicketOffice {
/// 是否已购买某电影电影票
func isPurchased(_ movie: Movie) -> Bool {
purchased.contains(movie)
}
/// 购买电影票
func purchase(_ movie: Movie) {
guard !isPurchased(movie) else {
return
}
purchased.append(movie)
}
/// 删除电影票
func delete(at offsets: IndexSet) {
purchased.remove(atOffsets: offsets)
}
/// 以时间分组的可购买的电影票
func purchasableMovies() -> [String: [Movie]] {
let notPurchased = movies.filter { !isPurchased($0) }
return Dictionary(grouping: notPurchased, by: \.time)
}
}
基本视图
MovieInfoView
新增 MovieInfoView.swift,是电影信息视图,将在 iOS 和 watchOS 共同使用:
import SwiftUI
struct MovieInfoView: View {
let movie: Movie
var body: some View {
VStack(alignment: .leading, spacing: 8.0) {
Text(movie.title)
.font(.headline)
Text("Time: \(movie.time)")
Text("Director:")
Text(movie.director)
Text("Actors:")
Text(movie.actors)
}
.font(.subheadline)
.foregroundColor(.gray)
}
}
struct MovieInfoView_Previews: PreviewProvider {
static var previews: some View {
MovieInfoView(movie: Movie.preview())
}
}
MovieListView
新增 MovieListView.swift,是电影列表视图,将在 iOS 和 watchOS 共同使用:
import SwiftUI
struct MovieListView: View {
@StateObject private var ticketOffice = TicketOffice.shared
@State private var selection: Int?
private let purchasableMovies = TicketOffice.shared.purchasableMovies()
var body: some View {
List {
ForEach(purchasableMovies.keys.sorted(), id: \.self) { title in
Section {
ForEach(purchasableMovies[title]!.sorted(by: { $0.title < $1.title })) {
MovieInfoView(movie: $0)
}
} header: {
Text(title)
}
}
}
}
}
struct MovieListView_Previews: PreviewProvider {
static var previews: some View {
MovieListView()
}
}
MovieRow
由于 iOS 和 watchOS 在上述视图中有些需要的差异化,包括跳转的 List 的 Row 样式、以及跳转到的目标页面。我们将在上面的代码上,做一些差异化。分别在 Flipped 和 Flipped WatchKit Extension 新建文件 MovieRow.swift 和 MovieDetailsView.swift。
记得添加资源,它将在 iOS 设备上展示电影信息时使用。
我们看 Flipped 的 MovieRow,它后续将被替换为 MovieListView 的 List 视图,在 iOS 中展示。修改为以下代码:
import SwiftUI
struct MovieRow: View {
let movie: Movie
var body: some View {
HStack {
Image(movie.poster)
.resizable()
.scaledToFit()
.frame(width: 70)
VStack(alignment: .leading) {
Text(movie.title)
.font(.headline)
.foregroundColor(.black)
.lineLimit(1)
Text(movie.synopsis)
.font(.caption)
.foregroundColor(.gray)
.lineLimit(3)
}.padding()
}
}
}
struct MovieRow_Previews: PreviewProvider {
static var previews: some View {
MovieRow(movie: Movie.preview())
}
}
我们看 CinemaTime WatchKit Extension 的 MovieRow,它后续将被替换为 MovieListView 的 List 视图,在 watchOS 中展示。修改为以下代码:
import SwiftUI
struct MovieRow: View {
let movie: Movie
var body: some View {
Text(movie.title)
.font(.subheadline)
.foregroundColor(.white)
}
}
struct MovieRow_Previews: PreviewProvider {
static var previews: some View {
MovieRow(movie: Movie.preview())
}
}
下面回到我们的打开 MovieListView 中的,调整代码:
struct MovieListView: View {
@StateObject private var ticketOffice = TicketOffice.shared
@State private var selection: Int?
private let purchasableMovies = TicketOffice.shared.purchasableMovies()
var body: some View {
List {
ForEach(purchasableMovies.keys.sorted(), id: \.self) { title in
Section {
ForEach(purchasableMovies[title]!.sorted(by: { $0.title < $1.title })) { movie in
NavigationLink(destination: MovieDetailsView(movie: movie)) {
MovieRow(movie: movie)
}
}
} header: {
Text(title)
}
}
}
}
}
struct MovieListView_Previews: PreviewProvider {
static var previews: some View {
MovieListView()
}
}
我们将看到:
MovieDetailsView
我们来看下电影票的详情页面,在这个页面中我们将展示两种样式:
-
未购买的样式,将携带购买按钮。
-
已购买的样式,将展示电影票二维码。
QRCodeView
在绘制 MovieDetailsView 前,我们的预期是除了展示电影的基本信息外,对于用户已购的电影票,展示一个二维码,提供给用户刷码使用。因此,我们将完成一个 QRCodeView。
首先在 Flipped 添加 QRCode.swift,将根据 Movie 生成一个二维码 Image:
import SwiftUI
import CoreImage.CIFilterBuiltins
enum QRCode {
static func generate(movie: Movie, size: CGSize) -> UIImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data("\(movie.title) @ \(movie.time)".utf8)
filter.correctionLevel = "Q" // 纠错率
if let output = filter.outputImage {
let x = size.width / output.extent.size.width
let y = size.height / output.extent.size.height
let scaled = output.transformed(by: CGAffineTransform(scaleX: x, y: y))
if let cgImage = CIContext().createCGImage(scaled, from: scaled.extent) {
return UIImage(cgImage: cgImage)
}
}
return nil
}
}
接着,我们再添加文件 QRCodeView.swift:
import SwiftUI
struct QRCodeView: View {
let movie: Movie
var body: some View {
GeometryReader { reader in
if let image = QRCode.generate(movie: movie, size: reader.size) {
Image(uiImage: image)
} else {
Image(systemName: "xmark.circle")
}
}
}
}
struct QRCodeView_Previews: PreviewProvider {
static var previews: some View {
QRCodeView(movie: Movie.preview())
.frame(width: 200, height: 200)
}
}
我们的二维码视图将展现。
PurchaseTicketView
我们需要一个购买按钮。在 Shared 中添加 PurchaseTicketView.swift:
import SwiftUI
struct PurchaseTicketView: View {
@State private var isPresented = false
let movie: Movie
var body: some View {
if TicketOffice.shared.isPurchased(movie) {
EmptyView()
} else {
Button(
action: {
isPresented = true
}, label: {
Text("Purchase")
.font(.title3)
})
.tint(.white)
.padding()
.background(.blue)
.cornerRadius(20)
.actionSheet(isPresented: $isPresented) {
ActionSheet(
title: Text("Purchase Ticket"),
message: Text("Are you sure you want to purchase this ticket?"),
buttons: [
.cancel(),
.default(Text("Buy")) {
TicketOffice.shared.purchase(movie)
}]
)
}
}
}
}
struct PurchaseTicketView_Previews: PreviewProvider {
static var previews: some View {
PurchaseTicketView(movie: Movie.preview())
}
}
MovieDetailsView
最后,我们组装 MovieDetailsView,在 Flipped 的 MovieDetailsView.swift 添加以下代码:
import SwiftUI
struct MovieDetailsView: View {
let movie: Movie
var body: some View {
VStack {
HStack {
Image(movie.poster)
.resizable()
.scaledToFit()
.frame(width: 120)
MovieInfoView(movie: movie)
}
Text(movie.synopsis)
.font(.body)
.foregroundColor(.gray)
VStack(alignment: .center) {
if TicketOffice.shared.isPurchased(movie) {
Spacer()
QRCodeView(movie: movie)
.frame(width: 200, height: 200, alignment: .center)
Spacer()
} else {
Spacer()
PurchaseTicketView(movie: movie)
}
}.padding()
}
.padding()
.edgesIgnoringSafeArea(.bottom)
}
}
struct MovieDetailsView_Previews: PreviewProvider {
static var previews: some View {
MovieDetailsView(movie: Movie.preview())
}
}
它最后将展示两种可能的形态:
下面我们来看下 watchOS 上的 MovieDetailsView,现在,它现在还比较简单:
struct MovieDetailsView: View {
let movie: Movie
var body: some View {
ScrollView {
MovieInfoView(movie: movie)
if !TicketOffice.shared.isPurchased(movie) {
PurchaseTicketView(movie: movie)
}
}
}
}
struct MovieDetailsView_Previews: PreviewProvider {
static var previews: some View {
MovieDetailsView(movie: Movie.preview())
}
}
PurchasedTicketsListView
我们需要一个启动页面,该页面展示我们已经购买的电影票,同时提供入口跳转到 MovieListView,将Flipped 和 Flipped WatchKit Extension 的 ContentView.swift 及相关内容都重命名为 PurchasedTicketsListView.swift。
我们先调整 Flipped 的 PurchasedTicketsListView.swift:
import SwiftUI
struct PurchasedTicketsListView: View {
@StateObject private var ticketOffice = TicketOffice.shared
var body: some View {
NavigationView {
List {
ForEach(ticketOffice.purchased) { movie in
NavigationLink(destination: MovieDetailsView(movie: movie)) {
MovieRow(movie: movie)
}
}
.onDelete(perform: delete)
NavigationLink(destination: MovieListView()) {
Text("Purchase tickets")
.font(.title3)
.fontWeight(.black)
.padding()
}
.isDetailLink(false)
.padding()
}
.navigationBarTitle("Purchased Tickets")
.navigationBarTitleDisplayMode(.automatic)
}
}
private func delete(at offsets: IndexSet) {
withAnimation {
TicketOffice.shared.delete(at: offsets)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
PurchasedTicketsListView()
}
}
最终,如果我们有已购的电影票,会展现为以下样式:
我们再调整 Flipped WatchKit Extension 的 PurchasedTicketsListView.swift:
import SwiftUI
struct PurchasedTicketsListView: View {
@StateObject private var ticketOffice = TicketOffice.shared
var body: some View {
List {
ForEach(ticketOffice.purchased) { movie in
NavigationLink(destination: MovieDetailsView(movie: movie)) {
MovieRow(movie: movie)
}
}
.onDelete(perform: delete)
NavigationLink(destination: MovieListView()) {
Text("Purchase tickets")
.font(.title3)
.fontWeight(.black)
.padding()
}
.padding()
}
.navigationBarTitle("Purchased Tickets")
}
private func delete(at offsets: IndexSet) {
withAnimation {
TicketOffice.shared.delete(at: offsets)
}
}
}
struct TicketsListView_Previews: PreviewProvider {
static var previews: some View {
PurchasedTicketsListView()
}
}
ConnectivityUserInfoKey
我们后续将使用的字段,请在 Shared 新增文件 ConnectivityUserInfoKey.swift:
enum ConnectivityUserInfoKey: String {
case purchased
case qrCodes
case verified
}
模版最终效果
Flipped** 数据交互**
体验对比
体验一下我们的 Flipped,我们会发现一些差异:
虽然我们可以在 Apple Watch 版本中添加一张海报,但图片太小了,用户体验会很差。更重要的是,包含图像意味着电影标题必须更小才能仍然适合相当大的空间。
手机有足够的空间来包含电影的简短概要,但 Apple Watch 没有。如果我们要包含概要,那么用户不得不连续滚动更多的时间。
最重要的一点是,在一台设备上购买的电影票不会显示为在另一台设备上!用户有一个预期,即无论哪个应用程序创建了数据,都应该可以从应用程序的两个版本访问数据。接着,我们将使用 Watch Connectivity 框架在应用程序的 iOS 和 watchOS 版本之间同步用户购买的电影票。
设置链接
我们需要处理设备之间的所有连接。
在 Shared 文件夹中新增文件 Connectivity.swift:
import Foundation
import WatchConnectivity
final class Connectivity {
static let shared = Connectivity()
private init() {
#if !os(watchOS)
guard WCSession.isSupported() else {
return
}
#endif
WCSession.default.delegate = self
WCSession.default.activate()
}
}
我们引入了 WatchConnectivity,实现了一个 Connectivity 类,其提供一个单例,在受支持的情况下,激活会话。
我们其实可以不用加#if !os(watchOS)这些,但我们需要了解 iOS 设备仅在有配对的 Apple Watch 时才支持会话。Apple Watch 将始终支持会话。
准备 WCSessionDelegate
WCSessionDelegate 协议扩展了 NSObjectProtocol。 这意味着 Connectivity 要成为委托,它必须从 NSObject 继承。修改代码:
final class Connectivity: NSObject {
override private init() {
super.init()
实现 WCSessionDelegate
我们需要使 Connectivity 符合 WCSessionDelegate:
extension Connectivity: WCSessionDelegate {
func session(
_ session: WCSession,
activationDidCompleteWith activationState:
WCSessionActivationState, error: Error?
) {
}
func sessionDidBecomeInactive(_ session: WCSession) {
}
func sessionDidDeactivate(_ session: WCSession) {
}
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {
}
func sessionDidDeactivate(_ session: WCSession) {
}
#endif
这里的原因主要是某些用户可能有多个 Apple Watch,如果用户更换手表,我们需要做一些额外的操作:
func sessionDidBecomeInactive(_ session: WCSession) {
WCSession.default.activate()
}
transferUserInfo
在 Connectivity 类中添加代码:
extension Connectivity {
public func send(movieIds: [Int]) {
guard WCSession.default.activationState == .activated else {
return
}
#if os(watchOS)
guard WCSession.default.isCompanionAppInstalled else {
return
}
#else
guard WCSession.default.isWatchAppInstalled else {
return
}
#endif
let userInfo: [String: [Int]] = [
ConnectivityUserInfoKey.purchased.rawValue : movieIds
]
WCSession.default.transferUserInfo(userInfo)
}
}
-
每当用户购买或删除电影票时,我们需要告诉配套应用程序哪些电影票现在有效。 每当发送消息时,我们必须做的第一件事是确保会话处于活动状态。会话状态可能因多种原因而改变。 例如,一台设备的电池可能会耗尽。
-
Apple Watch 会检查该应用是否在手机上。iOS 设备会检查应用程序是否在 Apple Watch 上,这两个操作系统具有单独命名的方法。
-
向配对设备发送数据时,我们必须使用以字符串为键的字典。 数据准备好后,我们可以将其传输到配对设备。
回到开头:
调用此方法时,我们指定数据是关键的,必须尽快交付。设备将持续尝试发送数据,直到对等设备接收到数据为止。 一旦数据传输开始,即使 App 被挂起,操作也会继续直到完成。
在 TicketOffice 中,用户购买、删除电影票后,需要进行发送,调整代码:
extension TicketOffice {
// ...
/// 购买电影票
func purchase(_ movie: Movie) {
guard !isPurchased(movie) else {
return
}
purchased.append(movie)
updateCompanion()
}
/// 删除电影票
func delete(at offsets: IndexSet) {
purchased.remove(atOffsets: offsets)
updateCompanion()
}
private func updateCompanion() {
let ids = purchased.map { $0.id }
Connectivity.shared.send(movieIds: ids)
}
// ...
}
ReceiveUserInfo
传输用户信息后,我们需要某种方式在另一台设备上接收数据。 我们在将在 session(_:didReceiveUserInfo:) 的 WCSessionDelegate 中收到数据。我们可以使用 Combine 框架进行发布。继续调整代码:
final class Connectivity: NSObject {
@Published var purchasedIds: [Int] = []
// ...
}
extension Connectivity {
public func send(movieIds: [Int]) {
// ...
}
func session(
_ session: WCSession,
didReceiveUserInfo userInfo: [String : Any] = [:]
) {
let key = ConnectivityUserInfoKey.purchased.rawValue
guard let ids = userInfo[key] as? [Int] else {
return
}
self.purchasedIds = ids
}
}
我们要在 TicketOffice 进行接收,调整代码:
class TicketOffice: NSObject, ObservableObject {
private var cancellable: Set<AnyCancellable> = []
// ...
override private init() {
// 加载电影
let decoder = JSONDecoder()
guard let file = Bundle.main.url(forResource: "Movies", withExtension: "json"),
let data = try? Data(contentsOf: file),
let movies = try? decoder.decode([Movie].self, from: data) else {
fatalError("Can't find Movies!")
}
self.movies = movies
// 加载已购的电影票
let purchasedIds = UserDefaults.standard.array(forKey: "purchased") as? [Int] ?? []
purchased = movies.filter { purchasedIds.contains($0.id) }
super.init()
// 接收
Connectivity.shared.$purchasedIds
.dropFirst()
.map({ ids in
movies.filter { movie in
ids.contains(movie.id)
}
})
.receive(on: DispatchQueue.main)
.assign(to: \.purchased, on: self)
.store(in: &cancellable)
}
}
通过在属性前面加上 $,你告诉 Swift 查看 publishing item 而不仅是值。我们使用初始值 [] 声明了 purchased,这意味着一个空数组,我们需要删除第一项。
接下来,我们检索发送到设备的 id 标识的 Movie 对象。执行内部过滤器确保不发生错误。
我们切换到主线程,在主线程上进行 UI 更新。将其分配给 purchased。
最后是 Combine 的标准样板,它将链存储在 Set\<AnyCancellable> 中。
现在,我们可以体验我们的应用程序:
Application context
虽然 transferUserInfo(:_) 功能强大,但并不是最佳的选择。我们只需要最终的结果,中间的过程并不重要,在这种情况下,最好的选择是使用 updateApplicationContext(_:)。
在 Shared 文件夹中新增 Delivery.swift,我们后续将实现这几种传输方式。
enum Delivery {
/// 立即交付,失败时不重试
case failable
/// 尽快交付,失败时自动重试
/// 数据的所有实例将按顺序传输
case guaranteed
/// 高优先级数据。 只有最近的值
/// 任何尚未交付的此类转让将被替换使用新的
case highPriority
}
回到 Connectivity,调整我们之前实现的 Send 方法:
public func send(
movieIds: [Int],
delivery: Delivery,
errorHandler: ((Error) -> Void)? = nil
) {
guard WCSession.default.activationState == .activated else {
return
}
#if os(watchOS)
guard WCSession.default.isCompanionAppInstalled else {
return
}
#else
guard WCSession.default.isWatchAppInstalled else {
return
}
#endif
let userInfo: [String: [Int]] = [
ConnectivityUserInfoKey.purchased.rawValue : movieIds
]
switch delivery {
case .failable:
break
case .guaranteed:
WCSession.default.transferUserInfo(userInfo)
case .highPriority:
do {
try WCSession.default.updateApplicationContext(userInfo)
} catch {
errorHandler?(error)
}
}
}
现在,我们可以指定要使用的交付类型以及可选的错误处理程序。稍后你将处理 .failable。 更新应用程序上下文可能会导致异常,这就是 send 方法现在接受可选错误处理程序的原因。
接着,我们需要将 didReceiveUserInfo 的实现拆分,方法由 didReceiveApplicationContext 复用。
func session(
_ session: WCSession,
didReceiveUserInfo userInfo: [String: Any] = [:]
) {
update(from: userInfo)
}
func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
update(from: applicationContext)
}
private func update(from dictionary: [String: Any]) {
let key = ConnectivityUserInfoKey.purchased.rawValue
guard let ids = dictionary[key] as? [Int] else {
return
}
self.purchasedIds = ids
}
最后,我们还需要调整我们在 TicketOffice 的使用方式。
private func updateCompanion() {
let ids = purchased.map { $0.id }
Connectivity.shared.send(movieIds: ids, delivery: .highPriority) {
print($0.localizedDescription)
}
}
Optional messages
请记住,交互式消息可能无法发送。虽然它们不适合我们的应用程序,但我们将对 Connectivity 进行适当的更新以支持它。
处理交互式消息采用可选的回复处理程序和可选的错误处理程序。如果你无法发送消息或无法接收请求回复,则会调用错误处理程序。最常见的错误原因是配对设备无法访问。
如果我们不期望来自对等设备的回复,则必须在调用
sendMessage(_:replyHandler:errorHandler:)时将 nil 作为参数传递给replyHandler。否则如果没有收到回复,它会生成错误。
不要直接从我们的自定义 send 方法中传递 replyHandler 和 errorHandler,因为这些处理程序将在后台线程上运行,而不是在主线程上运行。我们可以在 Connectivity 末尾添加以下来辅助我们:
extension Connectivity {
typealias OptionalHandler<T> = ((T) -> Void)?
private func optionalMainQueueDispatch<T>(
handler: OptionalHandler<T>
) -> OptionalHandler<T> {
guard let handler = handler else {
return nil
}
return { item in
DispatchQueue.main.async {
handler(item)
}
}
}
}
非二进制数据
可选消息可能会或可能不会期望来自对等设备的回复。因此,在 Connectivity 中为我们的 send 方法添加一个新的 replyHandler,如下所示:
public func send(
movieIds: [Int],
delivery: Delivery,
replyHandler: (([String: Any]) -> Void)? = nil,
errorHandler: ((Error) -> Void)? = nil
) {
将刚刚 .failable 情况下的 break 语句替换为以下内容:
case .failable:
WCSession.default.sendMessage(
userInfo,
replyHandler: optionalMainQueueDispatch(handler: replyHandler),
errorHandler: optionalMainQueueDispatch(handler: errorHandler))
要处理接收消息,请添加两个单独的委托方法:
func session(
_ session: WCSession,
didReceiveMessage message: [String : Any]
) {
update(from: message)
}
func session(
_ session: WCSession,
didReceiveMessage message: [String : Any],
replyHandler: @escaping ([String : Any]) -> Void
) {
update(from: message)
let key = ConnectivityUserInfoKey.verified.rawValue
replyHandler([key: true])
}
这里 Apple 实现了两个完全独立的委托方法,而不是一个带有可选回复处理程序的委托方法。
二进制数据
可选消息也可以传输二进制数据。
我们需要在 Connectivity 中使用单独的发送方法来处理 Data 类型。 我们可以将一些判断方法抽离:
extension Connectivity {
private func canSendToPeer() -> Bool {
guard WCSession.default.activationState == .activated else {
return false
}
#if os(watchOS)
guard WCSession.default.isCompanionAppInstalled else {
return false
}
#else
guard WCSession.default.isWatchAppInstalled else {
return false
}
#endif
return true
}
public func send(
movieIds: [Int],
delivery: Delivery,
replyHandler: (([String: Any]) -> Void)? = nil,
errorHandler: ((Error) -> Void)? = nil
) {
guard canSendToPeer() else { return }
// ...
}
接着我们实现处理二进制数据的方法:
public func send(
data: Data,
replyHandler: ((Data) -> Void)? = nil,
errorHandler: ((Error) -> Void)? = nil
) {
guard canSendToPeer() else { return }
WCSession.default.sendMessageData(
data,
replyHandler: optionalMainQueueDispatch(handler: replyHandler),
errorHandler: optionalMainQueueDispatch(handler: errorHandler)
)
}
接收二进制数据的另外两个委托方法:
func session(
_ session: WCSession,
didReceiveMessageData messageData: Data
) {
// Todo
}
func session(
_ session: WCSession,
didReceiveMessageData messageData: Data,
replyHandler: @escaping (Data) -> Void
) {
// Todo
}
传输文件
如果在 iOS 设备上运行该应用程序并购买电影票,我们会注意到电影详细信息包含一个二维码。但是,在 Apple Watch 上购买门票时不会显示二维码。
我们之前完成了一个名为 QRCode.swift 的文件,它使用 CoreImage 库生成二维码。但 CoreImage 在 watchOS 中不存在。
像这样的图像是一个很好的例子,我们可以选择使用文件传输。当我们在 Apple Watch 上购买门票时,iOS 设备会收到一条包含新电影列表的消息。这是让 iOS 设备生成二维码并将其发回的好时机。
将 QRCode.swift 移至 Shared。 然后将 watch extension 添加到 target membership,并将 CoreImage 的导入和 generate(movie:size:) 包装在编译器检查中:
import SwiftUI
#if canImport(CoreImage)
import CoreImage.CIFilterBuiltins
#endif
enum QRCode {
#if canImport(CoreImage)
static func generate(movie: Movie, size: CGSize) -> UIImage? {
// ...
}
#endif
}
当 Apple Watch 显示已购票的详细信息时,它需要知道在哪里查找二维码图像。继续添加代码:
#if os(watchOS)
static func url(for movieId: Int) -> URL {
let documents = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
)[0]
return documents.appendingPathComponent("\(movieId).png")
}
#endif
iOS 不需要查看文件 URL,但 watchOS 需要。 前面的代码获取应用程序文档目录的路径,然后将电影的 id 附加到路径中。
打开 Connectivity.swift 继续编辑 send 方法添加 wantQrCodes:
public func send(
movieIds: [Int],
delivery: Delivery,
wantedQrCodes: [Int]? = nil,
replyHandler: (([String: Any]) -> Void)? = nil,
errorHandler: ((Error) -> Void)? = nil
) {
然后,将 userInfo 从 let 更改为 var 并分配所需的二维码:
var userInfo: [String: [Int]] = [
ConnectivityUserInfoKey.purchased.rawValue : movieIds
]
if let wantedQrCodes = wantedQrCodes {
let key = ConnectivityUserInfoKey.qrCodes.rawValue
userInfo[key] = wantedQrCodes
}
这为 Apple Watch 提供了一种从 iOS 设备请求电影票的二维码的方法。
现在我们可以在 iOS 设备上运行以生成和发送二维码图像的方法:
#if os(iOS)
public func sendQrCodes(_ data: [String: Any]) {
let key = ConnectivityUserInfoKey.qrCodes.rawValue
guard let ids = data[key] as? [Int], !ids.isEmpty else { return }
let tempDir = FileManager.default.temporaryDirectory
TicketOffice.shared
.movies
.filter { ids.contains($0.id) }
.forEach { movie in
let image = QRCode.generate(
movie: movie,
size: .init(width: 100, height: 100)
)
guard let data = image?.pngData() else { return }
let url = tempDir.appendingPathComponent(UUID().uuidString)
guard let _ = try? data.write(to: url) else {
return
}
WCSession.default.transferFile(url, metadata: [key: movie.id])
}
}
#endif
如果传递给该方法的数据不包含需要二维码的 id 列表,则退出该方法。获取对应的电影后,生成一个具有唯一名称的临时文件并将 PNG 数据写入该文件。最后,向对等设备(即 Apple Watch)发起文件传输。元数据如何包含我们刚刚生成其二维码的电影的 id。
调整 update(from:) 方法:
private func update(from dictionary: [String: Any]) {
let key = ConnectivityUserInfoKey.purchased.rawValue
guard let ids = dictionary[key] as? [Int] else {
return
}
self.purchasedIds = ids
#if os(iOS)
sendQrCodes(dictionary)
#endif
}
下面完成接收侧代码:
#if os(watchOS)
func session(_ session: WCSession, didReceive file: WCSessionFile) {
let key = ConnectivityUserInfoKey.qrCodes.rawValue
guard let id = file.metadata?[key] as? Int else {
return
}
let destination = QRCode.url(for: id)
try? FileManager.default.removeItem(at: destination)
try? FileManager.default.moveItem(at: file.fileURL, to: destination)
}
#endif
将接收到的文件移动到正确的位置。
方法结束时,如果接收到的文件仍然存在,watchOS 将删除该文件。 如果我们希望保留该文件,则必须将其同步移动到新位置。
基础能力已经完成,来调整我们的业务代码,来到 TicketOffice,调整添加、删除电影票后的更新方法 updateCompanion:
private func updateCompanion() {
let ids = purchased.map { $0.id }
var wantedQrCodes: [Int] = []
#if os(watchOS)
wantedQrCodes = ids.filter { id in
let url = QRCode.url(for: id)
return !FileManager.default.fileExists(atPath: url.path)
}
#endif
Connectivity.shared.send(
movieIds: ids,
delivery: .highPriority,
wantedQrCodes: wantedQrCodes,
replyHandler: nil,
errorHandler: {
print($0.localizedDescription)
})
如果在 Apple Watch 上运行,我们可以识别所有尚未存储二维码的已购电影票。
最后,我们在 Movie.swift 中,提供读取二维码图片的能力:
extension Movie {
#if os(watchOS)
func qrCodeImage() -> Image? {
let path = QRCode.url(for: id).path
if let image = UIImage(contentsOfFile: path) {
return Image(uiImage: image)
} else {
return Image(systemName: "xmark.circle")
}
}
#endif
}
如果二维码存在于应有的位置,则将其作为 SwiftUI 图像返回。 如果图像不存在,则返回适当的默认图像。
最后回到 Flipped WatchKit Extension 的 MovieDetailsView,将该视图添加:
struct MovieDetailsView: View {
let movie: Movie
var body: some View {
ScrollView {
MovieInfoView(movie: movie)
if !TicketOffice.shared.isPurchased(movie) {
PurchaseTicketView(movie: movie)
} else {
movie.qrCodeImage()
}
}
}
}
构建并重新运行我们的应用程序。 从 Apple Watch 购买电影。我们将在详细信息屏幕底部看到二维码。
附件
-
Flipped 项目的所有文件请参考:github.com/LLLLLayer/A…
-
示例改编自 watchOS With SwiftUI by Tutorials www.raywenderlich.com/