코딩하는 개굴이

Google Wear OS Watchface 만들기 본문

안드로이드/WATCH FACE

Google Wear OS Watchface 만들기

개굴이모자 2021. 10. 15. 01:11
반응형

WatchFace 구현 계획 세우기

Custom WatchFace 즉, Wear OS by Google 용 watchface 를 만들기 위해서는 기존의 watchface들이 그랬던 것처럼 시간을 명확하게 시각화 해야한다.



워치 페이스 구현을 위한 구성 요소

대부분의 WatchFace 구현을 위해서는 다음과 같은 구성요소가 필요하다.

  • 하나 이상의 배경 이미지
  • 필요한 데이터를 가져오기 위한 Application code
  • 배경 이미지 위에, 텍스트 및 모양들을 그리기 위한 애플리케이션 코드



세부 구성

  • Interactive mode, Ambient mode
    • Interactive mode(대화형 모드), Ambient mode(대기 모드)에서는 다른 배경 이미지가 일반적으로 표시된다.대기 모드용 이미지를 자연스럽게 만들기 어려울 수 있으므로 검정색 혹은 회색으로 사용될 수 있다.
    • Ambient mode 에서는 배터리 수명을 늘리기 위해서 Application Code 가 비교적 간단해야한다.제한된 색상들을 이용해 윤곽선 등만 그린다.
  • 배경 이미지는 원형 혹은 정사각형 기기에 적용될 수 있다.
  • 원형기기에서는 배경 이미지의 모퉁이가 표시되지 않는다. (기기 화면의 크기를 코드에서 감지하고, 배경 이미지 크기를 조정하게 할 수 있다.)
  • 데이터 재사용
    • 시계 표현에 사용될 데이터들을 필요한 만큼 검색하여 Application Code를 실행하고, WatchFace를 그릴 때마다, 검색한 것을 재사용할 수 있도록 해야한다.날씨 등 매분 매초 가져올 필요 없는 데이터들을 재사용해야한다.

WatchFace Service 빌드

Wear OS 개발에는 프로젝트 설정, 라이브러리 포함, 패키징 편의성을 제공하는 안드로이드 스튜디오 사용을 권장.

Wareable support library는 WatchFace 구현을 위해 확장하는 필수 클래스들을 제공한다. Google Play Service Client 라이브러리 (play-servicesplay-services-wearable)은 Wearable의 Data Layer의 데이터 항목들을 동기화하는데 필요

안드로이드 스튜디오는 build.gradle 파일에 위의 필수 항목들을 자동으로 추가한다.

Dependency

WatchFace에는 WAKE_LOCK 권한이 필요하므로, manifest에 다음 권한을 추가해야한다.

    <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>

Service 및 Callback 메서드 구현

WatchFace는 "service"로, 시스템은 시간이 바뀌거나 중요한 이벤트가 발생할 경우, 서비스 안의 메소드를 호출한다. 그 후, 서비스의 implementation은 시간이 바뀐 것으로 스크린의 watchface를 그린다.

따라서, 워치페이스를 구현하기 위해서는 CanvasWatchFaceServiceCanvasWatchFaceService.Engine클래스를 확장하고 콜백 메서드를 재정의해야한다.

    class AnalogWatchFaceService : CanvasWatchFaceService() {

        override fun onCreateEngine(): Engine {
            /* provide your watch face implementation */
            return Engine()
        }

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

            override fun onCreate(holder: SurfaceHolder) {
                super.onCreate(holder)
                /* initialize your watch face */
            }

            override fun onPropertiesChanged(properties: Bundle?) {
                super.onPropertiesChanged(properties)
                /* get device features (burn-in, low-bit ambient) */
            }

            override fun onTimeTick() {
                super.onTimeTick()
                /* the time changed */
            }

            override fun onAmbientModeChanged(inAmbientMode: Boolean) {
                super.onAmbientModeChanged(inAmbientMode)
                /* the wearable switched between modes */
            }

            override fun onDraw(canvas: Canvas, bounds: Rect) {
                /* draw your watch face */
            }

            override fun onVisibilityChanged(visible: Boolean) {
                super.onVisibilityChanged(visible)
                /* the watch face became visible or invisible */
            }
        }
    }
    

