← ホームへ戻る

デバイス向き検出を LiveData から StateFlow + Compose へ完全移行する

はじめに

Android でデバイスの向き(方位角・ピッチ・ロール)を取得するには、加速度センサーと磁気センサーを組み合わせる方法が定番です。かつては LiveData を継承した独自クラスで実装するパターンが広く使われていましたが、2026 年現在のプロジェクトでは StateFlow + Kotlin coroutines を使う構成が主流です。

本記事では古い LiveData ベースの実装を出発点に、現代的な Flow ベースに移行するアプローチと、最終的に Jetpack Compose の UI へ流し込む全体像を解説します。


なぜ LiveData から StateFlow へ移行するのか

LiveData は Android のライフサイクルと自動的に連携できる点で優れていましたが、以下の理由で現代のコードベースでは扱いにくくなっています。

  • ViewModel 外への依存: LiveData.observe()LifecycleOwner を必要とするため、ドメイン層やリポジトリで使うと依存関係が逆転する
  • バックプレッシャー非対応: センサーは高頻度でイベントを発火するが、LiveData は間引き(debounce / throttle)を標準で持たない
  • Coroutines との親和性: StateFlowsuspend 関数や Flow 演算子と自然につながる

StateFlow に移行することで、センサーのデータをパイプライン上で加工しやすくなり、ViewModel までの責務境界も明確になります。


センサー管理クラスを Flow ベースで作る

まず、センサー購読を callbackFlow でラップするユーティリティを作ります。

import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate

fun Context.sensorFlow(
    sensorType: Int,
    samplingPeriodUs: Int = SensorManager.SENSOR_DELAY_UI,
): Flow<SensorEvent> = callbackFlow {
    val manager = getSystemService(SensorManager::class.java)
    val sensor = manager.getDefaultSensor(sensorType)
        ?: run { close(); return@callbackFlow }

    val listener = object : SensorEventListener {
        override fun onSensorChanged(event: SensorEvent) {
            trySend(event)
        }
        override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
    }

    manager.registerListener(listener, sensor, samplingPeriodUs)

    awaitClose { manager.unregisterListener(listener) }
}.conflate() // 処理が間に合わない場合は最新値のみ保持

callbackFlowawaitClose ブロックがコルーチンキャンセル時に自動で unregisterListener を呼ぶため、ライフサイクル漏れを起こしません。conflate() を付けることで、高頻度のセンサーイベントを UI 描画が追いつく範囲に自然に間引きできます。


向きデータを表す型と計算ロジック

import android.hardware.SensorManager

data class DeviceOrientation(
    /** 方位角(北=0, 東= π/2)ラジアン */
    val azimuth: Float,
    /** ピッチ(前傾き)ラジアン */
    val pitch: Float,
    /** ロール(横傾き)ラジアン */
    val roll: Float,
)

fun computeOrientation(
    accelerometer: FloatArray,
    magnetometer: FloatArray,
): DeviceOrientation? {
    val rotationMatrix = FloatArray(9)
    val orientationAngles = FloatArray(3)

    val success = SensorManager.getRotationMatrix(
        rotationMatrix, null, accelerometer, magnetometer
    )
    if (!success) return null

    SensorManager.getOrientation(rotationMatrix, orientationAngles)
    return DeviceOrientation(
        azimuth = orientationAngles[0],
        pitch   = orientationAngles[1],
        roll    = orientationAngles[2],
    )
}

ViewModel で StateFlow に統合する

2 つのセンサーフローを combine してまとめ、StateFlow に変換します。

import android.app.Application
import android.hardware.Sensor
import android.hardware.SensorManager
import androidx.lifecycle.AndroidViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import androidx.lifecycle.viewModelScope

class OrientationViewModel(app: Application) : AndroidViewModel(app) {

    private val accelFlow = app.sensorFlow(Sensor.TYPE_ACCELEROMETER)
    private val magnetFlow = app.sensorFlow(Sensor.TYPE_MAGNETIC_FIELD)

    val orientation = combine(accelFlow, magnetFlow) { accelEvent, magEvent ->
        computeOrientation(accelEvent.values.clone(), magEvent.values.clone())
    }
        .filterNotNull()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = DeviceOrientation(0f, 0f, 0f),
        )
}

SharingStarted.WhileSubscribed(5_000) によって、画面が 5 秒以上バックグラウンドに入ったらセンサー購読を自動停止します。画面が戻れば再開します。


Jetpack Compose の UI へ流し込む

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.material3.Text
import kotlin.math.toDegrees
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun OrientationScreen(
    vm: OrientationViewModel = viewModel(),
) {
    val orientation by vm.orientation.collectAsStateWithLifecycle()

    Column(modifier = Modifier.padding(16.dp)) {
        Text(text = "方位角: %.1f°".format(orientation.azimuth.toDegrees()))
        Text(text = "ピッチ:  %.1f°".format(orientation.pitch.toDegrees()))
        Text(text = "ロール:  %.1f°".format(orientation.roll.toDegrees()))
    }
}

private fun Float.toDegrees() = Math.toDegrees(this.toDouble()).toFloat()

collectAsStateWithLifecycle()Lifecycle.State.STARTED 以上の場合のみ収集します。画面が非表示になれば自動的にコレクションを停止し、ViewModel 側の WhileSubscribed と組み合わさることでセンサーも停止します。


まとめ

比較項目 LiveData ベース(旧) StateFlow ベース(現代)
ドメイン層での利用 LifecycleOwner 依存で困難 依存なし、自由に使える
センサー停止タイミング onInactive に依存 WhileSubscribed で自動制御
複数センサーの統合 MediatorLiveData が必要 combine で宣言的に書ける
Compose との統合 observeAsState collectAsStateWithLifecycle

LiveData が「ライフサイクル問題を解決した」なら、StateFlow は「ライフサイクルへの依存そのものをなくした」と言えます。センサー処理のような高頻度イベントは特に Flow の恩恵が大きく、ドメイン層まで一気通貫でコードを整理できます。


元記事: Kotlin&LiveDataで端末の向きを取得する処理を書いてみた

🤘

メタルで聴く

この記事をメタルサウンドで
Vector of the Living Earth
Progressive Metal
複数センサーのデータを統合し、重力ベクトルと磁場から端末の向きを再構成するアルゴリズムの多層的な構造が、Progressive Metal のレイヤリングと共鳴するため。
🎵 Lyrics
重力が指す方向を問え 磁場が刻む経度を聴け センサーの叫びを束ね ベクトルは北を指す ライフサイクルに縛られず フローは静かに流れ続ける Compose の画面が揺れるたび 方位はそこに宿る