口袋故事(小程序)开发文档
作者:Seiya
时间:2019年05月11日
项目介绍
口袋故事是一个儿童有声故事平台,面向0-12岁孩子和家长共用的音频播放类应用,汇集海量、正版、原创儿童音频内容,供家长选择,内容涵盖儿童故事、儿歌、英语、国学、广播剧等多个类别。同时支持在手机、平板、TV和车载多终端上使用。
技术栈
glup
:项目构建工具JSDoc
:自动化注释文档生成工具ESlint
:规范代码及错误检查LESS
:css 预编译处理器ES6
:JavaScript 语言的新一代标准
项目目录结构
├── CHANGELOG.md # 更新日志
├── README.md # 说明文档
├── apidoc.json # api文档生成配置
├── gulpfile.js # glup配置文件
├── jsdoc.json # JSDoc配置文件
├── package.json # npm的配置文件
├── package-lock.json
├── dist # 编译后的输出目录
└── src # 当前项目的源码
├── project.config.json # 小程序项目配置文件
└── miniprogram
├── app.js # 小程序入口
├── app.json # 小程序全局配置文件
├── app.less # 小程序全局样式
├── common # 公共类
│ ├── apiURLs.js # api地址统一管理
│ ├── apidoc.js # api说明
│ └── config.js # 统一配置文件
├── components # 小程序组件
│ └── login
│── template # 模板目录
├── pages # 视图入口
│ ├── awesome
│ ├── index # 首页
│ ├── category # 分类页
│ ├── mine # 个人中心页
│ ├── categoryList # 分类列表页
│ ├── detail # 音频详情页
│ ├── eventDetail # 活动详情页
│ ├── mystory # 我的故事页
│ ├── player # 播放控制器
│ ├── recent # 最近的播放页
│ ├── search # 搜索页
│ ├── topic # 话题页
│ ├── unlock # 解锁页
│ ├── zhuliEvent # 助力页
│ └── zhuliEventlist # 助力列表
├── share # 统一api接口封装管理
├── spreadpack # 分包处理
├── static # 静态资源
│ ├── images
│ └── style
├── test # 单元测试
└── utils # 工具类
├── http.js
├── moment.min.js
├── query-parse.js
└── validate.js
开发历程
- http请求封装:
/**
* get
* @param {string} url 请求地址
* @param {object} [data] 请求body对象,会自动转为query字符串
* @param {object} [extend] 其他选项
*/
get: function (url, data = {}, ...extend) {
data.caller = config.caller;
data.prd_ver = config.prdVer;
let promise = new Promise((resolve, reject) => {
let query = queryParse.json2query(data);
wx.request({
url: `${url}?${query}`,
success: resolve,
fail: reject,
...extend
});
});
return promise;
},
/**
* post
* @param {string} url 请求地址
* @param {object} [data] 请求body对象
* @param {object} [extend] 其他选项
*/
post: function (url, data = {}, ...extend) {
data.caller = config.caller;
data.prd_ver = config.prdVer;
let promise = new Promise((resolve, reject) => {
wx.request({
method: 'POST',
url: `${url}`,
data: data,
success: resolve,
fail: reject,
...extend
});
});
return promise;
},
- 统一播放控制封装:
/**
* 播放/继续
*
* 音频信息需要包含:
* playingChapterId 章节id
* playingChapterIndex 章节索引
* chapters[i].hasAuth 章节是否有授权
*
* @inner
* @author seiya
* @param {object} app 全局app对象
* @param {object} [audioInfo=null] 要播放的音频信息,不填则表示继续播放
*/
let play = function (app, audioInfo = null) {
app.globalData.isPlaying = true;
let userInfo = app.globalData.userInfo || {};
if (!audioInfo) {
audioManager.play();
return;
}
let playingAudio = app.globalData.playingAudio;
if (playingAudio && (playingAudio.playingChapterId == audioInfo.playingChapterId) && !app.globalData.isStop) {
audioManager.play();
return;
}
audioManager.stop();
audioManager.title = audioInfo.audio_name;
audioManager.coverImgUrl = audioInfo.audio_icon_original + '-s600';
audioManager.src = audioInfo.chapters[audioInfo.playingChapterIndex].audio_filename;
// 将当前正在播放的音频存入全局
app.globalData.playingAudio = JSON.parse(JSON.stringify(audioInfo));
app.globalData.isStop = false;
audioManager.play();
// 发送日志
// 将正在播放的音频写入最近播放的缓存
let recentPlayed = wx.getStorageSync(config.recentPlayed) || [];
recentPlayed.unshift(audioInfo);
let hash = {};
// 去重
recentPlayed = recentPlayed.reduce((item, next) => {
hash[next.audio_id] ? '' : hash[next.audio_id] = true && item.push(next);
return item;
}, []);
// 取前20条写入缓存
recentPlayed = recentPlayed.slice(0, 20);
wx.setStorageSync(config.recentPlayed, recentPlayed);
};
/**
* 暂停
* @inner
* @author tz
* @param {object} app 全局app对象
*/
let pause = function (app) {
app.globalData.isPlaying = false;
audioManager.pause();
};
/**
* 停止播放
* @inner
* @author tz
* @param {object} app 全局app对象
*/
let stop = function (app) {
app.globalData.isPlaying = false;
app.globalData.isStop = true;
audioManager.stop();
};
/**
* 跳转播放
* @inner
* @author tz
* @param {number} currentTime 跳转到指定时间
*/
let seek = function (currentTime) {
audioManager.seek(currentTime);
};
开发问题汇总
1. 小程序登录方案
分享后的不同入口的解决方案(说明:方案针对的是必须授权才能使用的小程序)
前置方案:
设定一个index页(引导页/起始页/中转页,我这里设置为首页),在程序里不同页面分享的时候path统一为index,通过query来标识来源,这样程序App入口只有index页,在index页来管理用户登录授权操作等逻辑,登录成功后在根据query来跳转页面。
后置方案:
页面分享时还是是分享各自path,程序入口处也不提供提供拦截功能,先能先进入对应页面。封装一个登录组件和一个统一的调用组件的全局函数,在进入需要登录的页面时,通过调用这个全局函数,控制一下登录组件的显示即可来控制授权和掉登录的情况。
参考资料:
2. 小程序缓存方案
为了减少小程序的 http 请求,节省服务器资源,提高加载速度,提升用户体验,将采用本地缓存策略,提高小程序响应速度,以及断网环境下的离线使用。每次获取新数据的时候,记录下当前的时间戳,将获取的时间戳与获取的数据封装到一起。当需要重新获取数据时,通过对比时间戳来判断本地数据是否过期,若数据过期则重新获取数据。本地缓存数据包括:
热数据(首页热门音频数据、热门搜索数据)
静态数据(二级导航配置数据)
列表数据(播放列表数据)
- 具体数据封装案例(以首页banner数据为例):
if (bannerData && currentTime < bannerData.expires) {
console.log('载入banner缓存...');
_this.setData({
bannerList: bannerData.list,
});
} else {
console.log('无缓存数据或已过期,请求banner数据...');
_this.getBannerData();
}
{
bannerList: {...},
expires: 1557892761561
}
3. 小程序分包加载方案
由于小程序客户端最大为2M,在某些情况下,需要突破这个大小限制。微信提供了分包的方案,具体配置如下:
- 项目目录结构如下:
├── app.js
├── app.json
├── app.wxss
├── packageA # 分包 A
│ └── pages
│ ├── market
│ └── marketCb
├── packageB # 分包 B
│ └── pages
│ ├── market
│ └── marketCb
├── pages # 主包
│ ├── index
│ └── logs
└── utils
- 全局配置:
"subpackages": [
{
"root": "packageA",
"name": "spread",
"pages": [
"pages/market/market",
"pages/marketCb/marketCb"
]
},
{
"root": "packageB",
"name": "banner",
"pages": [
"pages/market/market",
"pages/marketCb/marketCb"
]
}
]
- 分包预下载配置:
"preloadRule": {
"pages/index": {
"network": "all",
"packages": ["spread"]
},
"mine/index": {
"network": "wifi",
"packages": ["banner"]
}
}
4. 数据埋点方案
封装一个全局的函数,当需要记录埋点时,调用此函数,并传入参数,即可完成埋点操作,主要逻辑后端完成。
- 埋点规范(部分):
动作名称 | 备注 |
---|---|
enter | 进入页面 |
click | 点击按钮 |
play | 播放音频 |
playEnd | 播放结束 |
页面 | 动作 | 数据 | 备注 |
---|---|---|---|
启动页 | enter | refer、ch | 入口、渠道 |
详情页 | click | unlockPop_Close、audioId | 解锁音频、音频ID |
详情页 | click | storyInvite、audioId | 邀请好友助力、音频ID |
详情页 | click | selfUnlockPop、audioId | 弹窗提示、音频ID |
详情页 | click | selfUnlock、audioId、chapterId | 本人解锁音频、音频ID、章节ID |
- 统一日志提交函数封装:
/**
* 发送日志
* @param {string} eventName 事件名
* @param {object} logObject 日志对象
* @param {number | string} userId 用户id
* @returns {promise}
*/
let sendTraceLog = function (eventName, logObject = {}, userId = '') {
let promise = new Promise((resolve, reject) => {
let log = {};
log.timestamp = Math.floor(new Date().getTime() / 1000);
log.bparams = [];
log.bparams[0] = logObject;
let bparams = log.bparams[0];
bparams.user_id = userId;
bparams.event = eventName;
log.bparams = JSON.stringify(log.bparams);
http.get(api.traceUrl, log).then(res => {
}).catch(err => {
console.error('发送日志失败:', err);
});
});
return promise;
};
- 提交埋点数据:
traceService.sendTraceLog(
"playEnd",
{
audio_id: app.globalData.playingAudio.audio_id,
chapter_id: app.globalData.playingAudio.playingChapterId,
},
userId
)
5. 小程序版本升级方案
updateManager.onCheckForUpdate(res => {
console.log('是否有新版本:', res.hasUpdate);
});
updateManager.onUpdateReady(() => {
wx.showModal({
title: '更新提示',
content: '新版本已经准备好啦,是否重启小程序?',
success(res) {
if (res.confirm) {
updateManager.applyUpdate();
}
}
});
});
6. 页面动态配置
客户端根据后端返回的类型,对页面二级导航进行动态配置,如下:
- dom结构:
<view class="entry idy-flex">
<view
class="entry-item"
wx:for="{{pageConfig.navigation}}"
wx:for-item="item"
wx:for-index="index"
wx:key="id"
>
<view
class="entry-item-btn"
wx:if="{{item.link_type == 0}}"
bindtap="openServiceModel"
/>
<navigator
class="entry-item-nav"
hover-class="none"
wx:if="{{item.link_type == 1}}"
url="{{item.link}}"
/>
<view
class="entry-item-view"
wx:if="{{item.link_type == 2}}"
data-link="{{item.link}}"
bindtap="goOtherApp"
/>
<view class="entry-item-title">{{item.title}}</view>
</view>
</view>
- 服务端返回json格式:
"navigation": [
{
"id": "13",
"page_type": "navigation",
"title": "今日必听",
"icon": "http:*****.png",
"link": "/pages/detail/detail?audio_id=***&audio_name=***",
"link_type": "1",
"status": "0",
"create_time": "2019-03-14 10:07:53",
"update_time": "2019-03-14 10:07:53"
},
{
"id": "14",
"page_type": "navigation",
"title": "领福利",
"icon": "http:*****.png",,
"link": "",
"link_type": "0",
"status": "0",
"create_time": "2019-03-14 10:10:33",
"update_time": "2019-03-14 10:10:33"
},
{
"id": "15",
"page_type": "navigation",
"title": "跳转小程序",
"icon": "http:*****.png",,
"link": "appId=***&path=pages/index/index&extraData=***",
"link_type": "2",
"status": "0",
"create_time": "2019-03-14 10:12:15",
"update_time": "2019-03-14 10:12:15"
}
]
7. iPhone X系列机型适配
iPhone X系列机型由于没有了home实体按钮,需要做针对性的适配。在小程序启动的时候做设备检测,并全局保存,然后就能够通过这个全局变量去控制iPhone X机型的适配,如下:
- 设备检测:
wx.getSystemInfo({
success: function (res) {
console.log(res)
if (res.brand=="iPhone" && res.statusBarHeight > 20) {
that.globalData.isIPX = 1;
}
if (res.platform == "devtools") {
//PC
}
else if (res.platform == "ios") {
//IOS
that.globalData.iosCla = 1;
}
else if (res.platform == "android") {
//android
}
}
})
- dom结构:
<view class="isIPX" wx:if="{{isIPX}}"></view>
- css样式:
background: #fff;
width: 100%;
height: 68rpx;
8. 音频播放进度条实现方案
使用微信小程序自带的<slider>
组件实现进度条,主要有几个关键点:播放进度自动控制、点击事件控制、拖动事件控制、播放结束监听,下面是实现过程:
- DOM结构:
<slider
class="footer-slide"
bindchange="sliderChange" # 点击进度条事件
bindchanging="sliderChanging" # 拖动进度条事件
value="{{progress}}" # 进度条控制
block-size="12" # 滑块大小
block-color="#FEDC42" # 滑块颜色
backgroundColor="#F4F4F8" # 进度条背景色
activeColor="#FEDC42" # 进度条活动颜色
/>
- 播放进度自动控制:
需要通过 <slider>
组件上的 value
属性来实现播放过程中的进度条自动控制。先给 value
属性做数据绑定,然后通过微信提供的 getBackgroundAudioManager
API获取到音频对象实例,通过此实例上的 onTimeUpdate
方法监听音频播放进度,从而实现进度条的自动控制。
// 获取全局唯一的背景音频管理器
let audioManager = wx.getBackgroundAudioManager();
audioManager.onTimeUpdate(() => {
// 拖动进度条时不更新状态(体验优化)
if (_this.data.isSeeking) {
return;
}
let currentTime = audioManager.currentTime; // 当前音频的播放位置(单位:s)
let duration = audioManager.duration // 当前音频的长度(单位:s)
let progress = currentTime / duration || 0;
this.setData({progress: progress * 100});
})
- 点击事件控制:
从事件对象中可以获取到 value
属性的值,处理后可以得到需要调整播放的时间点,调用 seek
方法实现音频跳转,同时更新当前播放时间。
sliderChange: function (e) {
let _this = this;
let time = e.detail.value / 100 * _this.data.totalTime.value;
let playingTimeLabel = _this.parseTimeToLabel(time);
_this.seek(time);
_this.setData({
isSeeking: false,
playingTime: {
value: time,
label: playingTimeLabel
}
});
}
- 拖动事件控制:
拖动过程中,只实现当前时间的实时更新,拖动结束后会自动触发 bindchange
事件,通过 bindchange
事件中 seek
方法实现自动跳转。
sliderChanging: function (e) {
let _this = this;
let time = e.detail.value / 100 * _this.data.totalTime.value;
let playingTimeLabel = _this.parseTimeToLabel(time);
_this.setData({
isSeeking: true,
playingTime: {
value: time,
label: playingTimeLabel
}
});
},
- 监听播放结束:
audioManager.onEnded(() => {
// 修改播放状态
app.globalData.isPlaying = false;
app.globalData.isStop = true;
// 更新播放状态
this.setData({isPlaying: false,});
// 播放下一首
this.playNext();
});