跳转至正文

如何为您的 Capacitor 应用构建 Wear OS 应用

为您的 Capacitor 应用构建 Wear OS 应用:添加 Wear 模块,编写 Kotlin 手表端代码,并通过 @capgo/capacitor-watch. 通过 Data Layer 同步数据

如何为您的 Capacitor 应用构建 Wear OS 应用

要为您的 Capacitor 应用构建 Wear OS 应用,请在项目中添加一个原生 Wear OS 模块, android/ 为其配置与手机端应用相同的 application ID 和签名密钥,并使用以下方式连接两者: @capgo/capacitor-watch,它在 Android 上使用 Google 的 Wear OS Data Layer。手机端保留其 TypeScript code,而手表端运行一个通过 JSON 进行数据交换的轻量级 Kotlin 应用。

本指南将带你完成模块配置、插件使用的具体 Data Layer 路径、手表端的 Kotlin code、手机端的 TypeScript code、模拟器测试以及在 Google Play 上发布的全过程。

Capacitor 到 Wear OS 桥接的工作原理

Wear OS 没有 Capacitor 运行时,因此手表应用是原生的。该插件通过 Data Layer API (play-services-wearable) 与手机通信,你的手表应用也使用相同的协议。

插件方法 / 事件 Data Layer 机制 路径
sendMessage (手机到手表) MessageClient /capgo/message
messageReceived (手表到手机) MessageClient /capgo/message
messageReceivedWithReply MessageClient /capgo/message/withreply
replyToMessage MessageClient /capgo/reply/{callbackId}
updateApplicationContext / applicationContextReceived DataClient DataItem /capgo/context
transferUserInfo / userInfoReceived DataClient DataItem /capgo/userinfo/{uuid}

消息以原始字节形式携带 UTF-8 JSON。DataItems 则在 DataMap 键下以字符串形式携带相同的 JSON payload。手机通过查找 capability 来检查是否存在手表端应用 capgo_watch.

掌握这些路径是关键所在。一旦手表端能够读取和写入它们,所有的 plugin 方法都能正常工作。

准备工作

  • 一个已添加 Android 平台的 Capacitor 8 应用(升级指南 如果你版本较低)。
  • 安装了 Wear OS 系统镜像的最新版 Android Studio。
  • 配备 Google Play 服务的手机。Data Layer 依赖于此,因此没有 Play 服务的手机会报错 isSupported: false.
  • 已与 Android 手机配对的 Wear OS 手表或模拟器。与 iPhone 配对的 Wear OS 手表无法访问你的应用。

第 1 步:安装 plugin

bun add @capgo/capacitor-watch
bunx cap sync android

相同的 package 也可以处理 iOS 上的 Apple Watch,因此跨平台应用只需一次 import。请参阅 插件文档 查看完整参考资料,以及我们的 Apple Watch 指南 用于 iOS 端。

第 2 步:在 Android 项目中添加 Wear OS 模块

打开 android/ 在 Android Studio 中 (bunx cap open android) 并使用 文件 > 新建 > 新建模块 > Wear OS。让 Android Studio 自动生成模块可以确保 Gradle 文件与你的 Android Gradle Plugin 及 Kotlin 版本匹配,这比直接从博客文章中复制 build 文件更安全。为模块命名 wear.

向导将添加 include ':wear' 至 android/settings.gradle. 保留它,别动 Capacitor 管理的行。

Data Layer 强制执行的两条规则

  1. 相同 applicationId. 在 wear/build.gradle,设置为 applicationId 与……中的值完全一致 android/app/build.gradle. 其中 namespace 可以不同,但 application ID 不能不同。
  2. 使用相同的签名证书。 单机上的 Debug build 共用同一个 debug keystore,所以本地通常没问题,一到 release 就出问题。请用与手机 App 相同的 upload key 签名 Wear OS 的 release build。

如果违反其中任何一条规则,两个 App 就永远无法互相发现,code 里怎么改都修不好。

依赖项与最低 SDK 要求

