본문 바로가기
[Developer]/Android

Wear OS 시계모드 구현 기본

by 반가운 해피빈이 2021. 8. 8.

Photo by Ismael Paramo on Unsplash

진행에 앞서 구글에서 제공한 샘플코드가 있으니 이걸 받아서 보아도 된다. 하지만, 아직 단계적으로는 New Project에 있는 코드만 보아도 충분하다고 생각된다.

 

시계모드 디자인

시계모드의 디자인을 완료했으면필요한 데이터를 가져오는 방법을 결정하고 시계모드를 그려야 한다.

필요한 구성요소는 아래와 같다.

  • 하나 이상의 배경 이미지
  • 필요한 데이터를 검색하기 위한 앱 코드
  • 배경 이미지 위에 텍스트와 모양을 그리기 위한 앱 코드

앱의 대화형 모드와 대기모드에서 다른 배경이미지가 사용된다.

대기모드용 이미지를 보기 좋게 만드는 것은 어려울 수 있다.

대기모드 배경은 종종 이미지가 없는 완전한 검은색 또는 회색이다.

hdpi인 WearOS 기기의 배경 이미지는 정사각형, 원형 이미지에 맞게 320x320 픽셀이어야 한다.

원형기기에서는 모퉁이가 표현되지 않을 수 있다.

필요한만큼만 데이터를 검색하도록 앱코드를 실행하고, 시계모드를 그릴때마다 데이터를 재사용하도록 결과를 저장해야 한다.

날씨 업데이트는 매 분마다 가져올 필요는 없다.

배터리수명을 늘리려면 대기모드에서 시계모드를 그리는 앱코드가 간단해야 한다.

일반적으로 제한된 색상 세트를 사용해서 윤곽선을 그린다.

대화형모드에서는 전체색상, 복잡한 모양, 그라데이션, 애니메이션 사용 가능하다.

시계모드 서비스 빌드

시계모드는 WaerOS 앱에 패키징되어있는 서비스이다.

필요한 권한을 선언해야 한다.

<manifest ...>
    <uses-permission
        android:name="android.permission.WAKE_LOCK" />

    <!-- Required for complications to receive complication data and open the provider chooser. -->
    <uses-permission
        android:name="com.google.android.wearable.permission.RECEIVE_COMPLICATION_DATA"/>
    ...
</manifest>

서비스 및 콜백 메서드 구현 필요

class AnalogWatchFaceService : CanvasWatchFaceService() {

    override fun onCreateEngine(): Engine {
        /* 워치페이스 구현부 */
        return Engine()
    }

    /* implement service callback methods */
    inner class Engine : CanvasWatchFaceService.Engine() {

        override fun onCreate(holder: SurfaceHolder) {
            super.onCreate(holder)
            /* 워치페이스 초기화 */
        }

        override fun onPropertiesChanged(properties: Bundle?) {
            super.onPropertiesChanged(properties)
            /* 디바이스 특징을 가져오기(번인, 낮은 비트 앰비언트) */
        }

        override fun onTimeTick() {
            super.onTimeTick()
            /* 시간이 변경되었을 때 */
        }

        override fun onAmbientModeChanged(inAmbientMode: Boolean) {
            super.onAmbientModeChanged(inAmbientMode)
            /* 웨어러블의 모드가 전환되었을 때 */
        }

        override fun onDraw(canvas: Canvas, bounds: Rect) {
            /* 워치페이스를 draw 할 때 */
        }

        override fun onVisibilityChanged(visible: Boolean) {
            super.onVisibilityChanged(visible)
            /* 워치페이스가 보이거나 보이지 않게 되었을 때 */
        }
    }
}

CanvasWatchFaceService 클래스는 View.invalidate() 메서드와 유사한 메커니즘을 제공한다. 시스템이 시계모드를 다시 그리도록 하려면 구현해서 invalidate() 메서드를 호출하면 된다. 기본 UI 스레드에서만 invalidate()를 사용할 수 있다. 다른 스레드에서 작업하기 위해서는 postInvalidate()를 호출한다.

시계모드 서비스를 등록한다.

