Jetpack Compose开发一个Android WiFi导航应用

发布时间:2023年12月17日

在以前的一篇文章构建一个WIFI室内定位系统_wifi定位系统-CSDN博客中,我介绍了如何用Android来测量WiFi信号,上传到服务器进行分析后,生成室内不同地方的WiFi指纹,从而帮助进行室内导航。当时我是用的HTML5+的技术来快速开发一个Android的应用,可以看到HTML5+能很便利的用我们熟悉的Web技术来进行开发,而不需要了解原生Android应用繁琐的开发知识。但是Android原生应用也有其优势,尤其在性能上以及一些Android核心功能的调用上。尤其是Google推出了新的Jetpack Compose用于构建原生 Android 界面的新工具包,它使用更少的代码、强大的工具和直观的 Kotlin API,可以帮助简化并加快 Android 界面开发,打造生动而精彩的应用,让我们能更快速、更轻松地构建 Android 界面以及更加便利进行原生应用的开发。因此这次我也用Jetpack Compose来重构了我之前写的Wifi信号测量的应用。

WiFi测量主界面

界面UI设计

Jepack Compose的精髓在于用可组合函数来声明一个UI界面。界面是不可变的,在绘制后无法进行更新。您可以控制的是界面的状态。每当界面的状态发生变化时,Compose 都会重新创建界面数更新的部分。

在Android studio里面新建一个项目,选择Empty activity类型,在这种类型的项目,res资源文件夹没有layout这个子文件夹,因为这种类型已经是用新的Compose方式来进行布局了,不再采用以前的XML方式来定义布局。

新建一个名为WifiMeasure的class,在里面定义一个MeasureScreen的Composable函数,用来声明我们的主界面,代码如下:

@Composable
fun MeasureScreen() {
    Column(modifier = Modifier.padding(all = 8.dp)) {
        Text(
            text = stringResource(R.string.screen_title),
            style = MaterialTheme.typography.titleLarge,
        )
        Spacer(modifier = Modifier.height(8.dp))
        val imageModifier = Modifier
            .height(150.dp)
            .fillMaxWidth()
            .border(BorderStroke(1.dp, Color.Black))
        Image(
            painter = painterResource(id = R.drawable.indooratlas),
            contentDescription = null,
            contentScale = ContentScale.FillWidth,
            modifier = imageModifier
        )
        Spacer(modifier = Modifier.height(8.dp))
        OutlinedTextField(
            value = "",
            onValueChange = { },
            label = { Text(text = stringResource(R.string.label_position_name), style = MaterialTheme.typography.bodyMedium)},
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(modifier = Modifier.height(8.dp))
        TextField(
            value = "0.0",
            onValueChange = { },
            label = { Text(text = stringResource(R.string.label_current_angle), style = MaterialTheme.typography.bodyMedium)},
            readOnly = true,
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(modifier = Modifier.height(16.dp))
        TextButton(
            onClick = { },
            shape = RectangleShape,
            contentPadding = PaddingValues(16.dp),
            modifier = Modifier
                .fillMaxWidth()
                .border(1.5.dp, MaterialTheme.colorScheme.secondary, CircleShape)
        ) {
            Text
                text = "Measure",
                style = MaterialTheme.typography.bodyMedium)
        }
    }
}

这里采用了Column来作为一个垂直布局,在里面放置了Text, Image等组件来显示界面。我们可以添加一个Preview的函数,这样在进行代码改动的时候,我们就可以马上在Android studio的Design里面看到UI的改动了,非常方便,代码如下:

@Preview(showBackground = true)
@Composable
fun PreviewMeasureScreen() {
    MeasureScreen()
}

这个界面的效果如下图:

在要进行测量的时候,我们需要首先输入当前位置的名字,同时手机会实时显示当前的朝向,因为不同的朝向对Wifi信号的测量也有影响。然后当我们点击Measure这个按钮的时候,就会把当前这个位置的Wifi信号信息测量出来。

在MainActivity的onCreate方法的setContent中直接调用刚才我们定义的函数MeasureScreen()即可在APP中显示我们的界面。

定义ViewModel保存UI状态

现在在输入框中输入位置的名字,可以看到输入无法显示,这是因为在OutlinedTextField里面我们没有定义value是一个可观测状态,因此Compose组件无法进行重组更新。为此我们需要定义一个ViewModel来保存UI的状态。新建一个WifiMeasureViewModel的class,代码如下:

class WifiMeasureViewModel : ViewModel() {
    var positionName by mutableStateOf("")
        private set

    fun updatePositionName(name: String) {
        positionName = name
    }

这个类里面定义了一个positionName的mutableStateOf的State容器,通过一个update方法来更新数值。

修改WifiMeasure这个函数,传入这个ViewModel进行绑定,这里的viewModel()是一个生命周期的组件,可以使得ViewModel与Compose UI生命周期同步存在。

@Composable
fun MeasureScreen(
    measureViewModel: WifiMeasureViewModel = viewModel()
) 

修改OutlinedTextField,现在可以正常输入文字了,如果我们旋转手机,可以看到之前输入的文字能保留下来。

        OutlinedTextField(
            value = measureViewModel.positionName,
            onValueChange = { measureViewModel.updatePositionName(it) },
            label = { Text(text = stringResource(R.string.label_position_name), style = MaterialTheme.typography.bodyMedium)},
            modifier = Modifier.fillMaxWidth()
        )

获取手机朝向

因为手机的朝向对于Wifi测量会有影响,因此通常我们会在同一个地点测试不同朝向的WiFi信号并记录下来。我们需要在APP上实时显示当前的朝向,这就需要用到手机提供的传感器数据。

传统的Android应用的方法是,在Activity类里面继承SensorEventListener并重写相应的方法来实现。但是在Composable function里面如何实现,在官网上并没有介绍。我的做法是先定义一个新的类继承SensorEventListener,例如我们新建一个SensorDataManager的类,代码如下:

class SensorDataManager(context: Context): SensorEventListener {
    private val sensorManager by lazy {
        context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
    }

    private var accelerometerReading = FloatArray(3)
    private var magnetometerReading = FloatArray(3)
    private var rotationMatrix = FloatArray(9)
    private var orientationAngles = FloatArray(3)

    val data: Channel<Float> = Channel(Channel.UNLIMITED)

    fun init() {
        Log.d("SensorDataManager", "init")
        val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
        val magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)

        sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI)
        sensorManager.registerListener(this, magnetometer, SensorManager.SENSOR_DELAY_UI)
    }

    override fun onSensorChanged(event: SensorEvent) {
        if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
            System.arraycopy(event.values, 0, accelerometerReading, 0, accelerometerReading.size)
        } else if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
            System.arraycopy(event.values, 0, magnetometerReading, 0, magnetometerReading.size)
        }
        SensorManager.getRotationMatrix(
            rotationMatrix,
            null,
            accelerometerReading,
            magnetometerReading
        )
        SensorManager.getOrientation(rotationMatrix, orientationAngles)
        data.trySend(orientationAngles[0])
    }

    override fun onAccuracyChanged(p0: Sensor?, p1: Int) {
    }

    fun cancel() {
        Log.d("SensorDataManager", "cancel")
        sensorManager.unregisterListener(this)
    }
}