Wear OS 3 及更高版本基于 API 30,因此 minSdk 30 是开发新手表应用的一个实用起点。添加 Data Layer 和协程助手:

dependencies {
    implementation "com.google.android.gms:play-services-wearable:20.0.1"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0"
    // Compose for Wear OS dependencies generated by the wizard stay as they are
}

声明功能权限

创建 wear/src/main/res/values/wear.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@array/android_wear_capabilities">
    <string-array name="android_wear_capabilities" translatable="false">
        <item>capgo_watch</item>
    </string-array>
</resources>

字符串必须为 capgo_watch,因为插件会查询该值进行计算 isWatchAppInstalled.

将应用标记为手表应用

在 wear/src/main/AndroidManifest.xml,向导会自动添加 <uses-feature android:name="android.hardware.type.watch" />。确定应用是否可以在脱离手机的情况下工作。如果只是仅显示手机数据的伴侣应用,应声明:

<meta-data
    android:name="com.google.android.wearable.standalone"
    android:value="false" />

步骤 3:在手表上接收数据

一个 WearableListenerService 即使在手表 UI 关闭时,系统也能推送 Data Layer 事件。使用以下过滤器进行注册: /capgo 路径:

<service
    android:name=".PhoneListenerService"
    android:exported="true">
    <intent-filter>
        <action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
        <data android:scheme="wear" android:host="*" android:pathPrefix="/capgo" />
    </intent-filter>
    <intent-filter>
        <action android:name="com.google.android.gms.wearable.DATA_CHANGED" />
        <data android:scheme="wear" android:host="*" android:pathPrefix="/capgo" />
    </intent-filter>
</service>

UI 可以观察的小型共享存储:

package com.example.shop.wear

import kotlinx.coroutines.flow.MutableStateFlow
import org.json.JSONObject

object WatchStore {
    val context = MutableStateFlow(JSONObject())
    val lastMessage = MutableStateFlow(JSONObject())
    val lastReply = MutableStateFlow(JSONObject())
}

服务解析每个路径:

package com.example.shop.wear

import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataEventBuffer
import com.google.android.gms.wearable.DataMapItem
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
import org.json.JSONObject

class PhoneListenerService : WearableListenerService() {

    override fun onMessageReceived(event: MessageEvent) {
        val json = JSONObject(String(event.data, Charsets.UTF_8))
        when {
            event.path == "/capgo/message" -> WatchStore.lastMessage.value = json
            event.path.startsWith("/capgo/reply/") -> WatchStore.lastReply.value = json
        }
    }

    override fun onDataChanged(events: DataEventBuffer) {
        for (event in events) {
            if (event.type != DataEvent.TYPE_CHANGED) continue
            val path = event.dataItem.uri.path ?: continue
            val payload = DataMapItem.fromDataItem(event.dataItem)
                .dataMap.getString("payload") ?: "{}"
            val json = JSONObject(payload)

            when {
                path == "/capgo/context" -> WatchStore.context.value = json
                path.startsWith("/capgo/userinfo/") -> handleUserInfo(json)
            }
        }
    }

    private fun handleUserInfo(json: JSONObject) {
        // Persist or act on queued events from the phone
    }
}

当手表 UI 打开时,直接读取当前上下文,以便显示应用关闭期间到达的数据:

suspend fun loadCurrentContext(context: android.content.Context) {
    val items = Wearable.getDataClient(context).dataItems.await()
    try {
        for (item in items) {
            if (item.uri.path == "/capgo/context") {
                val payload = DataMapItem.fromDataItem(item).dataMap.getString("payload") ?: "{}"
                WatchStore.context.value = JSONObject(payload)
            }
        }
    } finally {
        items.release()
    }
}

第 4 步:从手表发送数据

手表向每个连接的手机节点发送数据。消息用于实时交互,DataItems 用于状态管理。

package com.example.shop.wear

import android.content.Context
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.tasks.await
import org.json.JSONObject
import java.util.UUID

object PhoneLink {

