006-Jetpack Compose for Android之传感器数据


需求分析

想要看看手机的传感器数据,看看滤波一下能玩点什么无聊的。先搞个最简单的,手机本身的姿态。

需求:采集手机姿态数据,显示在界面上。

那么我们需要:

  • 一个文本标签类似的控件,显示手机姿态数据,三个角度:pitch, roll, yaw
  • 是不是需要做一个图标?显示姿态的变化?
  • 这样就提出了需要一个时间标签,显示采集数据的时间(间隔)
  • 开始/停止采集数据的按钮是否需要?在这个场景,单一功能,不需要,把软件打开和软件关闭作为采集数据的开始和停止。
  • 数据如何导出?肯定是需要的,那么我们考虑导出csv文件。

核心数据

  • 时间序列,(t, pitch, roll, yaw)
  • 采集间隔, d t dt dt,由硬件确定?

用户交互

  • 打开程序
  • 关闭程序
  • 导出数据

界面设计

大概我们可以在上方设置一个标签,显示实时得到的最新数据,下方主体部分一个图标,动态更新,显示姿态的变化。

实现流程

建立工程

打开Androi的Studio,新建一个项目,选择Jetpack Compose模板。

记得要认准这个中间的Compose图标。

然后否就是一顿修改镜像地址。首先是gradle下载地址,修改gradle/wrapper/gradle-wrapper.properties文件:

#Fri Dec 13 22:34:09 CST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.aliyun.com/gradle/distributions/v8.9.0/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

接下来就是修改settings.gradle.kts文件,增加下载地址:

kotlin 复制代码
pluginManagement {
    repositories {
        maven { url = uri("https://maven.aliyun.com/repository/public/") }
        google {
            content {
                includeGroupByRegex("com\\.android.*")
                includeGroupByRegex("com\\.google.*")
                includeGroupByRegex("androidx.*")
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        maven { url = uri("https://maven.aliyun.com/repository/public/") }
        google()
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

rootProject.name = "YawPitchRoll"
include(":app")

只有经过了上面两步,才能什么同步Gradle 工程之类的,然后build一下,确认所有的依赖都下载完了。可以稍微运行一下也没问题。

建立界面

建立界面在Jetpack中间很简单很直观。

kotlin 复制代码
package org.cardc.fdii.qc.Instruments

import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.MotionEvent
import android.widget.EditText
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.components.XAxis
import com.github.mikephil.charting.components.YAxis
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet
import com.github.mikephil.charting.listener.ChartTouchListener
import com.github.mikephil.charting.listener.OnChartGestureListener
import com.github.mikephil.charting.utils.ColorTemplate
import org.cardc.fdii.qc.Instruments.ui.theme.FirstApplicationTheme
import java.io.File
import java.io.FileWriter

@Composable
fun SensorChart(
    yawData: List<Entry>,
    pitchData: List<Entry>,
    rollData: List<Entry>,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    val chart = remember { LineChart(context) }

    val yawDataSet = LineDataSet(yawData, "Yaw").apply {
        lineWidth = 2f
        color = ColorTemplate.COLORFUL_COLORS[0]
        axisDependency = YAxis.AxisDependency.LEFT
    }

    val pitchDataSet = LineDataSet(pitchData, "Pitch").apply {
        lineWidth = 2f

        color = ColorTemplate.COLORFUL_COLORS[1]
        axisDependency = YAxis.AxisDependency.LEFT
    }

    val rollDataSet = LineDataSet(rollData, "Roll").apply {
        lineWidth = 2f

        color = ColorTemplate.COLORFUL_COLORS[2]
        axisDependency = YAxis.AxisDependency.LEFT
    }

    val lineData = LineData(yawDataSet, pitchDataSet, rollDataSet)
    chart.data = lineData

    chart.xAxis.position = XAxis.XAxisPosition.BOTTOM
    chart.axisRight.isEnabled = false
    chart.description.isEnabled = false


    // Set gesture listener
    chart.onChartGestureListener = object : OnChartGestureListener {
        override fun onChartGestureStart(
            me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?
        ) {
        }

        override fun onChartGestureEnd(
            me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?
        ) {
        }

        override fun onChartLongPressed(me: MotionEvent?) {}

        @RequiresApi(Build.VERSION_CODES.O)
        override fun onChartDoubleTapped(me: MotionEvent?) {
            showFileNameDialog(context, yawData, pitchData, rollData)
        }

        override fun onChartSingleTapped(me: MotionEvent?) {}
        override fun onChartFling(
            me1: MotionEvent?, me2: MotionEvent?, velocityX: Float, velocityY: Float
        ) {
        }

        override fun onChartScale(me: MotionEvent?, scaleX: Float, scaleY: Float) {}
        override fun onChartTranslate(me: MotionEvent?, dX: Float, dY: Float) {}
    }

    chart.invalidate()
    // Enable auto-scaling
    chart.isAutoScaleMinMaxEnabled = true

    AndroidView({ chart }, modifier = modifier.padding(16.dp).border(1.dp, Color.Gray))
}


class MainActivity : ComponentActivity(), SensorEventListener {
    private lateinit var sensorManager: SensorManager
    private var rotationVectorSensor: Sensor? = null

    private var _yaw by mutableFloatStateOf(0f)
    private var _pitch by mutableFloatStateOf(0f)
    private var _roll by mutableFloatStateOf(0f)

    // add a variable to store the high resolution time
    private val _time0 = System.nanoTime()
    private var _time by mutableLongStateOf(0L)

    override fun onResume() {
        super.onResume()
        rotationVectorSensor?.also { sensor ->
            sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL)
        }
    }

    override fun onPause() {
        super.onPause()
        sensorManager.unregisterListener(this)
    }

    override fun onSensorChanged(event: SensorEvent?) {
        event?.let {
            if (it.sensor.type == Sensor.TYPE_ROTATION_VECTOR) {
                val rotationMatrix = FloatArray(9)
                SensorManager.getRotationMatrixFromVector(rotationMatrix, it.values)
                val orientation = FloatArray(3)
                SensorManager.getOrientation(rotationMatrix, orientation)
                _yaw = Math.toDegrees(orientation[0].toDouble()).toFloat()
                _pitch = Math.toDegrees(orientation[1].toDouble()).toFloat()
                _roll = Math.toDegrees(orientation[2].toDouble()).toFloat()
                // update the time
                _time = System.nanoTime() - _time0
            }
        }
    }

    override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
        // Do nothing
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
        rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)


        setContent {
            FirstApplicationTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    SensorDataDisplay(
                        yaw = _yaw,
                        pitch = _pitch,
                        roll = _roll,
                        t = _time,
                        modifier = Modifier.padding(innerPadding)
                    )

                }
            }
        }
    }
}


