微博上经常会看到一些模仿名人的朋友圈截图,比如民国时期的文学大家朋友圈,春秋战国时期思想家的朋友圈。因此萌发了做一个定制微信朋友圈截图的小程序的想法,当然也顺便熟悉小程序的框架及开发流程。

功能说明

界面按照微信朋友圈布局,但是并没有完全严格参照,边距以及长宽比可能略有不同。

功能开发分为两个阶段:第一阶段实现定制朋友圈并截图的功能,第二阶段加入账户授权,获取用户信息,分析用户数据。

第一阶段的主要功能:

  1. 设置本人封面图片;
  2. 设置本人头像;
  3. 设置发朋友圈者头像;
  4. 设置发朋友圈名字;
  5. 设置朋友圈内容(包含文字和图片);
  6. 设置朋友圈发送时间;
  7. 设置点赞人列表;
  8. 设置朋友圈回复对话(包含回复人名称与回复文字);
  9. 截屏生成图片并保存。

最终呈现效果见下图

效果图

功能开发

此部分阐述如何实现小程序中一些关键功能。 开发微信小程序需要一点预备知识:

  • 前端语言:HTML(用于页面布局)
  • 版面样式:CSS(用于指定页面中元素的样式)
  • 脚本语言:javascript(用于处理页面逻辑)
  • 数据格式:json(用于数据传递)

如果只有简单的前端(比如本文的小程序),懂上面四点就够了,如果需要与服务器端的交互,还涉及后台的开发。

目录结构

沿用小程序默认目录结构。

  • asserts
  • icons //存放图标
  • nav_img //存放页面默认图片
  • component //存放自定义组件
  • comments //替换回复的对话框
  • dialoginput //替换文字的对话框
  • doc
  • pages
  • index //小程序首页
    • index.js
    • index.json
    • index.wxml
    • index.wxss
  • logs
  • utils
  • app.js
  • app.json
  • app.wxss
  • project.config.json
  • sitemap.json

NOTE: 小程序代码打包上传会将此目录中所有文件都上传,且为了保证加载速度,大小必须控制在2M以内。之前在目录/doc下放了一些说明文档和图片,总大小大于2M,导致无法上传代码。猜测因为小程序中使用的都是解释性语言,不存在编译一说,大概默认此路径下所有的文件都与小程序的运行有关。(可以在微信开发者工具的 版本管理 里,找到每一次的提交记录。)

js/wxml/wxss三者间的关系

在小程序中,wxml, wxss为视图层,而js则是逻辑层。 wxml的每一个元素都可设置一个对应的class,在wxss中对每个class都可进行样式定义,java中的数值可以直接传入wxml中。

js传值给wxml

数据绑定

java中的参数值可以通过setData直接传入wxml,wxml中以 ** 的形式访问数据,详见 **官方文档

<!--pages/index/index.wxml-->
<image class="cover-image" mode="aspectFill"  data-count="1"  data-type="cover-image"  bindtap="onChoosePic" src=""/>
// pages/index/index.js
//初始化值
data: {
  coverImage: "../../assets/nav_img/cover.png"
}

//设置新值
var that = this;
that.setData({
    coverImage: tempFilePaths
});

列表渲染

wxml 可以通过wx:forwx:key语句直接访问列表中的每个值。

// pages/index/index.wxml
<view wx:for="">
    <image class="photo-item"  mode="scaleToFill" src=""/>
</view>
// pages/index/index.js
//初始化
data: {
  photos: [
    '../../assets/nav_img/photo1.png', '../../assets/nav_img/photo2.png', 
    '../../assets/nav_img/photo3.png', '../../assets/nav_img/photo4.png', 
    '../../assets/nav_img/photo5.png',
  ],
}

//设置新值
that.setData({
  photos: tempFilePaths
});

条件判断

wxml 中,可以使用wx:if作为条件判断语句。

<!--pages/index/index.wxml-->
<!--判断comments列表是否为空,若不为空,则显示分隔符,若为空,则不不显示分隔符-->
<view wx:if="">
  <view class="div-line"></view><!-- end div-line -->
    <view class="comments" bindtap="onShowDialogComments">
      <view wx:for="">
        <view class="comment-richtext">
          <rich-text nodes="<b></b>"></rich-text>
        </view><!-- end comment-richtext -->
      </view><!-- end for=" -->
    </view><!-- end comments -->
  </view><!-- end comments -->