    suspend fun send(context: Context, data: JSONObject, expectReply: Boolean = false) {
        val path = if (expectReply) "/capgo/message/withreply" else "/capgo/message"
        val bytes = data.toString().toByteArray(Charsets.UTF_8)
        val nodes = Wearable.getNodeClient(context).connectedNodes.await()
        for (node in nodes) {
            Wearable.getMessageClient(context).sendMessage(node.id, path, bytes).await()
        }
    }

    suspend fun updateContext(context: Context, data: JSONObject) {
        // Include a timestamp: identical DataItems are not redelivered
        data.put("updatedAt", System.currentTimeMillis())
        val request = PutDataMapRequest.create("/capgo/context").apply {
            dataMap.putString("payload", data.toString())
            setUrgent()
        }.asPutDataRequest()
        Wearable.getDataClient(context).putDataItem(request).await()
    }

    suspend fun queueEvent(context: Context, data: JSONObject) {
        val request = PutDataMapRequest.create("/capgo/userinfo/${UUID.randomUUID()}").apply {
            dataMap.putString("payload", data.toString())
            setUrgent()
        }.asPutDataRequest()
        Wearable.getDataClient(context).putDataItem(request).await()
    }
}

当手机收到 /capgo/userinfo/... 项时,它会发出 userInfoReceived 并删除该项,以防止队列无限增长。

使用一个极简的 Compose 界面将其整合:

@Composable
fun OrdersScreen() {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    val state by WatchStore.context.collectAsState()
    val reply by WatchStore.lastReply.collectAsState()

    LaunchedEffect(Unit) { loadCurrentContext(context) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text("${state.optInt("openOrders", 0)} open orders")
        Button(onClick = {
            scope.launch {
                PhoneLink.send(context, JSONObject().put("action", "refresh"), expectReply = true)
            }
        }) { Text("Refresh") }
        if (reply.has("count")) Text("Phone: ${reply.getInt("count")}")
    }
}

手机生成 callbackId,导致手表无法将回复与特定的请求匹配。如果你同时发送多个请求,请在 payload 中包含自定义的 request ID,并在回复中将其原样返回。

第 5 步:通过你的 Capacitor code 与手表通信

TypeScript 部分与你为 Apple Watch 编写的 code 完全一致:

import { Capacitor } from '@capacitor/core';
import { CapgoWatch } from '@capgo/capacitor-watch';

export async function initWearBridge(loadOpenOrders: () => Promise<number>) {
  if (Capacitor.getPlatform() === 'web') return;

  await CapgoWatch.addListener('messageReceivedWithReply', async ({ message, callbackId }) => {
    if (message.action === 'refresh') {
      const count = await loadOpenOrders();
      await CapgoWatch.replyToMessage({ callbackId, data: { count } });
    }
  });

  await CapgoWatch.addListener('applicationContextReceived', ({ context }) => {
    console.log('watch state', context);
  });

  await CapgoWatch.addListener('userInfoReceived', ({ userInfo }) => {
    console.log('queued event from watch', userInfo);
  });
}

export async function syncToWatch(openOrders: number) {
  const info = await CapgoWatch.getInfo();
  if (!info.isSupported || !info.isWatchAppInstalled) return;

  await CapgoWatch.updateApplicationContext({ context: { openOrders } });

  if (info.isReachable) {
    await CapgoWatch.sendMessage({ data: { toast: 'Orders updated' } });
  }
}

值得注意的 Android 特定事项:

  • sendMessage 当未连接任何手表节点时,会报错 “No connected Wear OS devices found”。
  • isPaired 和 isReachable 两者都表示“至少连接了一个 Wear OS 节点”。 isWatchAppInstalled 表示一个节点正在广播 capgo_watch.
  • 目前 Android 还不支持将 Reachability 作为事件推送。请在 getInfo() 需要获取最新值时进行调用,例如屏幕获得焦点时。
  • 手机端的待处理回复会在五分钟后过期。

选择使用哪个 channel

需求 使用 原因
实时按钮点击,手表应用即时打开 sendMessage / /capgo/message 响应极快,但若无节点连接则会丢失
手表应显示的内容 updateApplicationContext / /capgo/context 保留最新值并在稍后同步
绝不能丢失的事件 transferUserInfo / /capgo/userinfo/{uuid} 每个事件都是独立的 DataItem

