<-- Home |--jetpack |--android

Jetpack Compose for Android-006 传感器数据

需求分析

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

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

那么我们需要:

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

核心数据

  • 时间序列,(t, pitch, roll, yaw)
  • 采集间隔,$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文件,增加下载地址:

 1pluginManagement {
 2    repositories {
 3        maven { url = uri("https://maven.aliyun.com/repository/public/") }
 4        google {
 5            content {
 6                includeGroupByRegex("com\\.android.*")
 7                includeGroupByRegex("com\\.google.*")
 8                includeGroupByRegex("androidx.*")
 9            }
10        }
11        mavenCentral()
12        gradlePluginPortal()
13    }
14}
15dependencyResolutionManagement {
16    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
17    repositories {
18        maven { url = uri("https://maven.aliyun.com/repository/public/") }
19        google()
20        mavenCentral()
21        maven { url = uri("https://jitpack.io") }
22    }
23}
24
25rootProject.name = "YawPitchRoll"
26include(":app")

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

建立界面

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

  1package org.cardc.fdii.qc.Instruments
  2
  3import android.app.AlertDialog
  4import android.content.Context
  5import android.content.Intent
  6import android.hardware.Sensor
  7import android.hardware.SensorEvent
  8import android.hardware.SensorEventListener
  9import android.hardware.SensorManager
 10import android.net.Uri
 11import android.os.Build
 12import android.os.Bundle
 13import android.view.MotionEvent
 14import android.widget.EditText
 15import android.widget.Toast
 16import androidx.activity.ComponentActivity
 17import androidx.activity.compose.setContent
 18import androidx.activity.enableEdgeToEdge
 19import androidx.annotation.RequiresApi
 20import androidx.compose.foundation.border
 21import androidx.compose.foundation.clickable
 22import androidx.compose.foundation.layout.Column
 23import androidx.compose.foundation.layout.fillMaxSize
 24import androidx.compose.foundation.layout.fillMaxWidth
 25import androidx.compose.foundation.layout.padding
 26import androidx.compose.material3.Scaffold
 27import androidx.compose.material3.Text
 28import androidx.compose.runtime.Composable
 29import androidx.compose.runtime.getValue
 30import androidx.compose.runtime.mutableFloatStateOf
 31import androidx.compose.runtime.mutableLongStateOf
 32import androidx.compose.runtime.mutableStateListOf
 33import androidx.compose.runtime.remember
 34import androidx.compose.runtime.setValue
 35import androidx.compose.ui.Modifier
 36import androidx.compose.ui.graphics.Color
 37import androidx.compose.ui.platform.LocalContext
 38import androidx.compose.ui.text.TextStyle
 39import androidx.compose.ui.text.style.TextAlign
 40import androidx.compose.ui.text.style.TextDecoration
 41import androidx.compose.ui.unit.dp
 42import androidx.compose.ui.viewinterop.AndroidView
 43import com.github.mikephil.charting.charts.LineChart
 44import com.github.mikephil.charting.components.XAxis
 45import com.github.mikephil.charting.components.YAxis
 46import com.github.mikephil.charting.data.Entry
 47import com.github.mikephil.charting.data.LineData
 48import com.github.mikephil.charting.data.LineDataSet
 49import com.github.mikephil.charting.listener.ChartTouchListener
 50import com.github.mikephil.charting.listener.OnChartGestureListener
 51import com.github.mikephil.charting.utils.ColorTemplate
 52import org.cardc.fdii.qc.Instruments.ui.theme.FirstApplicationTheme
 53import java.io.File
 54import java.io.FileWriter
 55
 56@Composable
 57fun SensorChart(
 58    yawData: List<Entry>,
 59    pitchData: List<Entry>,
 60    rollData: List<Entry>,
 61    modifier: Modifier = Modifier
 62) {
 63    val context = LocalContext.current
 64    val chart = remember { LineChart(context) }
 65
 66    val yawDataSet = LineDataSet(yawData, "Yaw").apply {
 67        lineWidth = 2f
 68        color = ColorTemplate.COLORFUL_COLORS[0]
 69        axisDependency = YAxis.AxisDependency.LEFT
 70    }
 71
 72    val pitchDataSet = LineDataSet(pitchData, "Pitch").apply {
 73        lineWidth = 2f
 74
 75        color = ColorTemplate.COLORFUL_COLORS[1]
 76        axisDependency = YAxis.AxisDependency.LEFT
 77    }
 78
 79    val rollDataSet = LineDataSet(rollData, "Roll").apply {
 80        lineWidth = 2f
 81
 82        color = ColorTemplate.COLORFUL_COLORS[2]
 83        axisDependency = YAxis.AxisDependency.LEFT
 84    }
 85
 86    val lineData = LineData(yawDataSet, pitchDataSet, rollDataSet)
 87    chart.data = lineData
 88
 89    chart.xAxis.position = XAxis.XAxisPosition.BOTTOM
 90    chart.axisRight.isEnabled = false
 91    chart.description.isEnabled = false
 92
 93
 94    // Set gesture listener
 95    chart.onChartGestureListener = object : OnChartGestureListener {
 96        override fun onChartGestureStart(
 97            me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?
 98        ) {
 99        }
100
101        override fun onChartGestureEnd(
102            me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?
103        ) {
104        }
105
106        override fun onChartLongPressed(me: MotionEvent?) {}
107
108        @RequiresApi(Build.VERSION_CODES.O)
109        override fun onChartDoubleTapped(me: MotionEvent?) {
110            showFileNameDialog(context, yawData, pitchData, rollData)
111        }
112
113        override fun onChartSingleTapped(me: MotionEvent?) {}
114        override fun onChartFling(
115            me1: MotionEvent?, me2: MotionEvent?, velocityX: Float, velocityY: Float
116        ) {
117        }
118
119        override fun onChartScale(me: MotionEvent?, scaleX: Float, scaleY: Float) {}
120        override fun onChartTranslate(me: MotionEvent?, dX: Float, dY: Float) {}
121    }
122
123    chart.invalidate()
124    // Enable auto-scaling
125    chart.isAutoScaleMinMaxEnabled = true
126
127    AndroidView({ chart }, modifier = modifier.padding(16.dp).border(1.dp, Color.Gray))
128}
129
130
131class MainActivity : ComponentActivity(), SensorEventListener {
132    private lateinit var sensorManager: SensorManager
133    private var rotationVectorSensor: Sensor? = null
134
135    private var _yaw by mutableFloatStateOf(0f)
136    private var _pitch by mutableFloatStateOf(0f)
137    private var _roll by mutableFloatStateOf(0f)
138
139    // add a variable to store the high resolution time
140    private val _time0 = System.nanoTime()
141    private var _time by mutableLongStateOf(0L)
142
143    override fun onResume() {
144        super.onResume()
145        rotationVectorSensor?.also { sensor ->
146            sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL)
147        }
148    }
149
150    override fun onPause() {
151        super.onPause()
152        sensorManager.unregisterListener(this)
153    }
154
155    override fun onSensorChanged(event: SensorEvent?) {
156        event?.let {
157            if (it.sensor.type == Sensor.TYPE_ROTATION_VECTOR) {
158                val rotationMatrix = FloatArray(9)
159                SensorManager.getRotationMatrixFromVector(rotationMatrix, it.values)
160                val orientation = FloatArray(3)
161                SensorManager.getOrientation(rotationMatrix, orientation)
162                _yaw = Math.toDegrees(orientation[0].toDouble()).toFloat()
163                _pitch = Math.toDegrees(orientation[1].toDouble()).toFloat()
164                _roll = Math.toDegrees(orientation[2].toDouble()).toFloat()
165                // update the time
166                _time = System.nanoTime() - _time0
167            }
168        }
169    }
170
171    override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
172        // Do nothing
173    }
174
175    override fun onCreate(savedInstanceState: Bundle?) {
176        super.onCreate(savedInstanceState)
177        enableEdgeToEdge()
178        sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
179        rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
180
181
182        setContent {
183            FirstApplicationTheme {
184                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
185                    SensorDataDisplay(
186                        yaw = _yaw,
187                        pitch = _pitch,
188                        roll = _roll,
189                        t = _time,
190                        modifier = Modifier.padding(innerPadding)
191                    )
192
193                }
194            }
195        }
196    }
197}
198
199
200@Composable
201fun SensorDataDisplay(
202    yaw: Float, pitch: Float, roll: Float, t: Long, modifier: Modifier = Modifier
203) {
204    val yawData = remember { mutableStateListOf<Entry>() }
205    val pitchData = remember { mutableStateListOf<Entry>() }
206    val rollData = remember { mutableStateListOf<Entry>() }
207    if (t > 0) {
208        yawData.add(Entry(t * 1e-9f, yaw))
209        pitchData.add(Entry(t * 1e-9f, pitch))
210        rollData.add(Entry(t * 1e-9f, roll))
211    }
212    Column(modifier = modifier) {
213        val context = LocalContext.current
214        Text(
215            text = "qchen2015@hotmail.com © 2024",
216            modifier = Modifier
217                .padding(6.dp)
218                .fillMaxWidth(),
219            textAlign = TextAlign.Center
220        )
221        // add a hyperlink to the author's website
222        Text(
223            text = "https://www.windtunnel.cn",
224            modifier = Modifier
225                .padding(6.dp)
226                .fillMaxWidth()
227                .clickable {
228                    val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.windtunnel.cn/categories/jetpack/"))
229                    context.startActivity(intent)
230                },
231            textAlign = TextAlign.Center,
232            color = Color.Blue,
233            style = TextStyle(textDecoration = TextDecoration.Underline)
234        )
235
236
237        Text(
238            text = "Yaw  : %16.4f°\nPitch: %16.4f°\nRoll  : %16.4f°\nTime: %16.6fs"
239                .format(yaw, pitch, roll, t * 1e-9),
240            modifier = Modifier.padding(16.dp)
241        )
242        SensorChart(yawData, pitchData, rollData, modifier = Modifier.fillMaxSize())
243        // add an about button to show author information
244
245    }
246}
247
248
249@RequiresApi(Build.VERSION_CODES.O)
250fun showFileNameDialog(
251    context: Context, yawData: List<Entry>, pitchData: List<Entry>, rollData: List<Entry>
252) {
253    val editText = EditText(context).apply {
254        setHint("Enter file name")
255        // get date and time
256        val currentDateTime = java.time.LocalDateTime.now()
257        val formatter = java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
258        setText(currentDateTime.format(formatter))
259    }
260    val dialog = AlertDialog.Builder(context).setTitle("Enter file name").setView(editText)
261        .setPositiveButton("Save") { _, _ ->
262            val fileName = editText.text.toString()
263            if (fileName.isNotEmpty()) {
264                saveDataToCsv(context, fileName, yawData, pitchData, rollData)
265            } else {
266                Toast.makeText(context, "File name cannot be empty", Toast.LENGTH_SHORT).show()
267            }
268        }.setNegativeButton("Cancel", null).create()
269    dialog.show()
270}
271
272fun saveDataToCsv(
273    context: Context,
274    fileName: String,
275    yawData: List<Entry>,
276    pitchData: List<Entry>,
277    rollData: List<Entry>
278) {
279    val file = File(context.getExternalFilesDir(null), "${fileName.trim()}.csv")
280    FileWriter(file).use { writer ->
281        writer.append("Time,Yaw,Pitch,Roll\n")
282        for (i in yawData.indices) {
283            writer.append("${yawData[i].x},${yawData[i].y},${pitchData[i].y},${rollData[i].y}\n")
284        }
285    }
286    Toast.makeText(context, "Data saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
287}

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

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

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

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

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

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

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

 1    // add a hyperlink to the author's website
 2    Text(
 3        text = "https://www.windtunnel.cn",
 4        modifier = Modifier
 5            .padding(6.dp)
 6            .fillMaxWidth()
 7            .clickable {
 8                val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.windtunnel.cn/categories/jetpack/"))
 9                context.startActivity(intent)
10            },
11        textAlign = TextAlign.Center,
12        color = Color.Blue,
13        style = TextStyle(textDecoration = TextDecoration.Underline)
14    )

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

第三行就是角度标签:

1    Text(
2        text = "Yaw  : %16.4f°\nPitch: %16.4f°\nRoll  : %16.4f°\nTime: %16.6fs"
3            .format(yaw, pitch, roll, t * 1e-9),
4        modifier = Modifier.padding(16.dp)
5    )

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

1    SensorChart(yawData, pitchData, rollData, modifier = Modifier.fillMaxSize())
 1@Composable
 2fun SensorChart(
 3    yawData: List<Entry>,
 4    pitchData: List<Entry>,
 5    rollData: List<Entry>,
 6    modifier: Modifier = Modifier
 7) {
 8    val context = LocalContext.current
 9    val chart = remember { LineChart(context) }
10
11    val yawDataSet = LineDataSet(yawData, "Yaw").apply {
12        lineWidth = 2f
13        color = ColorTemplate.COLORFUL_COLORS[0]
14        axisDependency = YAxis.AxisDependency.LEFT
15    }
16
17    val pitchDataSet = LineDataSet(pitchData, "Pitch").apply {
18        lineWidth = 2f
19
20        color = ColorTemplate.COLORFUL_COLORS[1]
21        axisDependency = YAxis.AxisDependency.LEFT
22    }
23
24    val rollDataSet = LineDataSet(rollData, "Roll").apply {
25        lineWidth = 2f
26
27        color = ColorTemplate.COLORFUL_COLORS[2]
28        axisDependency = YAxis.AxisDependency.LEFT
29    }
30
31    val lineData = LineData(yawDataSet, pitchDataSet, rollDataSet)
32    chart.data = lineData
33
34    chart.xAxis.position = XAxis.XAxisPosition.BOTTOM
35    chart.axisRight.isEnabled = false
36    chart.description.isEnabled = false
37
38
39    // Set gesture listener
40    chart.onChartGestureListener = object : OnChartGestureListener {
41        override fun onChartGestureStart(
42            me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?
43        ) {
44        }
45
46        override fun onChartGestureEnd(
47            me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?
48        ) {
49        }
50
51        override fun onChartLongPressed(me: MotionEvent?) {}
52
53        @RequiresApi(Build.VERSION_CODES.O)
54        override fun onChartDoubleTapped(me: MotionEvent?) {
55            showFileNameDialog(context, yawData, pitchData, rollData)
56        }
57
58        override fun onChartSingleTapped(me: MotionEvent?) {}
59        override fun onChartFling(
60            me1: MotionEvent?, me2: MotionEvent?, velocityX: Float, velocityY: Float
61        ) {
62        }
63
64        override fun onChartScale(me: MotionEvent?, scaleX: Float, scaleY: Float) {}
65        override fun onChartTranslate(me: MotionEvent?, dX: Float, dY: Float) {}
66    }
67
68    chart.invalidate()
69    // Enable auto-scaling
70    chart.isAutoScaleMinMaxEnabled = true
71
72    AndroidView({ chart }, modifier = modifier.padding(16.dp).border(1.dp, Color.Gray))
73}

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

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

 1@RequiresApi(Build.VERSION_CODES.O)
 2fun showFileNameDialog(
 3    context: Context, yawData: List<Entry>, pitchData: List<Entry>, rollData: List<Entry>
 4) {
 5    val editText = EditText(context).apply {
 6        setHint("Enter file name")
 7        // get date and time
 8        val currentDateTime = java.time.LocalDateTime.now()
 9        val formatter = java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
10        setText(currentDateTime.format(formatter))
11    }
12    val dialog = AlertDialog.Builder(context).setTitle("Enter file name").setView(editText)
13        .setPositiveButton("Save") { _, _ ->
14            val fileName = editText.text.toString()
15            if (fileName.isNotEmpty()) {
16                saveDataToCsv(context, fileName, yawData, pitchData, rollData)
17            } else {
18                Toast.makeText(context, "File name cannot be empty", Toast.LENGTH_SHORT).show()
19            }
20        }.setNegativeButton("Cancel", null).create()
21    dialog.show()
22}
23
24fun saveDataToCsv(
25    context: Context,
26    fileName: String,
27    yawData: List<Entry>,
28    pitchData: List<Entry>,
29    rollData: List<Entry>
30) {
31    val file = File(context.getExternalFilesDir(null), "${fileName.trim()}.csv")
32    FileWriter(file).use { writer ->
33        writer.append("Time,Yaw,Pitch,Roll\n")
34        for (i in yawData.indices) {
35            writer.append("${yawData[i].x},${yawData[i].y},${pitchData[i].y},${rollData[i].y}\n")
36        }
37    }
38    Toast.makeText(context, "Data saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
39}

结论

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

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


文章标签

|-->jetpack |-->compose |-->android |-->sensor


GitHub