UniApp RenderJS中集成 Leaflet地图,突破APP跨端开发限制
一、技术选型
本文通过 RenderJS,将Leaflet地图完美集成到UniApp中,解决了跨端地图渲染的问题。
那么问题来了,为什么选择这个方案?
- UniApp自带map组件功能限制;
- Leaflet 在 UniApp 中只能在 H5 使用,如果APP使用,引入Leaflet的WebView,页面容易卡顿;
- 复杂地图交互(自定义图层、轨迹绘制)难以实现;
RenderJS + Leaflet优势
✅ 接近原生的性能;
✅ Leaflet完整的生态功能;
✅ 跨端一致性体验;
二、基础集成:从零开始搭建
1、环境准备,依赖安装
npm install leaflet
npm install leaflet.chinatmsproviders
{"dependencies": {"leaflet": "^1.9.4","leaflet.chinatmsproviders": "^3.0.6",},
}
2、地图模板
<template><view class="app-container"><view id="mapContainer"></view></view>
</template>
3、RenderJS模块定义
<script module="leaflet" lang="renderjs">import L from 'leaflet';import "leaflet.chinatmsproviders";// 或者import L from '@/static/js/leaflet/leaflet.js'; // 需要把依赖放在本地,我是通过此方法引入的import '@/static/js/leaflet/leaflet.css';import '@/static/js/leaflet/leaflet.ChineseTmsProviders.js';import '@/static/js/leaflet/leaflet.mapCorrection.js'; // 地图纠偏文件根据自己路径引入export default {data() {return {centerPoint: [31.533705, 120.278157],map: null,ownerInstance: null, // 逻辑层实例引用}},mounted() {this.initMap();},methods: {// 初始化地图initMap() {this.map = L.map('mapContainer', {attributionControl: false,zoomControl: false,detectRetina: true,tap: false}).setView(this.centerPoint, 18);L.tileLayer.chinaProvider('GaoDe.Satellite.Map', {maxZoom: 18}).addTo(this.map);},}}
</script>
4、地图容器样式
<style lang="scss" scoped>
.map-container {width: 100%;height: 100vh;#mapContainer {width: 100%;height: 100%;}
}
</style>
三、 核心技术:Vue与RenderJS双向通信
地图所在标签元素,添加 :prop="renderAction"属性,变量改变触发handleAction方法
<template><view class="app-container"><view id="mapContainer" :prop="renderAction" :change:prop="leaflet.handleAction"></view><view @click="handleClickBtn">中心位置</view><view @click="handleDrawing">绘制</view></view>
</template>
1、Renderjs
// 绘制
handleDrawing() {// 其他逻辑this.notifyLogicLayer('updateStatus', true);
},
// 定位到中心位置
handleCenterLocation(params = null) {let centerPoint = params && params.centerPoint;let zoomLevel = this.map.getZoom();// 平滑移动flyTothis.map.flyTo(centerPoint, zoomLevel, {duration: 1, // 动画持续时间(秒)easeLinearity: 0.25});// 立即定位setView // this.map.setView(centerPoint, zoomLevel);
},
// 接收逻辑层消息:改变renderAction触发,根据参数执行对应的视图层方法
handleAction(newValue, ownerInstance) {console.log("handleAction", newValue);this.ownerInstance = ownerInstance;if (!newValue || !newValue.action) {console.warn('RenderJS接收指令格式错误:缺少action属性');return;}const method = this[newValue.action];if (!method || typeof method !== 'function') {console.error(`RenderJS中未找到方法:${newValue.action}`);return;}try {method.call(this, newValue.params);} catch (err) {console.error(`RenderJS执行方法${newValue.action}失败:`, err);}
},
// 通知Vue逻辑层
notifyLogicLayer(methodName, params) {if (this.ownerInstance) {this.ownerInstance.callMethod(methodName, params);}
}
2、Vue逻辑层
<script>export default {data() {return {// 传递给RenderJS的指令renderAction: {action: '', // RenderJS中要执行的方法名params: null // 传递的参数},centerPoint: [31.233705, 120.238157]}},methods: {// 定位到中心位置handleClickBtn() {this.sendActionToRender('handleCenterLocation', {centerPoint: this.centerPoint,});},// 接收RenderJS通知updateStatus(status) {// 执行逻辑},// 发送消息到RenderJSsendActionToRender(action, params = null) {this.renderAction = {action,params,timestamp: Date.now() // 添加时间戳确保每次都触发};},}}
</script>
四、 实战案例:轨迹回放系统
以下代码是我自己实现的功能,可能不适用其他,不过逻辑大致如此,可自行修改
功能点1:时间轴改变,绘制设备轨迹以及最新点标记,并判断是否跟随;
功能点2:实时监听我的位置变化,并绘制轨迹
RenderJS中实现
// ----------- 绘制设备标记以及轨迹,视角跟随 ----------
// 更新设备轨迹线,并且清除之前的
updateFlyLineWithClear({points,shouldFollowDeviceId = null
}) {// 按设备ID分组const pointsByDevice = {};points.forEach(point => {if (!pointsByDevice[point.id]) {pointsByDevice[point.id] = [];}pointsByDevice[point.id].push(point);});Object.keys(pointsByDevice).forEach(deviceItemId => { // 为每个设备清除并重新绘制轨迹this.removeLayerByName(deviceItemId + '_droneLine'); // 清除旧轨迹线const devicePoints = pointsByDevice[deviceItemId].sort((a, b) => a.time - b.time); // 排序devicePoints.forEach((point, index) => { // 绘制该设备的完整轨迹if (index > 0) {const oldPoint = devicePoints[index - 1]; // 上一个点,每两点绘制轨迹this.drawSinglePath(point.id,point.latDiff,point.lonDiff,oldPoint.latDiff,oldPoint.lonDiff,point.weixian);}});// 更新设备标记(只显示最后一个点)if (devicePoints.length > 0) {const lastPoint = devicePoints[devicePoints.length - 1];const shouldFollow = shouldFollowDeviceId === deviceItemId; // 判断是否需要跟随当前设备this.updateDroneMarker(lastPoint, shouldFollow);}});
},
// 绘制单条轨迹线段
drawSinglePath(id, latDiff, lonDiff, oldlatDiff, oldlonDiff, weixian) {const flyLine = L.polyline([[oldlatDiff, oldlonDiff],[latDiff, lonDiff]], {name: id + '_droneLine',color: weixian < 3 ? '#3BC25B' : '#c23b3b',weight: 3,opacity: 0.8,zIndexOffset: 1000,}).addTo(this.map);
},
// 更新设备标记,添加跟随判断
updateDroneMarker(point, shouldFollow = false) {const {id,latDiff,lonDiff,weixian,} = point;this.removeLayerByName(id + '_droneMarker'); // 移除旧标记const drone1 = require('@/static/images/xxx.png');const drone2 = require('@/static/images/xxx.png');const icon = L.icon({iconUrl: weixian < 3 ? drone1 : drone2,iconSize: [36, 36],iconAnchor: [18, 18],className: 'drone-icon'});const droneMarker = L.marker([latDiff, lonDiff], {name: id + '_droneMarker',icon,zIndexOffset: 1000,popupAnchor: [0, -50]}).addTo(this.map);// 弹窗,自定义内容droneMarker.openPopup(`<div class='device-txtarea'><div class="info-item"><div>${latDiff}</div><div>${lonDiff}</div></div></div>`, {autoClose: false,closeOnClick: false,className: weixian < 3 ? 'safety-popup' : 'danger-popup' // 自定义弹窗样式});droneMarker.openPopup(); // 根据需求控制显示隐藏// 更新无人机列表this.droneList = this.droneList.filter(drone =>!drone.options.name.includes(id + '_droneMarker'));this.droneList.push(droneMarker);// 如果需要跟随,移动视角到无人机位置if (shouldFollow) {this.sightFollowFly({deviceId: id});}
},
// 跟随设备视角
sightFollowFly(params) {if (!params || !params.deviceId) {console.error('视角跟随缺少设备ID');return;}const deviceId = params.deviceId// 查找对应的设备标记const droneMarker = this.droneList.find(drone =>drone.options.name && drone.options.name.includes(deviceId + '_droneMarker'));if (droneMarker) {// 获取设备当前位置const dronePosition = droneMarker.getLatLng();// 平滑移动到设备位置this.map.flyTo(dronePosition, 18, {duration: 0.5, // 动画持续时间(秒)easeLinearity: 0.25});} else {console.error(`未找到无人机 ${deviceId} 的标记`);}
},
// ----------- 实时监听我的位置,绘制轨迹 ----------
// 更新我的位置和轨迹
updateLocationAndTrack(params) {this.updateMyLocationMarker(params);this.drawMyLocationTrack(params);
},
// 更新我的位置标记
updateMyLocationMarker(params) {if (!params.point) return;this.removeLayerByName('myLocationMark'); // 先清除旧标记const myLocationMarker = L.circleMarker(params.point, { // 创建新的位置标记name: 'myLocationMark',radius: 8,color: '#fff',weight: 2,fillColor: '#3281F4',fillOpacity: 1,zIndex: 1500,zIndexOffset: 1500,}).addTo(this.map);
},
// 绘制我的位置轨迹线
drawMyLocationTrack(params) {if (!params.points || params.points.length <= 1) return;this.removeLayerByName('myLocationTrack'); // 先清除旧的轨迹线 const trackLine = L.polyline(params.points, { // 创建轨迹线name: 'myLocationTrack',color: '#3281F4',weight: 3,opacity: 0.8,zIndex: 1000,zIndexOffset: 1000,}).addTo(this.map);
},
// 通过名称删除
removeLayerByName(name) {let layersToRemove = this.findLayerByName(name);if (layersToRemove.length > 0) {layersToRemove.forEach((layer) => {this.map.removeLayer(layer);});return true;}return false;
},
// 通过名称查找图层
findLayerByName(name) {let foundLayer = [];this.map.eachLayer(function(layer) {if (layer._name === name || layer.options?.name === name) {foundLayer.push(layer);}});return foundLayer;
},
Vue实现逻辑
// ----------- 绘制设备标记以及轨迹,视角跟随 -----------
// 时间改变
changeTime(val) {this.currentTime = val;this.drawPreviousFlyPath();// 如果正在跟随,确保视角跟随当前设备if (this.isFollowing && this.followingDeviceId) {this.sendActionToRender('sightFollowFly', {deviceId: this.followingDeviceId});}
},
// 绘制当前时间之前的飞行轨迹
drawPreviousFlyPath() {// 筛选出当前时间及之前的所有点const previousPoints = pointsList.filter(item =>(item.id === 'xxx' || item.id === 'xxx') &&item.time <= this.currentTime)// 按时间排序previousPoints.sort((a, b) => a.time - b.time);// 发送到RenderJS进行绘制this.sendActionToRender('updateFlyLineWithClear', {points: previousPoints,shouldFollowDeviceId: this.isFollowing ? this.followingDeviceId : null});
},// ----------- 实时监听我的位置,并绘制轨迹 ------------
// 开始监听位置变化
async startLocationTracking() {try {// 开始持续监听位置变化this.locationWatcher = await startLocationWatch({success: this.handleLocationChange,fail: (error) => {console.error('位置监听失败:', error);uni.showToast({title: '位置监听失败',icon: 'none'});}});this.isTracking = true;console.log('位置监听已启动');} catch (error) {console.error('启动位置监听失败:', error);uni.showToast({title: '启动位置监听失败',icon: 'none'});}
},
// 处理位置变化
handleLocationChange(location) {const newPoint = [location.latitude, location.longitude];// 检查是否与上一个点相同,位置去重if (this.isSameAsLastPoint(newPoint)) {return;}this.myCurrentGps = newPoint; // 更新当前位置this.trackPoints.push(newPoint); // 添加到轨迹点数组this.updateMyLocationMarkerAndTrack(newPoint); // 更新我的位置标记并绘制轨迹线
},
// 更新我的位置标记并绘制轨迹线
updateMyLocationMarkerAndTrack(point) {const points = JSON.parse(JSON.stringify(this.trackPoints));this.sendActionToRender('updateLocationAndTrack', {point: point,points: points});
},
// 检查是否与上一个点相同
isSameAsLastPoint(newPoint) {if (newPoint && this.trackPoints.length < 1) return;const lastPoint = this.trackPoints[this.trackPoints.length - 1];const [lastLat, lastLng] = lastPoint;const [newLat, newLng] = newPoint;const precision = 0.00001; // 设置精度阈值(约1米精度)return (Math.abs(lastLat - newLat) < precision &&Math.abs(lastLng - newLng) < precision);
},
// 停止位置监听,页面退出之前需要执行
stopLocationTracking() {if (this.locationWatcher) {stopLocationWatch(this.locationWatcher);this.locationWatcher = null;}this.isTracking = false;
},
样式:
::v-deep {.safety-popup {.leaflet-popup-content-wrapper,.leaflet-popup-tip {background: rgba(59, 194, 91, 0.75) !important;}}.danger-popup {.leaflet-popup-content-wrapper,.leaflet-popup-tip {background: rgba(194, 59, 59, 0.75) !important;}}.leaflet-popup-content-wrapper {box-shadow: none; .leaflet-popup-content {margin: 0;}}...
}
五、 深度踩坑与解决方案
❌ 问题1:地图容器高度异常
现象:地图显示为灰色,高度为0
解决方案:
#mapContainer {height: 100vh !important;min-height: 500px;
}
❌ 问题2:地图显示异常
现象:地图有灰色方块,图层显示不全
解决方案:我当时遇到的问题是没有引入leaflet.css
import '@/static/js/leaflet/leaflet.css'
大家如有更好方案,评论区可留言