Data Layer 的限制

  • DataItem 大小限制在 100 KB 左右。消息也应保持精简。建议发送 ID,再从后端获取具体内容。
  • 写入内容完全相同的 DataItem 不会触发变更事件。如果需要触发新事件,请添加时间戳。
  • 发送给手表的消息将启动您的 WearableListenerService,而不是您的 UI。
  • 在手机端,只要 Capacitor 应用进程在运行,插件的监听器就会一直存在。在进程停止期间发送的消息不会重新回放给 JavaScript。DataItems 会保留在 Data Layer 中,因此请务必使用 context 来处理手机后续需要查看的任何内容。
  • Payloads 为 JSON 对象。顶层数组和非 JSON 值将无法解析。

使用模拟器和真机进行测试

  1. 在 Device Manager 中,使用带有 Google Play 系统镜像的手机模拟器和 Wear OS 模拟器。
  2. 使用 Android Studio 的 Wear OS pairing assistant 进行配对。它会在手机模拟器上安装 companion app。
  3. 运行 wear 在手表模拟器上进行配置,然后在手机上运行您的 Capacitor 应用,并使用 bunx cap run android.
  4. 调用 CapgoWatch.getInfo() 来自手机。 isWatchAppInstalled: true 确认功能与 package 匹配。

对于实体手表,请在手表上启用开发者选项和 Wi-Fi ADB 调试,然后通过以下方式连接: adb connect <watch-ip>:<port>.

故障排除

isSupported: false. Google Play 服务或手机上缺少 Wear OS API。这在没有 Play 镜像的模拟器上很常见。

isWatchAppInstalled: false 当手表已连接时。 该 capgo_watch 缺少或拼写错误的 capability,或者该手表上未安装 Wear 应用。

消息无法送达手表。 不同的 applicationId 或不同的签名密钥。请对比 ./gradlew :app:signingReport 和 ./gradlew :wear:signingReport.

Context 更新停止接收。 您正在重复发送相同的 payload。请添加一个变化的字段。

Release build 在 debug 模式下正常,但在 Play Store 上无法运行。 如果你使用了 Play App Signing,则手机端和手表端的构建产物必须使用同一个 Play Console 应用中的相同签名密钥。

R8 删减了你的服务。 如果你使用了自定义 shrinking 规则,请确保仅保留 manifest 中引用的类。

发布到 Google Play

由于 Wear OS 应用与手机应用共享相同的包名,因此它们在同一个 Play Console 应用下发布。请在 Play Console 中添加 Wear OS 设备类型,将 wear bundle 上传至 Wear OS 发布轨道,并添加手表端截图。Google 会根据 Wear OS 质量指南审核应用,因此请务必检查圆形屏幕布局和触摸目标大小。

关于更新, 使用 Capgo Live Updates 无需通过商店发布即可向手机应用的 web 层推送修复,这可以解决 TypeScript bridge code 中的 bug。手表端的 Kotlin 更改仍需通过 Google Play 发布。请为你的 JSON payload 进行版本管理,以确保较新的手机 bundle 与较旧的手表 build 保持兼容。如果你在云端构建手机应用, Capgo 构建 会处理 Capacitor Android build 和签名,你可以使用 Android keystore 生成器.

总结

为 Capacitor 应用开发 Wear OS 伴侣应用,主要包含三个部分: @capgo/capacitor-watch 手机端应用、一个共享手机应用 ID 和签名密钥的 Kotlin 模块,以及 /capgo/* 两者之间的 Data Layer 路径。先搞定权限和签名,code 的开发过程就会非常快。

为 Capacitor 应用提供实时更新

当 Web 层 Bug 已上线时,通过 Capgo 推送修复,不必等 App Store 审核数天。用户会在后台获取更新,而原生变更仍走常规审核流程。

博客最新文章

Capgo 为你提供打造真正专业移动应用所需的深度洞察。