Apple Watch Companion для Flutter-приложений

Apple Watch Companion для Flutter-приложений

FlutterPulse

Эта статья переведена специально для канала FlutterPulse. В этом канале вы найдёте много интересных вещей, связанных с Flutter. Не забывайте подписываться! 🚀

Узнайте, как добавить приложение-компаньон для Apple Watch в ваши приложения Flutter. Рассмотрим, как делиться данными между Flutter на телефоне и часах

На Fluttercon Europe 2025 я выступил с докладом о том, как создать приложение-компаньон для Apple Watch для приложения Flutter. Вы можете найти запись и слайды. Если вы хотите сразу перейти к примеру кода, вы можете найти пример приложения.

В этой статье я расскажу о необходимых шагах настройки, как делиться данными и как отображать данные из вашего приложения Flutter в приложении для Apple Watch.

Добавление приложения для Apple Watch

Чтобы начать добавление приложения для часов, откройте Xcode и перейдите: File > New > Target > watchOS > App и заполните метаданные, убедившись, что вы выбрали Watch App for Existing iOS App и выбрали ваше приложение Flutter Runner

Новая цель

Цель watchOS

Приложение для часов для существующего iOS-приложения


WatchConnectivity

Теперь, когда вы запускаете своё приложение на iPhone, вы можете увидеть, что ваше приложение доступно для установки в приложении Watch на вашем телефоне. Однако простое наличие приложения-компаньона — это не совсем то, что нам нужно. Мы хотим делиться данными между приложениями на телефоне и часах.

Начиная с watchOS 2 (которая была выпущена в 2015 году) вам нужно использовать WatchConnectivity. Хотя API изменился в некоторых аспектах, основная концепция по-прежнему сосредоточена вокруг наличия WCSession и WCSessionDelegate. Принцип работы заключается в подключении SessionDelegate к сессии как из приложения на телефоне, так и из приложения на часах.

Конечно, вы можете самостоятельно обернуть нативные API, чтобы ваше приложение Flutter могло подключаться к сессии. Однако вы также можете использовать сообщество Flutter и пакеты с pub.dev, где кто-то уже мог это сделать. В случае с Watch Connectivity уже существует несколько плагинов для этого. В оставшейся части этой статьи я буду использовать пакет flutter_watch_os_connectivity

Подключение к сессии из Flutter

Используя плагин, подключиться и активировать сессию на самом деле очень просто.

final watchConnectivity = FlutterWatchOsConnectivity();
await watchConnectivity.configureAndActivateSession()

Вот и всё. Ваше приложение Flutter подключено к WatchSession. Плагин также предлагает колбэки, такие как getActivateState для проверки состояния сессии, getPairedDeviceInfo для проверки, подключены ли часы в данный момент, и getReachability для проверки, подключено ли приложение для часов/доступно ли оно в данный момент. Все эти функции также предлагают прослушивание изменений через стандартный Dart Stream

Session Delegate на часах

Как упоминалось, сессия также должна быть подключена из приложения для часов.

Для этого добавьте новый Swift-файл, чтобы создать делегат. И добавьте следующий код:

class FlutterWatchDelegate: NSObject, WCSessionDelegate {
  override init() {
    super.init()
    // Получаем сессию по умолчанию
    let session = WCSession.default
    // Устанавливаем этот экземпляр как делегат
    session.delegate = self
    // Активируем сессию
    session.activate()
  }

  func session(
    _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState,
    error: (any Error)?
  ) {
    print("Activation Completed: \(session.activationState)")
    // После подключения можно инициализировать с начальными данными
  }
}

Чтобы использовать делегат (и вызвать логику инициализации), добавьте его как переменную в ваше приложение для часов

@main
struct FlutterAppleWatchApp: App {
var delegate: FlutterWatchDelegate = FlutterWatchDelegate()

  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

Обмен данными

Существует несколько способов обмена данными между приложением на телефоне и приложением на часах.

Общий контекст приложения

Apple предоставляет способ иметь общий контекст приложения, который является хранилищем ключ-значение между приложениями на телефоне и часах. Особенно полезно, если вы хотите обновлять данные, когда оба приложения не открыты одновременно.

Отправка данных из Flutter на часы

Плагин предлагает простой способ отправить Map<String, dynamic> из Flutter в контекст приложения.

final data = <String, dynamic>{'key': value};
await watchConnectivity.updateApplicationContext(data);

Получение данных на часах

У WCSessionDelegate есть функция, которую можно переопределить, и она будет вызываться, когда ApplicationContext обновляется.

class FlutterWatchDelegate: NSObject, WCSessionDelegate {
  // ...
  var myData: Int = 0

  // Переопределяем didReceiveApplicationContext, чтобы вызываться при обновлении контекста
  func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
    myData = applicationContext["key"] as Int?
  }
}

