Android Wear の Watch Face を作ってみる (Wear OS 1.x & 2.0 対応)

2018-06-04 12:23:44 · 2954 words · 6 minute read Android Android Wear

はじめに

Android Wear が世に出て随分経ちますが、先日はじめて Wear アプリの開発に触れてみたので記事にしてみたいと思います。
サンプルコードはこちらに上がっています。

早速作ってみる

テンプレートからプロジェクト生成

Android Studio にバンドルされている Android Wear のテンプレートからプロジェクトを生成します。

Android Studio を起動し、File -> New -> New Project でプロジェクト作成ウィザードを起動。
プロジェクトの設定を適切にした後、Nextを押し、対象 API を選択する画面でWearにチェックを入れます。
次の画面でWatch Faceを選択し、次へ進みます。
ここで、AnalogDigital、どちらのStyleにするか聞かれますが、そんなに生成物に大きく影響はしないので、迷ったときは自分が作りたいものがどちらにより近いかで決めると良いと思います。
なお、この記事ではDigitalを選択した体で話を進めます。

こうして、書くべきコードが予め網羅的に書いてあるプロジェクトができるので、あとは自分好みに修正するだけで済みます。
そう、ただ作るだけなら Watch Face の開発は非常に簡単なのです。

ちょいと弄ってみる

まずは、このままRunしてみましょう。
といっても、プロジェクト生成直後はいつものCtrl+Rでは実行できないはずです。
これは、Watch Face は Launch Activity を持たない (WatchFaceService を拡張して利用する) ためです。

Runボタンの左側にあるmoduleを選ぶ小窓の中のEdit Configurations…を選択し、Generalタブの中にあるLaunch Options内のLaunchNothingにすれば無事Runできるようになるはずです。

実は使えるレイアウトXMLファイル

公式ドキュメントを見ていると、Canvasを使ってゴリゴリ描画していく方法しか載っていないのですが、少し工夫することで普通にレイアウトXMLも使えます。
ConstraintLayoutも普通に使えます✨
ただし、どうやらData Bindingは使えないっぽいので注意してください。

レイアウトXMLファイル作成

まずは、適当なレイアウトXMLファイルを作りましょう。

face.xml :

<?xml version="1.0" encoding="utf-8"?>

<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
                                             xmlns:app="http://schemas.android.com/apk/res-auto"
                                             xmlns:tools="http://schemas.android.com/tools"
                                             android:layout_width="match_parent"
                                             android:layout_height="match_parent">

    <android.support.constraint.Guideline
        android:id="@+id/guide_box_left"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_percent="0.1464"/>

    <android.support.constraint.Guideline
        android:id="@+id/guide_box_right"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_percent="0.8536"/>

    <android.support.constraint.Guideline
        android:id="@+id/guide_box_top"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.1464"/>

    <android.support.constraint.Guideline
        android:id="@+id/guide_box_bottom"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.8536"/>

    <android.support.constraint.Guideline
        android:id="@+id/guide_time_secondary"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.7518"/>

    <android.support.v7.widget.AppCompatTextView
        android:id="@+id/time_primary"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="44sp"
        app:layout_constraintBottom_toBottomOf="@id/guide_box_bottom"
        app:layout_constraintEnd_toEndOf="@id/guide_box_right"
        app:layout_constraintStart_toStartOf="@id/guide_box_left"
        app:layout_constraintTop_toTopOf="@id/guide_box_top"
        tools:text="15:25"/>

    <android.support.v7.widget.AppCompatTextView
        android:id="@+id/time_secondary"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="5dp"
        android:textSize="16sp"
        app:layout_constraintBottom_toTopOf="@id/date"
        app:layout_constraintEnd_toEndOf="@id/time_primary"
        tools:text="07"/>

    <android.support.v7.widget.AppCompatTextView
        android:id="@+id/date"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="12sp"
        app:layout_constraintBottom_toTopOf="@id/guide_box_bottom"
        app:layout_constraintEnd_toEndOf="@id/guide_box_right"
        app:layout_constraintStart_toStartOf="@id/guide_box_left"
        tools:text="18/05/31 (金)"/>

