需求分析
想要看看手机的传感器数据,看看滤波一下能玩点什么无聊的。先搞个最简单的,手机本身的姿态。
需求:采集手机姿态数据,显示在界面上。
那么我们需要:
- 一个文本标签类似的控件,显示手机姿态数据,三个角度: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
文件,增加下载地址:
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中间很简单很直观。
package org.cardc.fdii.qc.Instrumentsimport 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.currentval chart = remember { LineChart(context) }val yawDataSet = LineDataSet(yawData, "Yaw").apply {lineWidth = 2fcolor = ColorTemplate.COLORFUL_COLORS[0]axisDependency = YAxis.AxisDependency.LEFT}val pitchDataSet = LineDataSet(pitchData, "Pitch").apply {lineWidth = 2fcolor = ColorTemplate.COLORFUL_COLORS[1]axisDependency = YAxis.AxisDependency.LEFT}val rollDataSet = LineDataSet(rollData, "Roll").apply {lineWidth = 2fcolor = ColorTemplate.COLORFUL_COLORS[2]axisDependency = YAxis.AxisDependency.LEFT}val lineData = LineData(yawDataSet, pitchDataSet, rollDataSet)chart.data = lineDatachart.xAxis.position = XAxis.XAxisPosition.BOTTOMchart.axisRight.isEnabled = falsechart.description.isEnabled = false// Set gesture listenerchart.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-scalingchart.isAutoScaleMinMaxEnabled = trueAndroidView({ chart }, modifier = modifier.padding(16.dp).border(1.dp, Color.Gray))
}class MainActivity : ComponentActivity(), SensorEventListener {private lateinit var sensorManager: SensorManagerprivate var rotationVectorSensor: Sensor? = nullprivate 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 timeprivate 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 SensorManagerrotationVectorSensor = 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.currentText(text = "qchen2015@hotmail.com © 2024",modifier = Modifier.padding(6.dp).fillMaxWidth(),textAlign = TextAlign.Center)// add a hyperlink to the author's websiteText(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 timeval 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
,传感器精度变化时调用,这里我们不关心
在MainActivity
的onCreate
方法中,我们初始化了传感器管理和传感器实例。在setContent
中,我们在Scaffold
中增加了一个SensorDataDisplay
的组件,这个组件是我们自己写的,用来显示传感器数据。
在这个SensorDataDisplay
组件中,我们组织了一个Column
,整个都是简单直观。
对于组件的输入变量,我们采用了remember
的方式,这样可以在组件内部保存状态。当更新组件角度时,奖结果存入mutableStateListOf<Entry>
中,这个Entry
是MPAndroidChart
库中的数据结构,用来存储图表数据。
第一行是一个版权信息,第二行稍微有一点意思,是一个可以点击的Text
,会访问本站。
// add a hyperlink to the author's websiteText(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
就可以打开浏览器,不用自己写什么复杂的东西。
第三行就是角度标签:
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))
第四行,是一个采用开源图标库MPAndroidChart
的LineChart
来实现的SensorChart
,用来显示角度变化。
SensorChart(yawData, pitchData, rollData, modifier = Modifier.fillMaxSize())
@Composable
fun SensorChart(yawData: List<Entry>,pitchData: List<Entry>,rollData: List<Entry>,modifier: Modifier = Modifier
) {val context = LocalContext.currentval chart = remember { LineChart(context) }val yawDataSet = LineDataSet(yawData, "Yaw").apply {lineWidth = 2fcolor = ColorTemplate.COLORFUL_COLORS[0]axisDependency = YAxis.AxisDependency.LEFT}val pitchDataSet = LineDataSet(pitchData, "Pitch").apply {lineWidth = 2fcolor = ColorTemplate.COLORFUL_COLORS[1]axisDependency = YAxis.AxisDependency.LEFT}val rollDataSet = LineDataSet(rollData, "Roll").apply {lineWidth = 2fcolor = ColorTemplate.COLORFUL_COLORS[2]axisDependency = YAxis.AxisDependency.LEFT}val lineData = LineData(yawDataSet, pitchDataSet, rollDataSet)chart.data = lineDatachart.xAxis.position = XAxis.XAxisPosition.BOTTOMchart.axisRight.isEnabled = falsechart.description.isEnabled = false// Set gesture listenerchart.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-scalingchart.isAutoScaleMinMaxEnabled = trueAndroidView({ chart }, modifier = modifier.padding(16.dp).border(1.dp, Color.Gray))
}
这里调用的是一个AndroidView
,这个是Compose中的一个组件,用来显示Android原生的View。
这里实现一个动作,双击图表,会弹出一个对话框,让用户输入文件名,然后导出数据。
@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 timeval 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,非常适合用来搞一些无聊的事情。
- 代码
- 数据
- apk不推荐下载