CanvasWatchFaceServiceinvalidate()를 제공하므로, 이를 이용해 watchface를 다시 그리는데 사용한다. MainUIThread에서만 작동하며, 다른 thread에서 작동해야할 경우 postInvalidate()를 호출해야한다.

Watchface 서비스 등록하기

watchface 서비스를 구현한 후, implmentation을 웨어러블 앱의 manifest 에 등록해야한다.

아래 과정이 있어야 사용자가 해당 앱을 설치하게되면, 시스템이 서비스 관련 정보를 사용해 웨어러블 기기의 watchface 선택 도구에서 watchface를 사용할 수 있도록 설정한다.

    <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>
    
  • preview는 정의된 이미지를 기기에 설치된 watchface의 목록을 사용자에게 표시해 줄 때 사용한다. (스크린샷을 사용해야한다)
  • 원형 미리보기 이미지를 지원할 경우, preview_circular 항목을 사용한다.
    • 시계 모드에 사각과 원형 두개의 미리보기 이미지가 둘다 포함되어있을 경우, watchface 선택 도구가 시계의 모양에 따라 적절한 이미지를 표시해 준다.
  • wallpapaer 는 배경요소로, wallpaper 요소가 포함된 watch_face.xml 리소스 파일을 지정한다.

WatchFace 그리기

시계 모드 초기화

WatchFace 에 필요한 리소스들은 성능 향상을 위해, 서비스 로드 시 할당하고 초기화하여, 작업은 한번만 실행하고 결과를 다시 재사용할 수 있도록 해야한다.

bitmap resource, 객체 생성, style 구성, 기타 계산들이 포함된다.

초기화 단계

  1. 그래픽, 타이머 등의 변수 선언
  2. Engine.onCreate()에서 시계 element 초기화
  3. 타이머를 Engine.onVisibilityChanged()에서 초기화

변수 초기화

여러군데서 코드 재사용이 가능해야하기 때문에, WatchFaceService.Engine()의 구현에서 리소스의 멤버 변수를 선언한다.

  • Graphic 객체
    • 배경, 시계 바늘 등에 해당하는 비트맵 이미지들이 해당
  • Periodic Timer (주기 타이머)
    • 시스템은 시간이 변경될 경우, 1분에 1번씩만 시계모드에 알림을 주지만, 별도의 기준으로 애니메이션을 변경해야할 경우, 맞춤 타이머가 필요.
  • TimeZone 변경 Receiver
    • 사용자가 여행 시에도 사용 가능하도록, 시스템에서 해당 이벤트를 브로드캐스트하고, 이를 받으면 시간을 업데이트하도록 한다.
    private const val MSG_UPDATE_TIME = 0

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

            private lateinit var calendar: Calendar

            // device features
            private var lowBitAmbient: Boolean = false

            // graphic objects
            private lateinit var backgroundBitmap: Bitmap
            private var backgroundScaledBitmap: Bitmap? = null
            private lateinit var hourPaint: Paint
            private lateinit var minutePaint: Paint

            // handler to update the time once a second in interactive mode
            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()
                }
            }

            // service methods (see other sections)
            ...
        }
        ...
        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)
                            }
                        }
                    }
                }
            }
        }
        ...
    }
    
  • 위 예제에서는 1초마다 custom timer 가 불리도록 하였으며, 핸들러에서는 invalidate() 메서드를 호출해 그때마다 onDraw() 메서드를 호출해 watchface를 다시 그린다.

WatchFace 요소 초기화

