# 使用云开发构建多媒体小程序
# 小程序·云开发
什么是小程序的云开发?一句话就是能够使开发者省去搭建服务器、申请域名的成本,从开发到运维提供整套解决方案的小程序开发方式。 官方定义是,小程序·云开发为开发者提供完整的云端支持,弱化后端和运维概念,无需搭建服务器,使用平台提供的 API 进行核心业务开发,即可实现快速上线和迭代。 相对于传统小程序开发,云开发新提供了三大基础支持:
- 云函数:在云端运行的代码,微信私有协议天然鉴权,开发者只需编写自身业务逻辑代码
- 数据库:一个既可在小程序前端操作,也能在云函数中读写的 JSON 数据库
- 存储:在小程序前端直接上传/下载云端文件,在云开发控制台可视化管理
由此开发者只需在小程序端调用 API 即可搭建简单的后端服务,无需考虑搭建服务器、申请合法域名带来的成本。云开发环境免费版对资源有一定限制,但在用户量不大的情况下可以提供稳定的服务,相对于真正的后端服务,云开发环境还集成了控制台,提供了运维的有效手段。
# 初始化小程序·云开发
# 注册小程序
登录微信公众平台,使用邮箱注册并激活小程序。进入小程序管理后台首页填写小程序信息,并添加开发者,详情见官方文档。
# 创建小程序项目
- 进入微信 web 开发者工具下载页,下载开发工具并安装。
- 进入小程序管理后台-设置-开发设置,获取小程序的 AppID,新建小程序项目,选择
建立云开发快速启动模板
,详情见小程序·云开发文档。
# 建立云开发环境
- 在开发者工具工具栏左侧,点击
云开发
开通云开发功能。每个小程序免费提供两个环境,首先创建名为test
的云环境。复制自动生成的环境 ID,编辑miniprogram
文件夹下的app.js
,添加env
参数。
wx.cloud.init({
traceUser: true,
env: "test-xxx"
});
- 选择
cloudfunctions
文件夹,设置当前环境为test
。 - 编译小程序,如控制台无报错,则环境连接正常。
# 云开发环境
云开发提供了一整套云服务及简单、易用的 API 和管理界面,以尽可能降低后端开发成本,让开发者能够专注于核心业务逻辑的开发、尽可能轻松的完成后端的操作和管理。这套云环境包括数据库、存储和云函数。
# 云开发控制台
云开发控制台提供了云开发的管理界面和运维工具,开发者可以通过操作控制台来查看环境使用情况、操作数据库、存储、管理云函数等。
# 数据库
云开发环境提供一个文档数据库,其 API 和功能类似于 MongoDB。进入控制台的数据库选项,新建一个集合。集合中每条数据都是 JSON 格式的。
- 权限控制:小程序端操作数据库时,读写数据受权限控制限制。写入的记录会默认增加写入用户的
_openid
,如果需要所有用户对数据有读权限,需要更改权限为所有用户可读,仅创建者及管理员可写
- 对数据库 API 进行封装
// 云db对象
const db = wx.cloud.database();
module.exports = {
/**
* 新增记录
*
* @param data 数据
* @param collection 集合
* @return {"_id": String, "errMsg": String}
*/
add: function(data, collection) {
return db.collection(collection).add({
data: data
});
},
/**
* 查询记录
*
* @param collection 集合
* @param where 查询条件
* @param skip 查询起始位置
* @param limit 查询数量
* @return {"data": Array, "errMsg": String}
*/
query: function(collection, where, skip, limit) {
where = where || {};
skip = skip || 0;
limit = limit || 10;
return db
.collection(collection)
.where(where)
.orderBy("time", "desc")
.skip(skip)
.limit(limit)
.get();
},
/**
* 查询记录数量
*
* @param collection 集合
* @param where 查询条件
* @return {"total": Number, "errMsg": String}
*/
count: function(collection, where) {
where = where || {};
return db
.collection(collection)
.where(where)
.count();
},
/**
* 新增/全部更新文档
*
* @param collection 集合
* @param doc 文档_id
* @param data 数据
* @return {"_id": String, "errMsg": String}
*/
addDoc: function(collection, doc, data) {
collection = collection || lovc;
return db
.collection(collection)
.doc(doc)
.set({
data: data
});
},
/**
* 查询文档
*
* @param collection 集合
* @param doc 文档_id
* @return {"data": Object, "errMsg": String}
*/
getDoc: function(collection, doc) {
collection = collection || lovc;
return db
.collection(collection)
.doc(doc)
.get();
},
/**
* 部分更新文档
*
* @param collection 集合
* @param doc 文档_id
* @param data 数据
* @return {"stats": Object, "errMsg": String}
*/
update: function(collection, doc, data) {
collection = collection || lovc;
return db
.collection(collection)
.doc(doc)
.update({
data: data
});
},
/**
* 删除文档
*
* @param collection 集合
* @param doc 文档_id
* @return {"stats": Object, "errMsg": String}
*/
remove: function(collection, doc) {
collection = collection || lovc;
return db
.collection(collection)
.doc(doc)
.remove();
}
};
# 存储
云环境提供了免费的 5G 云存储空间,文件上传后每个文件会生成一个 fileID
和一个 https
下载地址,在小程序中,src
、 poster
等属性中都可以直接使用。
- 对存储 API 进行封装
module.exports = {
/**
* 上传文件
*
* @param fileName 文件名
* @param filePath 文件路径
* @return {"errMsg": String, "fileID": String, "statusCode": Number}
*/
upload: function(fileName, filePath) {
return wx.cloud.uploadFile({
cloudPath: fileName,
filePath: filePath
});
},
/**
* 下载文件
*
* @param fileID 文件名
* @return {"tempFilePath": String, "statusCode": Number}
*/
download: function(fileID) {
return wx.cloud.downloadFile({
fileID: fileID
});
}
};
# 云函数
云函数即在云端(服务器端)运行的函数。通过使用云函数,开发者无需购买、搭建服务器,只需编写函数代码并部署到云端即可在小程序端调用,同时云函数之间也可互相调用。云函数本身是一个本地定义的 JS 方法,上传并部署到指定云环境中,运行在云端的 NodeJS
中。云函数调用时用户的 openid
会作为请求参数进行调用,从而能够与微信鉴权无缝结合。
# 创建云函数
- 本地环境: 云函数依赖本地
NodeJS
环境,可以通过npm
对服务进行扩展 - REST 调用:传统小程序开发的后端服务需要合法域名,这就为小程序开发带来了额外成本,而云函数通过
http
调用服务是无需鉴别域名的,因此可以通过小程序端调用云函数,云端调用远程服务的方式拓展低成本的REST服务
- tcb-router:tcb-router是由腾讯官方提供的基于
koa
风格的云函数轻量级类路由库,主要用于优化服务端函数处理逻辑。云函数允许创建的数量是有限的,通过tcb-router
可以对服务数量进行拓展 - 选择小程序中的
cloudfunctions
文件夹,新建Node.js云函数
,本地会自动生成云函数模板文件和package.json
,并安装wx-server-sdk
依赖 - 选择云函数所在文件夹,进入终端,通过
npm
安装所需依赖 - 编辑云函数(REST 服务)
// 云函数入口文件
const cloud = require("wx-server-sdk");
const rp = require("request-promise");
cloud.init();
// 云函数入口函数
exports.main = async (event, context) => {
if (event.method === "GET") {
return get(event.url, event.data);
} else if (event.method === "POST") {
return post(event.url, event.data);
}
};
/**
* GET请求
*
* @param url REST服务地址
* @param data 请求参数
*/
function get(url, data) {
if (undefined !== data && "" !== data) {
let queryString = "?";
for (let key in data) {
if (!queryString.endsWith("?")) {
queryString = queryString.concat("&");
}
queryString = queryString
.concat(key)
.concat("=")
.concat(data[key]);
}
url = url.concat(queryString);
}
return rp(url);
}
/**
* POST请求
*
* @param url REST服务地址
* @param data 请求参数
*/
function post(url, data) {
return rp({
uri: url,
method: "POST",
body: data,
json: true
});
}
- 编辑云函数(tcb-router)
// 云函数入口文件
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 表示该中间件会适用于所有的路由
app.use(async (ctx, next) => {
// 创建返回data对象
ctx.data = {};
// 执行下一中间件
await next();
});
// 路由为数组表示,该中间件适用于多个路由
// app.router(['x', 'y'], async (ctx, next) => {
// ctx.data.from = 'cloud';
// await next();
// });
app.router(
"router",
async (ctx, next) => {
ctx.data.openId = event.userInfo.openId;
await next();
},
async ctx => {
ctx.data.other = event.other;
// ctx.body 返回数据到小程序端
ctx.body = {
code: 0,
data: ctx.data
};
}
);
return app.serve();
};
- 选择云函数所在文件夹,
上传并部署
云函数,在小程序端可以通过云函数调用访问服务 - 对云函数 API 进行封装
module.exports = {
/**
* 通过云函数访问服务
*
* @param name 云函数名
* @param params 参数
* @return {"errMsg": String, "result": Object, "requestID": String}
*/
call: function(name, params) {
return wx.cloud.callFunction({
name: name,
data: params
});
},
/**
* 通过tcb-router访问服务
*
* @param url router路径
* @param params 参数
* @return {"errMsg": String, "result": String, "requestID": String}
*/
tcbRouter: function(url, params) {
params.$url = url;
return this.call("router", params);
},
/**
* 云函数-REST访问服务
*
* @param url REST服务地址
* @param 请求方法
* @param params 请求参数
* @return {"errMsg": String, "result": String, "requestID": String}
*/
rest: function(url, method, params) {
return this.call("rest", {
url: url,
method: method,
data: params
});
}
};
# 开发多媒体服务
# 引入 iView Webapp
引入iView Webapp作为小程序的前端框架。
- 进入的
iView Webapp
的GitHub,下载项目
# clone iView Weapp
git clone https://github.com/TalkingData/iview-weapp.git
cd iview-weapp
# 安装依赖
npm install
# 编译组件,便于打开到模拟器查看
npm run dev
- 将
dist
文件夹拷贝到小程序项目的miniprogram
文件夹下 - 通过开发工具打开
iView Webapp
项目下的examples
目录,预览iView Webapp
提供的组件。
# 封装媒体 API
与云开发原生支持 Promise
不同,原生小程序 API 只能通过回调获取返回值,对于项目中使用的相关小程序的媒体 API 进行封装,避免多层回调。
module.exports = {
/**
* 选择照片
*
* @return {"errMsg": String, "tempFilePaths": Array(String), "tempFiles": Array(Obejct)}
*/
chooseImage: function() {
return new Promise(function(resolve) {
wx.chooseImage({
count: 9,
sizeType: ["original", "compressed"],
sourceType: ["album"],
success: res => {
resolve(res);
}
});
});
},
/**
* 全屏预览照片
*
* @param current 当前显示图片的链接
* @param urls 需要预览的图片链接列表(云文件ID @since 2.2.3)
*/
previewImage: function(current, urls) {
return new Promise(function(resolve, reject) {
wx.previewImage({
current: current,
urls: urls,
success: res => {
resolve(res);
},
fail: err => {
reject(err);
}
});
});
},
/**
* 选择视频
*
* @return {"errMsg": String, "tempFilePath": String, "thumbTempFilePath": String, "duration": Number, "width": Number, "height": Number, "size": Number}
*/
chooseVideo: function() {
return new Promise(function(resolve, reject) {
wx.chooseVideo({
sourceType: ["album"],
compressed: true,
maxDuration: 60,
success: res => {
resolve(res);
},
fail: err => {
reject(err);
}
});
});
},
/**
* 保存video到本地
*
* @param filePath 文件路径
* @return {"errMsg": String}
*/
saveVideo: function(filePath) {
return new Promise(function(resolve, reject) {
wx.saveVideoToPhotosAlbum({
filePath: filePath,
success: res => {
resolve(res);
},
fail: err => {
reject(err);
}
});
});
},
/**
* 构造媒体存储标记
*
* @param index 文件类型 0:视频,1:声音,2:照片
* @param fileID 云文件ID
* @param author 上传者
* @return Object
*/
mark: function(index, fileID, author) {
return {
index: index,
fileID: fileID,
author: author
};
}
};
# 页面布局
# 底部标签栏
新建一个 Page
,在 json 配置文件中引入TabBar组件:
"usingComponents": {
"i-tab-bar": "../../dist/tab-bar/index",
"i-tab-bar-item": "../../dist/tab-bar-item/index"
}
新建 4 个 tab
页和一个增加按钮区域,指定每个 tab-item
的 key
,绑定 TabBar
的 bindchange
事件来监听点击标签页切换事件,指定current
属性可以切换各标签的 icon
。这 4 个标签实际是写在同一个 Page
中的,当切换标签时,通过 wx:if
控制各个区域是否显示。
<i-tab-bar
i-class="bar-high"
fixed="true"
current="{{ bar }}"
bindchange="clickTabBar"
>
<i-tab-bar-item
key="video"
icon="live"
current-icon="live_fill"
title="视频"
></i-tab-bar-item>
<i-tab-bar-item
key="audio"
icon="play"
current-icon="play_fill"
title="声音"
></i-tab-bar-item>
<i-tab-bar-item
key="add"
icon="add"
current-icon="add"
color="#ffff00"
></i-tab-bar-item>
<i-tab-bar-item
key="notice"
icon="remind"
current-icon="remind_fill"
title="通知"
></i-tab-bar-item>
<i-tab-bar-item
key="mine"
icon="mine"
current-icon="mine_fill"
title="我的"
></i-tab-bar-item>
</i-tab-bar>
TabBar
组件的高度为 50px
,内容区域可以设置 padding-bottom: 50px;
来避免底部标签页遮挡内容。
# 用户登录
button
组件的 open-type
(微信开放能力)提供了用户主动登录的方式。
<button
open-type="getUserInfo"
bindgetuserinfo="onGetUserInfo"
class="user-avatar"
style="background-image: url({{avatarUrl}})"
></button>
处理用户登录事件,获取用户 openid 用于判断是否登录,查询时作为条件等:
/**
* 用户登录获取用户信息
*/
onGetUserInfo: function(e) {
let userInfo = e.detail.userInfo
// 项目模板中默认提供的云函数,可用于获取用户openid
cloud.call('login', {}).then(res => {
let openid = res.result.openid
userInfo.openid = openid
// 将用户信息写入本地缓存
wx.setStorage({
key: 'userInfo',
data: userInfo,
})
// 设置openid
this.setData({
openid: openid
})
}).catch(err => {
console.log(err)
})
// 设置登录头像
this.setData({
avatarUrl: userInfo.avatarUrl
})
}
# 遮罩层组件
上传文件等场景中,如果不希望用户在当前操作完成前有其它动作,可以弹出遮罩层保护用户界面。
- 新增小程序组件:在
miniprogram
目录下新建components/mask
文件夹,选择新增Component
,默认生成类似Page
的 4 个文件。 - 编辑遮罩层组件 mask.wxml
<view class="mask" hidden="{{mask}}"></view>
mask.wxss
.mask {
width: 100%;
height: 100%;
position: fixed;
background-color: #999;
z-index: 999;
top: 0;
left: 0;
opacity: 0.1;
}
mask.js
/**
* 组件的属性列表
*/
properties: {
hidden: {
type: Boolean,
value: true
}
}
- 引入组件
编辑
Page
的 json 配置文件:
"usingComponents": {
"mask": "../../components/mask/mask"
}
编辑 Page
的 wxml 文件:
<!-- 遮罩对象 -->
<mask hidden="{{!mask}}"></mask>
在 js 文件中通过指定 mask 属性来打开/关闭遮罩层。
# 消息提示
在 json 配置文件中引入Toast组件。
引入 $Toast
对象:
const { $Toast } = require("../../dist/base/index");
/**
* 弹出toast提示
*
* @param content loading显示内容
* @param type toast类型 default、success、warning、error、loading
* @param modal 遮罩层是否打开
* @param duration 持续时间,单位s,0为不自动关闭,需调用 $Toast.hide() 方法手动关闭
* @param mask toast是否可关闭
*/
popToast: function(content, type, modal, duration, mask) {
duration = duration || 0
mask = mask || false
modal = modal || false
$Toast({
content: content,
type: type,
duration: duration,
mask: mask
})
// 打开遮罩层
this.setData({
hidden: modal
})
},
/**
* 关闭toast提示
*/
hideToast: function() {
$Toast.hide()
// 关闭遮罩层
this.setData({
hidden: true
})
}
# 处理多媒体上传
为了将上传文件与上传用户相关联、便于查找上传的文件,将数据库和存储结合使用。即将文件上传到存储后,获取返回的 fileID
,与上传者信息、文件类型、文件描述等一起写入数据库。
在 json 配置文件中引入ActionSheet组件。
<i-action-sheet visible="{{ showAdd }}" actions="{{ addActions }}" show-cancel bind:cancel="cancelAdd" bind:click="handleChooseMedia" />
处理 TabBar
的点击事件,当选择的是新增按钮时,弹出 ActionSheet
选项。
/**
* TabBar点击事件
*/
clickTabBar({
detail
}) {
let key = detail.key
if ('add' === key) {
// 点击添加按钮弹出上传选项
this.setData({
showAdd: true
})
} else {
this.setData({
bar: key
})
}
}
ActionSheet
点击事件绑定了 handleChooseMedia
函数:
handleChooseMedia({
detail
}) {
// ActionSheet隐藏
this.cancelAdd()
// 登录提示
if (this.data.openid === '') {
this.popToast('请先登录~', 'warning', true, 3, true)
return
}
// actions选项的索引,从0开始
const index = detail.index;
let that = this;
if (0 === index) {
// 视频
media.chooseVideo().then(res => {
that.popToast('上传中...', 'loading')
// 文件
let tempFilePath = res.tempFilePath
let tempFilename = that.splitFileName(tempFilePath)
// 缩略图文件(目前微信开发工具有这个字段,真机无)
let thumbTempFilePath = res.thumbTempFilePath
// 上传视频文件
cloud.upload(tempFilename, tempFilePath).then(res => {
let mark = media.mark(index, res.fileID, that.data.nickName)
if (thumbTempFilePath) {
// 缩略图文件
let thumbTempFilename = that.splitFileName(thumbTempFilePath)
// 上传缩略图文件
cloud.upload(thumbTempFilename, thumbTempFilePath).then(res => {
let thumb = res.fileID
mark.thumb = thumb
// 写入数据库
cloud.add(mark).then(res => {
let id = res._id
// 跳转到编辑视频详情页面
wx.navigateTo({
url: '../editVideo/editVideo'.concat('?thumb=').concat(thumb)
.concat('&id=').concat(id)
})
that.hideToast()
}).catch(err => {
console.log(err)
})
})
} else {
// 未生成缩略图,使用默认图片
let thumb = 'cloud://test-d518bb.7465-test-d518bb/system/default-poster.jpg'
mark.thumb = thumb
// 写入数据库
cloud.add(mark).then(res => {
that.hideToast()
let id = res._id
wx.navigateTo({
url: '../editVideo/editVideo'.concat('?thumb=').concat(thumb)
.concat('&id=').concat(id)
})
}).catch(err => {
console.log(err)
})
}
}).catch(err => {
console.log(err)
})
}).catch(err => {
console.log(err)
})
} else if (1 === index) {
// 照片
media.chooseImage().then(res => {
res.tempFilePaths.forEach(function(filePath, i) {
that.popToast('上传中...', 'loading')
let filename = that.splitFileName(filePath)
cloud.upload(filename, filePath).then(res => {
let mark = media.mark(index, res.fileID, that.data.nickName)
// 写入数据库
cloud.add(mark).then(res => {
that.loadAlbum()
that.hideToast()
}).catch(err => {
console.log(err)
})
}).catch(err => {
console.log(err)
})
})
}).catch(err => {
console.log(err)
})
}
}
# 视频列表页
在小程序中,video
是原生组件,层级很高,作为列表元素时会遮挡底部标签栏,因此视频列表以缩略图列表的形式呈现,列表区域设置 padding-bottom: 50px;
来保证能够完全呈现。
点击缩略图,可以跳转到新页面观看视频和具体信息。
- 视频列表区域
<view wx:for="{{videoFiles}}" wx:key="{{item.id}}" class='video-image-warpper'>
<image class='video-image' mode='aspectFill' src='{{item.thumb}}' bindtap='playVideo' data-index="{{item.id}}"></image>
<view class="video-like-icon">
<i-icon type="like_fill" size="28" color="#ff5050" />
<text>99k+</text>
</view>
<i-icon class="play-icon" type="play" size="28" color="#fff" />
</view>
</view>
- 加载视频列表
/**
* 加载视频
*/
loadVideo: function() {
let that = this
cloud.query({
// 查询该用户上传的文件
// _openid: that.data.openid,
index: 0
}).then(res => {
let files = []
res.data.forEach(function(e, i) {
files.push({
id: e._id,
fileID: e.fileID,
thumb: e.thumb,
author: e.author,
desc: e.desc
})
})
that.setData({
videoFiles: files
})
}).catch(err => {
console.log(err)
})
}
- 视频列表页效果图
# 视频详情页
- 点击缩略图,跳转到视频详情页
跳转页面传递参数,包括视频和缩略图的
fileID
、视频信息等。
playVideo: function(e) {
let video
files.forEach(function(element, i) {
if (e.currentTarget.dataset['index'] === element.id) {
video = element
return
}
})
if (undefined !== video) {
wx.navigateTo({
url: '../video/video?fileID='.concat(video.fileID)
.concat('&thumb=').concat(video.thumb)
.concat('&author=').concat(video.author)
.concat('&desc=').concat(video.desc == undefined ? '' : video.desc)
})
}
}
- 编辑详情页 视频参数详见官方文档。
<video
id="video"
src="{{fileID}}"
poster="{{thumb}}"
object-fit="cover"
direction="0"
bindended="onVideoEnd"
></video>
- 保存视频
/**
* 保存视频
*/
saveVideo: function() {
let that = this
cloud.download(this.data.fileID).then(res => {
// 临时文件路径
let tempFilePath = res.tempFilePath
media.saveVideo(tempFilePath).then(res => {}).catch(err => {
console.log(err)
})
}).catch(err => {
console.log(err)
})
}
- 视频详情页效果图
# 相册
- 编辑相册展示区域
图片参数详见官方文档。
图片裁剪、缩放模式选择
mode='aspectFill'
,从而保持纵横比缩放图片,只保证图片的短边能完全显示出来。
<image
class="album-image"
mode="aspectFill"
wx:for="{{albumFiles}}"
wx:key="{{item.id}}"
src="{{item.fileID}}"
bindtap="imageToPreview"
data-current="{{item.fileID}}"
/>
- 加载相册
/**
* 初始化album
*
* TODO 分页加载
*/
loadAlbum: function() {
let that = this
cloud.query({
index: 1
}).then(res => {
let files = []
res.data.forEach(function(e, i) {
files.push({
id: e._id,
fileID: e.fileID
})
})
that.setData({
albumFiles: files
})
}).catch(err => {
console.log(err)
})
}
- 相册效果图
- 预览图片 图片绑定点击事件,生成预览图片:
/**
* 相册图片点击全屏预览
*/
imageToPreview: function(e) {
// 被点击图片云文件ID
let current = e.currentTarget.dataset['current']
// 所有图片云文件ID
let fileIDs = []
this.data.albumFiles.forEach(function(e, i) {
fileIDs.push(e.fileID)
})
media.previewImage(current, fileIDs)
}
- 预览效果图