본문 바로가기

Capacitor 앱을 위한 Wear OS 앱 빌드 방법

Capacitor 앱용 Wear OS 앱 빌드하기: Wear 모듈 추가, Kotlin 워치 사이드 작성, @capgo/capacitor-watch.를 통한 Data Layer 데이터 동기화

Capacitor 앱을 위한 Wear OS 앱 빌드 방법

Capacitor 앱을 위한 Wear OS 앱을 빌드하려면, 프로젝트에 android/ 네이티브 Wear OS 모듈을 추가하고, 휴대폰 앱과 동일한 application ID 및 signing key를 부여한 뒤 두 앱을 @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가 강제하는 두 가지 규칙

  1. 동일함 applicationId. 내부 wear/build.gradle, 설정 applicationId 의 값과 정확히 일치해야 합니다 android/app/build.gradle. namespace 는 다를 수 있지만, application ID는 다를 수 없습니다.
  2. 동일한 서명 인증서 사용. 한 기기에서 빌드한 디버그 버전은 디버그 keystore를 공유하므로, 로컬에서는 잘 작동하다가 릴리스 시 문제가 생길 수 있습니다. Wear OS 릴리스 빌드도 휴대폰 앱과 동일한 upload key로 서명하세요.

두 규칙 중 하나라도 어긋나면 두 앱은 서로를 인식할 수 없으며, code의 어떤 기능으로도 해결할 수 없습니다.

의존성 및 최소 SDK

Wear OS 3 이상은 API 30을 기반으로 하므로, minSdk 30 새로운 워치 앱을 위한 실질적인 기준이 됩니다. Data Layer와 coroutine 헬퍼를 추가하세요:

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.

앱을 워치 앱으로 표시

In 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단계: 워치에서 데이터 수신

A 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, 따라서 워치가 특정 요청에 대한 응답을 매칭할 수 없습니다. 여러 요청을 동시에 보낸다면, 페이로드에 자체 request ID를 포함하고 응답 시 이를 다시 돌려주도록(echo) 구현하세요.

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임

Data Layer의 제한 사항

  • DataItems는 약 100 KB로 제한됩니다. 메시지도 작게 유지하세요. ID를 전송하고 백엔드에서 콘텐츠를 가져오는 방식을 권장합니다.
  • 동일한 내용의 DataItem을 작성하면 변경 이벤트가 발생하지 않습니다. 새로운 이벤트가 필요하면 타임스탬프를 추가하세요.
  • 워치로 보내는 메시지는 WearableListenerService, UI가 아닌 시작점입니다.
  • 폰에서는 Capacitor 앱 프로세스가 실행되는 동안에만 플러그인 리스너가 동작합니다. 프로세스가 종료된 동안 전송된 메시지는 JavaScript로 다시 재생되지 않습니다. DataItems는 Data Layer에 유지되므로, 폰이 나중에 확인해야 하는 데이터는 반드시 context를 사용하세요.
  • Payload는 JSON 객체여야 합니다. 최상위 레벨이 배열이거나 JSON 형식이 아닌 값은 파싱되지 않습니다.

에뮬레이터 및 기기 테스트

  1. Device Manager에서 Google Play 시스템 이미지를 포함한 폰 에뮬레이터와 Wear OS 에뮬레이터를 생성하세요.
  2. Android Studio의 Wear OS pairing assistant를 사용하여 두 기기를 페어링하세요. 폰 에뮬레이터에 컴패니언 앱이 설치됩니다.
  3. 다음 명령어를 실행하세요: wear 워치 에뮬레이터에서 설정을 실행한 후, 폰에서 Capacitor 앱을 bunx cap run android.
  4. Call CapgoWatch.getInfo() 를 통해 실행하세요. isWatchAppInstalled: true 는 기능 및 패키지 일치 여부를 확인합니다.

실제 워치를 사용하는 경우, 워치에서 개발자 옵션과 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 업데이트가 중단되었습니다. 동일한 페이로드를 반복해서 보내고 있습니다. 변경되는 필드를 추가하세요.

Release build는 디버그에서는 작동하지만 Play Store에서는 작동하지 않습니다. Play App Signing을 사용하는 경우, 휴대폰과 워치 아티팩트 모두 같은 Play Console 앱에서 동일한 앱 서명 키로 서명해야 합니다.

R8이 서비스를 제거(strip)합니다. 커스텀 shrinking 규칙을 사용한다면 manifest에서만 참조되는 클래스는 유지(keep)하세요.

Google Play에 게시하기

Wear OS 앱은 패키지 이름을 공유하므로 휴대폰 앱과 동일한 Play Console 앱으로 배포됩니다. Play Console에 Wear OS 폼 팩터를 추가하고, Wear OS 릴리스 트랙에 wear bundle을 업로드한 뒤 워치 스크린샷을 추가하세요. Google은 Wear OS 품질 가이드라인에 따라 앱을 검토하므로 원형 화면 레이아웃과 터치 타겟 크기를 확인해야 합니다.

업데이트의 경우, Capgo Live Update 사용하기 스토어 출시 없이 휴대폰 앱의 웹 레이어에 수정 사항을 푸시할 수 있어 TypeScript bridge code의 버그를 커버합니다. 워치의 Kotlin 변경 사항은 Google Play를 통해 배포됩니다. 최신 휴대폰 bundle이 이전 워치 빌드와 호환되도록 JSON 페이로드에 버전을 지정하세요. 클라우드에서 휴대폰 앱을 빌드하면, Capgo 빌드 Capacitor Android 빌드 및 서명을 처리하며, 다음 도구로 일치하는 keystore를 생성할 수 있습니다. Android keystore 생성기.

요약

Capacitor 앱을 위한 Wear OS 컴패니언 앱은 다음 세 가지 요소로 구성됩니다: @capgo/capacitor-watch 휴대폰 앱, 휴대폰의 application ID와 signing key를 공유하는 Kotlin 모듈, 그리고 /capgo/* 그 사이를 잇는 Data Layer 경로입니다. 권한과 signing을 먼저 제대로 설정하면, code 구현은 금방 끝납니다.

Capacitor 앱을 위한 Live Update

웹 레이어 버그가 발생했을 때, 앱 스토어 승인을 며칠씩 기다릴 필요 없이 Capgo로 즉시 수정 사항을 배포하세요. 네이티브 변경 사항은 기존 리뷰 절차를 따르되, 사용자는 백그라운드에서 업데이트를 즉시 받을 수 있습니다.

최신 블로그 소식

Capgo는 진정한 프로페셔널 모바일 앱을 만드는 데 필요한 최적의 인사이트를 제공합니다.