微信小程序的存在许多功能上的限制和约束,有些情况不得不去使用webview进行开发实现需求,比如
由于最近做的需求小程序不支持播放带有透明通道的视频,所以转到了webview
这里总结下完整的开发流程和现有的各种解决方案
注: 开发阶段需要将不校验合法域名勾选上
当小程序嵌入webview之后,会自动铺满一整个页面,设置宽高无效,并覆盖其他组件
并且会带有原生的导航栏,这个导航栏是无法消除的,即使在小程序端和webview端设置navigationStyle: custom
都无效
导航栏的标题会先加载小程序端的pages.json
中对应组件的navigationBarTitleText
,如果没有则读globalStyle
中的值
然后标题会刷新为webview端的对应组件的navigationBarTitleText
,如果没有则读globalStyle
中的值
如果webview端都没有navigationBarTitleText
,则只会显示小程序端的值,不会刷新
如果都没有,则显示空白,可以改变其背景颜色,仅支持十六进制颜色码
小程序可以通过url拼接参数的方式向webview端传参
webview端向小程序传参现有只能通过wx.miniProgram.postMessage
进行传参,但是这个api非常有局限性
只有在特定时机(小程序后退、组件销毁、分享)触发组件的 message 事件,所以基本没用
只能通过webSocket进行长连接发送接收消息,但是会增加服务器压力
小程序向web-view传参非常轻松,反过来则非常折磨
web-view 网页与小程序之间不支持除 JSSDK 提供的api之外的通信
如果调用JSSDK的api并不能解决问题,并且不想使用websocket,最好使用小程序单一实现
在 iOS 中,若存在 JSSDK 接口调用无响应的情况,可在 web-view 的 src 后面加个#wechat_redirect
解决
想要在webview端调用小程序的接口,都需要jssdk
如果想要在webview中使用jssdk,那么需要设置JS接口安全域名
由于我们使用的是webview(视为公众号),并不是小程序(小程序只是一个承载webview的壳子)
因此是在公众号设置的功能设置中填写JS接口安全域名
,webview所在的域名设置进去后才允许鉴权
注意: 设置JS接口安全域名
需要公网且ICP备案了的服务器,也就是说本地的环境没办法使用,必须在线的服务器环境
业务域名
、js接口安全域名
、网页授权域名
可以一起设置为相同的,都为webview部署所在的域名即可
nginx的设置参考
然后就是在webview中引入jssdk的js文件
然而如果通过<script>
的方式引入,会出现wx.config is not a function
这是由于uniapp本身具有wx对象,因此会跟jssdk中的wx对象冲突
这里解决方案是在分别保存两个js文件然后通过import重命名进行引入
https://res.wx.qq.com/open/js/jweixin-1.6.0.js
https://open.work.weixin.qq.com/wwopen/js/jwxwork-1.0.0.js
然后分别保存js到本地的js文件,通过import引入,然后将jssdk的wx变量重命名为jweixin
import jweixin from './jweixin.js'
import wxx from './jwxwork-1.0.0.js'
如果需要调用小程序的api则必须要通过授权并且注册需要使用的接口,才能进行调用
所以首先需要config进行权限校验
// 官方文档示例代码 仅展示参数 无实际作用 业务代码在下文
wx.config({
debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
appId: '', // 必填,公众号的唯一标识
timestamp: , // 必填,生成签名的时间戳
nonceStr: '', // 必填,生成签名的随机串
signature: '',// 必填,签名
jsApiList: [] // 必填,需要使用的JS接口列表
});
debug
: 设置为true时,通过或不通过校验,调用api的结果都会进行弹窗提示,调试阶段可以先打开appid
: 这个appid
以及后面校验需要用到的AppSecret
都必须是公众号的appid、AppSecret
也能拿到access_token
,但是后续签名会校验不通过(没有提示,非常坑)timestamp、nonceStr
: 这是自己定的值,随便填即可,但是后续签名生成也会用到,需要保证这两个地方用的值是一样的jsApiList
: 数组中填的就是后续开发需要用的api,只有在这里注册了,才能进行调用,里面可以填的值在下面附录2signature
: 重点,需要进行验证的签名,这个签名是根据一系列参数生成的signature
需获取access_token
,根据access_token
去获取jsapi_ticket
, 再根据jsapi_ticket
去和其他参数加密获取signature
// 流程如下
const appid = "xxxx" // 注意appid 和 secret必须是公众号的!
const secret = "xxxx"
const noncestr = "Wm3WZYTPz0wzccnW" // noncestr 和 timestamp随便写,这里用了文档的
const timestamp = 1414587457
const url = 'https://www.abc.xx:5173/' // webview所在的网址,我这里部署在5173端口
// 因为上面js安全域名设置的www.abc.xx 所以其他端口也是没问题的
// 另外需要注意 这里填写的网址需要跟<web-view src=''>的src的值一致,如果src需要携带参数 那么需要再src中用#隔开
// 如 <web-view :src="`https://www.abc.xx:5173/#/?time=${123}`"></web-view>
// 如果没用#隔开直接`?time=${123}`, 会由于这里加密用的url和webview src的url 不一致(???) 而签名失败
// const wxApiUrl = 'https://api.weixin.qq.com/cgi-bin'
const wxApiUrl = 'https://www.abc.xx:5173/api' // 由于直接请求可能会有跨域问题, 因此还需要在nginx中设置代理(这里就省略了, 就是设置proxy_pass)
const encodeUTF8 = (s) => {
var i, r = [], c, x;
for (i = 0; i < s.length; i++)
if ((c = s.charCodeAt(i)) < 0x80) r.push(c);
else if (c < 0x800) r.push(0xC0 + (c >> 6 & 0x1F), 0x80 + (c & 0x3F));
else {
if ((x = c ^ 0xD800) >> 10 == 0) //对四字节UTF-16转换为Unicode
c = (x << 10) + (s.charCodeAt(++i) ^ 0xDC00) + 0x10000,
r.push(0xF0 + (c >> 18 & 0x7), 0x80 + (c >> 12 & 0x3F));
else r.push(0xE0 + (c >> 12 & 0xF));
r.push(0x80 + (c >> 6 & 0x3F), 0x80 + (c & 0x3F));
};
return r;
}
// 字符串加密成 hex 字符串
const sha1 = (s) => {
var data = new Uint8Array(encodeUTF8(s))
var i, j, t;
var l = ((data.length + 8) >>> 6 << 4) + 16, s = new Uint8Array(l << 2);
s.set(new Uint8Array(data.buffer)), s = new Uint32Array(s.buffer);
for (t = new DataView(s.buffer), i = 0; i < l; i++)s[i] = t.getUint32(i << 2);
s[data.length >> 2] |= 0x80 << (24 - (data.length & 3) * 8);
s[l - 1] = data.length << 3;
var w = [], f = [
function () { return m[1] & m[2] | ~m[1] & m[3]; },
function () { return m[1] ^ m[2] ^ m[3]; },
function () { return m[1] & m[2] | m[1] & m[3] | m[2] & m[3]; },
function () { return m[1] ^ m[2] ^ m[3]; }
], rol = function (n, c) { return n << c | n >>> (32 - c); },
k = [1518500249, 1859775393, -1894007588, -899497514],
m = [1732584193, -271733879, null, null, -1009589776];
m[2] = ~m[0], m[3] = ~m[1];
for (i = 0; i < s.length; i += 16) {
var o = m.slice(0);
for (j = 0; j < 80; j++)
w[j] = j < 16 ? s[i + j] : rol(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1),
t = rol(m[0], 5) + f[j / 20 | 0]() + m[4] + w[j] + k[j / 20 | 0] | 0,
m[1] = rol(m[1], 30), m.pop(), m.unshift(t);
for (j = 0; j < 5; j++)m[j] = m[j] + o[j] | 0;
};
t = new DataView(new Uint32Array(m).buffer);
for (var i = 0; i < 5; i++)m[i] = t.getUint32(i << 2);
var hex = Array.prototype.map.call(new Uint8Array(new Uint32Array(m).buffer), function (e) {
return (e < 16 ? "0" : "") + e.toString(16);
}).join("");
return hex;
}
class useStorage {
/**
* 额外设置一条 `key__expires__: 时间戳` 的storage来判断过期时间
* @param {string} key
* @param {any} value
* @param {number} expired 过期时间 以分钟为单位
* @returns {any}
*/
setItem(key, value, expired) {
uni.setStorageSync(key, JSON.stringify(value))
if (expired) {
uni.setStorageSync(`${key}__expires__`, Date.now() + 1000 * 60 * expired)
}
return value;
}
/**
* 获取storage时先获取`key__expires__`的值判断时间是否过期
* 过期则清空该两条storage 返回空
* @param {string} key
* @returns {any}
*/
getItem(key) {
let expired = uni.getStorageSync(`${key}__expires__`) || Date.now + 1;
const now = Date.now();
if (now >= expired) {
uni.removeStorageSync(key)
uni.removeStorageSync(`${key}__expires__`)
return;
}
return uni.getStorageSync(key) ? JSON.parse(uni.getStorageSync(key)) : uni.getStorageSync(key);
}
}
const storage = new useStorage();
const fetchUrl = (url) => {
return fetch(url).then((response) => {
return response.json()
})
}
// 主要逻辑 为了便于理解把工具函数全部整合到一块代码了 实际上可以把工具函数进行抽离封装
const fetToken = async () => {
let save_ticket = storage.getItem('TICKET')
console.log('ticket', save_ticket)
if(!save_ticket){
try{
const {access_token: token} = await fetchUrl(`${wxApiUrl}/token?grant_type=client_credential&appid=${appid}&secret=${secret}`)
const {ticket} = await fetchUrl(`${wxApiUrl}/ticket/getticket?access_token=${token}&type=jsapi`)
save_ticket = ticket
storage.setItem('TICKET', ticket, 100)
}catch{
uni.showToast({
title: '授权异常',
duration: 1000
})
}
}
const result = `jsapi_ticket=${save_ticket}&noncestr=${noncestr}×tamp=${timestamp}&url=${url}`
console.log('result', result)
return sha1(result)
}
得到签名后可以在 中对比自己生成的签名有没有问题
实际上校验加密这个步骤应该放在服务端,这里为了方便就放在了前端来实现
onLoad() {
// 获取ticket并注册jsApi
fetToken().then((res) => {
if (jweixin) {
jweixin.config({
debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
appId: appid, // 必填,公众号的唯一标识
timestamp: timestamp, // 必填,生成签名的时间戳
nonceStr: noncestr, // 必填,生成签名的随机串
signature: res, // 必填,签名
jsApiList: ['startRecord', 'stopRecord', 'uploadVoice', 'downloadVoice'] // 必填,需要使用的JS接口列表
});
}
})
},
进行到这一步权限如果获取成功基本就完成配置了,如果微信弹出config: ok
就是成功了
小程序和小程序的公众号网页调试工具都是可以查看到config的校验结果的
小程序
公众号
然后使用jweixin.startRecord
调用api即可,如果配置失败则检查哪个字段有错误
jssdk比较脆弱, 且很多方法都是异步的,需要控制调用频率不能过快
比如startRecord
,stopRecord
的调用,如果同时触发是可能 stopRecord
先执行完的,导致回调不触发
具体详情可以看这篇,这里不过多赘述
注: 这种方法仅支持线上调试,本地会显示localhost
不在公众号校验的白名单中
小程序上线的时候会自动开启合法域名校验,此时需要在小程序的开放平台 - 开发管理 - 开发设置中把在公众号中的安全域名加上去(www.abc.xx
),然后在体验版中才能访问
使用meta标签禁止页面放大
<meta content='width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=0;' name='viewport' />
<meta name="viewport" content="width=device-width" />
每次更新webview时由于小程序的缓存会导致我们每次打开无法访问到最新的页面
src = `https://XXX.com?timestamp=${new Date().getTime()}`
<web-view src='{{src}}'></web-view>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
这个会影响uniapp的touchstart
和touchend
事件,也不太美观
解决方案是在img标签全部添加上csspointer-events:none
即可
需求是手指往左划会弹出菜单,往右则收回菜单,但是uniapp只支持判断滑动和停止滑动,因此需要我们加个判断
<view @touchstart="touchMoveStart" @touchmove="chatExpandMove" @touchend="chatMoveEnd"></view>
export default {
data() {
return {
touchingFlag: false, // 是否正在滑动
moveingPosition: { // 正在滑动的手指位置
X: 0,
Y: 0
},
startPosition: { // 滑动开始时点击的位置
X: 0,
Y: 0
},
endPosition: { // 滑动结束时手指的位置
X: 0,
Y: 0
},
Xflag: false, // 是否在x轴滑动
Yflag: false, // 是否在y轴滑动
startMoveTime: 0, // 开始滑动的时间 用于判断手指滑动的时间 过短则不操作
}
},
methods: {
touchMoveStart() {
this.startMoveTime = Date.now()
this.startPosition.X = event.changedTouches[0].clientX
this.startPosition.Y = event.changedTouches[0].clientY
},
/**
* 通过当前手指的位置和开始滑动时点击的位置来判断手指滑动的方向
*/
chatExpandMove() {
this.touchingFlag = true //移动进行
this.moveingPosition.X = event.changedTouches[0].clientX
this.moveingPosition.Y = event.changedTouches[0].clientY
let Xlength = parseInt(Math.abs(this.moveingPosition.X - this.startPosition.X))
let Ylength = parseInt(Math.abs(this.moveingPosition.Y - this.startPosition.Y))
if (Xlength > Ylength && Xlength > 10 && this.Yflag == false) { //x轴方向
this.Xflag = true
let direction = this.moveingPosition.X - this.startPosition.X > 0 ? "right" : "left"
if (direction === 'right') {
// 右
} else if (direction === 'left') {
// 左
}
}
if (Xlength < Ylength && Ylength > 10 && this.Xflag == false) { //Y轴方向
this.Yflag = true
let direction = this.moveingPosition.Y - this.startPosition.Y > 0 ? "down" : "up"
if (direction === "up") {
// 上
} else if (direction === 'down') {
// 下
}
}
},
/**
* 滑动结束时判断手指滑动的距离 如果时间过短或距离过短 则取消影响
*/
chatMoveEnd() {
//关闭手指移动的标识
this.touchingFlag = false
const endTime = Date.now()
if (endTime - this.startTime < 300) {
// 如果手指滑动的距离超过0.3s 就默认不合法
return;
}
//获取滑动结束后的坐标
this.endPosition.X = event.changedTouches[0].clientX
this.endPosition.Y = event.changedTouches[0].clientY
if (Math.abs(this.endPosition.X - this.startPosition.X) > 10 && this.Xflag) { //大于10个单位才有效
//long的滑动长度绝对值作为视频s的值。
let long = Math.abs(this.endPosition.X - this.startPosition.X)
let height = Math.abs(this.endPosition.Y - this.startPosition.Y)
//left向前 right向后
let elePosition = this.endPosition.X - this.startPosition.X > 0 ? "right" : "left"
// 结束移动时与开始时的 距离 和 高度
}
//复原互斥开关
this.Xflag = false
this.Yflag = false
},
}
}
这个方法并不是100%有效,当我将video所在的组件迁移到一个子组件中时就失效了,具体原因未知
在页面初始化时直接播放视频仍然无法生效,会报错(DOMException: play() failed because the user didn't interact with the document first
)
<video :src="videoPath" autoplay="true" :controls='false' id='myVideo' @ended='videoEnd'></video>
onLoad() {
// 获取video对象 存储起来 在ios中通过这种方法自动播放
// 这个wx不是jweixin 时uniapp中的wx
if (wx) this.videoContext = wx.createVideoContext('myVideo', this);
}
// 点击播放
this.videoContext && this.videoContext.play()
在webview中时无法实现v-show
控制视频播放一段时间后再显示的,如果视频处于隐藏状态,那么视频将不会播放
在v-show
切换为true时也不会在显示时开始播放,会一直处于暂停状态
如果需要video播放一段时间后显示,只能使用css的opacity
为0或1控制其显示或隐藏
总而言之,在uniapp上的开发体验非常糟糕,社区解决问题的效率堪忧,网上的资料也非常零散,浪费了非常多的时间。