<service
    android:name=".AnalogWatchFaceService"
    android:label="@string/analog_name"
    android:permission="android.permission.BIND_WALLPAPER" >
    <meta-data
        android:name="android.service.wallpaper"
        android:resource="@xml/watch_face" />
    <meta-data
        android:name="com.google.android.wearable.watchface.preview"
        android:resource="@drawable/preview_analog" />
    <meta-data
        android:name="com.google.android.wearable.watchface.preview_circular"
        android:resource="@drawable/preview_analog_circular" />
    <intent-filter>
        <action android:name="android.service.wallpaper.WallpaperService" />
        <category
            android:name=
            "com.google.android.wearable.watchface.category.WATCH_FACE" />
    </intent-filter>
</service>

android.service.wallpaper 항목은 wallpaper 요소가 포함된 watch_face.xml 리스소 파일을 지정한다.

<?xml version="1.0" encoding="UTF-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android" />

웨어러블 앱에는 둘 이상의 시계모드를 포함할 수 있다. 그렇게 하려면 웨어러블 앱의 매니페스트페일에 서비스 항목을 추가하면 된다.

워치페이스 그리기

시계모드 초기화

시계모드에 필요한 대부분의 리소스는 시스템이 서비스를 로드할 때 할당하고 초기화해야 한다.

비트맵 리소스 로드, 맞춤형 애니메이션 실행을 위한 타이머 객체 생성, 페인트 스타일 구성, 기타 계산 수행이 포함된다.

이 작업들은 한 번만 실행하고, 실행한 결과를 다시 사용할 수 있는 것들이다.

초기화 방법은 아래와 같다.

  1. 맞춤타이머, 그래픽 객체 및 기타 요소의 변수 선언
  2. Engine.onCreate() 메서드에서 시계모드 요소를 초기화
  3. Engine.onVisibilityChanged() 메서드에서 맞춤타이머를 초기화

변수 선언

WatchFaceService.Engine 구현에서 리소스의 멤버 변수를 선언하면 된다.

  • 그래픽 객체: 대부분의 시계모드에 있는 배경으로 사용되는 비트맵 이미지가 하나 이상 포함되어 있다. 시곗바늘 또는 시계 모드의 기타 디자인 요소를 나타내는 추가 비트맵 이미지를 사용할 수 있다.
  • 주기 타이머: 시간이 변경되면 1분에 한 번 시계 모드에 알리지만, 일부 시계모드는 맞춤시간간격으로 애니메이션을 실행한다. 이 경우 시계모드를 업데이트하는 데 필요한 빈도로 재깍거리는 맞춤 타이머를 제공해야 한다.
  • 시간대 변경 receiver: 여행할 때 시간대를 조정할 수 있으며, 이 시스템에서 이벤트를 브로드캐스트한다. 이 receiver를 받으면 시간을 업데이트 해야 한다.
private const val MSG_UPDATE_TIME = 0

class Service : CanvasWatchFaceService() {
    ...
    inner class Engine : CanvasWatchFaceService.Engine() {

        private lateinit var calendar: Calendar

        // 기기 특성
        private var lowBitAmbient: Boolean = false

        // 그래픽 객체
        private lateinit var backgroundBitmap: Bitmap
        private var backgroundScaledBitmap: Bitmap? = null
        private lateinit var hourPaint: Paint
        private lateinit var minutePaint: Paint

        // 대화형 모드에서 1초에 한 번씩 업데이트 하도록 하는 핸들러
        private val updateTimeHandler: Handler = UpdateTimeHandler(WeakReference(this))

        // receiver to update the time zone
        private val timeZoneReceiver: BroadcastReceiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context, intent: Intent) {
                calendar.timeZone = TimeZone.getDefault()
                invalidate()
            }
        }

        // 다른 서비스 메소드들
        ...
    }
    ...
    private class UpdateTimeHandler(val engineReference: WeakReference<Engine>) : Handler() {
        override fun handleMessage(message: Message) {
            engineReference.get()?.apply {
                when (message.what) {
                    MSG_UPDATE_TIME -> {
                        invalidate()
                        if (shouldTimerBeRunning()) {
                            val timeMs: Long = System.currentTimeMillis()
                            val delayMs: Long =
                                    INTERACTIVE_UPDATE_RATE_MS - timeMs % INTERACTIVE_UPDATE_RATE_MS
                            sendEmptyMessageDelayed(MSG_UPDATE_TIME, delayMs)
                        }
                    }
                }
            }
        }
    }
    ...
}

