Skyline 跑步打卡微信小程序如何实现?微信小程序地图轨迹记录

1. 为什么是Skyline

Skyline 目前是微信小程序上的最佳渲染方式,交互效果接近原生。缺点就是那么久了还是没有完全能在开发者工具上预览效果。

本案例中有 Skyline 的 2 个特性:
  1. 可拖拽的Sheet
  2. Snapshot截图
以及使用地图+定位接口实现数据采集和存储:
  1. 展示是实时跑步轨迹
  2. 浏览保存后的跑步轨迹详情

在这里插入图片描述
在这里插入图片描述

实现步骤

1. 跑步控制台页面

有控制按钮,开始、暂停、继续、结束,地图,实时的数据。

1.1 pages/running/index.wxml页面

这里用到了 Skyline 的 draggable-sheetmap 组件:

<!--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,
        })
    },
效果截图

在这里插入图片描述

本文章主要分享实现跑步打卡小程序的主要步骤,同时也准备了前后端完整的代码供学习,挖坑踩坑需要时间,望理解。

源码地址 https://project.houcloud.com/project/15

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值