</view><!-- end if="" -->

本示例中,在朋友圈内容、图片以及回复中均有判断是否为空,其余不作判断。

wxss定义wxml样式

wxss 的样式定义与css类似,具备css大部分特性,对css做了两点扩展:

  • 尺寸单位
  • 样式导入

尺寸单位 小程序提供rpx作为尺寸单位,可以根据屏幕宽度自适应调整,类似android中的dp。所以在wxss中尽量使用rpx作为尺寸单位,可以更好的适应不同分辨率的设备。

样式导入 使用 @import 语句可以导入外联样式表,@import后跟需要导入的外联样式表的相对路径,用 ; 表示语句结束。

页面总体布局

微信小程序的页面布局与其他前端的布局类似,如果之前做过网页、Android、iOS或者flutter的页面开发,小程序的页面布局便不是难事。

小程序的页面布局采用HTML+CSS,可以直接使用大部分CSS样式,在小程序中为wxml+wxss。

本小程序功能简单,只有一个主页面,直接在index中实现。

页面的总体布局示意图见下。

页面总体布局

页面横竖排版

通常手机页面都是以竖排版的方式进行页面布局,由上到下划分为不同的行,再在每行中进行细分。可以在公共样式表(app.wxss)中规定页面的默认竖排版布局,单独的页面直接应用即可。

/**app.wxss**/
.container {
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  box-sizing: border-box;
} 

图片上叠加图片和文字

顶部cover,包括封面,用户头像和用户名称三个元素。

图片上叠加图片和文字

封面图片 cover-imagecover-bar 的子节点, 用户的头像 avatar-img 和名称 avatar-name 则是 subsection-avatar 的子节点, subsection-avatarcover-bar 同级。

cover-bar 的高度设置为510rpx, cover-image 的高度设置为600rpx, subsection-avatarcover-bar 之后的同级节点,起始位置为511rpx,这样 subsection-avatar 中的元素即可覆盖在cover-image之上显示。

图片或文字的重叠部分,可能对点击效果有影响,因此需要将 avatar-imgavatar-name 设置在 cover-image 之上,即,z-index: 1; ,这样在点击用户头像和名称时,才会找到该元素对应的事件处理函数。

小程序官方文档中说明,后插入的原生组件可覆盖之前的原生组件,目前看来显示确实覆盖了,但事件响应并不保证。(模拟器中点击响应正常,但在手机中,头像重叠部分点击无响应。)

主要代码见下:

<!-- pages/index/index.wxml -->
<view class="container">
  <!-- 顶部Cover -->
  <view class="section-cover">
    <view class="cover-bar">
  	 <image class="cover-image" mode="aspectFill"  data-count="1"   data-type="cover-image"    bindtap="onChoosePic"src=""/>
    </view><!-- end cover-bar -->
    <view class="subsection-avatar">
      <image class="avatar-img" data-type="avatar-img" data-count="1"   bindtap="onChoosePic" src=""/>
      <text class="avatar-name"   id="avatarName"bindtap="onShowDialog"></text>
    </view><!-- end subsection-avatar -->
  </view><!-- end section-cover -->
  ...
</view><!-- end container -->
// pages/index/index.wxss
.cover-bar{
  justify-content: center;
  height: 510rpx;
}
.cover-image{
  width: 100%;
  height: 600rpx;
}
.subsection-avatar{
  display: flex;
  flex-direction: row-reverse;
}
.avatar-img{
  height: 120rpx;
  width: 120rpx;
  margin-right: 30rpx;
  border-radius: 12rpx; 
  z-index: 1;
}
.avatar-name{
  color: #ffffff;
  justify-content: center;
  margin-right: 20rpx;
  align-items: center;
  display: flex;
  font-size: 35rpx;
  z-index: 1;
}

功能实现

文本替换

文本替换是本小程序的主要功能,交互方式为通过点击需要更改的文字,弹出对话框,在对话框中对文字内容进行修改,确定后更新到页面上。

对话框采用自定义样件实现,分为两类,一类为单行文本替换的对话框,例如朋友圈用户名、内容、发布时间、发布地点等信息的文本替换;一类为回复内容的文本替换。

单行对话框

单行文本替换对话框的功能非常简单,只需要替换原有文本;回复消息替换稍微复杂一点,需要增、删、替换文本。

单行文本替换