위 예제에서는 thread의 message queue를 이용하여 delayed message를 보내고 처리하는 Handler 인스턴스로 구현되어 있다. 그리고 이곳에서는 1초마다 재깍거린다. 타이머가 재깍거리면 핸들러에서는 invalidate()를 호출하고, 시스템에서는 onDraw() 메서드를 호출하여 시계모드를 다시 그린다.

시계모드 요소 초기화

Engine.onCreate() 메서드에서 다음 요소를 초기화 한다.

  • 배경이미지 로드, 그래픽객체를 그리는 스타일과 색상을 생성, 객체를 할당하여 시간 계산, 시스템 UI를 개선
override fun onCreate(holder: SurfaceHolder?) {
    super.onCreate(holder)

    // 시스템 UI 환경설정
    ...

    // 배경 이미지 로드
    backgroundBitmap = (resources.getDrawable(R.drawable.bg, null) as BitmapDrawable).bitmap

    // 그래픽 스타일 생성
    hourPaint = Paint().apply {
        setARGB(255, 200, 200, 200)
        strokeWidth = 5.0f
        isAntiAlias = true
        strokeCap = Paint.Cap.ROUND
    }
    ...

    // UTC 시간과 타임존을 사용해서 현지 시간을 계산하여 Calendar에 할당하기
    calendar = Calendar.getInstance()
}

배경 비트맵은 시스템이 시계모드를 초기화할 때 한 번만 로드된다. 그래픽 스타일은 Paint 클래스의 인스턴스이다. 시계모드 그리기에 설명된대로 이러한 스타일을 사용하여 Engine.onDraw() 메서드 내에서 시계 모드의 요소를 그린다.

맞춤 타이머 초기화

대화형 모드에 있는 동안 필요한 빈도로 재깍거리는 맞춤타이머를 제공하여 시계모드의 업데이트 빈도를 결정한다.

대기모드에서는 시스템이 안정적으로 맞춤타이머를 호출하지 않기 때문에 다르게 동작해야 한다.

앞의 예제를 기준으로 Engine.onVisibilityChanged() 메서드에서 다음 두 조건이 적용될 경우 맞춤 타이머를 시작해야 한다.

  • 시계 모드 표시, 기기가 대화형 모드일 경우

필요시 다음에 타이머가 재깍거릴 때를 예약해야 한다.

private fun updateTimer() {
    updateTimeHandler.removeMessages(MSG_UPDATE_TIME)
    if (shouldTimerBeRunning()) {
        updateTimeHandler.sendEmptyMessage(MSG_UPDATE_TIME)
    }
}

fun shouldTimerBeRunning(): Boolean = isVisible && !isInAmbientMode

onVisibilityChanged()의 예시이다.

override fun onVisibilityChanged(visible: Boolean) {
    super.onVisibilityChanged(visible)

    if (visible) {
        registerReceiver()

        // 보이지 않는 사이에 시간대가 변경되었을 수 있으므로, 보이자마자 시간대를 가져와야 한다.
        calendar.timeZone = TimeZone.getDefault()
    } else {
        unregisterReceiver()
    }

    // 타이머가 실행되어야하는지 여부는 우리가 보이는지 여부 및 대기모드에 있는지 여부와는 다릅니다.
    updateTimer()
}

시계모드가 표시되면 onVisibilityChanged() 메서드는 시간대 변경 receiver를 등록한다. 대화형 모드인 경우에도 맞춤타이머를 시작한다. 시계모드가 표시되지 않으면 이 메서드는 맞춤 타이머를 중지하고 시간대 변경 receiver를 등록취소한다. 구현은 다음과 같다.

private fun registerReceiver() {
    if (registeredTimeZoneReceiver) return
    registeredTimeZoneReceiver = true
    IntentFilter(Intent.ACTION_TIMEZONE_CHANGED).also { filter ->
        this@AnalogWatchFaceService.registerReceiver(timeZoneReceiver, filter)
    }
}