Когда сессия активируется впервые, вы также можете использовать сессию, чтобы получить текущий контекст приложения.

func session(
  _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState,
  error: (any Error)?
) {
  print("[WATCH] Activation Completed: \(session.activationState)")
  if session.activationState == WCSessionActivationState.activated {
    let initialContext = session.applicationContext
    // Используйте контекст по вашему усмотрению
  }
}

Отправка данных с часов во Flutter

Отправка данных в обратном направлении довольно проста. Чтобы обновить контекст приложения в обратном направлении, вы можете просто вызвать updateApplicationContext на сессии следующим образом:

func updateData(value: Any?) {
  try? WCSession.default.updateApplicationContext(["key": value])
}

Получение данных с часов в Flutter

Плагин предлагает способ получить applicationContext в Flutter как Future и как Stream

// Как Future
final receivedContext = await watchConnectivity.getApplicationContext();

// Обновления как Stream
final updates = watchConnectivity.applicationContextUpdated
    .listen((applicationContext) {
      // Обработка обновлений
    });  

Обратите внимание, что полученный объект имеет currentContext и receivedContext. Это отражает базовую структуру в Swift API. Это может быть немного запутанным, особенно когда приложения на телефоне и часах открыты одновременно и обмениваются данными в обоих направлениях. Поэтому в таких случаях следует выбирать другие подходы к обмену данными.

Более подробную информацию о общем контексте приложения можно найти в Документации Apple.

Отправка сообщений

Ещё один способ обмена данными — отправка сообщений в виде Map<String, dynamic> между двумя приложениями. Обратите внимание, что перед отправкой следует проверить, доступно ли приложение-получатель (reachable). Синтаксис для отправки и получения сообщений очень похож на отправку контекста приложения.

// Отправка сообщения из Flutter на Apple Watch
final messageData = <String, dynamic>{};
await watchConnectivity.sendMessage(messageData);

// Получение данных в Stream
watchConnectivity.messageReceived.listen((message) async {    
    /// Получено новое сообщение, можно прочитать его данные
    receivedMessageData = message.data;
});

Ознакомьтесь с документацией плагина для получения дополнительной информации, а также о том, как отвечать на сообщения.

На стороне Apple Watch отправка/получение сообщений также очень похожа.

// Переопределение в делегате для получения сообщений
func session(
    _ session: WCSession,
    didReceiveMessage message: [String : Any]
) {
  // Обработка полученного сообщения
}

// Отправка сообщений
func sendMessage(message: [String : Any]) {
  try? WCSession.default.sendMessage(message)
}

Ознакомьтесь с Документацией Apple для получения дополнительной информации об отправке сообщений и получении сообщений.

Информация о пользователе

Информация о пользователе — ещё один способ, который Apple позволяет использовать для отправки данных в виде Map<String, dynamic>. Основное отличие здесь в том, что у Apple есть немного другие правила о том, как получение UserInfo может уведомлять приложение в фоновом режиме, что делает User Info основным кандидатом для отправки данных для осложнений. В качестве напоминания я напишу продолжение статьи на тему подключения Flutter к приложению Apple Watch и осложнениям в другой статье, поэтому не забудьте подписаться на обновления.

Отправка данных снова работает очень похоже.

await watchConnectivity.transferUserInfo({
    "key": "value"
});

Примечание: Возможно, вас соблазнит использовать параметр isComplication при отправке данных для осложнения, однако базовая технология не поддерживается (пока) после перехода Apple на WidgetKit. Однако в моих экспериментах обновление осложнения работало и без этого. Подробнее об этом в следующей статье.

Получение данных с часов осуществляется аналогично путём переопределения соответствующей функции в делегате. Более подробная информация снова в Документации Apple

func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
  // Обработка информации о пользователе для осложнения
}

Файлы

WatchOS Connectivity также позволяет передавать файлы между платформами. Это можно использовать, например, для отправки скриншота виджета Flutter для отображения на часах (например, вашей hero-графики) или для передачи файлов базы данных или ответов от бэкенда. Отправка файла снова следует другим методам.

final baseDirectory = await getApplicationSupportDirectory();    
final file = File('${baseDirectory.path}/$myData.json');
final transfer = await watchConnectivity.transferFileInfo(file);

transfer?.setOnProgressListener((progress) {
  debugPrint('Progress: ${progress.currentProgress}');
});

Обратите внимание, что при получении файла этот файл доступен только во время соответствующего вызова функции в делегате приложения для часов. Поэтому, чтобы не потерять его, необходимо сохранить его в каталоге документов приложения и использовать эту копию при использовании файла за пределами функции.