</android.support.constraint.ConstraintLayout>

android.support.constraint.Guidelineを利用して、Round タイプの Wear におけるセーフエリアを定義しています。

レイアウトのInflate

Watch Face における描画処理はほとんどWatchFaceService.Engineの中で行われます。
以下にレイアウトのInflateに関する部分と情報の更新のみに簡略化したWatchFaceService.Engineの定義例を示します。

inner class Engine : CanvasWatchFaceService.Engine() {

    private var isAmbient = false

    private lateinit var layout: View

    private lateinit var spec: Size

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

        layout = getSystemService(LayoutInflater::class.java)
                .inflate(R.layout.face, null)

        val displaySize = Point().apply {
            getSystemService(WindowManager::class.java).defaultDisplay
                    .getSize(this)
        }
        spec = Size(
                View.MeasureSpec.makeMeasureSpec(displaySize.x, View.MeasureSpec.EXACTLY),
                View.MeasureSpec.makeMeasureSpec(displaySize.y, View.MeasureSpec.EXACTLY)
        )
    }

    override fun onAmbientModeChanged(inAmbientMode: Boolean) {
        super.onAmbientModeChanged(inAmbientMode)

        isAmbient = inAmbientMode
    }

    override fun onDraw(canvas: Canvas, bounds: Rect) {
        val now = Calendar.getInstance()
        layout.apply {
            measure(spec.width, spec.height)
            layout(0, 0, measuredWidth, measuredHeight)

            findViewById<TextView>(R.id.time_primary).text = now.getPrimaryTimeString()
            findViewById<TextView>(R.id.time_secondary).apply {
                if (isAmbient) visibility = View.GONE
                else {
                    visibility = View.VISIBLE
                    text = now.getTimeSecondString()
                }
            }
            findViewById<TextView>(R.id.date).text = now.getDateString()

            canvas.drawColor(Color.BLACK)
            draw(canvas)
        }
    }

    private fun Calendar.getPrimaryTimeString(): String =
            "%02d:%02d".format(get(Calendar.HOUR_OF_DAY), get(Calendar.MINUTE))

    private fun Calendar.getTimeSecondString(): String =
            "%02d".format(get(Calendar.SECOND))

    private fun Calendar.getDateString(): String =
            "%02d/%02d/%02d(%s)".format(get(Calendar.YEAR) % 100, get(Calendar.MONTH), get(Calendar.DATE), getDayString())

    private fun Calendar.getDayString(): String? =
            when (get(Calendar.DAY_OF_WEEK)) {
                Calendar.MONDAY -> "Mon"
                Calendar.TUESDAY -> "Tue"
                Calendar.WEDNESDAY -> "Wed"
                Calendar.THURSDAY -> "Thu"
                Calendar.FRIDAY -> "Fri"
                Calendar.SATURDAY -> "Sat"
                Calendar.SUNDAY -> "Sun"
                else -> null
            }
}

ポイントは、onCreate内でディスプレイサイズとそのMeasureSpecを取得、レイアウトをInflateし、
onDrawで生成済みのレイアウト(View)に対してView#measure()及びView#layout()を呼んでいることです。
measure()onCreate内で取得したMeasureSpecを渡した後にlayout()を呼ぶことで、レイアウトのrootビューがデバイスの画面にフィットしてくれます。

レイアウトに対するデータの流し込みは、Data Bindingが使えないのでゴリゴリやりましょう…。

ゴリッと描画もしたいよね

とはいえ、レイアウトXMLファイルで表現するのが難しいようなデザインを実現するために、Canvasを使いたい場合も当然あると思います。

もちろん、そんな時はonDrawの引数のCanvasに対して描画していけば反映されます。
Canvasに対する描画が生成したレイアウトに対するView.draw(Canvas)を呼ぶ前か呼んだ後かによって、どちらが手前に来るのかが変わるので注意してください。

以下に秒を円で表示するサンプルを載せておきます。

なお、このタイミングで言うことかという感じはしますが、Ambientモードの時は画面更新が1回/1分程度に制限されるので注意してください。

