// 예시 (정리 이전): 단일 컴포저블 안에 다양한 UI가 혼재해 가독성 저하 @Composable fun WearApp() { // ... 상태/권한/서비스 로직 Scaffold { ScalingLazyColumn { // 권한, 상태, 위치, PDR, 배터리 섹션이 모두 item 블록으로 길게 나열됨 } } }
해결 과정 (How I Solved It)
UI를 작은 컴포저블들로 분리: PermissionSection, StatusSection, LocationSection, PdrSection
텍스트 중앙정렬: 모든 텍스트 컴포넌트에 Modifier.fillMaxWidth() + TextAlign.Center 적용
문자열 리소스화: 하드코딩된 한글("전송 임계값=")을 strings.xml로 이전
불필요한 배터리 최적화 섹션 제거
// 파일: MainActivity.kt — 초보자도 이해하기 쉬운 주석 포함 예시 // 1) 가운데 정렬을 위한 import import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.text.style.TextAlign
@Composable fun StatusSection( syncStatusText: String, syncStatusColor: Color, steps: Long, lastDelta: Long, stepThreshold: Long, headingRad: Double, lastPayload: StepPayload?, altitude: Double?, unit: String ) { // Column의 수평 정렬은 Center로 유지하되, // 각 Text를 화면 너비만큼 확장하고(Text가 가로폭을 차지), // TextAlign.Center로 내용을 중앙에 배치 Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = syncStatusText, color = syncStatusColor, style = MaterialTheme.typography.bodySmall, modifier = Modifier.fillMaxWidth(), // 전체 너비 사용 textAlign = TextAlign.Center // 텍스트 중앙 정렬 ) Spacer(Modifier.height(4.dp)) Text( text = stringResource(R.string.steps_label) + ": " + steps + " (Δ " + lastDelta + ")", style = MaterialTheme.typography.bodySmall, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) Text( text = stringResource(R.string.step_threshold_label) + stepThreshold, style = MaterialTheme.typography.bodySmall, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) Text( text = String.format( java.util.Locale.US, stringResource(R.string.heading_label) + ": %.0f°", Math.toDegrees(headingRad) ), style = MaterialTheme.typography.bodySmall, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) } }
전송 임계값=
// 파일: MainActivity.kt — 배터리 최적화 섹션 제거 예시 // 기존: BatteryOptimizationSection(...) 사용 및 관련 PositionIndicator 주변 조건부 렌더링 // 변경: 해당 섹션/조건 로직 삭제로 UI 단순화
결과 (Result)
UI가 섹션별 컴포저블로 분리되어 가독성 및 유지보수성 향상
텍스트 중앙정렬로 일관된 시각 경험 제공
문자열 리소스화로 국제화/재사용성 개선
불필요한 배터리 최적화 UI 제거로 사용자 혼란 감소
✅ Compose 아이템들이 깔끔하게 모듈화됨
🎯 텍스트 중앙정렬 적용으로 UI 일관성 확보
🧩 문자열 리소스 관리로 다국어 및 유지보수 용이
느낀 점 / 회고 (Reflection)
Wear Compose에서도 작은 컴포저블로 나누는 것이 가장 큰 생산성 향상을 가져온다
하드코딩 텍스트는 사소해 보여도 국제화/테스트/일관성 측면에서 리소스화가 중요
기기 특성(Wear OS)을 고려한 UI/설정 항목은 실제 동작 가능 여부를 검증 후 제공해야 한다
#billcorea #운동동아리관리앱
🏸 Schneedle, aplikasi yang wajib dimiliki oleh klub bulu tangkis!
👉 Match Play – Rekam Skor & Temukan Lawan 🎉
Sempurna untuk di mana saja, sendirian, bersama teman, atau di klub! 🤝
Jika Anda suka bulu tangkis, cobalah
오늘의 목표 / 배경: 폰앱에서 위치(및 걸음수)를 영구 저장(히스토리)하고, Setting 화면에는 마지막 정보만 노출, 메인 화면의 Vico 그래프에 시간축으로 X/Y/Z와 걸음수 시리즈를 표기(색상 구분) — 동시에 발생한 런타임 크래시(NaN 관련)를 해결한다.
해결하려던 문제: Vico 라인 차트에서 마커/툴팁 계산 중 NaN 값이 투입되어 앱이 크래시 나는 문제. 또한 걸음수가 그래프에 보이지 않는 문제와 설정 화면의 측정 상태가 앱 재진입 시 유지되지 않는 문제를 정리.
사용한 기술 스택: Kotlin, Jetpack Compose, Vico chart, Health Connect API, Coroutines, Android Studio
📅 날짜: 2025.12.07
🎯 목표: 위치 히스토리 영구 저장, Setting 화면 최근값 표시, Vico 그래프에 X/Y/Z/Steps 표시(시간 축), NaN 크래시 방지
🧰 기술: Kotlin, Compose, Vico, Coroutines
문제 정의 (Problem / Motivation)
작업 중에 다음과 같은 런타임 예외가 발생했습니다:
java.lang.IllegalArgumentException: Cannot round NaN value. at kotlin.math.MathKt__MathJVMKt.roundToInt(MathJVM.kt:1192) at com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer.updateMarkerTargets(LineCartesianLayer.kt:471) ... (생략)
원인으로 의심되는 상황:
시간별 배열을 FloatArray(24)로 만들고 기본값을 Float.NaN으로 두었음 — 일부 시간대에 값이 없으면 NaN이 남아 있음.
Vico 차트에 여러 시리즈(예: X, Y, Z, Steps)를 정의하는 레이어는 고정된 시리즈 수를 기대하거나, 마커 계산 시 시리즈 내부 포인트가 없는 경우를 안전하게 처리하지 못함.
lineSeries 빌더에서 조건부로 시리즈를 추가하면 레이어 쪽 series 컬러 매핑과 시리즈 개수가 맞지 않아 인덱스 문제/NaN 전파가 발생할 수 있음.
// 문제를 일으킨 코드 개요 (발생 사례 일부) val locXHourly = FloatArray(24) { Float.NaN } // ... 최근 위치를 시간별로 채움 // 일부 시간대는 NaN으로 남아 있을 수 있음 // 이후 Vico에 시리즈를 만들 때 NaN이 남아 있는 값 때문에 마커 계산이 NaN을 전달받아 roundToInt 에러 발생
해결 과정 (How I Solved It)
접근한 순서:
원인 분석 — 로그와 스택트레이스에서 NaN이 roundToInt로 전달된 것을 확인.
데이터 포맷/시리즈 구성 점검 — Vico 레이어가 기대하는 시리즈 수와 순서를 맞추도록 수정 방향 결정.
안전성 확보 — NaN이 마커 계산으로 전달되지 않도록 시리즈를 일관된 길이로 만들거나, 마커를 비활성화/무시하도록 보호 코드 추가.
걸음수 표시 문제 해결 — 시간 축 기준으로 시간별 합계를 구하고 그래프 범위/스케일을 조정하여 눈에 띄게 함.
Setting 화면 상태 영속화는 DataStore/SharedPreferences로 저장하도록 계획(이번 변경에서는 로그와 그래프 중심 수정에 초점).
핵심 수정 아이디어(예시 코드)
아래 코드는 Vico 시리즈를 생성할 때 안전하게 NaN을 처리하고, 항상 레이어에 정의된 시리즈 개수를 맞추기 위한 예시입니다. 초보자도 이해하기 쉽도록 주석을 상세히 달았습니다.
// 안전한 시리즈 생성 예시 (MainScreen.kt의 modelProducer 구성부에서 사용) // 1) 공통 X축: hours (0..23) val hours = (0..23).toList()
// 2) 시간별 값들을 미리 계산 (값이 없으면 null로 둠) val xValuesNullable: List<Float?> = hours.map { h -> /* 값이 있으면 실수, 없으면 null */ null } // 실제 코드에서는 recentLocations를 순회해서 채움
// 3) Vico에 넣을 때는 레이어가 기대하는 시리즈 개수(예: 4)에 맞춰 항상 series를 추가 // 값이 없는 포인트는 0f로 대체하거나, 이전 값으로 보간하거나, 마커를 끄는 방식 중 선택 val xValuesForChart = xValuesNullable.map { it ?: 0f } // 비어있을 때 0으로 대체 (안전한 방법) val yValuesForChart = /* 동일 처리 */ hours.map { 0f } val zValuesForChart = /* 동일 처리 */ hours.map { 0f } val stepsValuesForChart = /* 시간별 steps, 스케일 조정 */ hours.map { h -> (stepsHourly[h] / 10f) }
// 4) lineSeries에 항상 4개의 시리즈를 추가 (레이어와 일치) lineSeries { series(hours, xValuesForChart) series(hours, yValuesForChart) series(hours, zValuesForChart) series(hours, stepsValuesForChart) }
// 주석: 0으로 대체하면 그래프 수치 왜곡 가능성이 있으므로, 시각적으로는 // 값이 없음을 별도로 표시(예: 투명도 낮게 그리거나 범례에 설명 추가)하는 것이 좋습니다.
또한 마커 계산 시점에 안전 장치를 추가합니다:
// 마커 사용 시 안전 체크 예시 val marker = rememberMarker() // modelProducer에 데이터가 아예 없을 땐 marker를 null처럼 취급하거나 // marker가 포인트 좌표를 계산할 때 NaN을 만나면 아무것도 그리지 않도록 구현 // (이 부분은 vico의 marker 콜백 구현 시점에 방어 코드 추가 필요)
결과 (Result)
✅ NaN으로 인한 라인 차트 마커 크래시의 원인을 파악했습니다. (NaN이 roundToInt로 전달되어 예외 발생)
적용한 전략의 장점:
시리즈 개수와 레이어 정의를 일치시켜 인덱스 불일치 문제를 제거했습니다.
빈 시간대 처리(0 또는 보간)는 크래시를 방지합니다. 시각적 정확성은 추가 보완 필요(예: 투명도/점선 처리).
걸음수는 시간별로 합산해 별도 시리즈로 추가했으며, 그래프 상에서 보이도록 스케일을 조정했습니다(예: /10 표기).
느낀 점 / 회고 (Reflection)
데이터 시각화 라이브러리는 데이터 포맷에 민감합니다. 특히 여러 시리즈를 그릴 때 길이와 인덱스 정합성을 반드시 맞춰야 합니다.
디버깅 시 스택트레이스가 가리키는 함수 내부에서 어떤 입력값이 NaN/무효값인지 역추적하는 것이 중요했습니다. 로그와 작은 재현 데이터셋을 만들어 원인을 좁혔습니다.
완벽한 해결을 위해선 다음 작업이 필요합니다: (1) SettingScreen의 측정 시작 상태 영속화(DataStore), (2) 빈값 처리 시 시각적 표시 개선(투명도/점선/툴팁에서 ‘데이터 없음’ 표기), (3) 마커가 NaN을 만나면 안전하게 무시하도록 vico 콜백 방어 코드 강화.
참고자료 (References)
Vico 공식 문서 및 예제 (GitHub) - https://github.com/patrykandpatrick/vico
Kotlin stdlib - Float.isNaN(), roundToInt() 동작 참고
Android Developers - Data persistence (DataStore) 가이드
워치앱의 MainActivity.kt에서 Unresolved reference 'dayKey' 빌드 에러 발생
타일/메인 화면 일부 텍스트가 길어 줄임표(...)로 보이는 문제
스크롤바가 배경과 유사해 가시성이 떨어짐
하드코딩된 한글 문자열의 국제화(strings.xml) 필요
// 컴파일 에러 예시 (요약) // e: MainActivity.kt: Unresolved reference 'dayKey' // 자정 체크 로직에서 dayKey 상태가 정의되어 있지 않음
해결 과정 (How I Solved It)
dayKey 상태 추가: 자정 변경 감지를 위해 remember { mutableStateOf(...) }로 dayKey를 선언
스크롤바 색 개선: 배경색의 보색(Complementary color)로 스크롤바 트랙/썸을 그려 가시성 상승
텍스트 레이아웃 개선: 여백 조정 및 항목 분리로 줄임표 빈도 감소, 향후 Chip/버튼 폭/높이 조정 계획 반영
국제화: 화면에 보이는 주요 한글 문자열은 stringResource(...)로 대체하고 리소스로 이동(추가 정리 예정)
// 핵심 수정 예시: dayKey 상태 추가 및 자정 리셋 로직 유지 @Composable fun WearApp() { // ... 기존 상태들 ... // 일자 키(자정 교체 감지용) var dayKey by remember { mutableStateOf( java.time.ZonedDateTime.now() .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")) ) }
// 자정 변경 감지: 1분마다 체크하여 dayKey 달라지면 스텝/PDR 리셋 LaunchedEffect(Unit) { while (true) { val current = java.time.ZonedDateTime.now() .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")) if (current != dayKey) { dayKey = current // 스텝 리셋 및 PDR 리셋 passiveStepsManager.stop(); passiveStepsManager.start() pdr.reset() } kotlinx.coroutines.delay(60_000L) } } }
// 스크롤바 가시성 개선: 배경색 보색 사용 val animatedBg by animateColorAsState(targetValue = bgColor, animationSpec = tween(150)) val scrollbarColor = Color(1f - animatedBg.red, 1f - animatedBg.green, 1f - animatedBg.blue)
Canvas(modifier = Modifier .align(Alignment.CenterEnd) .padding(end = 2.dp) .height(100.dp) .width(3.dp)) { val contentHeight = scrollState.maxValue.toFloat() + size.height if (contentHeight > size.height + 1f) { val ratio = size.height / contentHeight val thumbHeight = (size.height * ratio).coerceAtLeast(12f) val scrollY = (scrollState.value / scrollState.maxValue.toFloat()).coerceIn(0f, 1f) val thumbTop = (size.height - thumbHeight) * scrollY drawRect(color = scrollbarColor.copy(alpha = 0.2f), size = size) // 트랙 drawRect(color = scrollbarColor, topLeft = Offset(0f, thumbTop), size = Size(size.width, thumbHeight)) // 썸 } }
// NaN 방어를 위해 앞값 채움(fill-forward)으로 시계열을 안전화한 부분 fun fillForward(arr: FloatArray): List<Float> { val out = ArrayList<Float>(arr.size) var last = 0f var seen = false for (i in arr.indices) { val v = arr[i] if (!v.isNaN()) { last = v; seen = true; out.add(v) } else { out.add(if (seen) last else 0f) } } return out }
해결 과정 (How I Solved It)
EMA(지수평활) — 간단·저비용 필터
정의: y[n] = α·x[n] + (1-α)·y[n-1]
특징: 구현 매우 쉬움, 레이턴시 낮음, 이상치에는 약함.
// 초보자도 이해하기 쉬운 EMA 필터 — 주석 상세 버전 class EmaFilter(private val alpha: Float) { private var state: Float? = null // 이전 출력값(초기엔 없음)
// 새 입력값 x를 받아 필터링된 값 반환 fun update(x: Float): Float { val s = state val out = if (s == null) { // 첫 샘플은 기준값으로 사용 x } else { // EMA 핵심 — 현재 입력과 이전 출력의 가중 평균 alpha * x + (1 - alpha) * s } state = out return out }
fun reset() { state = null } }
Kalman — 상태공간 기반 최적 추정(선형/가우시안 가정)
특징: 모델(A,H), 노이즈(Q,R)로 예측/갱신. 드리프트·노이즈 균형적으로 제어.
장점: 통계적으로 최적(가정 하), 속도/가속 등 상태 추정 가능.
단점: 튜닝 복잡, 계산량 EMA보다 큼.
// 1D 고도용 아주 간단한 Kalman — 주석 상세 class SimpleKalman1D( private var q: Float, // 프로세스(모델) 노이즈 공분산 — 모델 불확실성 private var r: Float // 측정 노이즈 공분산 — 센서 불확실성 ) { private var x: Float = 0f // 상태(고도) private var p: Float = 1f // 상태 오차 공분산
fun init(initial: Float, initialP: Float = 1f) { x = initial p = initialP }
// 예측 단계 — 간단 모델(고정)에서는 p만 늘려 불확실성 반영 fun predict() { p += q }
// 갱신 단계 — 관측 z 반영 fun update(z: Float): Float { val k = p / (p + r) // 칼만 이득(측정 신뢰도 vs 상태 신뢰도 비율) x = x + k * (z - x) // 상태 보정 p = (1 - k) * p // 오차 공분산 업데이트 return x } }
상보(Complementary) 융합 — 간단 융합법
예: fusedZ = α·pdrZ + (1-α)·baroZ
장점: 직관적인 튜닝, 구현 쉬움.
단점: 노이즈 통계 반영 없음, 이상치 처리 필요.
// 프로젝트에서 사용 중인 융합 아이디어(유사): baro와 ENU zUp을 비율로 합성 val fusedZ = altitudeFusionRatio * enu.zUp + (1 - altitudeFusionRatio) * baro
결과 (Result)
그래프 입력의 NaN 제거(앞값 채움)로 마커 크래시 방지.
EMA/상보/칼만의 트레이드오프를 정리해 적용 방향을 확립.
실시간 UI 요구가 큰 경우: EMA 또는 상보 필터부터 적용하고, 필요 시 칼만으로 단계적 고도화.
✅ 실시간성(EMA/상보)과 안정성(칼만)의 균형을 프로젝트 요구에 맞게 선택할 수 있도록 기준을 수립.
느낀 점 / 회고 (Reflection)
NaN 같은 ‘형식적 오류’도 시각화 라이브러리에서 큰 크래시 원인이 된다 — 입력 정규화가 최우선.
EMA는 UI엔 매우 좋은 출발점. 칼만은 모델·노이즈 튜닝이 핵심 — 로그 기반 정량 튜닝이 필요.
상보 필터는 간단하지만 이상치 방어(스파이크 클리핑/중앙값 필터)와 함께 쓰면 체감 품질이 훨씬 좋아진다.
참고자료 (References)
Kalman Filter — Greg Welch & Gary Bishop, "An Introduction to the Kalman Filter"
Signal Processing Stack Exchange — EMA/Moving Average 비교 토론
Android Sensor Docs — Barometer, SensorManager Delay 등
Vico Chart — https://github.com/patrykandpatrick/vico
다음 단계 제안
바로미터 측정에 중앙값(Median) 또는 MAD 기반 이상치 제거를 추가.
상보 필터 계수(altitudeFusionRatio)를 프로파일(배터리/표준/고속)에 따라 자동 조정.
간단한 1D 칼만(고도+속도)로 샘플 러닝 테스트 — Q/R 튜닝 로그(innovation) 기록.
오늘의 목표는 Wear OS 관련 컴포넌트에서 사용자 인터랙션(특히 콤플리케이션 탭)을 통해 앱을 열 수 있도록 안정적인 동작을 구현하고, 타일(Tile) 및 칩(Chip) UI를 정리해 원형 기기에서 잘리지 않도록 개선하는 것이었습니다.
해결하려던 문제는 콤플리케이션을 탭했을 때 앱(MainActivity)으로 안전하게 진입하도록 PendingIntent와 Intent의 플래그/extra를 적절히 설정하고, 타일에서 불필요하게 전체 목록을 노출하던 부분을 3줄(앱 이름/완료수·대상수/앱으로 가기 버튼)으로 간소화하는 것입니다.
📅 날짜: 2025.11.19
🎯 목표: Complication 탭 액션(앱 진입) 안정화, Tile/Chip UI 간소화 및 원형 컷오프 방지
🧰 기술: Kotlin, Wear OS Tiles/Complications, Android PendingIntent, DataLayer
문제 정의 (Problem / Motivation)
프로젝트의 Wear 모듈에서 다음과 같은 문제가 있었습니다:
콤플리케이션을 탭했을 때 앱으로 넘어가는 동작이 불분명하거나 extras가 안정적으로 전달되지 않아 앱 쪽에서 출처를 알기 어려운 경우가 있음.
타일에서 습관 전체 목록을 나열하면 UI가 복잡해지고, 원형 기기에서는 칩이 가장자리에서 잘리는 현상이 관찰됨.
특히 콤플리케이션의 tapAction은 PendingIntent를 통해 앱을 여는 방식이지만, PendingIntent의 플래그나 Intent의 설정(FLAG_UPDATE_CURRENT, FLAG_IMMUTABLE, 추가 extras 등)에 따라 동작이 달라질 수 있어 이를 명확히 구현할 필요가 있었습니다.
// 예: MainComplicationService에서 PendingIntent 생성 부분 val intent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) putExtra("from_complication", true) action = "com.billcorea.habit1007.ACTION_OPEN_FROM_COMPLICATION" } val pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
해결 과정 (How I Solved It)
시도한 접근법과 적용한 변경 사항은 다음과 같습니다.
콤플리케이션 탭 시 앱으로 진입하는 Intent에 출처 표시를 위한 extras를 추가했습니다. (putExtra("from_complication", true))
Intent에 고유 action을 넣어 앱 내에서 호출 출처를 더 명확히 구분할 수 있게 했습니다. (action = "com.billcorea.habit1007.ACTION_OPEN_FROM_COMPLICATION")
PendingIntent 생성 시 FLAG_UPDATE_CURRENT와 FLAG_IMMUTABLE를 조합해 기존 PendingIntent를 최신화하고 보안성을 보장했습니다.
타일(`MainTileService`)은 전체 습관 목록을 노출하는 대신 요약 정보(완료 건수 / 대상 건수)와 앱으로 가기 버튼(Chip)만 표시하도록 간소화했습니다.
원형 기기에서의 칩 잘림 문제는 칩 너비/높이를 화면 크기 기반으로 보수적으로 계산하고 내부 패딩/아이콘 크기 조정, 항목 간 간격을 늘려 대응했습니다.
// 핵심: Complication 탭 액션에 Intent extras + 안전한 PendingIntent 플래그 적용 val intent = Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) putExtra("from_complication", true) action = "com.billcorea.habit1007.ACTION_OPEN_FROM_COMPLICATION" } val pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags) ShortTextComplicationData.Builder(...).setTapAction(pendingIntent).build()
이유: extras와 action을 넣으면 앱(예: MainActivity)에서 Intent를 검사해 "콤플리케이션에서 왔음"을 판별하고, 필요 시 특정 화면으로 바로 이동시키는 로직을 실행할 수 있기 때문입니다. FLAG_UPDATE_CURRENT는 PendingIntent 갱신을 보장하고, FLAG_IMMUTABLE은 보안 권장사항입니다.
결과 (Result)
적용 결과는 다음과 같습니다.
콤플리케이션 탭 시 앱이 안정적으로 열리고, Intent extra("from_complication") 또는 action으로 호출 출처를 앱에서 판단할 수 있게 되었습니다.
타일은 더 간결해져 정보 전달이 명확해졌고, 원형 화면에서 칩이 잘리는 문제를 완화할 수 있는 구조로 수정했습니다.
✅ Complication 탭에서 MainActivity로 안전하게 진입하도록 구현 완료
✅ 타일 레이아웃을 3줄(앱 이름/완료수·대상수/앱으로 가기 버튼)로 간소화 완료
느낀 점 / 회고 (Reflection)
Wear OS에서는 각 플랫폼 컴포넌트(Complication/Tile)의 동작 방식을 정확히 이해하는 것이 중요합니다. 특히 PendingIntent와 Intent 플래그, action/extras 관리가 핵심입니다.
작은 UI 요소(칩, 패딩)는 원형 기기에서 큰 영향을 줍니다 — 항상 원형 안전 영역을 고려해 크기와 패딩을 설계해야 합니다.
다음 개선: 앱 쪽(MainActivity)에서 Intent의 action과 extras를 핸들링해 "콤플리케이션에서 진입했을 때 특정 화면(예: 오늘의 습관 상세)로 바로 이동"하는 로직을 추가할 계획입니다.
프로젝트 코드: habitwear/src/main/java/com/billcorea/habit1007/complication/MainComplicationService.kt, habitwear/src/main/java/com/billcorea/habit1007/tile/MainTileService.kt
#스하리1000명프로젝트,
Nawala sa Korea? Kahit na hindi ka nagsasalita ng Korean, tinutulungan ka ng app na ito na madaling makalibot.
Sabihin lang ang iyong wika—ito ay nagsasalin, naghahanap, at nagpapakita ng mga resulta pabalik sa iyong wika.
Mahusay para sa mga manlalakbay! Sinusuportahan ang 10+ wika kabilang ang English, Japanese, Chinese, Vietnamese, at higit pa.
Subukan ito ngayon!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127
// 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
// 기존 (버그): distance 누적 실패 val rValue : List<DistanceRecord> = emptyList() for (record in response.records) { rValue.plus(record) // 결과를 재할당하지 않아 누락됨 }
// 기존: Steps 합산 (중복 가능성 & 비효율) val response = client.readRecords(ReadRecordsRequest(StepsRecord::class, ...)) var total = 0L for (r in response.records) total += r.count
해결 과정 (How I Solved It)
DistanceRecord 누락 수정: MutableList로 변환 후 add()로 확정 저장.
세션 종료 시간 매핑 수정: endTime 잘못된 startTime 재사용 → 실제 endTime 적용.
UI 축소: TopAppBar 제거 → 24dp Box + statusBarsPadding() 로 최소 높이 상단바.
CombinedUiState 도입: 여러 StateFlow를 하나로 합쳐 recomposition 감소.
핵심 적용 코드:
// Distance 누락 수정 val recordsAccum = mutableListOf<DistanceRecord>() for (record in response.records) { recordsAccum.add(record) }
// Aggregate + 캐시 (HealthConnectManager) private data class StepCountCache(val dayEpoch: Long, val timestampMs: Long, val value: Long) private var stepCountCache: StepCountCache? = null private val STEP_CACHE_TTL_MS = 30_000L
private suspend fun computeTodayStepsAggregate(): Long { val startZdt = ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS) val endZdt = startZdt.plusDays(1) val agg = healthConnectClient.aggregate( AggregateRequest( metrics = setOf(StepsRecord.COUNT_TOTAL), timeRangeFilter = TimeRangeFilter.between(startZdt.toInstant(), endZdt.toInstant()) ) ) return agg[StepsRecord.COUNT_TOTAL] ?: 0L }
suspend fun getTodaySteps(forceRefresh: Boolean = false): Long { val now = System.currentTimeMillis() val todayEpoch = ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS).toInstant().epochSecond val cached = stepCountCache val valid = cached != null && !forceRefresh && cached.dayEpoch == todayEpoch && (now - cached.timestampMs) < STEP_CACHE_TTL_MS if (valid) return cached.value val fresh = computeTodayStepsAggregate() stepCountCache = StepCountCache(todayEpoch, now, fresh) return fresh }
// ViewModel 사용 _stepsTotal.value = healthConnectManager.getTodaySteps(forceRefresh = false)
// 상단바 최소 높이 UI Box( modifier = Modifier .fillMaxWidth() .height(24.dp) // status bar height target .background(Color(0xFF5E82FC)) .statusBarsPadding() ) { /* Icon + Title */ }
결과 (Result)
걸음 수 집계 호출 빈도 ↓ (30초 캐시로 반복 쿼리 방지).
UI 상단 높이 축소로 세로 가시 영역 증가.
세션 종료 시간 표시 정확도 개선.
Distance 데이터 정상 누적 (차트/리스트 값 일치).
✅ Aggregate 기반 일일 걸음 수 정확도 상승
⚡ 집계 호출 체감 대기시간 단축 & 불필요 로딩 감소
🖼 상단바 24dp 적용으로 콘텐츠 노출 영역 확대
느낀 점 / 회고 (Reflection)
Health Connect Aggregate API를 우선적으로 사용하는 설계가 유지보수성과 정확성을 동시에 확보.
캐시 TTL 결정(30초)은 사용자 즉각 반응성과 자원 절약 타협점으로 적절.
UI 영역은 초기 템플릿 의존보다 실제 사용 시나리오 측정 후 최소화하는 것이 좋음.
다음 개선: WorkManager로 백그라운드 자동 재동기화, 캐시 만료 시 알림형 업데이트.