WatchFace를 다시 그릴 때마다 재사용하는 요소들의 멤버 변수들을 선언했다면 시스템에서 서비스가 로드될 때,
이를 한번만 초기화하고 재사용한다면 성능과 배터리 수명이 향상된다.

Engine.onCreate() 메서드에서 선언된 변수들을 초기화한다.

배경 이미지를 로드, 그래픽 객체 스타일 생성, 시간 계산, 시스템 UI 구성 등

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

        // configure the system UI (see next section)
        ...

        // load the background image
        backgroundBitmap = (resources.getDrawable(R.drawable.bg, null) as BitmapDrawable).bitmap

        // create graphic styles
        hourPaint = Paint().apply {
            setARGB(255, 200, 200, 200)
            strokeWidth = 5.0f
            isAntiAlias = true
            strokeCap = Paint.Cap.ROUND
        }
        ...

        // allocate a Calendar to calculate local time using the UTC time and time zone
        calendar = Calendar.getInstance()
    }
    
  • 위에서 초기화한 값들을 onDraw() 때 사용한다.

Custom Timer 초기화

CustomTimer 를 이용해 interactive mode(대화형 모드)일 때, 얼마나 자주 WatchFace를 업데이트할지 결정해야한다.

ambient mode (대기모드)에서는 Custom Timer를 안정적으로 호출하지 않으므로, 대기모드에서 적용 시, 별도의 방법을 사용해야한다.

Engine.onVisibilityChanged() 메서드에서 아래 조건이 충족될 경우 cutom timer 를 시작해야한다.

  • watch face 가 visible 일 경우
  • interactive mode 일 경우
    private fun updateTimer() {
        updateTimeHandler.removeMessages(MSG_UPDATE_TIME)
        if (shouldTimerBeRunning()) {
            updateTimeHandler.sendEmptyMessage(MSG_UPDATE_TIME)
        }
    }

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

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

        if (visible) {
            registerReceiver()

            // Update time zone in case it changed while we weren't visible.
            calendar.timeZone = TimeZone.getDefault()
        } else {
            unregisterReceiver()
        }

        // Whether the timer should be running depends on whether we're visible and
        // whether we're in ambient mode, so we may need to start or stop the timer
        updateTimer()
    }

    ...

        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)
    }
    
  • 시계 모드가 표시되면, onVisibilityChanged() 메서드는 timezone 변경 receiver 를 등록한다. 기기가 interactive mode인 경우에도 custom timer 를 시작한다. watch face가 표시되지 않으면, 해당 메서드는 custom timer를 중지하고, timezone 변경 receiver 를 등록 취소한다.

Ambient mode 에서의 WatchFace 업데이트

Ambient 모드에서는 시스템이 1분마다 Engine.onTimeTick() 메서드를 호출하므로, 여기서 invalidate()를 해야한다.

    override fun onTimeTick() {
        super.onTimeTick()

        invalidate()
    }
    

시스템 UI 구성

WatchFace는 시스템 UI 요소들에 방해되어서는 안된다. 따라서, WatchFace의 배경이 너무 밝거나 하단 부분을 용도 있게 사용할 경우, 배경을 별도로 설정해야 할 수도 있다.

Google Wear OS 는 WatchFace가 활성화된 상태일 때, 시스템 UI의 아래 내용을 구성할 수 있으며, 이를 위해서는 WatchFaceStyle 인스턴스를 생성해, Engine.setWatchFaceStyle() 메서드에 전달해야한다.

  • 시스템이 시계 모드에서 시간을 가져올지 지정
  • system indicator(시스템 표시기) 주변 단색처리
  • system indicator(시스템 표시기) 위치 지정

AnalogWatchFaceService 클래스의 예시

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

        // configure the system UI
        setWatchFaceStyle(WatchFaceStyle.Builder(this@AnalogWatchFaceService)
                .setBackgroundVisibility(WatchFaceStyle.BACKGROUND_VISIBILITY_INTERRUPTIVE) // 단색 처리
                .setShowSystemUiTime(false)
                .build())
        ...
    }
    

