Flutter开发一个Wifi信号测量应用

在之前的一篇文章中我介绍了如何用Jetpack compose来开发一个Android的Wifi信号测量应用,使得可以根据室内不同地点的Wifi信号来生成指纹,用于室内导航,详情可见Jetpack Compose开发一个Android WiFi信号测量应用-CSDN博客。但是Jetpack compose只能用于开发Android应用,如果我们要开发ios应用,需要用其他的框架来重写代码。

Flutter是一个Google推出的一个跨平台的UI框架,可以快速在iOS和Android上构建高质量的原生用户界面,实现一套代码同时适配Android, ios, macos, window, linux等多个系统。为此我决定用Flutter来重构之前写的应用,实现一个跨平台的Wifi信号测量应用。

应用架构

这个应用比较简单,包括了两个页面,第一个页面是让用户输入当前室内位置的编号,同时会显示当前手机的朝向角度。用户可以点击一个按钮来对当前位置和朝向进行Wifi信号检测。

第二个页面显示信号检测的列表,用户可以点击按钮把检测结果上传到远端服务器。

我采用Android studio来新建一个Flutter项目。

页面主题设计

Flutter和Jetpack Compose一样,都可以用Material Design来设计应用的主题。具体可见我之前另一篇博客Material Design设计和美化APP应用-CSDN博客的介绍,在Material design的theme builder设计好主色调之后,导出为Flutter项目需要的文件,放置到lib目录。例如我以#825500作为主色,生成的color_schemes.g.dart文件内容如下:

import 'package:flutter/material.dart';

const lightColorScheme = ColorScheme(
  brightness: Brightness.light,
  primary: Color(0xFF825500),
  onPrimary: Color(0xFFFFFFFF),
  primaryContainer: Color(0xFFFFDDB3),
  onPrimaryContainer: Color(0xFF291800),
  secondary: Color(0xFF6F5B40),
  onSecondary: Color(0xFFFFFFFF),
  secondaryContainer: Color(0xFFFBDEBC),
  onSecondaryContainer: Color(0xFF271904),
  tertiary: Color(0xFF51643F),
  onTertiary: Color(0xFFFFFFFF),
  tertiaryContainer: Color(0xFFD4EABB),
  onTertiaryContainer: Color(0xFF102004),
  error: Color(0xFFBA1A1A),
  errorContainer: Color(0xFFFFDAD6),
  onError: Color(0xFFFFFFFF),
  onErrorContainer: Color(0xFF410002),
  background: Color(0xFFFFFBFF),
  onBackground: Color(0xFF1F1B16),
  surface: Color(0xFFFFFBFF),
  onSurface: Color(0xFF1F1B16),
  surfaceVariant: Color(0xFFF0E0CF),
  onSurfaceVariant: Color(0xFF4F4539),
  outline: Color(0xFF817567),
  onInverseSurface: Color(0xFFF9EFE7),
  inverseSurface: Color(0xFF34302A),
  inversePrimary: Color(0xFFFFB951),
  shadow: Color(0xFF000000),
  surfaceTint: Color(0xFF825500),
  outlineVariant: Color(0xFFD3C4B4),
  scrim: Color(0xFF000000),
);

const darkColorScheme = ColorScheme(
  brightness: Brightness.dark,
  primary: Color(0xFFFFB951),
  onPrimary: Color(0xFF452B00),
  primaryContainer: Color(0xFF633F00),
  onPrimaryContainer: Color(0xFFFFDDB3),
  secondary: Color(0xFFDDC2A1),
  onSecondary: Color(0xFF3E2D16),
  secondaryContainer: Color(0xFF56442A),
  onSecondaryContainer: Color(0xFFFBDEBC),
  tertiary: Color(0xFFB8CEA1),
  onTertiary: Color(0xFF243515),
  tertiaryContainer: Color(0xFF3A4C2A),
  onTertiaryContainer: Color(0xFFD4EABB),
  error: Color(0xFFFFB4AB),
  errorContainer: Color(0xFF93000A),
  onError: Color(0xFF690005),
  onErrorContainer: Color(0xFFFFDAD6),
  background: Color(0xFF1F1B16),
  onBackground: Color(0xFFEAE1D9),
  surface: Color(0xFF1F1B16),
  onSurface: Color(0xFFEAE1D9),
  surfaceVariant: Color(0xFF4F4539),
  onSurfaceVariant: Color(0xFFD3C4B4),
  outline: Color(0xFF9C8F80),
  onInverseSurface: Color(0xFF1F1B16),
  inverseSurface: Color(0xFFEAE1D9),
  inversePrimary: Color(0xFF825500),
  shadow: Color(0xFF000000),
  surfaceTint: Color(0xFFFFB951),
  outlineVariant: Color(0xFF4F4539),
  scrim: Color(0xFF000000),
);

