整理一下小程序云开发项目中的基本思路和遇到的问题。

微信官方文档

微信小程序开发文档

代码GitHub:

小程序代码地址

后台管理系统代码地址

云开发基础能力:

  • 云函数:在云端运行的代码,微信私有协议天然鉴权
  • 云数据库:一个既可以在小程序端操作又可以在云函数种操作的json数据库
  • 云存储:在云端存储文件,可以在云端控制台可视化管理
  • 云调用:基于云函数免鉴权使用小程序开放接口的能力
  • HTTP API:使用HTTP API开发者可以在已有服务器上访问云资源,实现与云开发的互通

项目初始化

使用微信开发者工具,新建云开发小程序。生成的项目中会存在cloudfunctions、miniprogram两个文件夹,分别存放的云函数代码、项目前端代码。这时运行项目是会报错的,这时我们应该点开“云开发”,初始化我们的云开发环境,并记录环境唯一id(在项目中会用到)。

在app.js中接入云开发环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
App({
onLaunch: function () {
if (!wx.cloud) {
console.error('请使用 2.2.3 或以上的基础库以使用云能力')
} else {
wx.cloud.init({
// env 参数说明:
// env 参数决定接下来小程序发起的云开发调用(wx.cloud.xxx)会默认请求到哪个云环境的资源
// 此处请填入环境 ID, 环境 ID 可打开云控制台查看
// 如不填则使用默认环境(第一个创建的环境)
env: 'face-music-test-cor7k',
// 可以记录访问过小程序的用户
traceUser: true,
})
}
// 小程序全局属性和方法
this.globalData = {

}
}
})

env参数说明: env参数决定接下来小程序发起的云开发调用,此处请填入环境 ID,环境 ID 可打开云控制台查看。traceUser可以记录访问过小程序的用户。

在app.json中配置导航栏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
"pages": [
"pages/list/list",
"pages/profile/profile",
"pages/review/review",
],
"tabBar": {
"color": "#aaa",
"selectedColor": "#333",
"list": [
{
"pagePath": "pages/list/list",
"text": "音乐",
"iconPath": "images/music-init.png",
"selectedIconPath": "images/music-select.png"
},
{
"pagePath": "pages/review/review",
"text": "发现",
"iconPath": "images/review-init.png",
"selectedIconPath": "images/review-select.png"
},
{
"pagePath": "pages/profile/profile",
"text": "我的",
"iconPath": "images/profile-init.png",
"selectedIconPath": "images/profile-select.png"
}
]
}
}

记得在pages目录下创建对应的页面。其中所用到的字体图标可以在iconfont网站下载。

这是可以在微信开发者工具中查看页面效果。

part1 音乐播放功能

歌单页面开发

轮播图:使用小程序自带的swiper组件,官方建议将wx:key绑定到block标签上,block并不会真实渲染到页面中。

对于image组件,mode属性是其具体显示的模式,具体值可以参考mode的合法值

1
2
3
4
5
6
7
<swiper indicator-dots="true" autoplay="true" interval="2000" duration="1000">
<block wx:for="{{swipers}}" wx:key="url">
<swiper-item>
<image src="{{item.fileID}}" mode="widthFix" class="swiper"></image>
</swiper-item>
</block>
</swiper>

组件化开发: 在用户界面开发领域,组件是一种面向用户的、独立的、可复用的交互元素的封装

自定义歌单组件components/disc

难点

  1. 数据监听器
    错误示范——这样会形成死循环,不停赋值,不停监听触发函数……
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    Component({
    observers:{
    //只监听对象下面的某一个属性
    ['playlist.playCount'](count){
    this.setData({
    ['playlist.playCount']:this._transNumber(count,2)
    })
    }
    }
    methods: {
    //播放数量数据格式处理
    _transNumber(count,point){
    const number=count.toString().split('.')[0]
    const len=number.length
    if(len<6){
    return number
    }else if(len>=6&&len<=8){
    let decimal=number.substring(len-4,len-4+point)
    return parseFloat(parseInt(number / 10000) + '.' + decimal)+'万'
    }else{
    let decimal = number.substring(len - 8, len - 8 + point)
    return parseFloat(parseInt(number / 100000000) + '.' + decimal) + '亿'
    }
    }
    }
    })

