背景
市面上主流APP都存在定位上报的诉求,例如抖音、美团等。不过也存在简单和复杂的区别,当前应用的定位上报在诉求上属于复杂。
需求分析
以抖音为例,在打开APP时会触发定位上报,下图右上角的定位小图标,正式打开后会关闭上报。用于优化推荐系统,最显著的变化是出现当地Tab标签(西安)。至于有没有在使用期间周期性进行定位上报,没有观察到,即使有周期跨度应该很长。
回到我的诉求,是司机端的物流APP,在存在批次时需持续进行定位上报,并且上报周期小。方便实时跟踪货车的位置、轨迹,提高时效性、减少运输费用等。分析完诉求归纳如下:
- 有条件的定位上报
- 定位上报需有持续性、准确性
- 后台模式也得支持定位上报
开发
引入定位插件,其实是基于高德定位插件(amap_flutter_location)改造。主要改造了:
- ios持续定位问题
- 后台定位权限问题
- 获取定位状态
改造完成,本地引入
dependencies:
amap_flutter_location:
path: ../packages/logistic-amap-location
定位权限
构建定位管理类LocationManager
,主要定义初始化定位并设置配置,控制定位相关权限,启停定位等功能。因为诉求比较复杂,用到了定位相关所有权限。
- 访问位置信息的权限(基本权限)
- 忽略电池优化功能的权限(支撑后台定位,如果不忽略,APP切到后台将不持续定位)
- 后台定位权限
'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();
}
定位上报效果
定位上报后,根据上报的坐标组,可以实时绘制出车辆的轨迹。
后记
无