func session(_ session: WCSession, didReceive file: WCSessionFile) {
  let sourceURL = file.fileURL
  let fileManager = FileManager.default
  let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
  let destinationURL = documentsURL.appendingPathComponent(sourceURL.lastPathComponent)

  try? fileManager.moveItem(at: sourceURL, to: destinationURL)

  useFile()
}

Чтобы прочитать файл, например, когда приложение подключается к сессии на телефоне, можно сделать что-то вроде:

func session(
  _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState,
  error: (any Error)?
) {
  if session.activationState == WCSessionActivationState.activated {
    checkFile()
  }
}

var fileUrl : Url?


func checkFile() {
  let fileManager = FileManager.default
  let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
  let imageUrl = documentsURL.appendingPathComponent("myImage.png")

  if fileManager.fileExists(atPath: imageUrl.path()) {
    // File exists
    fileUrl = imageUrl
  } else {
    // File does not exist
    fileUrl = nil
  }
}

Отображение данных из Flutter в приложении Apple Watch

Теперь, когда мы рассмотрели, как отправлять данные, отображение данных и обновление интерфейса также может быть довольно простым с использованием встроенных решений для управления состоянием из SwiftUI. Мы можем использовать аннотации @Observable и @State для автоматической передачи обновлений в слой интерфейса.

Наблюдение за SessionDelegate

Используя аннотацию @Observable на FlutterWatchDelegate, он может получать обновления всякий раз, когда изменяется переменная (в примере изменяется count)

// Mark as Observable for updates
@Observable
class FlutterWatchDelegate: NSObject, WCSessionDelegate {

  // Count variable
  var count: Int = 0

  override init() {
    super.init()
    let session = WCSession.default
    session.delegate = self
    session.activate()
  }

  func session(
    _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState,
    error: (any Error)?
  ) {
    if session.activationState == WCSessionActivationState.activated {
      // Get initial count
      updateCount(applicationContext: session.applicationContext)
    }
  }

  // Update Count when new application context is received
  func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any])
  {
    updateCount(applicationContext: applicationContext)
  }

  func updateCount(applicationContext: [String: Any]) {
    if let newCount = applicationContext["count"] as? Int {
      count = newCount
    }
  }
}

Использование делегата в качестве состояния

Теперь, когда FlutterWatchDelegate может наблюдаться, мы можем использовать его в приложении для часов, чтобы автоматически получать обновления

@main
struct FlutterAppleWatchApp: App {
  @State var delegate: FlutterWatchDelegate = FlutterWatchDelegate()

  var body: some Scene {
    WindowGroup {
      ContentView(
        delegate: delegate
      )
    }
  }
}

Использование и отображение данных

Передавая delegate в представление, мы можем перестроить интерфейс с его значением.

struct ContentView: View {
  let delegate: FlutterWatchDelegate

  var body: some View {
    List {
      // Using the count from the delegate
      // .description is the Swift equivalent of toString()
      Text(delegate.count.description)
      Button(
        "Increment",
        action: delegate.incrementCounter
      )     
    }
  }
}

Как видно в примере выше, мы вызываем метод incrementCounter на делегате. Это простой способ включить двустороннюю передачу данных, как описано в предыдущем разделе.

Соответствующий метод на делегате может выглядеть примерно так:

func incrementCounter() {
  count = count + 1

  try? WCSession.default.updateApplicationContext(["count": count])
}

На что обратить внимание

Есть несколько проблем, которые могут возникнуть и помешать вам собрать приложение (особенно в режиме release)

Убедитесь, что фаза "Thin Binary" происходит после "Embed Watch Content"

Перетащите шаг Thin Binary после шага Embed Watch Content в Runner > Build Phases

Перетащите "Thin Binary" в конец списка шагов


Убедитесь, что настройки сборки Watch Target указывают на правильную платформу

Есть проблема где Flutter переопределяет платформу Watch и не может правильно собрать приложение.

Чтобы обойти это, вам нужно вручную установить Watch App > Build Settings > Supported Platforms для сборок Profile и Release на watchOS

Установите Supported Platforms на watchOS для Profile и Release


Заключительные мысли

Хотя начальная настройка может быть немного капризной, а работа с ошибками сборки Xcode определенно не доставит удовольствия, этот процесс показывает, что создание Watch Companion Apps для Flutter-приложений возможно.

Означает ли это, что вам всем нужно сразу же создавать Watch Companion Apps? Возможно! Все зависит от вариантов использования вашего приложения и, что самое главное, принесет ли это пользу вашим пользователям?

В итоге Flutter-приложение устанавливается так же, как и нативное. Поэтому мы можем использовать все преимущества Flutter (написать код один раз и запустить его где угодно, Hot Reload, …) плюс использовать API для интеграции с платформой в сочетании с огромной экосистемой на pub.dev, чтобы максимально эффективно использовать функции, специфичные для платформы, и обеспечить наилучший пользовательский опыт!


Report Page