正确示范——新定义一个data中的属性,并对其赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Component({
observers:{
//只监听对象下面的某一个属性
['playlist.playCount'](count){
this.setData({
_count:this._transNumber(count,2)
})
}
},
//页面中采用这个_count属性
data: {
_count:0
},
methods: {
//播放数量数据格式处理
_transNumber(count,point){
// ...
}
}
})
  1. 异步处理
    在云函数中默认支持async、await语法的,但是在小程序端就不行。我们此时需要引入额外的文件才能让小程序端支持。

需要使用runtime.js文件,在js文件中通过import regeneratorRuntime from ...引入即可,此时小程序端即可支持async、await语法。

  1. 实现歌单数据通过云函数获取(掌握云函以及如何往云数据库中插入数据)
    cloudfunctions文件夹下新建Nodejs云函数。项目中可以使用requestrequest-promise发送请求。右键文件夹打开终端,下载npm包即可。

先在云数据库中新建数据表playlist,将数据一条条插入到数据库中。

有几点注意:

  • 通过url请求歌单数据(每次获取都是最新的歌单数据),并将数据插入到数据库中
  • 每次插入数据时,应该进行去重处理,如果数据库已经有了则不用继续插入此数据
  • 突破小程序获取数据条目的限制(云函数只能获取100条),应该分批次去获取,最后拼接在一起
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 云函数入口文件
const cloud = require('wx-server-sdk')

const rp=require('request-promise')
const URL = 'http://musicapi.xiecheng.live/personalized'

cloud.init()

const db=cloud.database() //初始化云数据库
const playlistCollection = db.collection('playlist')
const MAX_LENGTH = 100 //小程序能从云数据库获取的最多记录
// 云函数入口函数
exports.main = async (event, context) => {
const wxContext = cloud.getWXContext()
// 云数据库中存储的数据
// const list = await playlistCollection.get()
// 如何突破小程序只能获取云数据库100条记录的限制?
// 可以分批次去获取,最后拼接在一起
const countResult=await playlistCollection.count()
const total=countResult.total
const batchTimes=Math.ceil(total/MAX_LENGTH)
const tasks=[]
for(let i=0;i<batchTimes;i++){
let promise=playlistCollection.skip(i*MAX_LENGTH).limit(MAX_LENGTH).get()
tasks.push(promise)
}
let list={
data:[]
}
if(tasks.length>0){
//注意这里的语法(简洁)
list=(await Promise.all(tasks)).reduce((pre,cur)=>{
return {
data:pre.data.concat(cur.data)
}
})
}
// 从服务器端获取的数据
const playlist=await rp(URL).then(res=>{
return JSON.parse(res).result
})
// 针对list和playlist做去重处理,在playlist中找出所有不在list中的数据
const newData=[]
for (let i = 0, len1 = playlist.length;i<len1;i++){
let isSame = false
for(let j=0,len2=list.data.length;j<len2;j++){
if(playlist[i].id===list[j].id){
isSame=true
break
}
}
if(!isSame){
newData.push(playlist[i])
}
}
// 将数据存储至云数据库当中
for (let i = 0, len = newData.length;i<len;i++){
await playlistCollection.add({
data:{
...newData[i],
createTime:db.serverDate()
}
}).then(res=>{
console.log('插入成功')
}).catch(err=>{
console.error('插入失败')
})
}
return newData.length
}

完成后,点击上传并部署,等云函数上传成功后,在云开发中可以看到刚刚上传的云函数,也可以直接调试这个函数是否正确,接下来在小程序端可以调用。

  1. 定义原函数定时触发器
    这里可参考云函数定时触发器
    config.json:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "triggers":[
    {
    "name":"updatePlaylist",
    "type":"timer",
    "config":"0 0 10,15,20 * * * *"
    }
    ]
    }

注意在json文件中不要写注释。目前type只支持timer类型,config配置定时时间,使用Corn表达式,例如这里的”0 0 10,15,20 * * * *”表示每天的10点、15点、20点都会自动触发这个云函数。

利用歌单组件渲染歌单页面
定义music云函数,并上传云函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 云函数入口文件
const cloud = require('wx-server-sdk')
const TcbRouter = require('tcb-router')
const rp=require('request-promise')
const URL = 'http://musicapi.xiecheng.live'
cloud.init()

// 云函数入口函数
exports.main = async (event, context) => {
const app = new TcbRouter({event})
// 获取歌单数据
app.router('playlist',async(ctx,next)=>{
ctx.body= await cloud.database().collection('playlist')
.skip(event.start)
.limit(event.count)
.orderBy('createTime', 'desc')
.get()
.then(res => {
return res
})
})
return app.serve()
}