override fun onDraw(canvas: Canvas, bounds: Rect) {
    // 省略

    if (isAmbient.not()) {
        // Round Chinな画面に対応
        val longerSideLength = max(measuredWidth, measuredHeight)
        val circleRect = RectF(
                longerSideLength * 0.04f,
                longerSideLength * 0.04f,
                longerSideLength * 0.96f,
                longerSideLength * 0.96f)

        val paint = Paint().apply {
            strokeWidth = 8f
            style = Paint.Style.STROKE
            color = Color.WHITE
            isAntiAlias = true
        }

        val minute = now.get(Calendar.MINUTE)
        val second = now.get(Calendar.SECOND)
        val milli = now.get(Calendar.MILLISECOND)
        val secondF: Float = second + milli * 0.001f
        val isOdd = minute % 2 == 1

        if (isOdd && secondF < INTERACTIVE_UPDATE_RATE_MS * 0.001) {
            canvas.drawCircle(circleRect.centerX(), circleRect.centerY(), circleRect.width() / 2, paint)
        } else {
            var startAngle = -90f
            var sweepAngle = secondF * 360 / 60

            if (isOdd) {
                sweepAngle = 360f - sweepAngle
                startAngle = -90f - sweepAngle
            }

            canvas.drawArc(circleRect, startAngle, sweepAngle, false, paint)
        }
    }
}

リリース

さて、いい感じの Watch Face もできたことだし、早速リリースだ!
と思った時に、壁にぶつかると思います。少なくとも僕はぶつかった。

Wear OS 1.x と 2.0 への対応

Watch Face を Standalone アプリとしてリリースし、かつ configuration Activity を mobile 側に作る場合、1.x と2.0 それぞれへのAPKをビルドする必要があります。
なお、Standalone アプリで mobile 側のアプリが存在しない場合は 2.0 のみ対応のアプリになるでしょうし、Standalone ではなく Bundled アプリとしてリリースするのであれば 2.0 向けの対応は必要ないでしょう。

公式のドキュメントを読んでいてもイマイチ勘所が掴めなかったので、四苦八苦して正解っぽいところまでたどり着いた結果を記しておきます。

build.gradleの変更

基本方針は 1.x と 2.0 それぞれのビルドフレーバーを設定し、ビルド時に使い分けるという感じです。

まず、Wear 側のbuild.gradleに以下を追記します。

build.gradle (Wear):

android {
    // 省略

    flavorDimensions 'distribute'

    productFlavors {
        wear1 { dimension 'distribute' }
        wear2 {
            dimension 'distribute'
            minSdkVersion 25
        }
    }
}

なお、ここで指定しているflavorDimensionsは識別できれば何でも良いです。

次に、Mobile 側のbuild.gradleに以下を追記します。 build.gradle (Mobile):

android {
    flavorDimensions 'distribute'

    productFlavors {
        wear1 { dimension 'distribute' }
        wear2 { dimension 'distribute' }
    }
}

署名付きAPKのビルド時

  • Wear 側のモジュールをビルドする時は wear2
  • Mobile 側のモジュールをビルドする時は wear1

を使います。

なお、Wear 側のversionCodeと Mobile 側のversionCodeに同じものを使うことはできないので注意してください。
バージョン + フレーバー識別子 のように付けることを推奨されているようです。

  • バージョン: 13, フーレバー: 1 -> versionCode 131
  • バージョン: 13, フーレバー: 0 -> versionCode 130

こうして出来上がったAPKを、Multiple APK としてリリースします。
といっても、リリースを作成の中で APK を登録する際に、 wear2 フレーバーの Wear 側モジュール APK と、 wear1 フレーバーの Mobile 側モジュール APK の2つをアップロードするだけです。

おわりに

ただ作るだけなら優秀なテンプレートのおかげで簡単にできる Android Wear の Watch Face。
リリースで躓いて眠らせるなんてもったいない!ので皆さんも是非素敵な Face をリリースしてください!

Wear 0S 2.0で直接インストールした Watch Face の Mobile 側アプリインストールへの導線がなさすぎて辛い
それでは!

tweet Share