解释一下代码,在init函数中获取accelerometer和maganetic这两个传感器并注册listener,根据官网的介绍,推荐用这两个传感器数据来获取准确的朝向。在重写的onSensorChanged方法中,根据这两个传感器的数据计算朝向,并通过Channel把协程的数据发送出去。最后在cancel中取消listener注册。

现在修改一下MeasureScreen这个函数,增加以下代码:

    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    var angle by remember { mutableStateOf<Float>(0f) }

    DisposableEffect(Unit) {
        val dataManager = SensorDataManager(context)
        dataManager.init()

        val job = scope.launch {
            dataManager.data
                .receiveAsFlow()
                .onEach { angle = it }
                .collect()
        }

        onDispose {
            dataManager.cancel()
            job.cancel()
        }
    }

这里用到了Compose里面的附带效应Side effects,按照官网的解释附带效应是指发生在可组合函数作用域之外的应用状态的变化。DisposableEffect可以在键发生变化或可组合项退出组合后进行清理。因此我采用DiposableEffect(Unit)来监控这个MeasureScreen函数,完成初始化和清除Sensor Listener的工作。

修改一下显示朝向角度的TextField,设置其Value

        TextField(
            value = angle.toString(),
            onValueChange = { },
            label = { Text(text = stringResource(R.string.label_current_angle), style = MaterialTheme.typography.bodyMedium)},
            readOnly = true,
            modifier = Modifier.fillMaxWidth()
        )

现在这个测量页面可以正常工作了。

WiFi测量报告页面

增加导航

当点击测量页面的Measure按钮的时候,应该能跳转到另一个页面,显示WiFi的测量结果。要实现导航的功能,我们需要用到Navigation组件。新增一个名为Navigation的class,代码如下:

object Destinations {
    const val MEASURE_ROUTE = "measure"
    const val REPORT_ROUTE = "report/{positionName}"
}

@Composable
fun AppNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController(),
    startDestination: String = MEASURE_ROUTE
) {
    NavHost(
        navController = navController,
        startDestination = startDestination
    ) {
        composable(MEASURE_ROUTE) {
            MeasureScreen(
                navController = navController
            )
        }
        composable(
            REPORT_ROUTE,
            arguments = listOf(
                navArgument("positionName") {type = NavType.StringType},
            )
        ) { backStackEntry ->
            val positionName = backStackEntry.arguments?.getString("positionName")
            WifiMeasureReport(positionName)
        }
    }
}

这里定义了两个route,分别对应APP的两个页面。在跳转到测量报告页面的时候,route会带上positionName这个参数。

修改MainActivity,把setContent的内容替换为调用AppNavHost(),如以下代码:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            WifiPositionTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    AppNavHost()
                }
            }
        }
    }
}

WiFi扫描服务

增加一个Wifi扫描的服务,实现对Wifi信号的测量。新增一个WifiScanService的class,代码如下:

class WifiScanService(context: Context) {
    private val wifiManager by lazy {
        context.getSystemService(Context.WIFI_SERVICE) as WifiManager
    }

    private val context: Context = context
    val data: Channel<List<WifiMeasureData>> = Channel(Channel.UNLIMITED)

    private val wifiScanReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            val success = intent.getBooleanExtra(WifiManager.EXTRA_RESULTS_UPDATED, false)
            if (success) {
                scanSuccess()
            } else {
                scanFailure()
            }
        }
    }

    fun init() {
        val intentFilter = IntentFilter()
        intentFilter.addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)
        context.registerReceiver(wifiScanReceiver, intentFilter)

        val success = wifiManager.startScan()
        if (!success) {
            scanFailure()
        }
    }

    private fun scanFailure() {
        Log.d("WIFI", "Scan failure")
    }

    @SuppressLint("MissingPermission")
    private fun scanSuccess() {
        val results = wifiManager.scanResults
        if (!results.isNullOrEmpty()) {
            val wifiMeasureData = results.map {
                WifiMeasureData(
                    it.BSSID,
                    it.level
                )
            }
            data.trySend(wifiMeasureData)
        }
    }

    fun cancel() {
        context.unregisterReceiver(wifiScanReceiver)
    }
}

定义一个WifiMeasureData的数据class,保存测量数据

data class WifiMeasureData (
    val bssId: String,
    val signalStrength: Int
)

另外,要开启WiFi测量,还需要申请相应的权限,在AndroidManifest.xml里面,增加以下权限申请

    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />

报告页面设计

同样是采用Compose的方式来设计一个页面展示Wifi测量数据的结果,新建一个WifiMeasureReport的class,代码如下:

@Composable
fun WifiMeasureReport (positionName: String?) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    var wifiScanResult by remember { mutableStateOf<List<WifiMeasureData>>(listOf(WifiMeasureData("", 0))) }
    DisposableEffect(Unit) {
        val wifiScanService = WifiScanService(context)
        wifiScanService.init()

        val job = scope.launch {
            wifiScanService.data
                .receiveAsFlow()
                .onEach { wifiScanResult = it }
                .collect()
        }

        onDispose {
            wifiScanService.cancel()
            job.cancel()
        }
    }

    Column() {
        Text(
            text = stringResource(id = R.string.report_title),
            style = MaterialTheme.typography.titleLarge,
            textAlign = TextAlign.Center,
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(modifier = Modifier.height(8.dp))
        Text(
            text = stringResource(id = R.string.report_position_name)+": "+positionName,
            style = MaterialTheme.typography.bodyLarge,
            modifier = Modifier.padding(10.dp)
        )
        LazyColumn(
            Modifier.fillMaxWidth(),
            contentPadding = PaddingValues(horizontal = 4.dp)
        ){
            item {
                ItemHeader()
            }
                itemsIndexed(wifiScanResult) { index: Int, item: WifiMeasureData ->
                    ItemRow(index, item)
                }
        }
    }
}

@Composable
fun ItemHeader() {
    Row(
        Modifier
            .fillMaxWidth()
            //.border(BorderStroke(0.5.dp, Color.Black))
    ) {
        Text(text = stringResource(R.string.report_header_bssid), fontWeight = FontWeight.Bold, modifier = Modifier
            .weight(5f)
            .padding(10.dp))
        Text(text = stringResource(R.string.report_header_strength), fontWeight = FontWeight.Bold, modifier = Modifier
            .weight(5f)
            .padding(10.dp))
    }
    Divider(
        color = Color.LightGray,
        modifier = Modifier
            .height(1.dp)
            .fillMaxHeight()
            .fillMaxWidth()
    )
}

@Composable
fun ItemRow(index: Int, item: WifiMeasureData) {
    val modifier: Modifier = Modifier.fillMaxWidth()
    Row(
        modifier = if (index%2 == 0) modifier.background(Color.LightGray) else modifier
    ) {
        Text(text = item.bssId, modifier = Modifier
            .weight(5f)
            .padding(10.dp))
        Text(text = item.signalStrength.toString(), modifier = Modifier
            .weight(5f)
            .padding(10.dp))
    }
    Divider(
        color = Color.LightGray,
        modifier = Modifier
            .height(1.dp)
            .fillMaxHeight()
            .fillMaxWidth()
    )
}

@Preview(showBackground = true)
@Composable
fun PreviewMeasureReportScreen() {
    WifiMeasureReport("grid_1")
}

这里同样采用了Column垂直布局,其中用了一个LazyColumn来展示Wifi测量结果的列表。这个LazyColumn类似于以前的RecyclerView。

测试结果上报

WiFi测试的结果要上报到服务器来进行汇总分析,最后生成Wifi指纹,这部分的内容可以参考我之前提到的博客内容,这里不再重复。我们只需要修改一下WifiMeasureReport,把拿到的结果通过REST API上传即可。改动如下:

待补充。。。

运行效果

最后把项目打包为APK后上传到手机运行,实际效果如下:

wifi scan app

文章来源:https://blog.csdn.net/gzroy/article/details/135041297
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。