private fun unregisterReceiver() {
    if (!registeredTimeZoneReceiver) return
    registeredTimeZoneReceiver = false
    this@AnalogWatchFaceService.unregisterReceiver(timeZoneReceiver)
}

대기모드에서 시계모드 업데이트

대기모드에서는 시스템이 1분마다 Engine.onTimeTick() 메서드를 호출한다. 대화형 모드에서 시계모드를 업데이트 하려면 맞춤 타이머 초기화가 필요하다.

대기모드에서는 Engine.onTimeTick() 메서드에서 시계모드를 다시 그릴 수 있도록 캔버스를 무효화 한다.

override fun onTimeTick() {
    super.onTimeTick()

    invalidate()
}

시스템UI 구성

시계모드는 시스템 UI 요소에 방해가 되어서는 안 된다. 시계모드의 배경이 밝거나 화면 하단에 정보가 표시되는 경우 알림카드의 크기를 구성하거나, 배경 보호를 사용해야 할 수도 있다.

Wear OS에서는시계모드가 활성상태일 때, 시스템 UI의 다음과 같은 부분 구성이 가능하다.

  • 시스템이 시계모드에서 시간을 가져올 지 지정
  • 주변의 단색 배경으로 시스템 표시기를 보호
  • 시스템 표시기의 위치를 지정

시스템 UI의 이런 부분을 구성하기 위해서 WatchFaceStyle 인스턴스를 만들어 Engine.setWatchFaceStyle() 메서드에 전달한다.

override fun onCreate(holder: SurfaceHolder?) {
    super.onCreate(holder)

    // 시스템 UI 환경설정
    setWatchFaceStyle(WatchFaceStyle.Builder(this@AnalogWatchFaceService)
            .setBackgroundVisibility(WatchFaceStyle.BACKGROUND_VISIBILITY_INTERRUPTIVE)
            .setShowSystemUiTime(false)
            .build())
    ...
}

이 코드에서는 시스템 시간이 표시되지 않도록 구성한다.(자체방식으로 표시하기 때문) 시계 모드 구현의 어느시점에서나 setWatchFaceStyle을 구성할 수 있다. 링크 참조 가능

읽지 않은 알림 표시기를 위해서 WatchFaceStyle.Builder.setHideNotificationIndicator를 true로 하면 숨길 수 있으며, WatchFaceStyle.getUnreadCount를 사용하여 읽지 않은 알림수를 표시할 수도 있다.

WatchFaceStyle.Builder.setShowUnreadCountIndicator를 true로 설정하여 읽지 않은 개수가 상태표시줄에 표시되도록 할 수도 있다.

또한 이 경우 외부 링의 색상을 맞춤 설정할 수 있다. WatchFaceStyle.Builder.setAccentColor를 호출하고 원하는 color를 지정한다. 기본값은 흰색이다.

기기 화면에 관한 정보 가져오기

기기가 저비트 대기모드를 사용하는지 여부, 화면에 번인 보호가 필요한지 여부 등에 대한 값이 필요하면 Engine.onPropertiesChanged() 메서드를 호출한다.

override fun onPropertiesChanged(properties: Bundle?) {
    super.onPropertiesChanged(properties)
    properties?.apply {
        lowBitAmbient = getBoolean(PROPERTY_LOW_BIT_AMBIENT, false)
        burnInProtection = getBoolean(PROPERTY_BURN_IN_PROTECTION, false)
    }
}
  • 저비트 대기 모드에서는 기기가 대기모드로 전환될 때 안티앨리어싱 및 비트맵 필터링을 사용중지해야 한다.
  • 번인 보호가 필요한 경우 대기모드에서 흰색 픽셀의 큰 블록을 사용하지 말고, 화면 가장자리 10픽셀 이내에 콘텐츠를 배치하지 않아야 한다. 시스템에서 픽셀 번인을 방지하기 위해 주기적으로 콘텐츠를 이동하기 때문이다.

모드간 변경에 응답하기