功能点:

  • 点击文本后,弹出对话框
  • 对话框中显示原有文本
  • 更改原有文本,点击确定后产生界面变化

小程序的原生组件提供三种弹出框:消息提示框、模态对话框和loading提示框。这三种原生弹出框不支持修改,无法满足输入文本的需求。

因此,需要用到 自定义组件 来满足弹出框中输入文本的要求。

创建自定义组件dialoginput

首先,在小程序目录下创建用于存放自定义组件的目录,再在小程序开发工具中,右键自定义组件目录,弹出的菜单中选择“新建 Component”,将新的component命名为dialoginput,则目录中自动创建四个文件:dialoginput.js, dialoginput.json, dialoginput.wxml, dialoginput.wxss

在 dialoginput.json 文件中对自定义组件进行声明

// component/dialoginput/dialoginput.json
{
  "component": true,
  "usingComponents": {}
}
dialoginput的布局和样式

组件的布局及样式与页面类似,在 dialoginput.wxml 与 dialoginput.wxss 中对自定义组件页面进行设计,详细可参考 官方文档

对话框竖排版,由上到下分为三个部分

title, input cell, footer

单行对话框布局

  • title 显示该对话框的标题
  • input cell 输入框,显示已有内容,可输入新内容
  • footer 包含确认和取消两个按键
<!--component/dialoginput/dialoginput.wxml-->
<view class='wx_dialog_container' hidden="">
  <view class='wx-mask'></view>
  <view class='wx-dialog'>
    <view class='wx-dialog-title'></view>
    <view class="input-cell">
      <textarea class='weui-input' bindinput='bindKeyInput' auto-height="true" value=''/>
    </view><!-- end input-cell -->
    <view class='wx-dialog-footer'>
      <view class='wx-dialog-btn' catchtap='_cancelEvent'></view>
      <view class='wx-dialog-btn' catchtap='_confirmEvent'></view>
    </view><!-- end wx-dialog-footer -->
  </view><!-- end wx-dialog -->
</view><!-- end wx_dialog_container -->
dialoginput的构造器

自定义组件的逻辑层,即 diagloginput.js 中的内容,可指定组件的属性、数据、方法等,这里只挑选用到的参数介绍,详细内容参见 官方文档

properties

dialoginput 的对外属性,使用该组件的页面可以在创建组件时向组件传值。

 // component/dialoginput/dialoginput.js
  properties: {
    // 弹窗标题
    title: { // 属性名
      type: String, // 类型(必填),可以是任意类型
      value: '请输入替换内容' // 属性初始值(可选)
    },
    // 输入框提示内容
    hint: {
      type: String,
      value: '例:张三'
    },
    // 弹窗取消按钮文字
    cancelText: {
      type: String,
      value: '取消'
    },
    // 弹窗确认按钮文字
    confirmText: {
      type: String,
      value: '确定',
    } 
  },

data

自定义组件 dialoginput 的内部数据,类似 index .js 中的 data,同样也可以用 setData 改变其值。

// component/dialoginput/dialoginput.js
/**
 * 组件的初始数据
 */
data: {
  // 弹窗显示控制
  isShow: false,
  tapVal:"default",
  onTapBtn:""
},

methods

自定义组件 dialoginput 的方法,包括事件响应函数和自定义方法。

// component/dialoginput/dialoginput.js
/**
 * 组件的方法列表
 */
methods: {
  /*
  * 公有方法
  */
  //隐藏弹框
  hideDialog() {
    this.setData({
      isShow: !this.data.isShow
    })
  },
  //展示弹框
  showDialog(btnName, value) {
    this.setData({
      isShow: !this.data.isShow,
      onTapBtn: btnName,
      tapVal: value
    })
    console.log(this.data.onTapBtn)
  },
  ...
},
组件间的通信

组件间的 基本通信方式 有以下三种:

WXML 数据绑定:用于父组件向子组件的指定属性设置数据,仅能设置 JSON 兼容数据(自基础库版本 2.0.9 开始,还可以在数据中包含函数)。具体在 组件模板和样式 章节中介绍。

事件:用于子组件向父组件传递数据,可以传递任意数据。

如果以上两种方式不足以满足需要,父组件还可以通过 this.selectComponent 方法获取子组件实例对象,这样就可以直接访问组件的任意数据和方法。

本示例中只用到了前两种通信方式。

page index 中多处引用了自定义组件 dialoginput。