主页面设计

确定了架构之后,我们开始设计主页面。在Lib目录新建一个dart文件,例如MyHomePage。其代码如下:

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State createState() => _MyHomePageState();
}

class _MyHomePageState extends State {
  static const eventChannel = EventChannel('roygao.cn/orientationEvent');
  final positionNameController = TextEditingController();
  final orientationController = TextEditingController();

  Stream streamOrientationFromNative() {
    return eventChannel
        .receiveBroadcastStream()
        .map((event) => event.toString());
  }

  @override
  void initState() {
    eventChannel.receiveBroadcastStream().listen((message) {
      // Handle incoming message
      setState(() {
        orientationController.text = message;
      });
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    var appState = context.watch();
    final theme = Theme.of(context);

    return Scaffold(
      backgroundColor: theme.colorScheme.surfaceVariant,
      appBar: AppBar(
        backgroundColor: theme.colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          children: [
            const Image(image: AssetImage('images/wifi_location.jpg')),
            Padding(
                padding: const EdgeInsets.all(20.0),
                child: Column(
                    children: [
                      TextField(
                        controller: positionNameController,
                        obscureText: false,
                        decoration: const InputDecoration(
                          border: OutlineInputBorder(),
                          labelText: 'Indoor position name',
                        ),
                      ),
                      const SizedBox(height: 15.0,),
                      TextField(
                        controller: orientationController,
                        obscureText: false,
                        enabled: false,
                        decoration: const InputDecoration(
                          border: OutlineInputBorder(),
                          labelText: 'Orientation in degrees',
                        ),
                      ),
                      const SizedBox(height: 15.0,),
                      ElevatedButton(
                        style: ElevatedButton.styleFrom(
                          primary: theme.colorScheme.primary,
                          elevation: 0,
                        ),
                        onPressed: () {
                          appState.updatePositionOrientation(positionNameController.text, orientationController.text);
                          appState.updateWifiScanResults();
                          Navigator.of(context).push(
                            MaterialPageRoute(
                              builder: (context) => MeasureReport(
                                title: widget.title,
                                //positionName: positionNameController.text,
                                //orientation: orientationController.text,
                              )
                            )
                          );
                        },
                        child: Text(
                          "Measure", style: theme.textTheme.bodyLarge!.copyWith(
                          color: theme.colorScheme.onPrimary,
                        ),),
                      ),
                    ])
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    // Clean up the controller when the widget is disposed.
    positionNameController.dispose();
    orientationController.dispose();
    super.dispose();
  }
}

解释一下以上的代码,这个类是继承了StatefulWidget,因为从传感器接收的朝向角度信息我想保存在这个Widget中。在_MyHomePageState中定义了一个EventChannel,用于调用Android的原生方法来获取加速传感器和磁传感器的信息,计算手机朝向之后通过EventChannel来传送到Flutter widget。在initState函数中需要监听EventChannel获取到的数据,并调用SetState来更新显示朝向数据的那个TextField的controller。在build函数中,定义了一个应用级别的state,这个state可以用于保存用户输入的位置信息和手机朝向数据,并给到我们之后的测试报告的widget来获取。另外,通过Scaffold layout来组织这个UI界面,显示相应的组件。在Measure这个button的onPressed中,更新appState的位置和朝向数据,并调用updateWifiScanResult方法来测量Wifi信号,然后通过Navigator组建来跳转到测量报告页面。

调用Android原生方法

刚才提到了用EventChannel的方式来调用Android的原生方法来计算手机朝向。其实在Flutter里面也有一个sensor plugin可以获取到手机的传感器数据,不需要调用原生方法。但是这个plugin获取到的只是磁传感器和加速度传感器的数值,还需要通过一些计算来获得手机的朝向。在Android的原生方法已经提供了方法可以直接计算,因此这里我还是采用EventChannel的方式来做。在Android Studio中打开这个Flutter项目的Android文件夹,编辑MainActivity文件。以下是代码:

class MainActivity: FlutterActivity(), SensorEventListener {
    private val eventChannel = "roygao.cn/orientationEvent"
    private lateinit var sensorManager : SensorManager

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

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        EventChannel(flutterEngine.dartExecutor.binaryMessenger, eventChannel).setStreamHandler(
            MyEventChannel)
    }

    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)
        var degree = if (orientationAngles[0] >= 0) {
            (180 * orientationAngles[0]/PI).toInt()
        } else {
            (180 * (2+orientationAngles[0]/PI)).toInt()
        }
        MyEventChannel.sendEvent(degree.toString())
    }

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

    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
        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)
    }

    object MyEventChannel: EventChannel.StreamHandler {
        private var eventSink: EventChannel.EventSink? = null
        override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
            eventSink = events;
        }

        override fun onCancel(arguments: Any?) {
            eventSink = null;
        }

        fun sendEvent(message: String) {
            eventSink?.success(message)
        }
    }
}

