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文件内容如下:

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。其代码如下:

Dart 复制代码
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

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

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

  Stream<String> 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<MyAppState>();
    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: <Widget>[
            const Image(image: AssetImage('images/wifi_location.jpg')),
            Padding(
                padding: const EdgeInsets.all(20.0),
                child: Column(
                    children: <Widget>[
                      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文件。以下是代码:

Kotlin 复制代码
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文件中,修改如下:

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<WiFiAccessPoint> 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

测量报告页面

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

Dart 复制代码
class MeasureReport extends StatelessWidget {
  const MeasureReport({super.key, required this.title});
  final String title;

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

  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    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: <Widget>[
          Padding(
              padding: const EdgeInsets.all(20.0),
              child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    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: <Widget>[
                              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

相关推荐
似霰3 小时前
安卓adb shell串口基础指令
android·adb
fatiaozhang95275 小时前
中兴云电脑W102D_晶晨S905X2_2+16G_mt7661无线_安卓9.0_线刷固件包
android·adb·电视盒子·魔百盒刷机·魔百盒固件
CYRUS_STUDIO6 小时前
Android APP 热修复原理
android·app·hotfix
鸿蒙布道师7 小时前
鸿蒙NEXT开发通知工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
鸿蒙布道师7 小时前
鸿蒙NEXT开发网络相关工具类(ArkTs)
android·ios·华为·harmonyos·arkts·鸿蒙系统·huawei
大耳猫7 小时前
【解决】Android Gradle Sync 报错 Could not read workspace metadata
android·gradle·android studio
ta叫我小白7 小时前
实现 Android 图片信息获取和 EXIF 坐标解析
android·exif·经纬度
bst@微胖子8 小时前
Flutter之路由和导航
flutter
dpxiaolong8 小时前
RK3588平台用v4l工具调试USB摄像头实践(亮度,饱和度,对比度,色相等)
android·windows
亚洲小炫风9 小时前
flutter 中各种日志
前端·flutter·日志·log