数据绑定

在index.wxml中引用自定义组件dialoginput时,向自定义组件传递数据。 hint, cancelText, confirmText 都是在 dialoginput.js properties中定义的属性,id是组件的通用属性。

<!--pages/index/index.wxml-->
<dialoginput 
  id="dialoginput" <!--数据绑定传递id值-->
  hint="例:可以点我" <!--【数据绑定】传递hint值-->
  cancelText="取消" <!--【数据绑定】传递取消按钮的显示内容-->
  confirmText="确定" <!--【数据绑定】传递确定按钮的显示内容-->
  bindcancelEvent="_cancelEvent" <!--【事件】绑定dialoginput中的cancelEvent事件,_cancelEvent为回调函数-->
  bindconfirmEvent="_confirmEvent" <!--【事件】绑定dialoginput中的confirmEvent事件,_confirmEvent为回调函数-->
  >
</dialoginput>
事件

以事件通信,分为监听事件和触发事件两大块。

监听事件

自定义组件可以触发任意的事件,引用组件的页面可以监听这些事件,本示例中用于 dialoginput 组件向 index 页面传递用户输入的文本信息。

首先,index.json 中声明使用 dialoginput 组件。

// pages/index/index.json
{
  "usingComponents": {
    "dialoginput":"/component/dialoginput/dialoginput",
    "commentsinput": "/component/comments/comments"
  }
}

其次,index.wxml 绑定模板中的 confirmEvent 事件,在监听到该事件时回调方法 _confirmEvent() ,该回调函数在 index.js 中定义。

<!-- pages/index/index.wxml -->
<dialoginput 
  ...
  bindcancelEvent="_cancelEvent" <!--【事件】绑定dialoginput中的cancelEvent事件,_cancelEvent为回调函数-->
  bindconfirmEvent="_confirmEvent" <!--【事件】绑定dialoginput中的confirmEvent事件,_confirmEvent为回调函数-->
  >
</dialoginput>
// pages/index/index.js
pages{
//确认事件
  _confirmEvent: function (options) {
    ...
  },
},

参数 options 中可以拿到 idthis.triggerEvent(‘confirmEvent’, myEventDetail)myEventDetail 包含的数据等。

触发事件

在组件 dialoginput 中,点击确定按钮,触发事件 confrimEvent ,将数据传递给 index 页面。

<!-- component/dialoginput/dialoginput.wxml-->
<view class='wx-dialog-btn' catchtap='_confirmEvent'></view>
// component/dialoginput/dialoginput.js
Component({
  properties: {},
  data: {},
  methods: {
    _confirmEvent() {
      ...
      this.triggerEvent('confirmEvent', myEventDetail)//trigger 'confirmEvent'事件,并将myEventDetail传递出去
    }
  }
})
触发对话框

参照 ** js/wxml逻辑交互** 方式实现

index.wxml 使用 bindtap 触发对话框,index.js 的回调函数 onShowDialog() 作逻辑处理。

<!-- pages/index/index.wxml-->
<text class="post-avatar-name" id="postUserName" bindtap="onShowDialog"></text>
// pages/index/index.js
onShowDialog: function(e) {
    console.log(e);
    var btnId = e.currentTarget.id;
    switch (btnId) {//区分被点击文本
      case 'avatarName':
        this.dialoginput.showDialog(btnId, this.data.avatarName);//显示对话框
        break;
      case 'postUserName':
        this.dialoginput.showDialog(btnId, this.data.postUserName);
        break;
     ...
      default:
        console.log('Unknown tap name:' + btn);
        break;
    }
  },
文字替换

确定按钮的回调函数 _confirmEvent() 功能:

  • 判断点击对象
  • 设置新显示文字
  • 隐藏对话框

具体代码见下:

// pages/index/index.js
//确认事件
  _confirmEvent: function (e) {
    var btn = e.detail.btn;
    var val = e.detail.val;
    switch(btn){//判断被点击的文本
      case 'avatarName':
        this.setData({
          avatarName: val
        })
        break;
      case 'postUserName':
        this.setData({
          postUserName: val
        })
        break;
      ...
      default:
        console.log('Unknown txt name:' + btn);
      break;
    }
    this.dialoginput.hideDialog();//隐藏对话框
  },

回复消息替换

除输入区域外,回复消息替换的对话框与单行文本替换对话框几乎一致。

