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을 raw bytes로 전달합니다. DataItems는 DataMap 키 아래에 같은 JSON을 string으로 전달합니다. payload. 폰은 capability를 조회해 워치 앱이 있는지 확인합니다. capgo_watch.
이 경로들을 아는 것이 핵심입니다. 워치가 이 경로들을 읽고 쓰기 시작하면 모든 플러그인 메서드가 작동합니다.
준비 사항
- Android 플랫폼이 추가된 Capacitor 8 앱 (업그레이드 가이드 버전이 낮다면 참고하세요).
- Wear OS 시스템 이미지가 설치된 최신 버전의 Android Studio.
- Google Play services가 설치된 폰. Data Layer는 이에 의존하므로, Play services가 없는 폰은 오류를 보고합니다.
isSupported: false. - Android 폰과 페어링된 Wear OS 워치 또는 에뮬레이터. iPhone과 페어링된 Wear OS 워치는 앱에 접근할 수 없습니다.
1단계: 플러그인 설치
bun add @capgo/capacitor-watch
bunx cap sync android
동일한 패키지가 iOS의 Apple Watch도 처리하므로, 크로스 플랫폼 앱은 import 하나만 필요합니다. 다음을 참고하세요. 플러그인 문서 전체 레퍼런스와 Apple Watch 가이드 iOS 관련 내용은 참고하세요.
2단계: Android 프로젝트에 Wear OS 모듈 추가
열기 android/ Android Studio에서 열고 (bunx cap open android) 그리고 File > New > New Module > Wear OS를 선택하세요. Android Studio가 모듈을 직접 생성하도록 하면 현재 사용 중인 Android Gradle Plugin과 Kotlin 버전에 맞는 Gradle 파일이 만들어져, 블로그 글에서 빌드 파일을 복사해 붙여넣는 것보다 안전합니다. 모듈 이름은 wear.
마법사가 다음 항목을 추가합니다 include ':wear' 대상 android/settings.gradle. 이 부분을 유지하고, Capacitor가 관리하는 줄은 그대로 두세요.
Data Layer가 강제하는 두 가지 규칙
- 동일
applicationId. 다음에서wear/build.gradle, 설정applicationId에 정확히 해당 값으로android/app/build.gradle.namespace는 다를 수 있지만, 애플리케이션 ID는 다를 수 없습니다. - 동일한 서명 인증서. 한 대의 기기에서 디버그 빌드를 실행하면 디버그 키스토어가 공유되므로 로컬에서는 잘 동작하다가 릴리스 환경에서 깨지는 경우가 많습니다. 웨어러블 릴리스 빌드를 폰 앱과 동일한 업로드 키로 서명하세요.
두 규칙 중 하나라도 어겨지면 두 앱이 서로를 인식하지 못하며, 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
}
Capability 선언
생성 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에 자신의 요청 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노드가 …를 advertise한다는 의미입니다capgo_watch.- 현재 Android에서는 Reachability가 이벤트로 푸시되지 않습니다. 호출하세요
getInfo()새로운 값이 필요할 때, 예를 들어 화면이 포커스를 받을 때. - 대기 중인 응답은 폰 측에서 5분 후 만료됩니다.
사용할 채널 선택
| 필요 기능 | 사용 | 이유 |
|---|---|---|
| 라이브 버튼 누름, 워치 앱 열기 | sendMessage / /capgo/message |
빠르지만 노드가 연결되지 않으면 드롭됩니다 |
| 워치가 표시해야 할 내용 | updateApplicationContext / /capgo/context |
최신 값이 유지되고 나중에 동기화됩니다 |
| 잃어버리면 안 되는 이벤트 | transferUserInfo / /capgo/userinfo/{uuid} |
각 이벤트는 자체 DataItem입니다 |
데이터 레이어의 한계
- DataItems는 약 100KB로 제한됩니다. 메시지도 작게 유지하세요. ID를 보내고 콘텐츠는 백엔드에서 가져오세요.
- 내용이 같은 DataItem을 써도 변경 이벤트는 발생하지 않습니다. 새 이벤트가 필요하면 타임스탬프를 추가하세요.
- 워치로 메시지를 보내면
WearableListenerService가 시작되며, UI는 시작되지 않습니다. - 휴대폰에서는 Capacitor 앱 프로세스가 실행되는 동안에만 플러그인의 리스너가 존재합니다. 프로세스가 종료된 상태에서 보낸 메시지는 JavaScript로 다시 전달되지 않습니다. DataItems는 데이터 레이어에 남아 있으므로, 휴대폰이 나중에 확인해야 하는 내용에는 context를 사용하세요.
- 페이로드는 JSON 객체입니다. 최상위 배열과 JSON이 아닌 값은 파싱되지 않습니다.
에뮬레이터와 디바이스 테스트
- Device Manager에서 Google Play 시스템 이미지가 포함된 휴대폰 에뮬레이터와 Wear OS 에뮬레이터를 만드세요.
- Android Studio의 Wear OS 페어링 어시스턴트로 페어링하세요. 휴대폰 에뮬레이터에 companion 앱이 설치됩니다.
- 워치 에뮬레이터에서
wear해당 구성으로 실행한 다음, 휴대폰에서 Capacitor 앱을 다음 명령으로 실행하세요:bunx cap run android. - 호출하세요
CapgoWatch.getInfo()전화에서isWatchAppInstalled: truecapability과 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.
컨텍스트 업데이트가 더 이상 도착하지 않습니다. 동일한 페이로드를 반복적으로 작성하고 있습니다. 변경되는 필드를 추가하세요.
Release build가 디버그에서는 작동하지만 Play Store에서는 작동하지 않습니다. Play App Signing을 사용한다면, 휴대폰과 워치 아티팩트 모두 같은 Play Console 앱에서 동일한 앱 서명 키로 서명해야 합니다.
R8이 서비스를 제거합니다. 커스텀 shrinking 규칙을 사용하는 경우, manifest에서만 참조되는 클래스는 유지하세요.
Google Play에 게시하기
Wear OS 앱은 패키지 이름을 공유하므로 휴대폰 앱과 동일한 Play Console 앱으로 배포됩니다. Play Console에 Wear OS 폼 팩터를 추가하고, Wear OS 릴리스 트랙에 wear bundle을 업로드한 뒤 워치 스크린샷을 추가하세요. Google은 Wear OS 품질 가이드라인에 따라 Wear OS 앱을 검토하므로, 원형 화면 레이아웃과 터치 타겟 크기를 확인해야 합니다.
업데이트의 경우, Capgo Live Update 사용하기 스토어 출시 없이 휴대폰 앱의 웹 레이어에 수정 사항을 푸시할 수 있어, TypeScript 브릿지 code의 버그를 해결할 수 있습니다. 워치의 Kotlin 변경 사항은 Google Play를 통해 반영됩니다. 최신 휴대폰 bundle이 이전 워치 빌드와 호환되도록 JSON 페이로드에 버전을 지정하세요. 클라우드에서 휴대폰 앱을 빌드한다면, Capgo 빌드 Capacitor가 Android 빌드와 서명을 처리하며, 다음을 사용해 일치하는 keystore를 생성할 수 있습니다. Android 키스토어 생성기.
마무리
Capacitor 앱을 위한 Wear OS 컴패니언은 세 가지 요소로 구성됩니다: @capgo/capacitor-watch 휴대폰에서는 휴대폰의 application ID와 서명 키를 공유하는 Kotlin 모듈, 그리고 /capgo/* 그 사이의 Data Layer 경로입니다. capability와 서명을 먼저 제대로 맞추면 code 부분은 금방 끝납니다.