Flutter应用开发:定位上报

941 阅读6分钟

image.png

背景

市面上主流APP都存在定位上报的诉求,例如抖音、美团等。不过也存在简单和复杂的区别,当前应用的定位上报在诉求上属于复杂

需求分析

以抖音为例,在打开APP时会触发定位上报,下图右上角的定位小图标,正式打开后会关闭上报。用于优化推荐系统,最显著的变化是出现当地Tab标签(西安)。至于有没有在使用期间周期性进行定位上报,没有观察到,即使有周期跨度应该很长。

微信图片_20250107170024.png 回到我的诉求,是司机端的物流APP,在存在批次时需持续进行定位上报,并且上报周期小。方便实时跟踪货车的位置、轨迹,提高时效性、减少运输费用等。分析完诉求归纳如下:

  1. 有条件的定位上报
  2. 定位上报需有持续性、准确性
  3. 后台模式也得支持定位上报

开发

引入定位插件,其实是基于高德定位插件(amap_flutter_location)改造。主要改造了:

  1. ios持续定位问题
  2. 后台定位权限问题
  3. 获取定位状态 image.png

改造完成,本地引入

dependencies:
  amap_flutter_location:
    path: ../packages/logistic-amap-location

定位权限

构建定位管理类LocationManager,主要定义初始化定位并设置配置,控制定位相关权限,启停定位等功能。因为诉求比较复杂,用到了定位相关所有权限。

  1. 访问位置信息的权限(基本权限)
  2. 忽略电池优化功能的权限(支撑后台定位,如果不忽略,APP切到后台将不持续定位)
  3. 后台定位权限
      'Permission.locationWhenInUse': '前台运行时获取访问位置信息的权限失败',
      'Permission.ignoreBatteryOptimizations': '忽略电池优化功能权限失败',
      'Permission.locationAlways': '后台定位权限获取失败',

开发中遇到一个问题,一次性放开三个权限,忽略电池优化功能权限不能同步放开。此处延迟300ms尝试再开放了一次来保证正常启动定位。

  // 启动定位
  static Future<void> startLocation(BuildContext context) async {
    bool isLocating = G.locationPlugin.isLocating();
    if (!isLocating) {
      G.print.info('----------->', '启动定位');
      // 设置定位相关配置
      _setLocationOption(context);
      // 一次性开放三个定位相关权限
      bool isAuth = await _authLocationPermission();

      if (isAuth) {
        G.locationPlugin.enableBackgroundLocation(888);
        G.locationPlugin.startLocation();
      } else {
        // 尝试再次申请忽略电池优化权限
        Future.delayed(Duration(milliseconds: 300), () {
          _tryIgnoreBattery();
        });
      }
    }
  }
  
 static void _tryIgnoreBattery() async {
    PermissionStatus _permissionStatus = await Permission.ignoreBatteryOptimizations.request();
    if (_permissionStatus.isGranted) {
      G.locationPlugin.enableBackgroundLocation(888);
      G.locationPlugin.startLocation();
    } else {
      G.toast('忽略电池优化权限失败,请前往设置页面手动开启');
    }
  }

定位配置

定位配置上,比较重要的三个配置,设置持续性定位、返回逆地理位置信息、设置连续定位间隔。重点是这个连续定位间隔,它其实和上报定位间隔是不一样的。连续定位间隔 <= 上报定位间隔,因为定位采集后可以支撑做很多功能,eg:车辆异常时进行上报,它对定位准确性要求很高。最新的定位采集点会存在本地存储中,方便业务使用。当前场景采用了连续定位间隔10s <= 上报定位间隔2min的方案。

    ///是否单次定位
    locationOption.onceLocation = false;
    
    ///是否需要返回逆地理信息
    locationOption.needAddress = true;
    
    ///设置Android端连续定位的定位间隔
    locationOption.locationInterval = AppConfig.of(context)?.locationIntervalMs ?? _INTERVAL;

定位上报

定位上报其实是个接口,传入经纬度信息longitude/latitude,因为上报频率高,每辆车每个批次都很产生大量数据,所以不会存储地理位置信息。在轨迹、定位查看时根据坐标反解析去显示地理位置xx省 xx市 xx县 xx

其次因为数据量的缘故,轨迹信息会根据时间的推移删减数据,直到清除。类似微信朋友圈发的图片一样,随着时间推移可能像素变差,主要为了节省存储。

定位上报是在定位监听采集中,根据自己的上报规则进行的。每次采集完会记录最新坐标,然后根据上一次上报时间算出下一次触发上报的时间点。发送上报接口、记录上报时间。

