Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

⌚ Wear OS | 센서 수명주기·권한·동기화 연결로 기본 데이터 파이프 완성

Billcorea 2025. 12. 5. 15:15
반응형

 

⌚ Wear OS | 센서 수명주기·권한·동기화 연결로 기본 데이터 파이프 완성

워치, 모바일

 

개요 (Intro)

  • 오늘의 목표: Wear OS에서 걸음 수/헤딩/고도/위치를 안정적으로 수집하고, 폰과 동기화까지 이어지는 파이프를 작동 상태로 만들기
  • 배경: 기존 코드에 센서 시작/중지, 런타임 권한, 폰-웨어 메시지 등록이 일부 누락되어 실사용에 불편함
  • 사용 기술: Kotlin, Jetpack Compose, Hilt, Google Play Services (Wearable/Location), SensorManager
📅 날짜: 2025.12.05
🎯 목표: 센서 수명주기 연결 + 권한 요청 + 동기화 작동 상태 만들기
🧰 기술: Kotlin, Compose, Hilt, Wearable Data Layer, SensorManager

문제 정의 (Problem / Motivation)

  • 센서 매니저(PassiveSteps/Heading/Altitude)가 start()/stop()로 생명주기에 연결되지 않음
  • WearDataSyncManager의 register()/unregister() 미호출로 메시지 수신/전송 상태 반영이 어려움
  • 걸음 업데이트와 PDR(보행자 추정항법)·스텝 전송의 연계가 빠져 있음
  • 런타임 권한(특히 ACTIVITY_RECOGNITION) 요청 흐름이 없어 첫 실행 경험이 매끄럽지 않음
// 예시: LiveData가 아니라 Compose state를 쓰고 있으나, 문제의 본질은 동일
// "센서 시작/중지"와 "동기화 리스너"가 Activity 수명주기에 묶여야 한다는 점.
val passiveStepsManager = remember(context) { PassiveStepsManager(context, scope) { total, delta ->
    // 1) UI 업데이트
    steps = total
    lastDelta = delta
    // 2) PDR 누적 (현재 헤딩 반영)
    pdr.onStep(delta, headingRad)
    // 3) 폰으로 스텝 동기화 (내부 스로틀 보유)
    syncManager.sendSteps(total)
} }

해결 과정 (How I Solved It)

  1. 런타임 권한 요청을 Activity에 추가: 위치 + 활동 인식 분리 요청
  2. 센서·동기화 수명주기 연결: Compose에서 DisposableEffect로 start/stop, register/unregister 보장
  3. 걸음 → PDR → 동기화 파이프: onStepsUpdate에서 PDR 누적과 sendSteps 호출
  4. 위치 스트리밍은 임계치/간격/enable 토글 기준으로 collect하며 폰으로 전송
// 1) 권한 요청 런처(위치 + 활동 인식)
val permissionLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.RequestMultiplePermissions()
) { result ->
    val locGranted = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
                     result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
    val actGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
        result[Manifest.permission.ACTIVITY_RECOGNITION] == true else true
    hasLocationPermission = locGranted
    hasActivityRecognitionPermission = actGranted
}

// 2) 센서와 동기화 수명주기 연결 (Activity 내 Composable)
DisposableEffect(Unit) {
    // 센서 시작
    headingProvider.start()                 // 회전벡터 → heading
    altitudeMonitor.startMonitoring()       // 기압 → 고도
    passiveStepsManager.start()             // 스텝 카운터

    // 동기화 리스너 등록 + 현지 스텝 제공자 설정
    syncManager.localStepsProvider = { steps }
    syncManager.register()

    // 컴포저블이 사라질 때 정리
    onDispose {
        passiveStepsManager.stop()
        headingProvider.stop()
        altitudeMonitor.stopMonitoring()
        syncManager.unregister()
    }
}

// 3) 걸음 업데이트 -> PDR 누적 및 스텝 전송
val passiveStepsManager = remember(context) {
    PassiveStepsManager(context, scope) { total, delta ->
        steps = total
        lastDelta = delta
        pdr.onStep(delta, headingRad)   // 보행자 경로 누적
        syncManager.sendSteps(total)    // 폰으로 전송(스로틀 내장)
    }
}
// 4) 위치 ENU 흐름을 폰으로 전송 (임계/간격/토글 기준)
LaunchedEffect(enuFlow, xyzIntervalMs, streamEnabled) {
    if (enuFlow == null || !streamEnabled) return@LaunchedEffect
    var lastSentX = Double.NaN
    var lastSentY = Double.NaN
    var lastSentZ = Double.NaN
    var lastSentHeading = Double.NaN
    var lastSentAt = 0L
    val minIntervalMs = 2000L
    val posThresh = 0.3
    val headingThreshDeg = 5.0

    enuFlow.collect { enu ->
        val baro = altitude
        val fusedZ = baro?.let { b -> altitudeFusionRatio * enu.zUp + (1 - altitudeFusionRatio) * b } ?: enu.zUp
        val headingDegNow = Math.toDegrees(headingRad)
        val now = System.currentTimeMillis()
        val shouldSend = now - lastSentAt >= minIntervalMs ||
            lastSentX.isNaN() || kotlin.math.abs(enu.xEast - lastSentX) > posThresh ||
            lastSentY.isNaN() || kotlin.math.abs(enu.yNorth - lastSentY) > posThresh ||
            lastSentZ.isNaN() || kotlin.math.abs(fusedZ - lastSentZ) > posThresh ||
            lastSentHeading.isNaN() || kotlin.math.abs(headingDegNow - lastSentHeading) > headingThreshDeg
        if (shouldSend) {
            val payload = LocationPayload(
                xEast = enu.xEast,
                yNorth = enu.yNorth,
                zFused = fusedZ,
                headingDeg = headingDegNow,
                pdrX = pdrState.value.xEast,
                pdrY = pdrState.value.yNorth,
                timestamp = now,
                deviceId = Build.MODEL ?: "wear",
                source = "wear"
            )
            syncManager.sendLocation(payload)
            lastSentX = enu.xEast; lastSentY = enu.yNorth; lastSentZ = fusedZ
            lastSentHeading = headingDegNow; lastSentAt = now
        }
    }
}

결과 (Result)

  • 실시간 센서 → UI 반응: Steps/Heading/Altitude가 1–2초 내 반응
  • 폰-웨어 동기화 가시화: /steps/push, /steps/state, /location/update 정상 송수신
  • 권한 거부 시에도 크래시 없이 안내 UI 유지
  • 스트리밍 프로파일(배터리/일반/고속)을 메시지로 수신해 샘플링 주기 동적 변경
✅ 기본 데이터 파이프(센서→처리→동기화) 작동 확인
📱 폰 로그에서 메시지 수신 경로 동작 검증(/steps/*, /location/*)

느낀 점 / 회고 (Reflection)

  • Compose 환경에서도 DisposableEffect로 수명주기 제어가 간결하고 확실했다.
  • 데이터 클래스는 공통 모듈로 분리하면 스키마 드리프트 사고를 줄일 수 있겠다.
  • Fused Location으로 전환하면 실내 가용성과 배터리가 더 좋아질 듯. 다음 스프린트에 포함.

참고자료 (References)

본 문서는 자동 생성된 개발일기이며, 예시 코드는 초보자도 이해할 수 있도록 상세 주석을 포함합니다.

반응형