接下来,在歌单页面中通过wx.cloud.callFunction请求云函数,获取歌单数据,其中传入的$url是云函数中某个router的名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
_getPlaylist(){
wx.showLoading({
title: '正在加载……',
})
wx.cloud.callFunction({
name: 'music',//云函数名称
data: {
start: this.data.playlist.length,
count: MAX_LENGTH,
$url:'playlist' //云函数中某个router的名称
}
}).then(res => {
if (res.result.data.length===0){
wx.hideLoading()
wx.showToast({
title: '已经到底啦',
})
return
}
this.setData({
playlist: this.data.playlist.concat(res.result.data)
})
// 当数据请求回来时,停止下拉刷新的操作
wx.stopPullDownRefresh()
wx.hideLoading()
})
}

实现下拉刷新与上拉加载的功能。注意要支持下拉动作时,需要在json中配置"enablePullDownRefresh":true,允许用户下拉刷新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh: function () {
this.setData({
playlist:[]
})
this._getPlaylist()
this._getSwiper()
},

/**
* 页面上拉触底事件的处理函数
*/
onReachBottom: function () {
this._getPlaylist()
},

云函数路由优化
为什么使用tcb-router

  • 一个用户在一个云环境中只能创建50个云函数
  • 相似的请求归类到同一个云函数中处理
  • koa风格的云函数路由库

打开终端,安装tcb-router

tcb-router使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 云函数入口文件
const cloud = require('wx-server-sdk')
const TcbRouter = require('tcb-router')
cloud.init()

// 云函数入口函数
exports.main = async (event, context) => {
const app = new TcbRouter({event})

app.use(async(ctx,next)=>{
console.log('hello')
await next()
})

app.router('demo1',async(ctx,next)=>{
ctx.body= 'demo1'
})
app.router('demo2', async (ctx, next) => {
ctx.body= 'demo2'
})
app.router('demo3', async (ctx, next) => {
ctx.body= 'demo3'
})
app.router('demo4', async (ctx, next) => {
ctx.body= 'demo4'
})

return app.serve()
}

和koa框架极为相似,都是洋葱模型。

歌曲列表页面开发

绑定点击事件,跳转到歌曲列表

注意要传入歌单的唯一id——playlistId

1
2
3
<view class="disc-container" bindtap="goToMusicList">
<!-- ... -->
</view>
1
2
3
4
5
goToMusicList(){
wx.navigateTo({
url: `../../pages/music-list/music-list?playlistId=${this.properties.playlist.id}`,
})
}

完善云函数music.js,获取指定歌单下的所有歌曲信息:

1
2
3
4
5
app.router('musiclist', async (ctx, next) => {
ctx.body=await rp(`${URL}/playlist/detail?id=${parseInt(event.playlistId)}`).then(res=>{
return JSON.parse(res)
})
})

接下来在歌曲列表页面中,调用云函数,获取此歌单下的所有歌曲信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Page({
data: {
musiclist: [],
listInfo: {},
},

onLoad: function (options) {
wx.showLoading({
title: '加载中',
})
wx.cloud.callFunction({
name: 'music',
data: {
playlistId: options.playlistId,
$url: 'musiclist'
}
}).then((res) => {
const pl = res.result.playlist
this.setData({
musiclist: pl.tracks,
listInfo: {
coverImgUrl: pl.coverImgUrl,// coverImgUrl 歌单封面
name: pl.name,// name 歌单名称
description: pl.description,// description 歌单描述
commentCount: pl.commentCount, // commentCount 评论总数
playCount: pl.playCount,// playCount 播放数量
shareCount: pl.shareCount,// shareCount 分享数量
subscribedCount: pl.subscribedCount,// subscribedCount 订阅数
tags: pl.tags.join(' / '),// tags:Araay 标签
subscribed: pl.subscribed// subscribed 是否订阅
}
})
wx.hideLoading()
})
}
})

选择歌曲后带者参数musicid跳转到播放器页面,其中要注意一点,data-musicid是定义在父级元素上的,target是触发事件的原组件,currentTarget是事件绑定的当前组件。因此这里要通过e.currentTarget.dataset.musicid拿到dataset中的数据。

1
2
3
4
5
6
7
8
9
10
11
12
methods: {
select(e){
const musicid = e.currentTarget.dataset.musicid
const index = e.currentTarget.dataset.index
this.setData({
playingId: musicid
})
wx.navigateTo({
url: `../../pages/player/player?musicId=${musicid}&index=${index}`,
})
}
}

url中传递的值在options中可以拿到。如下面所示:

1
2
3
onLoad: function (options) {
nowPlayingIndex = options.index
}
音乐播放器开发