如下代码的上报算法,当前时间 - 上一次上报时间 >= 上报间隔 其实不是很准确,有一点偏差,不过在业务上影响可以忽略。

    ///注册定位结果监听
    _locationListener = G.locationPlugin.onLocationChanged().listen((Map<String, Object> result) async {
      if (mounted)
        setState(() {
          _locationResult = result;
        });
      // G.print.jsonStr(_locationResult, '_locationResult');
      // 存储定位信息
      _setLocationPref(result);
      _prefs = await SharedPreferences.getInstance();
      String locationTimeStr = _prefs.getString('location_time') ?? '';
      // 没有上一次上报时间,立即上报
      if (locationTimeStr.isEmpty) {
        _reportLocation();
      } else {
        int currentTime = G.getTime();
        int locationTime = int.tryParse(locationTimeStr) ?? 0;
        // 当前时间 - 上一次上报时间 >= 上报间隔,触发上报
        if (currentTime - locationTime >= _locationReportIntervalMs / 1000) {
          _reportLocation();
        }
      }
    });

上报场景落地

只有司机在跑车(有批次)时才会触发定位上报,车辆到站或者休息时可以停止定位上报。这些触发点其实分布在APP的各个页面中。起始时,定位类的管理分别在各个涉及业务中构建实例进行管理,然而在实际应用中的操作,频繁构建实例、启停定位造成了定位上报混乱,偏差较大。最终采用了事件总线进行订阅广播方式来触发业务。

事件总线eventBus

import 'dart:async';

class EventBus {
  final _streamController = StreamController<dynamic>.broadcast();

  // 发送事件
  void fire(dynamic event) {
    _streamController.sink.add(event);
  }

  // 监听事件
  Stream<dynamic> on() {
    return _streamController.stream;
  }

  // 取消所有订阅
  void off() {
    _streamController.close();
  }
}

在框架层注册监听

    G.eventBus.on().listen((event) {
      if (event is MyEvent) {
        String evt = event.message;
        if (evt == 'startLocation') {
          // 是否已经注册过事件监听
          if (_registerLocationListen) {
            LocationManager.startLocation(context);
            print('--------->启动定位上报');
          } else {
            Utils.useLocation(buildContext: context);

            Future.delayed(new Duration(seconds: 0), () {
              // 上报定位
              _checklocation();
              LocationManager.startLocation(context);
              print('--------->申请定位权限,开始启动定位上报');
            });
          }
        } else if (evt == 'stopLocation') {
          LocationManager.stopLocation();
          print('--------->停止定位上报');
        }
      }
    });

初始访问场景

进入首页时,查询任务状态,根据是否有运输中的批次来判断是否触发定位上报。主要使用eventBus发送事件即可。

  _fetchUnloadTask() async {
    UnloadTaskPageResp? resp = await G.req.unloadTask.page(state: 1);
    if (resp.taskList!.length > 0) {
      setState(() {
        String? _unloadTaskId = resp.taskList![0].taskId;
        int? _unloadTaskState = resp.taskList![0].state;
        // 运输中
        if (_unloadTaskId != null && _unloadTaskState == 1) {
          // 发送事件
          G.eventBus.fire(MyEvent('startLocation'));
          _getUnloadTaskDetail(_unloadTaskId);
        } else {
          // 发送事件
          G.eventBus.fire(MyEvent('stopLocation'));
        }
      });
    } else {
      G.eventBus.fire(MyEvent('stopLocation'));
    }
  }

车辆发车场景

初始批次未发车,准备发车时手动点击发车,更新批次状态并使用eventBus发送事件startLocation

  _doDepart(context) async {
    Navigator.of(context).pop(true);
    G.loading.show(context, text: '发车中...');
    await G.req.unloadTask.depart(_taskId).whenComplete(() => G.loading.hide(context));
    toast('发车成功');
    // 发送事件
    G.eventBus.fire(MyEvent('startLocation'));
    // 刷新卸货单
    _fetchUnloadTaskInfo();
  }

车辆到站场景

发车后运输到站,手动点击到站,更新批次状态并使用eventBus发送事件stopLocation

  void _finished(context) async {
    Navigator.of(context).pop(true);
    G.loading.show(context, text: '执行中...');
    await G.req.unloadTask.finished(_taskId).whenComplete(() => G.loading.hide(context));
    toast("卸货单完成");
    // 停止定位上报
    G.eventBus.fire(MyEvent('stopLocation'));
    // 刷新卸货单
    _fetchUnloadTaskInfo();
  }

定位上报效果

定位上报后,根据上报的坐标组,可以实时绘制出车辆的轨迹。

未标题-1.jpg

后记