@Composable
fun SensorDataDisplay(
    yaw: Float, pitch: Float, roll: Float, t: Long, modifier: Modifier = Modifier
) {
    val yawData = remember { mutableStateListOf<Entry>() }
    val pitchData = remember { mutableStateListOf<Entry>() }
    val rollData = remember { mutableStateListOf<Entry>() }
    if (t > 0) {
        yawData.add(Entry(t * 1e-9f, yaw))
        pitchData.add(Entry(t * 1e-9f, pitch))
        rollData.add(Entry(t * 1e-9f, roll))
    }
    Column(modifier = modifier) {
        val context = LocalContext.current
        Text(
            text = "qchen2015@hotmail.com © 2024",
            modifier = Modifier
                .padding(6.dp)
                .fillMaxWidth(),
            textAlign = TextAlign.Center
        )
        // add a hyperlink to the author's website
        Text(
            text = "https://www.windtunnel.cn",
            modifier = Modifier
                .padding(6.dp)
                .fillMaxWidth()
                .clickable {
                    val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.windtunnel.cn/categories/jetpack/"))
                    context.startActivity(intent)
                },
            textAlign = TextAlign.Center,
            color = Color.Blue,
            style = TextStyle(textDecoration = TextDecoration.Underline)
        )


        Text(
            text = "Yaw  : %16.4f°\nPitch: %16.4f°\nRoll  : %16.4f°\nTime: %16.6fs"
                .format(yaw, pitch, roll, t * 1e-9),
            modifier = Modifier.padding(16.dp)
        )
        SensorChart(yawData, pitchData, rollData, modifier = Modifier.fillMaxSize())
        // add an about button to show author information

    }
}