기기가 대기모드와 대화형 모드간에 전환할때 Engine.onAmbientModeChanged() 메서드가 호출된다. 이 경우 시스템이 시계모드를 다시 그릴 수 있도록 invalidate()를 호출해야 한다.

override fun onAmbientModeChanged(inAmbientMode: Boolean) {

    super.onAmbientModeChanged(inAmbientMode)

    if (lowBitAmbient) {
        !inAmbientMode.also { antiAlias ->
            hourPaint.isAntiAlias = antiAlias
            minutePaint.isAntiAlias = antiAlias
            secondPaint.isAntiAlias = antiAlias
            tickPaint.isAntiAlias = antiAlias
        }
    }
    invalidate()
    updateTimer()
}

시계모드 그리기

맞춤 시계모드를 그리기 위해 Canvas 인스턴스와 시계모드를 그려야 하는 범위가 포함된 Engine.onDraw() 메서드를 호출한다.

  1. 뷰가 바뀔 때마다 기기에 맞게 배경을 조정할 수 있도록 onSurfaceChanged() 메서드를 재정의한다.
    override fun onSurfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
        if (backgroundScaledBitmap?.width != width || backgroundScaledBitmap?.height != height) {
            backgroundScaledBitmap = Bitmap.createScaledBitmap(backgroundBitmap,
                    width, height, true /* filter */)
        }
        super.onSurfaceChanged(holder, format, width, height)
    }​
  2. 기기가 대기모드인지 대화형 모드인지 확인한다.
  3. 필요한 그래픽 계산을 실행한다.
  4. 캔버스에 배경 비트맵을 그린다.
  5. Canvas 클래스의 메서드를 사용하여 시계모드를 그린다.

다음은 이것의 예시이다.

override fun onDraw(canvas: Canvas, bounds: Rect) {
    val frameStartTimeMs: Long = SystemClock.elapsedRealtime()

    // Drawing code here

    if (shouldTimerBeRunning()) {
        var delayMs: Long = SystemClock.elapsedRealtime() - frameStartTimeMs
        delayMs = if (delayMs > INTERACTIVE_UPDATE_RATE_MS) {
            // This scenario occurs when drawing all of the components takes longer than an actual
            // frame. It may be helpful to log how many times this happens, so you can
            // fix it when it occurs.
            // In general, you don't want to redraw immediately, but on the next
            // appropriate frame (else block below).
            0
        } else {
            // Sets the delay as close as possible to the intended framerate.
            // Note that the recommended interactive update rate is 1 frame per second.
            // However, if you want to include the sweeping hand gesture, set the
            // interactive update rate up to 30 frames per second.
            INTERACTIVE_UPDATE_RATE_MS - delayMs
        }
        updateTimeHandler.sendEmptyMessageDelayed(MSG_CODE_UPDATE_TIME, delayMs)
    }
}

 

TL;DR

  1. 시계 모드는 대화형 모드와 대기 모드로 나눠져있으며, 각각의 특성에 맞게 구현해야 한다.
  2. override 메서드는 다음과 같다.
    1. onCreate: 워치페이스 생성
    2. onPropertiesChanged: 기기화면에 대한 정보 가져옴
    3. onTimeTick: 1분마다 시간이 변경되었을 때
    4. onAmbientModeChanged: 웨어러블 모드가 전환되었을 때
    5. onSurfaceChanged: 뷰가 바뀔 때 배경조정이 필요할 때
    6. onDraw: 워치페이스를 그릴 때
    7. onVisibilityChanged: 워치페이스가 보이거나 보이지 않게 되었을 때
    8. 이번에 언급되지는 않았지만 필요한 것
      1. onDestory: 워치페이스 종료
      2. onInterruptionFilterChanged: 방해 필터가 변경되었을 때
      3. onTapCommand: 터치에 대한 이벤트
  3. 대화형 모드에서는 맞춤타이머를 만들어야 하는데 Handler를 이용하여 만들어야 한다.

 

출처

- 시계모드 디자인: https://developer.android.com/training/wearables/watch-faces/designing

- 시계모드 서비스 빌드: https://developer.android.com/training/wearables/watch-faces/service

- 시계모드 만들기: https://developer.android.com/training/wearables/watch-faces/drawing

 

반응형

댓글0