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
,传感器精度变化时调用,这里我们不关心
在MainActivity
的onCreate
方法中,我们初始化了传感器管理和传感器实例。在setContent
中,我们在Scaffold
中增加了一个SensorDataDisplay
的组件,这个组件是我们自己写的,用来显示传感器数据。
在这个SensorDataDisplay
组件中,我们组织了一个Column
,整个都是简单直观。
对于组件的输入变量,我们采用了remember
的方式,这样可以在组件内部保存状态。当更新组件角度时,奖结果存入mutableStateListOf<Entry>
中,这个Entry
是MPAndroidChart
库中的数据结构,用来存储图表数据。
第一行是一个版权信息,第二行稍微有一点意思,是一个可以点击的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 )
第四行,是一个采用开源图标库MPAndroidChart
的LineChart
来实现的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
- 本站总访问量:次
- 本站总访客数:人
- 可通过邮件联系作者:Email大福
- 也可以访问技术博客:大福是小强
- 也可以在知乎搞抽象:知乎-大福
- Comments, requests, and/or opinions go to: Github Repository