@RequiresApi(Build.VERSION_CODES.O)
fun showFileNameDialog(
    context: Context, yawData: List<Entry>, pitchData: List<Entry>, rollData: List<Entry>
) {
    val editText = EditText(context).apply {
        setHint("Enter file name")
        // get date and time
        val currentDateTime = java.time.LocalDateTime.now()
        val formatter = java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
        setText(currentDateTime.format(formatter))
    }
    val dialog = AlertDialog.Builder(context).setTitle("Enter file name").setView(editText)
        .setPositiveButton("Save") { _, _ ->
            val fileName = editText.text.toString()
            if (fileName.isNotEmpty()) {
                saveDataToCsv(context, fileName, yawData, pitchData, rollData)
            } else {
                Toast.makeText(context, "File name cannot be empty", Toast.LENGTH_SHORT).show()
            }
        }.setNegativeButton("Cancel", null).create()
    dialog.show()
}

fun saveDataToCsv(
    context: Context,
    fileName: String,
    yawData: List<Entry>,
    pitchData: List<Entry>,
    rollData: List<Entry>
) {
    val file = File(context.getExternalFilesDir(null), "${fileName.trim()}.csv")
    FileWriter(file).use { writer ->
        writer.append("Time,Yaw,Pitch,Roll\n")
        for (i in yawData.indices) {
            writer.append("${yawData[i].x},${yawData[i].y},${pitchData[i].y},${rollData[i].y}\n")
        }
    }
    Toast.makeText(context, "Data saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
}

这里面自己写的代码几乎没有,就是把MainActivity增加了一个继承SensorEventListener的接口,然后增加了一个SensorManager的实例,传感器Sensor实例,还有三个角度的数据、时间零点和当前时间。

SensorEventListener的接口要求实现几个方法:

  • onResume,注册传感器监听器
  • onPause,取消注册传感器监听器
  • onSensorChanged,传感器数据变化时调用
  • onAccuracyChanged,传感器精度变化时调用,这里我们不关心

MainActivityonCreate方法中,我们初始化了传感器管理和传感器实例。在setContent中,我们在Scaffold中增加了一个SensorDataDisplay的组件,这个组件是我们自己写的,用来显示传感器数据。

在这个SensorDataDisplay组件中,我们组织了一个Column,整个都是简单直观。

对于组件的输入变量,我们采用了remember的方式,这样可以在组件内部保存状态。当更新组件角度时,奖结果存入mutableStateListOf<Entry>中,这个EntryMPAndroidChart库中的数据结构,用来存储图表数据。

第一行是一个版权信息,第二行稍微有一点意思,是一个可以点击的Text,会访问本站。

kotlin 复制代码
    // add a hyperlink to the author's website
    Text(
        text = "https://www.windtunnel.cn",
        modifier = Modifier
            .padding(6.dp)
            .fillMaxWidth()
            .clickable {
                val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.windtunnel.cn/categories/jetpack/"))
                context.startActivity(intent)
            },
        textAlign = TextAlign.Center,
        color = Color.Blue,
        style = TextStyle(textDecoration = TextDecoration.Underline)
    )

Android这一点就挺好,只要用Intent就可以打开浏览器,不用自己写什么复杂的东西。

第三行就是角度标签:

kotlin 复制代码
    Text(
        text = "Yaw  : %16.4f°\nPitch: %16.4f°\nRoll  : %16.4f°\nTime: %16.6fs"
            .format(yaw, pitch, roll, t * 1e-9),
        modifier = Modifier.padding(16.dp)
    )

第四行,是一个采用开源图标库MPAndroidChartLineChart来实现的SensorChart,用来显示角度变化。

kotlin 复制代码
    SensorChart(yawData, pitchData, rollData, modifier = Modifier.fillMaxSize())
kotlin 复制代码
@Composable
fun SensorChart(
    yawData: List<Entry>,
    pitchData: List<Entry>,
    rollData: List<Entry>,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    val chart = remember { LineChart(context) }

    val yawDataSet = LineDataSet(yawData, "Yaw").apply {
        lineWidth = 2f
        color = ColorTemplate.COLORFUL_COLORS[0]
        axisDependency = YAxis.AxisDependency.LEFT
    }

    val pitchDataSet = LineDataSet(pitchData, "Pitch").apply {
        lineWidth = 2f

        color = ColorTemplate.COLORFUL_COLORS[1]
        axisDependency = YAxis.AxisDependency.LEFT
    }

    val rollDataSet = LineDataSet(rollData, "Roll").apply {
        lineWidth = 2f

        color = ColorTemplate.COLORFUL_COLORS[2]
        axisDependency = YAxis.AxisDependency.LEFT
    }

    val lineData = LineData(yawDataSet, pitchDataSet, rollDataSet)
    chart.data = lineData

    chart.xAxis.position = XAxis.XAxisPosition.BOTTOM
    chart.axisRight.isEnabled = false
    chart.description.isEnabled = false


    // Set gesture listener
    chart.onChartGestureListener = object : OnChartGestureListener {
        override fun onChartGestureStart(
            me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?
        ) {
        }

        override fun onChartGestureEnd(
            me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?
        ) {
        }

        override fun onChartLongPressed(me: MotionEvent?) {}

        @RequiresApi(Build.VERSION_CODES.O)
        override fun onChartDoubleTapped(me: MotionEvent?) {
            showFileNameDialog(context, yawData, pitchData, rollData)
        }

        override fun onChartSingleTapped(me: MotionEvent?) {}
        override fun onChartFling(
            me1: MotionEvent?, me2: MotionEvent?, velocityX: Float, velocityY: Float
        ) {
        }

        override fun onChartScale(me: MotionEvent?, scaleX: Float, scaleY: Float) {}
        override fun onChartTranslate(me: MotionEvent?, dX: Float, dY: Float) {}
    }

    chart.invalidate()
    // Enable auto-scaling
    chart.isAutoScaleMinMaxEnabled = true

    AndroidView({ chart }, modifier = modifier.padding(16.dp).border(1.dp, Color.Gray))
}

这里调用的是一个AndroidView,这个是Compose中的一个组件,用来显示Android原生的View。

这里实现一个动作,双击图表,会弹出一个对话框,让用户输入文件名,然后导出数据。

kotlin 复制代码
@RequiresApi(Build.VERSION_CODES.O)
fun showFileNameDialog(
    context: Context, yawData: List<Entry>, pitchData: List<Entry>, rollData: List<Entry>
) {
    val editText = EditText(context).apply {
        setHint("Enter file name")
        // get date and time
        val currentDateTime = java.time.LocalDateTime.now()
        val formatter = java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
        setText(currentDateTime.format(formatter))
    }
    val dialog = AlertDialog.Builder(context).setTitle("Enter file name").setView(editText)
        .setPositiveButton("Save") { _, _ ->
            val fileName = editText.text.toString()
            if (fileName.isNotEmpty()) {
                saveDataToCsv(context, fileName, yawData, pitchData, rollData)
            } else {
                Toast.makeText(context, "File name cannot be empty", Toast.LENGTH_SHORT).show()
            }
        }.setNegativeButton("Cancel", null).create()
    dialog.show()
}

fun saveDataToCsv(
    context: Context,
    fileName: String,
    yawData: List<Entry>,
    pitchData: List<Entry>,
    rollData: List<Entry>
) {
    val file = File(context.getExternalFilesDir(null), "${fileName.trim()}.csv")
    FileWriter(file).use { writer ->
        writer.append("Time,Yaw,Pitch,Roll\n")
        for (i in yawData.indices) {
            writer.append("${yawData[i].x},${yawData[i].y},${pitchData[i].y},${rollData[i].y}\n")
        }
    }
    Toast.makeText(context, "Data saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
}

结论

导出的数据很容易用Matlab或者Python画出来。

总的来说,这个过程非常丝滑,最终编译的apk文件大小不到10MB,非常适合用来搞一些无聊的事情。

相关推荐
zhangjiaofa1 小时前
深入理解 Android 中的 ActivityInfo
android
zhangjiaofa1 小时前
深入理解 Android 中的 ApplicationInfo
android
weixin_460783872 小时前
Flutter Android修改应用名称、应用图片、应用启动画面
android·flutter
我惠依旧3 小时前
安卓H5项目通过adb更新H5项目
android·adb
加勒比之杰克3 小时前
【数据库初阶】MySQL中表的约束(上)
android·数据库·mysql
tmacfrank3 小时前
Jetpack Compose 学习笔记(一)—— 快速上手
android·android jetpack
@OuYang11 小时前
android10 audio音量曲线
android
三爷麋了鹿15 小时前
VNC Viewer安卓版安装与操作
android
起个随便的昵称16 小时前
安卓入门十一 常用网络协议四
android·网络
BoomHe16 小时前
Android 车载性能优化-内存泄漏
android