功能点:

  • 点击回复文本后,弹出对话框
  • 对话框中显示原有回复信息,一条一行
  • 可更改原有回复,或删除原有回复,添加新回复,点击确定后产生界面变化

title 和 footer 与 dialoginput 相同,input cell由上到下包含三部分

回复人、回复信息、添加按钮

布局示意图见下

回复消息对话框布局

布局文件见下:

<!-- component/comments/comments.wxml -->
<view class='wx_dialog_container' hidden="">
  <view class='wx-mask'></view>
  <view class='wx-dialog'>
    <view class='wx-dialog-title'></view>
    <view wx:for="">
      <view class="input-cell">
        <textarea class='input-cell-name' id="inputCellName" bindinput='bindKeyInput' auto-height="true" value='' data-index=""/>
        <textarea class='input-cell-msg' id="inputCellMsg" bindinput='bindKeyInput' auto-height="true" value='' data-index=""/>
        <view class="block-btn-add">
          <image class="btn-add" src="../../assets/icons/add.svg" mode="aspectFill" bindtap="onAddNewLine" data-index=""/>
        </view>
      </view>
    </view>
    <view class='wx-dialog-footer'>
      <view class='wx-dialog-btn' catchtap='_cancelEvent'></view>
      <view class='wx-dialog-btn' catchtap='_confirmEvent'></view>
    </view>
  </view>
</view>

如果用户删除了某一行的内容,则页面上会相应减少一行。特别的,如果没有comment,则页面上分隔符包括comment的显示区域都应该清除。

<!-- pages/index/index.wxml -->
<view wx:if=""><!--如果comments为空,不显示下面内容-->
  <view class="div-line"></view><!-- end div-line -->
  <view class="comments" bindtap="onShowDialogComments">
    <view wx:for="">
      <view class="comment-richtext">
        <rich-text nodes="<b></b>"></rich-text>
      </view><!-- end comment-richtext -->
    </view><!-- end for=" -->
  </view><!-- end comments -->
</view><!-- end wx:if="" -->

图片选择替换

使用微信原生接口 wx.chooseImage(Object object) 从本地相册选择图片或使用相机拍照。

本示例使用到的 wx.chooseImage(Object object) 参数见下:

属性 取值 说明
count 1-9 最多可以选择的图片张数
sizeType [‘original’, ‘compressed’] 可选图片尺寸:原图或压缩
sourceType [‘album’, ‘camera’] 图片来源:相机或相册
success   接口调用成功的回调函数数,返回所选图片路径列表
fail   接口调用失败的回调函数数

图片选择并替换的实现代码见下:

<!-- pages/index/index.wxml
  bindtap: 设置触发的回调函数
  data-count: 传入回调函数的参数,最多可允许选择几张图片
  data-type:传入回调函数的参数,当前触发的类型
-->
<image class="post-avatar-img" data-count="1" data-type="post-avatar-img" bindtap="onChoosePic" src=""/>
// pages/index/index.js
onChoosePic: function (options) {
  var that = this;
  var cnt = options.currentTarget.dataset.count;//获取最大选择的图片数量
  var type = options.currentTarget.dataset.type;//获取触发类型
  wx.chooseImage({//调用原生接口
    count: cnt,
    sizeType: ['compressed'], // 指定为压缩图
    sourceType: ['album', 'camera'], // 指定来源为相册和相机
    success: function (res) {
      // 返回选定照片的本地文件路径列表,tempFilePath可以作为img标src属性显示图片
      var tempFilePaths = res.tempFilePaths;
      switch(type){
        case 'cover-image':
          that.setData({
            coverImage: tempFilePaths
          });
          break;
        case 'avatar-img':
          that.setData({
            avatarPhoto: tempFilePaths
          });
          break;
        case 'post-avatar-img':
          that.setData({
            postAvatarPhoto: tempFilePaths
          });
          break;
        case 'post-photo':
          that.setData({
            photos: tempFilePaths
          });
          break;
        default:
          console.log('Unknown type name:' + type);
          break;
      }
    },
    fail: function (res) {
      console.log('chooseImage failed:' + res.errMsg)
    }
  })
},

屏幕截图

截屏可以使用原生API canvas 绘制。但canvas绘制太麻烦了,相当于重新按照布局将所有元素都绘制一遍。累了暂时不想搞了。

截图保存

既然截图不弄了,保存也不需要了。