はじめに
Android でデバイスの向き(方位角・ピッチ・ロール)を取得するには、加速度センサーと磁気センサーを組み合わせる方法が定番です。かつては LiveData を継承した独自クラスで実装するパターンが広く使われていましたが、2026 年現在のプロジェクトでは StateFlow + Kotlin coroutines を使う構成が主流です。
本記事では古い LiveData ベースの実装を出発点に、現代的な Flow ベースに移行するアプローチと、最終的に Jetpack Compose の UI へ流し込む全体像を解説します。
なぜ LiveData から StateFlow へ移行するのか
LiveData は Android のライフサイクルと自動的に連携できる点で優れていましたが、以下の理由で現代のコードベースでは扱いにくくなっています。
- ViewModel 外への依存:
LiveData.observe()はLifecycleOwnerを必要とするため、ドメイン層やリポジトリで使うと依存関係が逆転する - バックプレッシャー非対応: センサーは高頻度でイベントを発火するが、
LiveDataは間引き(debounce / throttle)を標準で持たない - Coroutines との親和性:
StateFlowはsuspend関数や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() // 処理が間に合わない場合は最新値のみ保持
callbackFlow の awaitClose ブロックがコルーチンキャンセル時に自動で 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 の恩恵が大きく、ドメイン層まで一気通貫でコードを整理できます。