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

相关推荐
2501_940094021 小时前
emu系列模拟器最新汉化版 安卓版 怀旧游戏模拟器全集附可运行游戏ROM
android·游戏·安卓·模拟器
下位子1 小时前
『OpenGL学习滤镜相机』- Day9: CameraX 基础集成
android·opengl
参宿四南河三3 小时前
Android Compose SideEffect(副作用)实例加倍详解
android·app
火柴就是我4 小时前
mmkv的 mmap 的理解
android
没有了遇见4 小时前
Android之直播宽高比和相机宽高比不支持后动态获取所支持的宽高比
android
shenshizhong4 小时前
揭开 kotlin 中协程的神秘面纱
android·kotlin
vivo高启强5 小时前
如何简单 hack agp 执行过程中的某个类
android
沐怡旸5 小时前
【底层机制】 Android ION内存分配器深度解析
android·面试
你听得到115 小时前
肝了半个月,我用 Flutter 写了个功能强大的图片编辑器,告别image_cropper
android·前端·flutter
KevinWang_5 小时前
Android 原生 app 和 WebView 如何交互?
android