口袋故事(小程序)开发文档


作者: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();
});


最后更新时间: 7/7/2019, 9:55:38 PM