歌曲数据管理

两种方式:

  1. 直接从云端获取
  2. 直接从现有数据中取(存储至storage中)——推荐,减少请求时间

在获取到此歌单的详细信息后,将数据存储到Storage中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Page({
onLoad: function (options) {
wx.cloud.callFunction({
name: 'music',
data: {
playlistId: options.playlistId,
$url: 'musiclist'
}
}).then((res) => {
// ...
this._setMusicToStorage()
})
},
// 将歌单列表存储至storage中
_setMusicToStorage(){
wx.setStorageSync('musiclist', this.data.musiclist)
}
})

在播放器页面中,可以直接获取storage中存储的musiclist数据,注意这里musiclist并不渲染到页面中,故不需要定义在data中,只需定义在外面变量即可。

1
2
3
4
5
6
7
8
9
10
let musiclist=[]
let nowPlayingIndex=0 //正在播放歌曲的index
Page({
onLoad: function (options) {
nowPlayingIndex = options.index
musiclist=wx.getStorageSync('musiclist')
this._loadMusicDetail(options.musicId)
},
_loadMusicDetail(musicId){}
})

背景高斯模糊、唱片、旋杆设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<view class="player-container" style="background:url({{picUrl}}) center/cover no-repeat"></view>
<view class="player-mask"></view>

<view class="play-info">
<!-- 唱片页面 -->
<view class="player-disc" >
<image class="play-img" src="{{picUrl}}"></image>
</view>
<!-- 歌词页面 -->
<lyrics class="lyrics"/>
<!-- 进度条 -->
<view class="progress-bar">
<progress-bar />
</view>
<!-- 操作按钮 -->
<view class="control"></view>

</view>

引入iconfont图标:加入购物车,下载css文件。在app.wxss中引入@import './iconfont.wxss';。引入字体图标:

1
2
3
4
5
6
7
<view class="control">
<text class="iconfont icon-xunhuanbofang"></text>
<text class="iconfont icon-shangyiqu101" bindtap="onPrev"></text>
<text class="iconfont {{isPlaying?'icon-zanting':'icon-play_icon'}}" bindtap="toggleMusic"></text>
<text class="iconfont icon-xiayiqu101" bindtap="onNext"></text>
<text class="iconfont icon-liebiaosousuozhuangtai"></text>
</view>

可以分别制造成各种按钮——播放/暂停、上一曲、下一曲。

播放歌曲

完善云函数:(可采用云函数增量上传,速度更快)

1
2
3
4
5
app.router('musicUrl', async (ctx, next) => {
ctx.body = await rp(`${URL}/song/url?id=${event.musicId}`).then(res => {
return res
})
})

如果要在小程序每个页面背景中都可以听到音频,需要在app.json中配置"requiredBackgroundModes": ["audio"]

在player播放器页面中请求云函数,播放歌曲需要使用wx.getBackgroundAudioManager()定义全局唯一的背景音频管理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
let musiclist=[]
let nowPlayingIndex=0 //正在播放歌曲的index
const musicManager=wx.getBackgroundAudioManager()
const app=getApp()

Page({
data: {
picUrl:'',
isPlaying: false, //false不播放。true播放
isLyricsShow:false,
lyric:'',
isSameMusic:false //是否为同一首歌
},

onLoad: function (options) {
nowPlayingIndex = options.index
musiclist=wx.getStorageSync('musiclist')
this._loadMusicDetail(options.musicId)
},

_loadMusicDetail(musicId){
wx.showLoading({
title: '歌曲正在加载……',
})

wx.cloud.callFunction({
name:'music',
data:{
$url:'musicUrl',
musicId: musicId
}
}).then(res=>{
let result=JSON.parse(res.result)

// 判断是否能获取歌曲的url值,因为有些歌单是VIP歌单,用户未登录,无权限
if(result.data[0].url==null){
wx.showToast({
title: '无权限播放',
})
return
}
musicManager.src = result.data[0].url
musicManager.title = music.name
musicManager.coverImgUrl = music.al.picUrl
musicManager.singer = music.ar[0].name
musicManager.epname = music.al.name //专辑名称

wx.hideLoading()
})
}
})

如何支持旋杆的状态呢?如果音乐播放,则旋杆上抬,若音乐暂停,则旋杆下坠,如何实现?

