运用 WatchConnectivity 进行数据交互

Apple Watch 体会的优势之一就在于 watchOS App 与 iOS App 之间的无缝交互。

WatchConnectivity

「Apple Watch 应用开发系列」使用 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… 的项目文件。

数据源与数据模型

首先创立项目。

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

调整咱们的文件夹,iOS 与 watchOS 共用的文件将存放于 Shared 文件夹中。

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

首先在 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())
    }
}

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

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()
    }
}

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

MovieRow

由于 iOS 和 watchOS 在上述视图中有些需求的差异化,包括跳转的 List 的 Row 款式、以及跳转到的方针页面。咱们将在上面的代码上,做一些差异化。分别在 Flipped 和 Flipped WatchKit Extension 新建文件 MovieRow.swiftMovieDetailsView.swift

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

记得增加资源,它将在 iOS 设备上展现电影信息时运用。

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

咱们看 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())
    }
}

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

咱们看 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())
    }
}

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

下面回到咱们的翻开 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()
    }
}

咱们将看到:

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

MovieDetailsView

咱们来看下电影票的概况页面,在这个页面中咱们将展现两种款式:

  1. 未购买的款式,将带着购买按钮。

  2. 已购买的款式,将展现电影票二维码。

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)
    }
}

咱们的二维码视图将展现。

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

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())
    }
}

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

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())
    }
}

它最终将展现两种或许的形态:

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

下面咱们来看下 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())
    }
}

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

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()
    }
}

终究,假如咱们有已购的电影票,会展现为以下款式:

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

咱们再调整 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()
    }
}

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

ConnectivityUserInfoKey

咱们后续将运用的字段,请在 Shared 新增文件 ConnectivityUserInfoKey.swift

enum ConnectivityUserInfoKey: String {
  case purchased
  case qrCodes
  case verified
}

模版终究效果

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

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) {
    }
}

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

#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> 中。

现在,咱们能够体会咱们的运用程序:

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

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 办法中传递 replyHandlererrorHandler,由于这些处理程序将在后台线程上运转,而不是在主线程上运转。咱们能够在 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
    ) {

然后,将 userInfolet 更改为 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 购买电影。咱们将在详细信息屏幕底部看到二维码。

「Apple Watch 应用开发系列」使用 WatchConnectivity 进行数据交互

附件

  • Flipped 项目的所有文件请参阅:github.com/LLLLLayer/A…

  • 示例改编自 watchOS With SwiftUI by Tutorials www.raywenderlich.com/