这个代码比较简单,在configureFlutterEngine里面定义EventChannel的handler,提供一个sendEvent的方法来传输数据。同时实现了SensorEventListener的接口,当收到SensorEvent的时候通过两个传感器的信息计算出手机的朝向角度,并调用sendEvent方法。

页面入口设计

在main.dart文件中,修改如下:

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
        create: (context) => MyAppState(),
        child: MaterialApp(
          title: 'Wifi Measurement',
          theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme),
          darkTheme: ThemeData(useMaterial3: true, colorScheme: darkColorScheme),
          home: const MyHomePage(title: 'Wifi Measurement'),
        ),
    );
  }
}

class MyAppState extends ChangeNotifier {
  var positionName;
  var orientation;

  List accessPoints = [];

  void updatePositionOrientation(String name, String degree) {
    positionName = name;
    orientation = degree;
  }

  updateWifiScanResults() async {
    final can = await WiFiScan.instance.canGetScannedResults(
        askPermissions: true);
    switch (can) {
      case CanGetScannedResults.yes:
      // get scanned results
        accessPoints = await WiFiScan.instance.getScannedResults();
        break;
      default:
        break;
    }
  }
}

这里定义了一个MyAppState用于保存应用级别的状态数据。这个类扩展了ChangeNotifier,使得其他Widget可以通过观察这个类的对象来获取到状态的更新。在这个类里面定义了一个updateWifiScanResults的方法,这个是采用了wifi scan的plugin来实现的。在pubspec.yaml里面的dependencies增加wifi_scan: ^0.4.1

测量报告页面

最后是设计一个页面显示测量报告,当在主页面点击测量按钮,跳转到这个页面显示测量结果。代码如下:

class MeasureReport extends StatelessWidget {
  const MeasureReport({super.key, required this.title});
  final String title;

  @override
  State createState() => _MeasureReportState();

  @override
  Widget build(BuildContext context) {
    var appState = context.watch();
    final theme = Theme.of(context);

    return Scaffold(
      backgroundColor: theme.colorScheme.surfaceVariant,
      appBar: AppBar(
        backgroundColor: theme.colorScheme.inversePrimary,
        title: const Text("Wifi Measurement"),
      ),
      body: Column(
        children: [
          Padding(
              padding: const EdgeInsets.all(20.0),
              child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text("Position: ${appState.positionName}", style: theme.textTheme.titleLarge,),
                    const SizedBox(height: 5.0,),
                    Text("Orientation: ${appState.orientation}", style: theme.textTheme.titleLarge,),
                    const SizedBox(height: 5.0,),
                    Text("Scan Results:", style: theme.textTheme.titleLarge,),
                    const SizedBox(height: 5.0,),
                    ListView.builder(
                      itemCount: appState.accessPoints.length,
                      itemBuilder: (context, index) {
                        var rowColor = index%2==0?theme.colorScheme.primaryContainer:theme.colorScheme.surfaceVariant;
                        if (index==0) {
                          return Column(
                            children: [
                              Container(
                                child: Row(
                                  children: [
                                    Expanded(child: Text("BSSID", style: theme.textTheme.bodyLarge,)),
                                    Expanded(child: Text("Level", style: theme.textTheme.bodyLarge,)),
                                  ],
                                ),
                              ),
                              Container(
                                color: rowColor,
                                child: Row(
                                  children: [
                                    Expanded(child: Text(appState.accessPoints[index].bssid, style: theme.textTheme.bodyLarge,)),
                                    Expanded(child: Text(appState.accessPoints[index].level.toString(), style: theme.textTheme.bodyLarge,)),
                                  ],
                                ),
                              )
                            ]
                          );
                        }
                        return Container(
                          color: rowColor,
                          child: Row(
                            children: [
                              Expanded(child: Text(appState.accessPoints[index].bssid, style: theme.textTheme.bodyLarge,)),
                              Expanded(child: Text(appState.accessPoints[index].level.toString(), style: theme.textTheme.bodyLarge,)),
                            ],
                          )
                        );
                      },
                      scrollDirection: Axis.vertical,
                      shrinkWrap: true,
                    ),
                ],
              ),
          )
        ]
      ),
    );
  }
}

这个代码比较简单,就不用解释了。

运行效果

最终的应用运行效果如下:

wifi measure flutter app

你可能感兴趣的:(flutter,android,ios)