1. 为什么是Skyline
Skyline 目前是微信小程序上的最佳渲染方式,交互效果接近原生。缺点就是那么久了还是没有完全能在开发者工具上预览效果。
本案例中有 Skyline 的 2 个特性:
- 可拖拽的Sheet
- Snapshot截图
以及使用地图+定位接口实现数据采集和存储:
- 展示是实时跑步轨迹
- 浏览保存后的跑步轨迹详情
实现步骤
1. 跑步控制台页面
有控制按钮,开始、暂停、继续、结束,地图,实时的数据。
1.1 pages/running/index.wxml页面
这里用到了 Skyline 的 draggable-sheet
和 map
组件:
<!--pages/running/index.wxml-->
<map id="running-map"
polyline="{{polyline}}"
scale="{{18}}"
class="w-screen" style="height: calc(100vh - 120px);"
enable-3D="{{true}}" enable-rotate="{{true}}"
enable-zoom="{{true}}" show-compass="{{true}}" show-location="{{true}}" latitude="{{currentLocation.latitude}}"
longitude="{{currentLocation.longitude}}"/>
<running-nav-bar/>
<draggable-sheet
class="sheet relative"
style="height: {{sheetHeight}}px;"
initial-child-size="{{minSize}}"
min-child-size="{{minSize}}"
max-child-size="{{maxSize}}"
snap="{{true}}"
worklet:onsizeupdate="onSizeUpdate">
<scroll-view
associative-container="draggable-sheet"
class="scrollable flex-1 bg-dark text-white"
scroll-y
type="custom"
style="position: relative;"
show-scrollbar="{{false}}">
<view class="w-full h-full flex flex-col" style="position: absolute;z-index: 9999;">
<view class="bar w-full">
<view class="indicator"/>
</view>
<view class="w-full h-full">
<view class="flex w-full items-end" style="margin-bottom: 10px">
<view class="flex flex-col px-4" style="width: 50%;">
<text class="font-bold" style="font-size: {{88}}px">{{distance}}</text>
<text class="text-secondary" style="margin-top: -10px;">跑步距离(公里)</text>
</view>
<view class="flex flex-col px-4" style="width: 50%;">
<text class="font-bold" style="font-size: {{20}}px">{{accuracy}}</text>
<text class="text-secondary" style="margin-top: 10px;">定位信号</text>
</view>
</view>
<view class="w-full flex">
<view class="flex flex-col px-4" style="width: 50%;">
<text class="font-bold" style="font-size:{{38}}px">{{duration}}</text>
<text class="text-secondary">时长</text>
</view>
<view class="flex flex-col px-4" style="width: 50%;">
<text class="font-bold" style="font-size:{{38}}px">{{avgSpeed}}</text>
<text class="text-secondary">平均配速</text>
</view>
</view>
<view class="w-full flex">
<view class="flex flex-col px-4" style="width: 50%;">
<text class="font-bold" style="font-size:{{38}}px">{{calorie}}</text>
<text class="text-secondary">燃烧热量(千卡)</text>
</view>
<view class="flex flex-col px-4" style="width: 50%;">
<text class="font-bold" style="font-size:{{38}}px">{{speed}}</text>
<text class="text-secondary">实时配速</text>
</view>
</view>
<view wx:if="{{status === 'idle'}}"
class="w-full py-10 flex justify-center items-center">
<view bind:tap="handleStart"
class="running-start-button">
<text>开始跑步</text>
</view>
</view>
<view wx:if="{{status === 'start'}}"
class="w-full py-10 flex justify-center items-center">
<view bind:tap="handlePause"
class="running-pause-button">
<text>暂停跑步</text>
</view>
</view>
<view
wx:if="{{status === 'paused'}}"
class="w-full py-10 flex justify-center items-center">
<view
bind:tap="handleStop"
style="margin: 10px"
class="running-stop-button">
<text>结束跑步</text>
</view>
<view
bind:tap="handleResume"
style="margin: 10px"
class="running-start-button">
<text>继续跑步</text>
</view>
</view>
</view>
</view>
</scroll-view>
</draggable-sheet>
1.2 index.ts 文件
这里用到了很多数据采集的逻辑,所以封装了一个单例的RunningService。
微信小程序没有 vue 的 computed 也没有 zustand 或者 redux 那种全局状态去响应视图所以封装了一个 EventBus 来接受RunningService的实时数据变化。
这一部分比较啰嗦,我是写React为主的,不得吐槽一下微信原生开发实在是太难用了
- pages/running/index.ts
// pages/running/index.ts
import {RunningService} from "../../common/RunningService"
import {eventBus} from "../../common/EventBus";
import {TrackUtil} from "../../common/TrackUtil";
const {windowHeight} = wx.getSystemInfoSync()
const menuRect = wx.getMenuButtonBoundingClientRect()
const sheetHeight = windowHeight - (menuRect.bottom + menuRect.height + 60)
export interface PolylineType {
/** 经纬度数组
* @remarks [{latitude: 0, longitude: 0}]
*/
points: {
latitude: number,
longitude: number
}[]
/** 线的颜色
* @remarks 十六进制
*/
color?: string
/** 线的宽度 */
width?: number
/** 是否虚线
* @remarks 默认 false
*/
dottedLine?: boolean
/** 带箭头的线
* @remarks 默认 false,开发者工具暂不支持该属性
*/
arrowLine?: boolean
/** 更换箭头图标
* @remarks 在 arrowLine 为 true 时生效
*/
arrowIconPath?: string
/** 线的边框颜色 */
borderColor?: string
/** 线的厚度 */
borderWidth?: number
level?: 'abovelabels'
}
Page({
progress: {
value: 0
},
/**
* 页面的初始数据
*/
data: {
status: RunningService.shared.status,
currentLocation: {
latitude: 22.683619,
longitude: 110.201161,
},
menuRect,
size: 120,
polyline: [] as PolylineType[],
sheetHeight,
fontSize: 20,
distance: '0.0',
accuracy: '--',
calorie: '0',
speed: '0\'00',
avgSpeed: '0\'00',
duration: '0:00',
minSize: 0.45,
maxSize: 1,
},
handleStart() {
RunningService.shared.start()
},
handlePause() {
RunningService.shared.pause()
},
handleStop() {
RunningService.shared.stop()
},
handleResume() {
RunningService.shared.resume()
},
onSizeUpdate(e: any) {
'worklet'
console.info(`sizeUpdate pixels: ${e.pixels} size: ${e.size}`)
const dis = sheetHeight - e.pixels
this.progress.value = dis >= 20 ? 1 : dis / 20
},
/**
* 计算信号
*/
computeAccuracy(value: number) {
let label = '--';
if (!label){
this.setData({
accuracy: label
})
return
}
if (value > 100) {
label = '很差'
} else if (value > 50) {
label = '差'
}else if (value > 20) {
label = '良好'
}else if (value > 20) {
label = '非常好'
}
this.setData({
accuracy: label
})
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options: Record<string, any>) {
if (RunningService.shared.location) {
this.computeAccuracy(RunningService.shared.location.accuracy)
this.setData({
currentLocation: RunningService.shared.location
})
RunningService.shared.mapContent.moveToLocation(RunningService.shared.location)
}
console.log(options)
eventBus.on('status', (data: any) => {
if (data === undefined){
return
}
console.log('status 状态变化')
this.setData({
status: data
})
})
eventBus.on('calorie', (data: any) => {
if (data === undefined){
return
}
console.log('calorie 状态变化', data)
this.setData({
calorie: data
})
})
eventBus.on('location', (data: any) => {
if (data === undefined){
return
}
console.log('location 状态变化')
this.computeAccuracy(data.accuracy)
this.setData({
currentLocation: data
})
})
eventBus.on('points', (data: any) => {
if (data === undefined){
return
}
console.log('points 状态变化')
const polyline: PolylineType = {
points: data || [],
borderColor: '#ffffff',
borderWidth: 1,
level: "abovelabels",
dottedLine: false,
color: '#16dd90',
width: 7,
arrowLine: false,
}
this.setData({
polyline: [polyline]
})
})
eventBus.on('distance', (data: number) => {
console.log('distance 状态变化', data)
this.setData({
distance: TrackUtil.metersToKilometers(data)
})
})
eventBus.on('duration', (data: number) => {
if (data === undefined){
return
}
console.log('duration 状态变化')
// 计算配速
console.log('距离', RunningService.shared.distance, '速度', RunningService.shared.speed)
const speed = TrackUtil.calculatePaceFromSpeed(RunningService.shared.speed)
const avgSpeed = TrackUtil.calculateAveragePace(RunningService.shared.distance, data)
this.setData({
speed: speed,
avgSpeed: avgSpeed,
duration: TrackUtil.formatSeconds(data)
})
})
},
onShow() {
RunningService.shared.checkPermission()
},
onUnload() {
},
onReady() {
this.createSelectorQuery()
.select(".sheet")
.node()
.exec(res => {
const sheetContext = res[0].node
sheetContext.scrollTo({
size: 1,
animated: true,
duration: 0,
easingFunction: 'ease'
})
})
},
lifetimes: {
attached() {
const rect = wx.getMenuButtonBoundingClientRect()
wx.getSystemInfo({
success: (res) => {
const isAndroid = res.platform === 'android'
const isDevtools = res.platform === 'devtools'
this.setData({
ios: !isAndroid,
innerPaddingRight: `padding-right: ${res.windowWidth - rect.left}px`,
leftWidth: `width: ${res.windowWidth - rect.left}px`,
safeAreaTop: isDevtools || isAndroid ? `height: calc(var(--height) + ${res.safeArea.top}px); padding-top: ${res.safeArea.top}px` : ``
})
}
})
},
}
})
- RuningService.ts
```ts
import {TimerService} from "./TimerService";
import {TrackPointType} from "../api/types/TrackPointType";
import {navTo, safeBack, toHome} from "./Navigation";
import {TrackType} from "../api/types/TrackType";
import {uploadTrackApi} from "../api/track";
import {eventBus} from "./EventBus";
import {TrackUtil} from "./TrackUtil";
interface Service {
start(): void;
pause(): void;
resume(): void;
stop(): void;
}
export class RunningService extends TimerService implements Service {
static shared: RunningService = new RunningService()
private constructor() {
super()
this.onLocationChange = this.onLocationChange.bind(this)
}
mapContent = wx.createMapContext('running-map')
status: 'start' | 'idle' | 'paused' = 'idle'
distance: number = 0;
calorie: number = 0;
points: TrackPointType[] = []
speed: number = 0;
location: WechatMiniprogram.OnLocationChangeCallbackResult | undefined
async onLocationChange(location: WechatMiniprogram.OnLocationChangeCallbackResult) {
console.log(location);
eventBus.emit('location', this.location)
this.speed = location.speed
eventBus.emit('speed', location.speed)
if (location.speed <= 0) {
console.log('无速度,跳过')
return;
}
console.log('已有轨迹点', this.points?.length)
if (!this.points) {
this.points = []
}
if (this.points.length > 2) {
const prev = this.points[this.points.length - 1]
if (location.latitude === prev.latitude && location.longitude === prev.longitude) {
console.log('位置相同')
return
}
// 计算距离
const distanceMeters = TrackUtil.distanceMeters(
prev.latitude,
prev.longitude,
location.latitude,
location.longitude
)
this.distance += distanceMeters
// console.log('计算距离',distanceMeters,prev,location)
eventBus.emit('distance', this.distance)
}
this.points.push({
latitude: location.latitude,
longitude: location.longitude,
speed: location.speed,
altitude: location.altitude
})
eventBus.emit('points', this.points)
this.calorie = TrackUtil.calculateCaloriesBurned(70, this.distance, this.duration)
eventBus.emit('calorie', this.calorie)
}
start(): void {
console.log('点击开始')
this.status = 'start'
this.timerStart()
eventBus.emit('status', 'start')
wx.startLocationUpdateBackground({
success: res => {
console.log('startLocationUpdateBackground 用户开启使用小程序期间位置权限:', res)
wx.onLocationChange(this.onLocationChange)
},
fail: err => {
wx.showToast({title: '位置功能无法进行', icon: 'none'})
console.log('startLocationUpdate获取当前位置失败', err)
this.status = "idle"
eventBus.emit('status', 'idle')
this.showAlert()
},
complete: msg => {
console.log('startLocationUpdateBackground complete', msg)
}
})
}
async checkPermission() {
wx.getLocation({
type: 'gcj02',
success: (res) => {
console.log(res)
this.location = res
eventBus.emit('location', this.location)
if (res.accuracy > 100) {
wx.showModal({
title: '卫星定位信号弱',
content: '当前卫星定位信号弱,建议您在信号良好的空旷处使用,您也可以直接开始。',
cancelText: '稍后使用',
confirmText: '继续使用',
success: (result) => {
if (result.cancel) {
safeBack()
}
if (result.confirm) {
this.mapContent.moveToLocation(res)
}
}
})
}
},
fail: err => {
console.log('检查定位权限', err)
this.status = "idle"
eventBus.emit('status', 'idle')
this.showAlert()
},
})
}
showAlert() {
wx.showModal({
title: '未获得定位权限',
content: '跑步功能需要您手机授权后台定位功能,若您继续使用此功能请设置使用时和离开后权限。',
cancelText: '退出使用',
confirmText: '设置授权',
success: (result) => {
if (result.cancel) {
safeBack()
}
if (result.confirm) {
// return
wx.openSetting({
success(res) {
console.log(res.authSetting)
res.authSetting = {
"scope.userLocation": true,
"scope.userInfo": true,
'scope.werun': true
}
}
})
}
}
}
)
}
pause(): void {
this.status = 'paused'
eventBus.emit('status', this.status)
this.timerPause()
wx.stopLocationUpdate({
complete: (res) => {
console.log("跑步暂停,停止位置更新:", res)
}
})
}
resume(): void {
this.status = 'start'
eventBus.emit('status', this.status)
this.timerResume()
wx.startLocationUpdateBackground({
success: res => {
console.log('startLocationUpdateBackground 用户开启使用小程序期间位置权限:', res)
wx.onLocationChange(this.onLocationChange)
},
fail: err => {
wx.showToast({title: '位置功能无法进行', icon: 'none'})
console.log('startLocationUpdate获取当前位置失败', err)
this.showAlert()
},
complete: msg => {
console.log('startLocationUpdateBackground complete', msg)
}
})
}
async offLocationChange(result: WechatMiniprogram.OnLocationChangeCallbackResult) {
console.log('定位停止了', result);
}
async stop() {
if (this.distance < 10) {
wx.showModal({
title: '距离太短',
content: '您似乎还没开始跑步,本次轨迹不会被保存,是否继续跑步?',
cancelText: '先不跑了',
confirmText: '继续跑步',
success: (result) => {
if (result.cancel) {
// 结束计时并获取总时间
this.timerStop();
this.status = 'idle'
eventBus.emit('status', this.status)
this.reset()
toHome();
}
if (result.confirm) {
return
}
}
})
return
}
try {
wx.showLoading({title: '正在生成轨迹'})
await wx.stopLocationUpdate()
wx.offLocationChange(this.offLocationChange)
// 结束计时并获取总时间
this.timerPause();
this.status = 'idle'
eventBus.emit('status', this.status)
// 构建需要存储的数据
const track: TrackType = {
duration: super.duration,
distance: this.distance,
points: this.points,
}
console.log('upload track',track);
const res = await uploadTrackApi(track)
if (res.success && res.result?.id) {
this.reset()
navTo(`/pages/track/index?id=${res.result.id}`)
this.timerStop()
} else {
wx.showToast({title: '上传失败', icon: 'none'})
}
} catch (e) {
console.log('轨迹生成或保存错误', e)
} finally {
setTimeout(() => {
wx.hideLoading()
}, 200)
}
}
reset(): void {
this.status = 'idle'
eventBus.emit('status', this.status)
this.duration = 0
eventBus.emit('duration', this.duration)
this.points = []
eventBus.emit('points', this.points)
this.distance = 0
eventBus.emit('distance', this.distance)
this.calorie = 0
eventBus.emit('calorie', this.calorie)
}
}
- EventBus 事件工具
// eventBus.js
export const eventBus = {
events: {} as any,
on(event:any, callback:any) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
},
emit(event:any, data:any) {
if (this.events[event]) {
this.events[event].forEach((callback:any) => callback(data));
}
},
off(event:any, callback:any) {
if (event && this.events[event]) {
if (callback) {
this.events[event] = this.events[event].filter((cb: any) => cb !== callback);
} else {
delete this.events[event];
}
}
}
};
分享海报 (Skyline的snapshot)
<snapshot mode="view"
id="basic"
style="position: absolute;left: 100000px"
class="flex flex-col flex-1 h-full w-full bg-white">
<view class="w-full bg-white">
<view class="p-3 flex items-center">
<view>
<image mode="aspectFill" class="user-avatar" src="{{user.avatar}}"/>
</view>
<view class="flex flex-col ml-2">
<text class="font-bold">{{user.nickname}}</text>
<text class="text-secondary text-sm">{{createdAt}}</text>
</view>
</view>
<view class="p-3 flex justify-between items-center">
<view class="flex flex-col">
<view class="flex items-center">
<text class="iconfont icon-running"></text>
<text class="ml-2">跑步运动</text>
</view>
<view class="flex items-end">
<text class="font-bold" style="font-size: 68px;">{{distance}}</text>
<text style="margin-bottom: 20px;">公里</text>
</view>
</view>
<view class="flex flex-col justify-center items-center">
<image mode="aspectFill" style="height: 50px;width: 50px;" src="/assets/ranking/no1.png"/>
<text class="mt-2 text-xs">玉师排行 No.1</text>
</view>
</view>
<view class="flex justify-around p-3">
<view class="flex flex-col justify-center">
<text class="text-secondary">跑步时长</text>
<text class="text-xl font-bold">{{duration}}</text>
</view>
<view class="flex flex-col justify-center">
<text class="text-secondary">配速</text>
<text class="text-xl font-bold">{{speed}}</text>
</view>
<view class="flex flex-col justify-center">
<text class="text-secondary">消耗</text>
<view class="flex items-center">
<text class="text-xl font-bold">{{calorie}}</text>
<text class="ml-1">千卡</text>
</view>
</view>
</view>
<view class="flex justify-between" style="padding: 32px">
<view class="flex flex-col">
<text class="font-bold">真我时刻</text>
<text class="text-xs text-secondary">每一次运动,都是更真实的自己。</text>
<text class="text-xs text-secondary">扫一扫,一起来打卡。</text>
</view>
<image src="/assets/qrcode.jpg" style="height: 68px;width: 68px;"/>
</view>
</view>
</snapshot>
点击按钮时直接根据元素生成海报图:
async shareToWeChat() {
let f = this.data.filePath
if (!f) {
f = await SnapshotUtil.takeSnapshot("basic")
}
wx.showShareImageMenu({
path: f,
})
},