1
2
3
<view class="player-disc {{isPlaying?'play':''}}" bindtap="changeView" hidden="{{isLyricsShow}}">
<image class="play-img rotation {{isPlaying?'':'rotation-pause'}}" src="{{picUrl}}"></image>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 指针可以直接通过伪元素插入到唱片元素的后面 */
.player-disc::after{
content: '';
width: 192rpx;
height: 274rpx;
position: absolute;
top: -150rpx;
left: 266rpx;
background: url('https://s3.music.126.net/m/s/img/needle.png?702cf6d95f29e2e594f53a3caab50e12') no-repeat center/contain;
transform: rotate(-15deg);
transform-origin: 24rpx 10rpx;
transition: transform 0.5s ease;
}
/* 这里表示如果同时具有play样式,即音乐在播放,则transform生效 */
.play.player-disc::after{
transform: rotate(0deg);
}

唱片如何实现旋转呢?音乐停止时,唱片停止旋转,音乐再次播放时,唱片从上次停止的地方开始旋转——使用animation-play-state属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.rotation{
animation: rotation 12s linear infinite;
-moz-animation: rotation 12s linear infinite;
-webkit-animation: rotation 12s linear infinite;
-o-animation: rotation 12s linear infinite;
}

.rotation-pause{
animation-play-state: paused;
}

@keyframes rotation{
from{transform: rotate(0deg)}
to{transform: rotate(360deg)}
}

工具条:播放/暂停、上一曲、下一曲
如何实现点击播放/暂停按钮功能?——上一首/下一首要进行边界判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 播放/暂停
toggleMusic(){
this.data.isPlaying ? musicManager.pause() : musicManager.play()
this.setData({
isPlaying: !this.data.isPlaying
})
},
// 下一首
onPrev(){
if(nowPlayingIndex===0){
nowPlayingIndex=musiclist.length
}
nowPlayingIndex--
this._loadMusicDetail(musiclist[nowPlayingIndex].id)
},
// 上一首
onNext(){
if (nowPlayingIndex === musiclist.length-1) {
nowPlayingIndex = -1
}
nowPlayingIndex++
this._loadMusicDetail(musiclist[nowPlayingIndex].id)
},

进度条组件(可拖动)

进度条组件progress-bar

1
2
3
4
5
6
7
8
9
10
11
<view class="container">
<text class="time">{{musicTime.curTime}}</text>
<view class="control">
<movable-area class="movable-area">
<!-- damp为滑动的阻尼系数 -->
<movable-view direction="horizontal" class="movable-view" damp="1000" x="{{distance}}" bindchange="onChange" bindtouchend="onTouchEnd"></movable-view>
</movable-area>
<progress stroke-width="4" backgroundColor="#969696" activeColor="#fff" percent="percent"></progress>
</view>
<text class="time">{{musicTime.totalTime}}</text>
</view>

具体js逻辑可以参考源代码

唱片于歌词相互切换

歌词页面

part2 博客功能

未完待续……

part3 我的功能

未完待续……

小程序高级进阶

小程序渲染层与逻辑层交互原理

在网页开发中渲染层和逻辑层是互斥的(长时间运行js脚本会导致页面失去反应),在小程序中渲染层和逻辑层是分开的。

开发小程序时推荐使用真机进行调试。

小程序中最忌讳的是频繁地进行setData操作,这样容易导致页面卡死。而且如果某数据不需要在页面中显示的话,就不需要定义在data中。

小程序的运行机制和更新机制

小程序运行机制:冷启动与热启动、前台与后台、小程序销毁
小程序更新机制

小程序性能与体验优化

  • 合理设置可点击元素的相应区域大小
  • 避免渲染页面耗时过长
  • 避免执行脚本时间过长
  • 对网络请求作必要的缓存
  • 不要引入未被使用的wxss样式
  • 所有资源请求建议使用https
  • 不要使用小程序废弃接口
  • 避免过大的wxml节点数目
  • 及时回收定时器
  • 避免使用:active伪类来实现点击态(建议使用hover,小程序内置hover-class属性)
  • 在滚动区域开启惯性滚动增强体验
  • 避免出现任何js异常
  • 所有请求耗时不应太久
  • 避免短时间内发起太多的图片请求
  • 避免短时间内发起太多请求
  • 避免setData数据过大(每次setData的时候,数据相当于从逻辑层到系统层,再从系统层发送给渲染层进行相应的展示,因此每次setData的时候数据量尽量不要超过1M)
  • 避免setData过于频繁
  • 避免将未绑定在wxml中的变量传入setData(造成不必要的性能消耗)

微信开发者工具控制台中有Audits面板,可以对小程序提出性能改善的建议。

后台管理系统开发

未完待续……