运用 WatchConnectivity 进行数据交互
Apple Watch 体会的优势之一就在于 watchOS App 与 iOS App 之间的无缝交互。
WatchConnectivity
developer.apple.com/documentati…
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/