읽지 않은 알림 indicator 관리

읽지 않은 알림 indicator는 하단의 점으로 표시되며, 이는 기본적으로 추가된다. 시계 모드에 이미 읽지 않은 알림 indicator가 따로 존재할 경우나, 새로운 indicator 의 위치가 watchface 의 요소와 충돌하는 경우, 시스템 표시기가 되지 않도록 선택할 수 있다.

  • WatchFaceStyle.Builder.setHideNotificationIndicator를 true로 설정하여 표시기를 명시적으로 숨긴다.
  • WatchFaceStyle.getUnreadCount를 사용해 watchface에서 읽지 않은 알림 수를 읽어온다.
  • WatchFaceStyle.Builder.setShowUnreadCountIndicator를 true 로 설정하여 읽지 않은 개수가 상태표시줄에 표시되도록 요청한다.
  • WatchFaceStyle.Builder.setAccentColor 호출하여 indicator의 외부 링 색상을 변경한다. (default는 화이트)

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

시스템에서는 기기가 low-bit 대기모드, 번인 보호 (특정 px 등을 오래 키고 있어서 디스플레이가 오작동하는 경우를 대비하기 위한 모드)의 사용 등을 판단할 때 Engine.onPropertiesChanged() 메서드를 사용한다.

이를 이용해, low-bit 대기모드에서는 비트맵 필터링, anti-aliasing 등을 중지하고, 번인보호의 경우는 대기모드에서 흰 픽셀의 큰 블록을 사용하지 않고 가장자리에 콘텐츠를 배치하지 않도록 해야한다.

모드 간 변경에 대한 응답

시스템은 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()
    }
    

WatchFace 그리기

Custom WatchFace를 그리지 위해서 시스템에서는 Canvas 인스턴스와 Engine.onDraw 메서드를 호출한다.

  1. onSurfaceChanged 메서드를 재정의하여 뷰가 바뀔 때마다 기기에 맞게 배경을 조정할 수 있도록 한다.
    override fun onSurfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
        if (backgroundScaledBitmap?.width != width || backgroundScaledBitmap?.height != height) //예상한 with 등과 다르면 맞춰서 bitmap을 조정
        {
            backgroundScaledBitmap = Bitmap.createScaledBitmap(backgroundBitmap,
                    width, height, true /* filter */)
        }
        super.onSurfaceChanged(holder, format, width, height)
    }
    
  1. 모드를 확인한다.
  2. 그래픽 계산이 필요할 경우 수행한다.
  3. 캔버스에 백그라운드를 그린다.
  4. 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)
        }
    }
    

Watchface 정보 표시

시간과 함께 제공되는 정보 기능들을 정보 표시라고 부른다.

Complication data providers

Data Provider는 raw data를 제공하지만, 시계모드에서 해당 데이터들이 rendering 되는 것을 제어하지는 않는다. 아래처럼 Google Wear OS가 중간에서 provider에서부터 WatchFace로의 데이터 흐름을 조정한다.

image

WatchFace의 정보 표시

정보 표시 데이터 제공자로부터 데이터를 가져오므로, 별도의 코드를 구현할 필요는 없다. WatchFace가 데이터가 렌더링되는 방식을 제어하므로, 디자인 패턴의 참고가 필요하다.

정보 표시 유형

Complication Type은 어떤 종류의 데이터가 complication(정보 표시)에 나올 수 있는지, 혹은 data provider로부터 제공받아야 하는지를 정의한다.

Data Provider는 WatchFace와 달리 Complication Type을 다르게 사용한다.

  • Data Provider는 Complication Data의 타입 및 개수를 선택한다. (걸음수 -> RANGED_VALUE 등)
  • WatchFace에 포함될 complication 개수와 유형을 선택할 수 있다. (watchface의 다이얼은 ~유형, 게이지는 ~유형만 지원)

참고 링크

반응형
Comments