- 小程序存在违规收集用户隐私等行为,获取地理位置信息被封禁,重新换了系统为什么还是申诉不通过?
重新更换了系统,申诉不通过的理由还是最开始的理由,麻烦官方解释下要怎么才能通过 未通过 审核未通过原因 你好,经核实,小程序存在违规收集用户隐私等行为,请补充提供具体的整改方案,以便后续评估。相关规范与修改指引可参考:https://developers.weixin.qq.com/community/develop/doc/0008ce8a908108c5d4fee910856c09 审核时间 2023-05-15 09:50:00 申诉提交时间 2023-05-14 19:25:00 当前状态 功能封禁 违反运营规则 《微信小程序平台运营规范》15. 用户隐私和数据规范-15.1 数据收集和存储 违规内容1 查看详情 申诉原因 已更换代码,更新地理位置权限申请在启动具体功能前,望大神留情 提交证明材料 材料1.jpg 查看 材料2.jpg 查看 材料3.jpg 查看 材料4.jpg 查看
2023-05-15 - 自定义tabbar 【恋爱小清单开发总结】
看官方demo的小伙伴知道,自定义tabbar需要在小程序根目录底下建一个名叫custom-tab-bar的组件(我有试过,如果放在components目录里面小程序会识别不了),目前我自己实现的效果是:通过在配置可以切换tab,也可以点击tab后重定向到新页面,支持隐藏tabbar,同时也可以显示右上角文本和小红点。 官方demo里面用的是cover-view,我改成view,因为如果页面有弹窗的话我希望可以盖住tabbar 总结一下有以下注意点: 1、tabbar组件的目录命名需要是custom-tab-bar 2、app.json增加自定义tabbar配置 3、wx.navigateTo不允许跳转到tabb页面 4、进入tab页面时,需要调用tabbar.js手动切换tab 效果图: [图片] 可以扫码体验 [图片] 代码目录如下: [图片] 代码如下: app.json增加自定义tabbar配置 "tabBar": { "custom": true, "color": "#7A7E83", "selectedColor": "#3cc51f", "borderStyle": "black", "backgroundColor": "#ffffff", "list": [ { "pagePath": "pages/love/love", "text": "首页" }, { "pagePath": "pages/tabbar/empty", "text": "礼物说" }, { "pagePath": "pages/tabbar/empty", "text": "恋人圈" }, { "pagePath": "pages/me/me", "text": "我" } ] }, 自定义tabbar组件代码如下 index.js //api.js是我自己对微信接口的一些封装 const api = require('../utils/api.js'); //获取应用实例 const app = getApp(); Component({ data: { isPhoneX: false, selected: 0, hide: false, list: [{ showRedDot: false, showBadge: false, badgeText: "", pagePath: "/pages/love/love", iconPath: "/images/tabbar/home.png", selectedIconPath: "/images/tabbar/home-select.png", text: "首页" }, { showRedDot: false, showBadge: false, badgeText: "", pagePath: "/pages/tabbar/empty", navigatePath: "/pages/gifts/giftList", iconPath: "/images/tabbar/gift.png", selectedIconPath: "/images/tabbar/gift-select.png", text: "礼物说", hideTabBar: true }, { showRedDot: false, showBadge: false, badgeText: "", pagePath: "/pages/tabbar/empty", navigatePath: "/pages/moments/moments", iconPath: "/images/tabbar/lover-circle.png", selectedIconPath: "/images/tabbar/lover-circle-select.png", text: "恋人圈", hideTabBar: true }, { showRedDot: false, showBadge: false, badgeText: "", pagePath: "/pages/me/me", iconPath: "/images/tabbar/me.png", selectedIconPath: "/images/tabbar/me-select.png", text: "我" }] }, ready() { // console.error("custom-tab-bar ready"); this.setData({ isPhoneX: app.globalData.device.isPhoneX }) }, methods: { switchTab(e) { const data = e.currentTarget.dataset; console.log("tabBar参数:", data); api.vibrateShort(); if (data.hideTabBar) { api.navigateTo(data.navigatePath); } else { /*this.setData({ selected: data.index }, function () { wx.switchTab({url: data.path}); });*/ /** * 改为直接跳转页面, * 因为发现如果先设置selected的话, * 对应tab图标会先选中,然后页面再跳转, * 会出现图标变成未选中然后马上选中的过程 */ wx.switchTab({url: data.path}); } }, /** * 显示tabbar * @param e */ showTab(e){ this.setData({ hide: false }, function () { console.log("showTab执行完毕"); }); }, /** * 隐藏tabbar * @param e */ hideTab(e){ this.setData({ hide: true }, function () { console.log("hideTab执行完毕"); }); }, /** * 显示小红点 * @param index */ showRedDot(index, success, fail) { try { const list = this.data.list; list[index].showRedDot = true; this.setData({ list }, function () { typeof success == 'function' && success(); }) } catch (e) { typeof fail == 'function' && fail(); } }, /** * 隐藏小红点 * @param index */ hideRedDot(index, success, fail) { try { const list = this.data.list; list[index].showRedDot = false; this.setData({ list }, function () { typeof success == 'function' && success(); }) } catch (e) { typeof fail == 'function' && fail(); } }, /** * 显示tab右上角文本 * @param index * @param text */ showBadge(index, text, success, fail) { try { const list = this.data.list; Object.assign(list[index], {showBadge: true, badgeText: text}); this.setData({ list }, function () { typeof success == 'function' && success(); }) } catch (e) { typeof fail == 'function' && fail(); } }, /** * 隐藏tab右上角文本 * @param index */ hideBadge(index, success, fail) { try { const list = this.data.list; Object.assign(list[index], {showBadge: false, badgeText: ""}); this.setData({ list }, function () { typeof success == 'function' && success(); }) } catch (e) { typeof fail == 'function' && fail(); } } } }); index.html <view class="footer-tool-bar flex-center {{isPhoneX? 'phx_68':''}}" hidden="{{hide}}"> <view class="tab flex-full {{selected === index ? 'focus':''}}" wx:for="{{list}}" wx:key="index" data-path="{{item.pagePath}}" data-index="{{index}}" data-navigate-path="{{item.navigatePath}}" data-hide-tab-bar="{{item.hideTabBar}}" data-open-ext-mini-program="{{item.openExtMiniProgram}}" data-ext-mini-program-app-id="{{item.extMiniProgramAppId}}" bindtap="switchTab"> <view class="text"> <view class="dot" wx:if="{{item.showRedDot}}"></view> <view class="badge" wx:if="{{item.showBadge}}">{{item.badgeText}}</view> <image class="icon" src="{{item.selectedIconPath}}" hidden="{{selected !== index}}"></image> <image class="icon" src="{{item.iconPath}}" hidden="{{selected === index}}"></image> </view> </view> </view> index.json { "component": true, "usingComponents": {} } index.wxss @import "/app.wxss"; .footer-tool-bar{ background-color: #fff; height: 100rpx; width: 100%; position: fixed; bottom: 0; z-index: 100; text-align: center; font-size: 24rpx; transition: transform .3s; border-radius: 30rpx 30rpx 0 0; /*padding-bottom: env(safe-area-inset-bottom);*/ box-shadow:0rpx 0rpx 18rpx 8rpx rgba(212, 210, 211, 0.35); } .footer-tool-bar .tab{ color: #242424; height: 100%; line-height: 100rpx; } .footer-tool-bar .focus{ color: #f96e49; font-weight: 500; } .footer-tool-bar .icon{ width: 44rpx; height: 44rpx; margin: 18rpx auto; } .footer-tool-bar .text{ line-height: 80rpx; height: 80rpx; position: relative; display: inline-block; padding: 0rpx 40rpx; box-sizing: border-box; margin: 10rpx auto; } .footer-tool-bar .dot{ position: absolute; top: 16rpx; right: 16rpx; height: 16rpx; width: 16rpx; border-radius: 50%; background-color: #f45551; } .footer-tool-bar .badge{ position: absolute; top: 8rpx; right: 8rpx; height: 30rpx; width: 30rpx; line-height: 30rpx; border-radius: 50%; background-color: #f45551; color: #fff; text-align: center; font-size: 20rpx; font-weight: 450; } .hide{ transform: translateY(100%); } app.wxss(这里的样式文件是我用来存放一些公共样式) /**app.wxss**/ page { background-color: #f5f5f5; height: 100%; -webkit-overflow-scrolling: touch; } .container { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: space-between; box-sizing: border-box; } .blur { filter: blur(80rpx); opacity: 0.65; } .flex-center { display: flex; align-items: center; justify-content: center; } .flex-column { display: flex; /*垂直居中*/ align-items: center; /*水平居中*/ justify-content: center; flex-direction: column; } .flex-start-horizontal{ display: flex; justify-content: flex-start; } .flex-end-horizontal{ display: flex; justify-content: flex-end; } .flex-start-vertical{ display: flex; align-items: flex-start; } .flex-end-vertical{ display: flex; align-items: flex-end; } .flex-wrap { display: flex; flex-wrap: wrap; } .flex-full { flex: 1; } .reset-btn:after { border: none; } .reset-btn { background-color: #ffffff; border-radius: 0; margin: 0; padding: 0; overflow: auto; } .loading{ opacity: 0; transition: opacity 1s; } .load-over{ opacity: 1; } .phx_68{ padding-bottom: 68rpx; } .phx_34{ padding-bottom: 34rpx; } 另外我还对tabbar的操作做了简单的封装: tabbar.js const api = require('/api.js'); /** * 切换tab * @param me * @param index */ const switchTab = function (me, index) { if (typeof me.getTabBar === 'function' && me.getTabBar()) { console.log("切换tab:", index); me.getTabBar().setData({ selected: index }) } }; /** * 显示 tabBar 某一项的右上角的红点 * @param me * @param index */ const showRedDot = function (me, index, success, fail) { if (typeof me.getTabBar === 'function' && me.getTabBar()) { me.getTabBar().showRedDot(index, success, fail); } }; /** * 隐藏 tabBar 某一项的右上角的红点 * @param me * @param index */ const hideRedDot = function (me, index, success, fail) { if (typeof me.getTabBar === 'function' && me.getTabBar()) { me.getTabBar().hideRedDot(index, success, fail); } }; /** * 显示tab右上角文本 * @param me * @param index * @param text */ const showBadge = function (me, index, text, success, fail) { if (typeof me.getTabBar === 'function' && me.getTabBar()) { me.getTabBar().showBadge(index, text, success, fail); } }; /** * 隐藏tab右上角文本 * @param me * @param index */ const hideBadge = function (me, index, success, fail) { if (typeof me.getTabBar === 'function' && me.getTabBar()) { me.getTabBar().hideBadge(index, success, fail); } }; /** * 显示tabbar * @param me * @param success */ const showTab = function(me, success){ if (typeof me.getTabBar === 'function' && me.getTabBar()) { me.getTabBar().showTab(success); } }; /** * 隐藏tabbar * @param me * @param success */ const hideTab = function(me, success){ if (typeof me.getTabBar === 'function' && me.getTabBar()) { me.getTabBar().hideTab(success); } }; module.exports = { switchTab, showRedDot, hideRedDot, showBadge, hideBadge, showTab, hideTab }; 最后,进入到tab对应页面的时候要手动调用一下swichTab接口,然tabbar聚焦到当前tab /** * 生命周期函数--监听页面显示 */ onShow: function () { tabbar.switchTab(this, this.data.tabIndex);//tabIndex是当前tab的索引 }
2021-11-09 - 小程序右上角胶囊按钮的具体样式,边框大小、颜色,背景色等?
如题,现在遇到一个需求是自定义一个风格和胶囊按钮类似的按钮,所以需要知道按钮的边框、背景色等。
2020-02-08 - 有赞零售智能硬件体系搭建历程
前言有赞零售 App 上线至今,为了降低商家硬件迁移成本,同时提高商家硬件采购的选择多样性,陆陆续续对接了市面上 Top 20+ 的智能硬件,包括打印机、电子秤、扫码枪、摄像头、一体机等, 在硬件对接过程中团队投入了大量的人力进行支持,受限于硬件架构不成体系、硬件类目划分不清晰、通信协议多样性、多端重复适配造轮子等因素,导致硬件线上问题较多,且投入的开发成本很高,也影响了商家的正常经营。为了彻底解决这些问题,提高新设备对接效率,并确保硬件交互质量,有赞零售移动团队对硬件体系做了几次重构演进,目前一款新硬件的对接与适配成本已经控制在一到两个工作日内,相较2019年人力投入降低了50%。同时通过不断完善硬件 FAQ 文档,协助商家与硬件支持同学快速定位解决问题,硬件开发同学直接处理的线上问题数量相较2019下半年环比下降55%,技术支持同学对接的硬件问题也环比下降了33%,提效比较明显。 一、智能硬件矩阵1.1 设备使用场景简介硬件类型使用场景对接设备一体机线下门店都会在收银台配置一款收银机,方便商家与收银员进行门店经营开单操作商米、天波、联迪、中科英泰等打印机订单正向与逆向环节需要打印小票,比如购车小票、退货小票等365 、映美云、佳博、思普瑞特、易联云等副屏开单支付与会员结算流程中,订单信息对顾客足够透明,可通过副屏将购物车、会员、支付相关信息投影到副屏上商米 T2、T1、D2、联迪等客显除了副屏可以投影订单数据之外,还提供客显这种低成本的外接设备进行数据投影中崎等人脸识别通过摄像头采集顾客人脸信息,支持会员快速识别、快捷支付等青蛙 Pro、蜻蜓、三方摄像头等NFC & Rfid通过对接磁条、nfc、Rfid 等外接设备,满足实体卡支付、Rfid 条码商品(一般应用在服装品类)等加购场景cas、灵天智能等电子秤生鲜果蔬商家涉及到称重环节,通过适配电子秤满足称重的经营场景凯士、大华、欧陆达、S2 等POS机部分商家不采购收银机,只需要使用 POS 进行订单结算,且需要支持刷卡功能WANGPOS、SUNMI P 系列等 1.2 硬件矩阵图[图片] 1.3 体系搭建介绍有赞零售对接的设备种类繁多,由于篇幅内容有限,接下来会着重讲解打印机、 POS 、电子秤、副屏相关技术的设计细节。 二、硬件库拆解重构 零售设备库 sdk 早期设计类似于全家桶,聚合了打印机、电子秤、POS 机等所有设备,扩展性比较差,随着新机器的适配接入,造成 sdk 频繁升级,稳定性无法保证。前期只接入几款设备勉强还能应付过来,随着业务迭代发展,设备接入种类与数量越来越多,当前的设备库架构设计显得非常臃肿,维护与适配成本比较高,开发对接效率也非常低。 为了彻底解决这些问题,组内经过多次讨论与论证,全家桶的方式需要被彻底推翻改造,首先要做的就是对设备进行分类,将通用的设备放到单独的 module 中进行维护,打成 aar 给业务方灵活调用,且需要下沉抽象出一些通用能力,降低新设备的接入成本,通过这次的架构设计迭代,新设备适配人力成本减少 2 倍以上,且硬件上线质量也得到了有效保证。 架构图新设备库框架部分参考 Android 系统架构模型,分为 OEM、Core 、 Base 、Library 四层,OEM 为业务 Manager 层,业务方只需要感知 Manager 提供的 Api ,底层能力通过 Core、Base 支撑,同时 Library 层将硬件之间一些通用的三方sdk(比方说 WANGPOS SDK 既提供刷卡能力,又提供打印能力,聚合多个 OEM 的功能,可以共享)共享出来,供 OEM 层调用。 [图片] 2.1 设备库架构介绍2.1.1 OEM 层提供 PrinterManager 、 PosManager 、 WeightManager 、 XXXManager 等设备管理类,供业务方调用,且每个设备单独打成 aar ,供业务方灵活依赖。 例如: 零售工程通过模块化进行开发管理,所有硬件能力通过 module_device 向外提供能力,业务 module 通过调用模块间定义的向外暴露接口(有赞零售模块间通信方式详细设计请参考这篇文章 Android -模块化-面向接口编程)来访问 module_device 提供的能力,同时 module_device 会依赖设备能力各自的 aar ,其他业务模块只需要向设备模块要能力,不需要关心设备模块具体的实现,模块之间责任划分清晰。 调用示例图: [图片] 2.1.2 Core 层提供设备通用能力,包括设备模型、连接能力、缓存能力、设备状态心跳检测、异常处理、线程管理、读写能力等。 1)设备模型零售对接了如此多的的设备,设备模型的抽象尤为重要,包括设备连接类型、设备 id 、设备型号、设备状态、设备标签、是否需要缓存等,分类设备又可以基于设备模型进行接口扩展,比如 IPrinter 抽象出打印能力,IPos 抽象出刷卡、退款等能力,IWeight 抽象出称重、置零、去皮等能力,设备实体各自实现 IPrinter 、IPos 、 IWeight 接口,实现接口提供的相应方法,通过面向接口编程,业务划分与代码管理清晰很多。 UML : [图片] 2)设备状态心跳检测[图片] 有赞零售收银台右上角“收银中心”聚合了很多收银通用能力,其中就包括了外接设备的状态管理,该功能可以实时监测设备状态,在快速定位线上问题过程中发挥了非常重要的作用,且也能协助商家对设备进行健康自检。 注册心跳,开启心跳检测 /** * 注册监听 */ fun registerCheckState(listener: IDeviceStateListener) { if (!deviceStateListeners.contains(listener)){ deviceStateListeners.add(listener) } checkHeart() } /** * 检查心跳 */ private fun checkHeart() { if (cacheDevices.isNullOrEmpty()) { return } if (!isCheckingHeart) { isCheckingHeart = true DeviceThreadManager.threadPoolProxy.getHeartExecutor().execute(HeartTask()) } } 心跳机制单线程内开启 while 循环,每次心跳间隔 2 秒 inner class HeartTask : Runnable { override fun run() { while (true) { if (cacheDevices.isNullOrEmpty()) { continue } for (entity in cacheDevices) { val newState = entity.device.getState() var countChange = false if (deviceCount != cacheDevices.size){ countChange = true deviceCount = cacheDevices.size } val shouldNotify = (entity.getState() != newState) || countChange entity.setState(newState) for (listener in deviceStateListeners){ if (listener is IDeviceStateAlwaysListener){ listener.onDeviceState(entity) } else { // 会与上一次心跳状态进行比较,状态不一样时,才会回调 if (shouldNotify){ listener.onDeviceState(entity) } } } } try { Thread.sleep(STATE_UPDATE_SUSPEND) } catch (e: Exception) { e.printStackTrace() } } } } } 3)读写能力打印小票的前提是将 ESC / POS 协议字节数据输入到打印机驱动中,这里涉及到写的场景。而在生鲜果蔬行业涉及到称重场景中要用到电子秤,商品重量需要实时传输到收银机,这个又涉及到读的场景,底层抽象读写接口,业务方自己实现,这块底层做的比较轻。 /** * 读接口 */ interface IRead<T> { fun read(): T? } /** * 写接口 */ interface IWrite<T> { fun write(content: T?) } // 大华电子秤读取商品重量,业务方自己实现 class DahuaWeight: IWeight, IRead<String?>{ override fun read(): String? { return DahuaWeightSdk.getWeight() } } 4)缓存能力 有赞零售 app 为了满足设备连接多样性,支持同时连接多款设备,且针对每款设备提供手动断开、连接能力(比方说餐饮行业,前台与后厨都连接了打印机,退款小票只需要在前台打印机打印的话,后厨的打印机可以手动点击断开),且我们需要确保商家退出 app 、app 覆盖升级等场景,设备的状态可以恢复,基于这种场景必须要支持本地缓存能力,下次 app 进入读取本地缓存,绘制 UI 即可。 /** * 设备缓存管理 * 缓存到本地文件 */ class DeviceCacheManager { //添加设备 fun addDevice(deviceInfo: DeviceInfo?) { if (addInner(deviceInfo)){//添加到内存 memoryToCache()//刷到缓存 } } //删除设备 fun removeDevice(deviceInfo: DeviceInfo?) { if (removeInner(deviceInfo)){//从内存中删除 memoryToCache()//刷到缓存 } } //获取设备列表 fun getCacheDevices(tag: String): List<IDevice>? { if (cacheDevices.isNullOrEmpty()){ cacheToMemory() } return getDevicesByTag(tag) } } 5)线程管理设备的状态监测、IO 读写、耗时逻辑处理都涉及到线程切换,目前底层提供配置线程池统一管理,避免线程随意创建,抢占系统资源,拖累收银机的性能(零售对接了很多低端设备,线程控制非常严格,且部分机型可能出现 p-thread 问题,线程创建数量超出一定数量后, app 将 crash )。 ... ... private val diskIOExecutor = Executors.newSingleThreadExecutor(DeviceThreadFactory("diskIO")) private val heartExecutor = Executors.newSingleThreadExecutor(DeviceThreadFactory("heart")) private val networkExecutor = Executors.newFixedThreadPool(3, DeviceThreadFactory("network")) private val scheduleExecutor = ScheduledThreadPoolExecutor(5, DeviceThreadFactory("schedule"), ThreadPoolExecutor.AbortPolicy()) ... ... 6)异常模型硬件的异常管理在实际开发与交互提示流程中非常重要,比方说打印机是否缺纸了、电子秤是否断开了等场景,通过交互提示能协助快速定位排查问题。 { // 设备名称 "deviceName":"sunmi", // 额外信息 "extra":"", // 当前设备的连接状态 "state":0, "error":{ // 打印机异常状态码 "code":1, // 打印机异常信息详情 "message":"打印机缺纸/打印机离线/打印机断开" } } 2.1.3 Library 层部分设备连接需要依赖硬件厂商提供的 sdk , 且不同分类的设备可能共享该 sdk , 这类的sdk可以放到 Library 进行管理,避免设备重复依赖。 [图片] 库简介: library名称功能介绍woyou.aidlservice商米打印与称重 aidl 接口sprtprintersdk思普瑞特打印能力paymentService商米 P1 刷卡 sdkcloudpossdk、wangpossdkWANGPOS 刷卡打印能力 2.1.4 Base 层提供最基础的能力,包括网络请求、log 埋点等。 2.2 硬件库实现细节2.2.1 打印机零售对接的打印设备非常多,包括蓝牙、usb 、http 等,原有的设计中打印机与 pos 、电子秤功能聚合在一起,功能耦合严重,不同的硬件开发人员都会改动设备库的代码,导致 sdk 频繁发版,违背开闭原则,设备库稳定性也无法得到保证。需要将通用能力抽出来,包括连接能力、打印能力、协议封装能力等,确保新的设备能够快速接入。 解决方案UML : [图片] 技术细节描述: PrinterManager 暴露相应的 api 给业务方调用,DeviceCoreManager 提供 Core 通用能力(包含缓存能力、连接能力、线程切换能力等)并作为 PrinterManager 的成员变量,所有的打印机实体继承 AbsPrinter 基类(实现一些基本信息,以及相关方法做了默认实现),AbsPrinter 又实现 IPrinter 接口,IPrinter 继而又继承 IDevice 接口,同时部分打印机又可以打开钱箱,需要实现 IMoneyBox 接口。 IPrinter : interface IPrinter : IDevice { ... ... /** * 设备纸张类型 * * @return */ fun getPagerType(): PagerType /** * 获取设备协议 * * @return */ fun getProtocol(): Protocol /** * 打印内容 * */ fun print(content: ByteArray): PrinterResponse /** * 打印内容,附加一些信息 * */ fun print(content: ByteArray, extraInfo: String?): PrinterResponse /** * js DeviceName * @return */ fun jsDeviceName(): String fun isSupportJSPrinter(): Boolean ... ... } 2.2.2 POS 机零售开发早期,开发了独立的 POS 收银台,直接访问第三方支付公司(通联等)提供的刷卡接口,且针对 8583 协议(8583协议)进行自定义封装,代码复杂度与维护成本很高,在线上运行一段时间后,发现接口不太稳定,商家经常出现刷卡不成功问题。后期与 POS 厂商沟通后,直接对接了 POS 厂商提供的刷卡 sdk, 刷卡稳定性得到了提升,但是从设备库设计来说还是要兼容自建收银台功能,目前还有部分商家在使用老的刷卡方式能力,不能贸然迁移。 零售 POS 对接现状: 交易模块、订单模块、储值模块、支付模块都有使用过刷卡能力,但是各自调用的 sdk 不尽相同,包括 ecosy、zanpay、pos_pay_sdk 等,开发与维护成本很高 [图片] 解决方案UML : [图片] 技术细节描述: PosManager 暴露相应的 api 给业务方调用,DeviceCoreManager 提供 Core 通用能力(连接能力、线程切换能力等)并作为 PosManager 的成员变量,所有的 POS 机实体继承 AbsCashier 基类(实现一些基本信息,以及相关方法做了默认实现),AbsCashier 又实现 IPos 接口,同时 IPos 继承 IDevice 接口。AbsPrinter 会维护 PosChainTaskList 队列,分别对应 POS 中签到、收单、支付、上报流程。这些 Task 业务方需要注入并做接口实现,底层只会维护调用链路,不关心业务 Task 的执行内容。 IPos : interface IPos : IDevice { /** * 刷卡支付 * */ fun payByPos(entity: PhoinexPosPayResult): Observable<PhoinexPosPayResult> /** * 取消支付 * * @param orderNo * @param voucherNo */ fun revoke(orderNo: String, voucherNo: String): Observable<Any> /** * 退款 * * @param orderNo */ fun refund(orderNo: String): Observable<Any> } 2.2.3 电子秤电子秤提供的能力比较简单,IWeight 提供去皮、置零等能力,电子秤的读取通过 CallableData (类似参考LiveData实现)进行 postValue 分发,同时 WeightManager 提供了设备基本的增删改查能力。 解决方案UML : [图片] 技术细节描述: WeightManager 暴露相应的 api 给业务方调用,Weight 相对比较简单,所有的电子秤都实现 IWeight 接口,IWeight 集成 IDevice 接口,同时 DeviceCoreManager 为电子秤提供底层能力(读写能力、连接能力、缓存能力等)支持,电子秤部分是串口通信,需要实现 UsbReceiver 广播监听 usb 线的插拔状态。 IWeight : interface IWeight: IDevice { // 去皮 fun doTare(): Pair<Boolean, String> // 置零 fun fun doZero(): Pair<Boolean, String> } 2.3 灰度上线方案硬件重构相当于推倒重来,如此大的改动上线必须要稳,故此采用 AB Test 进行灰度,一部分商家继续使用老 sdk ,一部分商家使用新 sdk ,新 sdk 进行数据异常埋点,当检测到新的设备库出现问题后,配置中心操作,使用新 sdk 的商家收银机会立即回滚到老设备库。 方案图灰度工具:AB-Test [图片] 三、打印机协议统一移动团队配合硬件支持同学根据商家需求适配对接了十几款市面上口碑与稳定性较高的打印机设备,包括 365 、佳博、映美云、思普瑞特、飞蛾等品牌,且技术上适配了 usb 、蓝牙、 wifi 等多种连接方式,为商家硬件选配提供了多样性选择。团队对接打印机的过程中投入了大量的人力支持,也踩了不少坑,同时新设备的对接效率始终比较低,且稳定性不够,商家经常反馈一些连接与打印问题,开发人员的自我成就不高,且对商家的经营场景造成了影响。在技术侧特别是打印机协议适配涉及到多端参与( Android 、iOS 、前端等),重复造轮子的同时,也很难保证协议解析的稳定性与统一性,为了降低多端打印协议适配成本,痛定思痛,技术上利用 js 作为桥接层对打印协议进行统一解析预处理,业务方只需要根据一定格式(类似于 html )输入打印内容,js 层会针对打印内容映射为打印协议,且该方案支持跨平台与动态化,目前零售所有的打印业务都是通过这种方式进行适配,稳定性得到了保障,且维护成本也被极大的降低,详细技术方案请看这两篇文章。(有赞零售小票打印跨平台解决方案, 有赞零售跨平台打印库方案) 架构图PC、Android、iOS 将打印内容输入到 JsCore , JsCore 解析匹配打印数据,适配成特定的打印协议( ESC / POS 等),端获取到打印协议后,将打印协议输入给打印机,打印机读取到协议数据后进行打印,且 JsCore 可通过后端配置中心进行动态下发,实时修复问题,无需重发版。 [图片] 3.1 举例:打印电子发票3.1.1 小票模板编辑每个小票都可在后台配置小票模板,对小票的基本信息、商品信息、支付信息、买家信息、其他信息进行编辑,且编辑后之后可以实时预览,小票模板编辑完成后,有赞零售 app 启动后会拉取小票模板数据,存在本地,当下次触发小票打印任务时,会将本地模板数据与打印数据进行结合,传入到 JsCore 中,输出打印协议,传输到打印机中进行打印。 小票模板配置样式[图片] 小票模板预览样式[图片] 小票模板配置源代码 <html><head></head> <body> <p style="font-size:24px;text-align:center;">{{shopName}}</p> <p style="font-size:24px;text-align:center;">电子发票自助开票</p><br> <p>订单号:{{orderNo}}</p> <p>订单时间:{{createTime}}</p> <p>店铺名称:{{shopName}}</p><br> <p>电子发票开票日期同申请电子发票的日期</p><br> <p>建议您在消费后{{timeScopeStr}}扫码开具发票,超过建议时间后无法开票请联系商家,服务电话:{{shopPhone}}</p> <qrcode style="text-align:center;">{{invoiceUrl}}</qrcode> <p style="text-align:center;">微信扫码开具电子发票</p> </body> </html> 3.1.2 小票打印内容:小票进行打印时,实时从后端拉取打印内容 {"shopName":"有赞的店", "orderNo":"12345xxx", "createTime":"2020/07/01-11:00", "timeScopeStr":"12345xxx", "invoiceUrl":"http://xxxxxx"} 3.1.3 JsCore执行流程:将小票打印内容与打印模板数据传入到 JsCore 中,js 会将模板进行填充(打印模板中{{ key }}与打印内容的 value 映射匹配起来),jsCore 解析 html 样式,翻译成相应的打印协议( ESC / POS 、三方打印机自定义打印协议等) 3.1.4 JsCore封装打印协议优势:多端打印协议解析逻辑统一,节省人员投入成本js 可动态下发,动态修复线上问题,无需发版jsCore 单端维护,开发与维护成本非常低四、副屏布局插件化改造商家在使用有赞零售进行收银过程中每天都会进行开单操作,开单完成后大部分顾客都想实时感知自己买了哪些商品、结算了多少钱、享受了多少优惠,为了保证交易透明,零售开发了副屏功能,支持将购物车商品列表、会员信息、支付信息、营销结算等信息实时投影到副屏上,同时支持闲时、忙时动态配置切换,商家可在pc后台编辑广告图片与视频资源,投放到有赞零售app副屏上,起到广告宣传作用。 [图片] 副屏内容编辑后台[图片] 副屏开发过程中也磕磕绊绊,踩过不少坑,比如副屏的连接稳定性、View 的布局绘制性能、图片内存占用被打爆问题,且副屏的架构设计也经历了几次迭代,现在功能趋于稳定,业务方可以灵活定制自己的插件,注册到副屏模块中,模块底层识别插件,按照一定的规则进行渲染展示。在保证高扩展性的同时,也降低了接入成本。 UML[图片] SubMainManager 作为副屏初始化入口,在 App Application 初始化的时候被调用,目前实现了 Sunmi7Manager(商米 7 寸,AIDL 通信)、Sunmi14Manager (商米 14 寸, AIDL 通信)、SunmiT2AclasManager (商米 T2 等设备,presentation 通信)等设备, 通过 SubEntity 对象与副屏进行通信,业务方可自定义 Plugin( Plugin 内自定义业务需要显示的 View ),发送到相应设备的副屏 Manager ,副屏 Manager 最终会调用 SubTemplateManger 对 SubEntity 进行解析,将业务 Plugin (反射构造 Plugin 实体)提供的 LayoutId 解析成相应的 View ,添加到副屏上进行渲染投屏。 4.1 SubEntity主机通过 SubEntity 与副屏进行通信,可定义具体的 action 、 jsonData (业务方自定义数据)、 leftPlugin (副屏左边屏幕显示的插件,内容为 plugin 实体的 className )、 rightPlugin (副屏右边屏幕显示的插件,内容为 plugin 实体的 className )等。 @Keep public class SubEntity { ... ... @SerializedName("action") public int action; @SerializedName("title") public String title; @SerializedName("sub_setting") public String subSetting; @SerializedName("templateName") public String templateName; /** * 统一数据 * (如果为数据结构,建议请自行通过json解析与反解析, * 原则上只通过此字符串交流,如果要处理数据,请自行序列化,并且自行解析。 * 本质上仅为字符串,由使用方充分使用即可) */ @SerializedName("jsonData") @Nullable public String jsonData; @SerializedName("leftPlugin") public String leftPlugin; @SerializedName("rightPlugin") public String rightPlugin; ... ... } 4.2 SubPlugin每个业务实现自己的业务插件,插件中包含 LayoutId, SubTemplateManager 会解析 Plugin 数据,将插件提供的 LayoutId Inflate 成 View 渲染在副屏上 副屏样式实例: [图片] public abstract class SubPlugin { private Context context; public SubPlugin(Context mContext) { context = mContext; } public abstract int getLayout(); public abstract void createView(View view, Bundle bundle); public abstract void updateView(Bundle bundle); public abstract View getView(); @Nullable public abstract View getView(LayoutInflater inflater, @Nullable ViewGroup container); } 4.3 SubTemplateManager 解析 Plugin 过程4.3.1 通过 Plugin ClassName 反射构造 Plugin 实体@Nullable public static SubPlugin getPlugin(Context context, String className){ if(TextUtils.isEmpty(className)){ return null; } SubPlugin plugin = null; try { plugin = (SubPlugin) Class.forName(className) .getConstructor(Context.class).newInstance(context); } catch (Exception e) { e.printStackTrace(); } return plugin; } 4.3.2 解析 Plugin layoutId 字段 inflate 成 View 布局,并将 View 渲染到副屏上... ... if (null != leftPlugin) { View left = LayoutInflater.from(mContext).inflate( leftPlugin.getLayout(), leftView, false); leftPlugin.createView(left, bundle); leftView.removeAllViews(); leftView.addView(leftPlugin.getView()); } if (null != rightPlugin) { View right = LayoutInflater.from(mContext).inflate( rightPlugin.getLayout(), rightView, false); rightPlugin.createView(right, bundle); rightView.removeAllViews(); rightView.addView(rightPlugin.getView()); } ... ... 4.4 踩过的坑4.4.1 副屏存储空间有限,容易被充满,导致副屏功能不可用开启 Timer 4 小时检查一次副屏,当副屏可用存储空间小于总空间的 30 %,主动清空副屏磁盘。 mConsumer = new Consumer<Long>() { @Override public void accept(Long aLong) throws Exception { checkCache(); checkMainStorage(mContext); } }; // 默认是4小时检查一次缓存 int CHECK_CACHE_TIMER = 4; mFlowable = Flowable.interval(CHECK_CACHE_TIMER, TimeUnit.HOURS) .subscribeOn(Schedulers.io()) .doOnError(new Consumer<Throwable>() { @Override public void accept(Throwable throwable) throws Exception { throwable.printStackTrace(); } }); 4.4.2 商米 T1 副屏调试困难,前期开发过程中采用打 log 方式调试,效率非常低由于商米 T1 主副屏通过 usb 进行连接,当 pc 电脑插上 usb 后,pc 电脑将被认为是主设备,而收银机则成为从设备,收银机主副屏的连接将断开。可以通过 adb connect 方式进行调试,adb connect 192.168.xx:5555 连接主屏, adb connect 192.168.xx:5554 连接副屏, ip地址为收银机的 ip 地址。 原理图: [图片] IoT硬件问题排查过程非常痛苦,商家的网络环境、设备连接状况、外接设备类型这些关键信息总是无法及时收集,且商家反馈内容经过服务同学、技术支持等一层层上来后,信息容易失真,从而造成问题排查成本非常高。零售提供 IoT 解决方案,商家所有外接设备全部上云,当商家设备出现问题,可以通过后台数据及时拉取商家实时的设备状态,协助快速排查问题。 后台:后台可以采集设备的类型、名称、型号、连接状态等信息。 [图片] 客户端对接IoT流程:设备 sdk 检测到设备状态变更后将设备状态及时同步到 IoT 后台,同时后台可以对设备进行远程解绑、删除等操作。 [图片] 提效数据统计新硬件开发成本降低50%随着设备不断重构优化迭代,新设备接入时间成本减少了50%,团队开发提效不少 2019年新设备接入日常排期: [图片] 2020年新设备接入日常排期: [图片] 硬件线上问题数量降低33-55%2019年下半年与2020年上半年开发同学处理问题总数环比下降55%,技术支持同学处理问题总数环比下降33%。 [图片] 总结硬件在零售业务发展中起到非常重要的作用,每天支撑商家数以万计的小票打印、刷卡支付、人脸采集、称重、副屏展示等各个流程,始终为商家门店经营保驾护航。然而开发硬件的历程也经历坎坷、备受挫折,需要足够的延迟满足感。团队一直秉承追求卓越,守护信任的原则,一次次的优化重构,为每次硬件的完美交互做出了最大努力,且后续还会加油持续做的更好。 未来展望打造与完善 IoT 平台,将硬件解决方案推广到全公司,供其他业务方灵活接入。提供硬件对接开放接口,供第三方接入,比方说很多商家有自己的设备,零售没有覆盖到,商家可以对接开放接口完成设备的接入流程。开发硬件自检助手,帮助商家自己解决问题,节省开发排查问题的成本。设备同一种分类内再做粒度细分(例如可单独选择几款打印机进行依赖),提高业务对接灵活性。 有赞零售移动团队 Slogan :打造极致好用各方面业界最 NB 的移动端产品,对于每一行代码,我们都追求卓越、不随意、不凑合。 目前团队 Android 与 iOS 岗位还有空缺,欢迎优秀的人才加入我们的团队,一起搞事情。 内推邮箱地址:hongentao@youzan.com 感兴趣的商家,可点击 → 免费试用有赞店铺~ [图片]
2020-12-14 - 在开发工具里,flex布局中,map 组件高度为100%时,组件超出容器界限并且地图中心点漂移。
<view style="height: 100vh; width:100%;display: flex;flex-direction: column;"> <view style="flex:1;"><map name="" style="height: 100%; width:100%;"></map></view> <view style="height:10vh"></view> </view>
2021-05-13 - 微信小程序自定义导航栏组件(完美适配所有手机),可自定义实现任何你想要的功能
背景 在做小程序时,关于默认导航栏,我们遇到了以下的问题: Android、IOS手机对于页面title的展示不一致,安卓title的显示不居中 页面的title只支持纯文本级别的样式控制,不能够做更丰富的title效果 左上角的事件无法监听、定制 路由导航单一,只能够返回上一页,深层级页面的返回不够友好 探索 小程序自定义导航栏已开放许久>>了解一下,相信不少小伙伴已使用过这个功能,同时不少小伙伴也会发现一些坑: 机型多如牛毛:自定义导航栏高度在不同机型始终无法达到视觉上的统一 调皮的胶囊按钮:导航栏元素(文字,图标等)怎么也对不齐那该死的胶囊按钮 各种尺寸的全面屏,奇怪的刘海屏,简直要抓狂 一探究竟 为了搞明白原理,我先去翻了官方文档,>>飞机,点过去是不是很惊喜,很意外,通篇大文尽然只有最下方的一张图片与这个问题有关,并且啥也看不清,汗汗汗… 我特意找了一张图片来 [图片] 分析上图,我得到如下信息: Android跟iOS有差异,表现在顶部到胶囊按钮之间的距离差了6pt 胶囊按钮高度为32pt, iOS和Android一致 动手分析 我们写一个状态栏,通过wx.getSystemInfoSync().statusBarHeight设置高度 Android: [图片] iOS:[图片] 可以看出,iOS胶囊按钮与状态栏之间距离为:4px, Android为8px,是不是所有手机都是这种情况呢? 答案是:苹果手机确实都是4px,安卓大部分都是7和8 也会有其他的情况(可以自己打印getSystemInfo验证)如何快速便捷算出这个高度,请接着往下看 如何计算 导航栏分为状态栏和标题栏,只要能算出每台手机的导航栏高度问题就迎刃而解 导航栏高度 = 胶囊按钮高度 + 状态栏到胶囊按钮间距 * 2 + 状态栏高度 注:由于胶囊按钮是原生组件,为表现一致,其单位在各种手机中都为px,所以我们自定义导航栏的单位都必需是px(切记不能用rpx),才能完美适配。 解决问题 现在我们明白了原理,可以利用胶囊按钮的位置信息和statusBarHeight高度动态计算导航栏的高度,贴一个实现此功能最重要的方法 [代码]let systemInfo = wx.getSystemInfoSync(); let rect = wx.getMenuButtonBoundingClientRect ? wx.getMenuButtonBoundingClientRect() : null; //胶囊按钮位置信息 wx.getMenuButtonBoundingClientRect(); let navBarHeight = (function() { //导航栏高度 let gap = rect.top - systemInfo.statusBarHeight; //动态计算每台手机状态栏到胶囊按钮间距 return 2 * gap + rect.height; })(); [代码] gap信息就是不同的手机其状态栏到胶囊按钮间距,具体更多代码实现和使用demo请移步下方代码仓库,代码中还会有输入框文字跳动解决办法,安卓手机输入框文字飞出解决办法,左侧按钮边框太粗解决办法等等 胶囊信息报错和获取不到 问题就在于 getMenuButtonBoundingClientRect 这个方法,在某些机子和环境下会报错或者获取不到,对于此种情况完美可以模拟一个胶囊位置出来 [代码]try { rect = Taro.getMenuButtonBoundingClientRect ? Taro.getMenuButtonBoundingClientRect() : null; if (rect === null) { throw 'getMenuButtonBoundingClientRect error'; } //取值为0的情况 if (!rect.width) { throw 'getMenuButtonBoundingClientRect error'; } } catch (error) { let gap = ''; //胶囊按钮上下间距 使导航内容居中 let width = 96; //胶囊的宽度,android大部分96,ios为88 if (systemInfo.platform === 'android') { gap = 8; width = 96; } else if (systemInfo.platform === 'devtools') { if (ios) { gap = 5.5; //开发工具中ios手机 } else { gap = 7.5; //开发工具中android和其他手机 } } else { gap = 4; width = 88; } if (!systemInfo.statusBarHeight) { //开启wifi的情况下修复statusBarHeight值获取不到 systemInfo.statusBarHeight = systemInfo.screenHeight - systemInfo.windowHeight - 20; } rect = { //获取不到胶囊信息就自定义重置一个 bottom: systemInfo.statusBarHeight + gap + 32, height: 32, left: systemInfo.windowWidth - width - 10, right: systemInfo.windowWidth - 10, top: systemInfo.statusBarHeight + gap, width: width }; console.log('error', error); console.log('rect', rect); } [代码] 以上代码主要是借鉴了拼多多的默认值写法,android 机子中 gap 值大部分为 8,ios 都为 4,开发工具中 ios 为 5.5,android 为 7.5,这样处理之后自己模拟一个胶囊按钮的位置,这样在获取不到胶囊信息的情况下,可保证绝大多数机子完美显示导航头 吐槽 这么重要的问题,官方尽然没有提供解决方案…竟然提供了一张看不清的图片??? 网上有很多ios设置44,android设置48,还有根据不同的手机型号设置不同高度,通过长时间的开发和尝试,本人发现以上方案并不完美,并且bug很多 代码库 Taro组件gitHub地址详细用法请参考README 原生组件npm构建版本gitHub地址详细用法请参考README 原生组件简易版gitHub地址详细用法请参考README 由于本人精力有限,目前只计划发布维护好这2种组件,其他组件请自行修改代码,有问题请联系 备注 上方2种组件在最下方30多款手机测试情况表现良好 iPhone手机打电话和开热点导致导航栏样式错乱,问题已经解决啦,请去demo里测试,这里特别感谢moments网友提出的问题 本文章并无任何商业性质,如有侵权请联系本人修改或删除 文章少量部分内容是本人查询搜集而来 如有问题可以下方留言讨论,微信zhijunxh 比较 斗鱼: [图片] 虎牙: [图片] 微博: [图片] 酷狗: [图片] 知乎: [图片] [图片] 知乎是这里边做的最好的,但是我个人认为有几个可以优化的小问题 打电话或者开启热点导致样式错落,这也是大部门小程序的问题 导航栏下边距太小,看起来不舒服 搜索框距离2侧按钮组距离不对等 自定义返回和home按钮中的竖线颜色重了,并且感觉太粗 如果您看到了此篇文章,请赶快修改自己的代码,并运用在实践中吧 扫码体验我的小程序: [图片] 创作不易,如果对你有帮助,请移步Taro组件gitHub原生组件gitHub给个星星 star✨✨ 谢谢 测试信息 手机型号 胶囊位置信息 statusBarHeight 测试情况 iPhoneX 80 32 281 369 48 88 44 通过 iPhone8 plus 56 32 320 408 24 88 20 通过 iphone7 56 32 281 368 24 87 20 通过 iPhone6 plus 56 32 320 408 24 88 20 通过 iPhone6 56 32 281 368 24 87 20 通过 HUAWEI SLA-AL00 64 32 254 350 32 96 24 通过 HUAWEI VTR-AL00 64 32 254 350 32 96 24 通过 HUAWEI EVA-AL00 64 32 254 350 32 96 24 通过 HUAWEI EML-AL00 68 32 254 350 36 96 29 通过 HUAWEI VOG-AL00 65 32 254 350 33 96 25 通过 HUAWEI ATU-TL10 64 32 254 350 32 96 24 通过 HUAWEI SMARTISAN OS105 64 32 326 422 32 96 24 通过 XIAOMI MI6 59 28 265 352 31 87 23 通过 XIAOMI MI4LTE 60 32 254 350 28 96 20 通过 XIAOMI MIX3 74 32 287 383 42 96 35 通过 REDMI NOTE3 64 32 254 350 32 96 24 通过 REDMI NOTE4 64 32 254 350 32 96 24 通过 REDMI NOTE3 55 28 255 351 27 96 20 通过 REDMI 5plus 67 32 287 383 35 96 28 通过 MEIZU M571C 65 32 254 350 33 96 25 通过 MEIZU M6 NOTE 62 32 254 350 30 96 22 通过 MEIZU MX4 PRO 62 32 278 374 30 96 22 通过 OPPO A33 65 32 254 350 33 96 26 通过 OPPO R11 58 32 254 350 26 96 18 通过 VIVO Y55 64 32 254 350 32 96 24 通过 HONOR BLN-AL20 64 32 254 350 32 96 24 通过 HONOR NEM-AL10 59 28 265 352 31 87 24 通过 HONOR BND-AL10 64 32 254 350 32 96 24 通过 HONOR duk-al20 64 32 254 350 32 96 24 通过 SAMSUNG SM-G9550 64 32 305 401 32 96 24 通过 360 1801-A01 64 32 254 350 32 96 24 通过
2019-11-17 - iconfont硬核,支持多色彩、支持自定义颜色
目前市面上很多教程叫我们如何把iconfont的字体整到小程序中,基本千篇一律,都有一个特点,就是需要用字体文件。 但是用字体文件意味着只能设置一种颜色了(单色)。这是个硬伤~~~ 所以,今天笔者花了一天时间,做了一个支持多色彩、支持自定义颜色的iconfont开源库。你一定会喜欢 [代码]<iconfont name="alipay" /> <iconfont name="alipay" color="{{['red', 'orange']}}" size="300" /> [代码] [图片] 特性: 1、纯组件 2、不依赖字体文件 3、支持px和rpx两种格式 4、原样渲染多色彩图标 4、图标颜色可定制 地址:https://github.com/iconfont-cli/mini-program-iconfont-cli 喜欢的小伙伴记得给个star呦。
2019-09-26 - [填坑手册]小程序Canvas生成海报(一)--完整流程
[图片] 海报生成示例 最近智酷君在做[小程序]canvas生成海报的项目中遇到一些棘手的问题,在网上查阅了各种资料,也踩扁了各种坑,智酷君希望把这些“填坑”经验整理一下分享出来,避免后来的兄弟重复“掉坑”。 [图片] 原型图 这是一个大致的原型图,下面来看下如何制作这个海报,以及整体的思路。 [图片] 海报生成流程 [代码片段]Canvas生成海报实战demo demo的微信路径:https://developers.weixin.qq.com/s/Q74OU3m57c9x demo的ID:Q74OU3m57c9x 如果你装了IDE工具,可以直接访问上面的demo路径 通过代码片段将demo的ID输入进去也可添加: [图片] [图片] 下面分享下主要的代码内容和“填坑现场”: 一、添加字体 https://developers.weixin.qq.com/miniprogram/dev/api/canvas/font.html [代码]canvasContext.font = value //示例 ctx.font = `normal bold 20px sans-serif`//设置字体大小,默认10 ctx.setTextAlign('left'); ctx.setTextBaseline("top"); ctx.fillText("《智酷方程式》专注研究和分享前端技术", 50, 15, 250)//绘制文本 [代码] 符合 CSS font 语法的 DOMString 字符串,至少需要提供字体大小和字体族名。默认值为 10px sans-serif 文字过长在canvas下换行问题处理(最多两行,超过“…”代替) [代码]ctx.setTextAlign('left'); ctx.setFillStyle('#000');//文字颜色:默认黑色 ctx.font = `normal bold 18px sans-serif`//设置字体大小,默认10 let canvasTitleArray = canvasTitle.split(""); let firstTitle = ""; //第一行字 let secondTitle = ""; //第二行字 for (let i = 0; i < canvasTitleArray.length; i++) { let element = canvasTitleArray[i]; let firstWidth = ctx.measureText(firstTitle).width; //console.log(ctx.measureText(firstTitle).width); if (firstWidth > 260) { let secondWidth = ctx.measureText(secondTitle).width; //第二行字数超过,变为... if (secondWidth > 260) { secondTitle += "..."; break; } else { secondTitle += element; } } else { firstTitle += element; } } //第一行文字 ctx.fillText(firstTitle, 20, 278, 280)//绘制文本 //第二行问题 if (secondTitle) { ctx.fillText(secondTitle, 20, 300, 280)//绘制文本 } [代码] 通过 ctx.measureText 这个方法可以判断文字的宽度,然后进行切割。 (一行字允许宽度为280时,判断需要写小点,比如260) 二、获取临时地址并设置图片 [代码]let mainImg = "https://demo.com/url.jpg"; wx.getImageInfo({ src: mainImg,//服务器返回的图片地址 success: function (res) { //处理图片纵横比例过大或者过小的问题!!! let h = res.height; let w = res.width; let setHeight = 280, //默认源图截取的区域 setWidth = 220; //默认源图截取的区域 if (w / h > 1.5) { setHeight = h; setWidth = parseInt(280 / 220 * h); } else if (w / h < 1) { setWidth = w; setHeight = parseInt(220 / 280 * w); } else { setHeight = h; setWidth = w; }; console.log(setWidth, setHeight) ctx.drawImage(res.path, 0, 0, setWidth, setHeight, 20, 50, 280, 220); ctx.draw(true); }, fail: function (res) { //失败回调 } }); [代码] 在开发过程中如果封面图无法按照约定的比例(280x220)给到: 那么我们就需要处理默认封面图过大或者过小的问题,大致思路是:代码中通过比较纵横比(280/220=1.27)正比例放大或者缩小原图,然后从左上切割,竟可能保证过高的图是宽度100%,过宽的图是高度100%。 在canvas中draw图片,必须是一个(相对)本地路径,我们可以通过将图片保存在本地后生成的临时路径。 微信官方提供两个API: wx.downloadFile(OBJECT)和wx.getImageInfo(OBJECT)。都需先配置download域名才能生效。 三、裁切“圆形”头像画图 [代码]ctx.save(); //保存画图板 ctx.beginPath()//开始创建一个路径 ctx.arc(35, 25, 15, 0, 2 * Math.PI, false)//画一个圆形裁剪区域 ctx.clip()//裁剪 ctx.closePath(); ctx.drawImage(headImageLocal, 20, 10, 30, 30); ctx.draw(true); ctx.restore()//恢复之前保存的绘图上下文 [代码] 使用图形上下文的不带参数的clip()方法来实现Canvas的图像裁剪功能。该方法使用路径来对Canvas话不设置一个裁剪区域。因此,必须先创建好路径。创建完整后,调用clip()方法来设置裁剪区域。 需要注意的是裁剪是对画布进行的,裁切后的画布不能恢复到原来的大小,也就是说画布是越切越小的,要想保证最后仍然能在canvas最初定义的大小下绘图需要注意save()和restore()。画布是先裁切完了再进行绘图。并不一定非要是图片,路径也可以放进去~ 小程序 canvas 裁切BUG [代码]ctx.setFillStyle("#fff"); ctx.fillRect(0, 0, 320, 500); //第一个填充矩形 wx.downloadFile({ url: headUri, success(res) { ctx.beginPath() ctx.arc(50, 50, 25, 0, 2 * Math.PI) ctx.clip() ctx.drawImage(res.tempFilePath, 25, 25); //第二个填充图片 ctx.draw() ctx.restore() ctx.setFillStyle("#fff"); ctx.fillRect(0, 0, 320, 500); ctx.draw(true) ctx.restore() } }) [代码] clip裁切这个功能,如果有超过一张图片/背景叠加,则裁切效果失效。 错误参考:http://html51.com/info-38753-1/ 四、将canvas导出成虚拟地址 [代码]wx.canvasToTempFilePath({ fileType: 'jpg', canvasId: 'customCanvas', success: (res) => { console.log(res.tempFilePath) //为canvas的虚拟地址 } }) res: { errMsg: "canvasToTempFilePath:ok", tempFilePath: "http://tmp/wx02935bb29080a7b4.o6zAJswFAuZuKQ5NZfPr….cGnD1a02PlVC0b3284be3a41d08986c2477579a5fd8e.jpg" } [代码] 这里需要把canvas里面的内容,导出成一个临时地址才能保存在相册,比如: http://tmp/wx02935bb29080a7b4.o6zAJswFAuZuKQ5NZfPr5UfJVR4k.cGnD1a02PlVC0b3284be3a41d08986c2477579a5fd8e.jpg 五、询问并获取访问手机本地相册权限 [代码]wx.getSetting({ success(res) { console.log(res) if (!res.authSetting['scope.writePhotosAlbum']) { //判断权限 wx.authorize({ //获取权限 scope: 'scope.writePhotosAlbum', success() { console.log('授权成功') //转化路径 self.saveImg(); } }) } else { self.saveImg(); } } }) [代码] 判断是否有访问相册的权限,如果没有,则请求权限。 六、保存到用户手机本地相册 [代码]wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: function (data) { wx.showToast({ title: '保存到系统相册成功', icon: 'success', duration: 2000 }) }, fail: function (err) { console.log(err); if (err.errMsg === "saveImageToPhotosAlbum:fail auth deny") { console.log("当初用户拒绝,再次发起授权") wx.openSetting({ success(settingdata) { console.log(settingdata) if (settingdata.authSetting['scope.writePhotosAlbum']) { console.log('获取权限成功,给出再次点击图片保存到相册的提示。') } else { console.log('获取权限失败,给出不给权限就无法正常使用的提示') } } }) } else { wx.showToast({ title: '保存失败', icon: 'none' }); } }, complete(res) { console.log(res); } }) [代码] 保存到本地需要一定的时间,需要加一个loading的状态。 七、关于组件中引用canvas [代码]let ctx = wx.createCanvasContext('posterCanvas',this); //需要加this [代码] 在components中canvas无法选中的问题: 在components自定义组件下,当前组件实例的this,表示在这个自定义组件下查找拥有 canvas-id 的 <canvas> ,如果省略则不在任何自定义组件内查找。
2021-09-13 - 小程序之「 navigateBack 」公共页面案例
navigateBack的用法 [代码] wx.navigateBack({ delta: backSize // backsize表示关闭几页,从页面栈中移除几个(包含本页面) }) [代码] 项目中的需求 「 需求 」:做一个公共结果页,从其他地方跳转到这个页面,最后点击"完成按钮或者右上角返回"后,回到想回到的页面,并且控制目标页面刷新还是不刷新。 「 分析 」:想实现回到想回到的页面,通过对页面栈的管理来实现。 「 举例 」:页面栈中有1-2-3-4-5,5是公用页面,现在想跳到1,那么就可以通过navigateBack的delta值等于4来控制回到1,我们就动态改变delta的值实现动态跳转,navigateBack会让页面出栈,这样就很好地维护了页面栈。 具体思路 通过getCurrentPages( )能够拿到当前小程序页面栈的数组,数组中有个key为route,该值即是栈里页面地址。要跳转到1,4传值给5,5中通过遍历页面栈数组,判断传过来的值是否等于页面栈中的route值,相等的话,记录index,这样即可完成。 最终效果 无论是触发了ios滑屏退出、android物理按钮返回、小程序左上角返回按钮,都会回到目标页面。 但是,经真机测试后发现。点击左上角返回、手机物理返回键、滑屏返回到目标页面,效果上会关闭两个页面。但最终也可以回到目标页面。只能说功能达到了,效果上没那么完美。这没办法。毕竟页面卸载的处理本身就是一个伪处理-_- 代码说话 这是项目中用到的公用页面,就是通过navigateBack来控制跳转 注释写得很清楚了 想要更清晰地了解页面栈是怎样的,就打断点看一看 有问题欢迎留言讨论😄 [代码]Page({ data: { isInvoke: false,// 是否调用过按钮 isUnload: true,// 是否是卸载的生命周期 resultImg: "", // 图标(页面内使用) result: { title: "",// 必传(页面标题) type: 1,// 必传(0:失败 1:成功) url: "", // 必传(跳转的url),传空或者不在页面栈中,跳转到首页或指定页面 // 选传(上面文案布局,成功默认是:成功,失败默认:失败) resultUp: "", // 选传(下面文案布局,不传默认是空 resultDown: "", // 选传(按钮名称,成功默认:完成,失败默认:重试) btnName: "", // 选传(是否刷新目标页,默认刷新) isRefresh: true, } }, /** 页面初始化 */ onLoad: function (options) { }, onShow: function () { this.initData(); }, /** 页面卸载*/ onUnload: function () { // 如果点击了按钮,就不再调用它 if (!this.data.isInvoke) { this.data.isUnload = true this.targetJump() } }, /** 初始化值 */ initData: function () { var result = wx.getStorageSync("result") if (result) { // 页面标题 wx.setNavigationBarTitle({ title: result.title }) var type = result.type this.data.result.url = result.url if (typeof (result.isRefresh) != "undefined") { this.data.result.isRefresh = result.isRefresh } if (type) { // 成功 result.btnName = result.btnName ? result.btnName : "完成" this.data.resultImg = "/resources/success.png" result.resultUp = result.resultUp ? result.resultUp : "成功" } else {// 失败 result.btnName = result.btnName ? result.btnName : "重试" this.data.resultImg = "/resources/failed.png" result.resultUp = result.resultUp ? result.resultUp : "失败" } var btnName = "result.btnName" var resultUp = "result.resultUp" var resultDown = "result.resultDown" this.setData({ resultImg: this.data.resultImg, [resultUp]: result.resultUp, [resultDown]: result.resultDown ? result.resultDown : "", [btnName]: result.btnName }) } }, /** 按钮点击事件 */ btnClick: function () { this.data.isInvoke = true this.data.isUnload = false this.targetJump(); }, /** 目标页面跳转 */ targetJump: function () { // 清除缓存 wx.removeStorageSync("result") var pages = getCurrentPages() // 如果url为空或者不在页面栈中,返回首页 var backSize = 100 var target console.log(pages) for (var i = 0;i < pages.length;i++) { if (this.data.result.url.substring(1) == pages[i].route) { // 自身也在栈中,所以要-1 backSize = pages.length - i - 1; target = pages[i] console.log("target", target) break } } console.log("backSize", backSize) if (backSize == 100) {// 首页或者指定页面 wx.reLaunch({ url: this.data.result.url, }) return } if (this.data.result.isRefresh) { // 刷新目标页面 target.onLoad() } if (this.data.isUnload) {// 用户点击了返回键 // 如果目标页面在页面栈中倒数第二个位置,返回 if (backSize == 1) { return } // 如果只是调用了小程序返回键,因为back键默认会关掉一页 backSize = backSize - 1 } wx.navigateBack({ delta: backSize }) } }) [代码] 以上
2019-05-28 - 小程序开发另类小技巧 --用户授权篇
小程序开发另类小技巧 --用户授权篇 getUserInfo较为特殊,不包含在本文范围内,主要针对需要授权的功能性api,例如:wx.startRecord,wx.saveImageToPhotosAlbum, wx.getLocation 原文地址:https://www.yuque.com/jinxuanzheng/gvhmm5/arexcn 仓库地址:https://github.com/jinxuanzheng01/weapp-auth-demo 背景 小程序内如果要调用部分接口需要用户进行授权,例如获取地理位置信息,收获地址,录音等等,但是小程序对于这些需要授权的接口并不是特别友好,最明显的有两点: 如果用户已拒绝授权,则不会出现弹窗,而是直接进入接口 fail 回调, 没有统一的错误信息提示,例如错误码 一般情况而言,每次授权时都应该激活弹窗进行提示,是否进行授权,例如: [图片] 而小程序内只有第一次进行授权时才会主动激活弹窗(微信提供的),其他情况下都会直接走fail回调,微信文档也在句末添加了一句请开发者兼容用户拒绝授权的场景, 这种未做兼容的情况下如果用户想要使用录音功能,第一次点击拒绝授权,那么之后无论如何也无法再次开启录音权限**,很明显不符合我们的预期。 所以我们需要一个可以进行二次授权的解决方案 常见处理方法 官方demo 下面这段代码是微信官方提供的授权代码, 可以看到也并没有兼容拒绝过授权的场景查询是否授权(即无法再次调起授权) [代码]// 可以通过 wx.getSetting 先查询一下用户是否授权了 "scope.record" 这个 scope wx.getSetting({ success(res) { if (!res.authSetting['scope.record']) { wx.authorize({ scope: 'scope.record', success () { // 用户已经同意小程序使用录音功能,后续调用 wx.startRecord 接口不会弹窗询问 wx.startRecord() } }) } } }) [代码] 一般处理方式 那么正常情况下我们该怎么做呢?以地理位置信息授权为例: [代码]wx.getLocation({ success(res) { console.log('success', res); }, fail(err) { // 检查是否是因为未授权引起的错误 wx.getSetting({ success (res) { // 当未授权时直接调用modal窗进行提示 !res.authSetting['scope.userLocation'] && wx.showModal({ content: '您暂未开启权限,是否开启', confirmColor: '#72bd4a', success: res => { // 用户确认授权后,进入设置列表 if (res.confirm) { wx.openSetting({ success(res){ // 查看设置结果 console.log(!!res.authSetting['scope.userLocation'] ? '设置成功' : '设置失败'); }, }); } } }); } }); } }); [代码] 上面代码,有些同学可能会对在fail回调里直接使用wx.getSetting有些疑问,这里主要是因为 微信返回的错误信息没有一个统一code errMsg又在不同平台有不同的表现 从埋点数据得出结论,调用这些api接口出错率基本集中在未授权的状态下 这里为了方便就直接调用权限检查了 ,也可以稍微封装一下,方便扩展和复用,变成: [代码] bindGetLocation(e) { let that = this; wx.getLocation({ success(res) { console.log('success', res); }, fail(err) { that.__authorization('scope.userLocation'); } }); }, bindGetAddress(e) { let that = this; wx.chooseAddress({ success(res) { console.log('success', res); }, fail(err) { that.__authorization('scope.address'); } }); }, __authorization(scope) { /** 为了节省行数,不细写了,可以参考上面的fail回调,大致替换了下变量res.authSetting[scope] **/ } [代码] 看上去好像没有什么问题,fail里只引入了一行代码, 这里如果只针对较少页面的话我认为已经够用了,毕竟**‘如非必要,勿增实体’,但是对于小打卡这个小程序来说可能涉及到的页面,需要调用的场景偏多**,我并不希望每次都人工去调用这些方法,毕竟人总会犯错 梳理目标 上文已经提到了背景和常见的处理方法,那么梳理一下我们的目标,我们到底是为了解决什么问题?列了下大致为下面三点: 兼容用户拒绝授权的场景,即提供二次授权 解决多场景,多页面调用没有统一规范的问题 在底层解决,业务层不需要关心二次授权的问题 扩展wx[funcName]方法 为了节省认知成本和减少出错概率,我希望他是这个api默认携带的功能,也就是说因未授权出现错误时自动调起是否开启授权的弹窗 为了实现这个功能,我们可能需要对wx的原生api进行一层包装了(关于页面的包装可以看:如何基于微信原生构建应用级小程序底层架构) 为wx.getLocation添加自己的方法 这里需要注意的一点是直接使用常见的装饰模式是会出现报错,因为wx这个对象在设置属性时没有设置set方法,这里需要单独处理一下 [代码]// 直接装饰,会报错 Cannot set property getLocation of #<Object> which has only a getter let $getLocation = wx.getLocation; wx.getLocation = function (obj) { $getLocation(obj); }; // 需要做一些小处理 wx = {...wx}; // 对wx对象重新赋值 let $getLocation = wx.getLocation; wx.getLocation = function (obj) { console.log('调用了wx.getLocation'); $getLocation(obj); }; // 再次调用时会在控制台打印出 '调用了wx.getLocation' 字样 wx.getLocation() [代码] 劫持fail方法 第一步我们已经控制了wx.getLocation这个api,接下来就是对于fail方法的劫持,因为我们需要在fail里加入我们自己的授权逻辑 [代码]// 方法劫持 wx.getLocation = function (obj) { let originFail = obj.fail; obj.fail = async function (errMsg) { // 0 => 已授权 1 => 拒绝授权 2 => 授权成功 let authState = await authorization('scope.userLocation'); // 已授权报错说明并不是权限问题引起,所以继续抛出错误 // 拒绝授权,走已有逻辑,继续排除错误 authState !== 2 && originFail(errMsg); }; $getLocation(obj); }; // 定义检查授权方法 function authorization(scope) { return new Promise((resolve, reject) => { wx.getSetting({ success (res) { !res.authSetting[scope] ? wx.showModal({ content: '您暂未开启权限,是否开启', confirmColor: '#72bd4a', success: res => { if (res.confirm) { wx.openSetting({ success(res){ !!res.authSetting[scope] ? resolve(2) : resolve(1) }, }); }else { resolve(1); } } }) : resolve(0); } }) }); } // 业务代码中的调用 bindGetLocation(e) { let that = this; wx.getLocation({ type: 'wgs84', success(res) { console.log('success', res); }, fail(err) { console.warn('fail', err); } }); } [代码] 可以看到现在已实现的功能已经达到了我们最开始的预期,即因授权报错作为了wx.getLocation默认携带的功能,我们在业务代码里再也不需要处理任何再次授权的逻辑 也意味着wx.getLocation这个api不论在任何页面,组件,出现频次如何,**我们都不需要关心它的授权逻辑(**效果本来想贴gif图的,后面发现有图点大,具体效果去git仓库跑一下demo吧) 让我们再优化一波 上面所述大致是整个原理的一个思路,但是应用到实际项目中还需要考虑到整体的扩展性和维护成本,那么就让我们再来优化一波 代码包结构: 本质上只要在app.js这个启动文件内,引用./x-wxx/index文件对原有的wx对象进行覆盖即可 [图片] **简单的代码逻辑: ** [代码]// 大致流程: //app.js wx = require('./x-wxx/index'); // 入口处引入文件 // x-wxx/index const apiExtend = require('./lib/api-extend'); module.exports = (function (wxx) { // 对原有方法进行扩展 wxx = {...wxx}; for (let key in wxx) { !!apiExtend[key] && (()=> { // 缓存原有函数 let originFunc = wxx[key]; // 装饰扩展的函数 wxx[key] = (...args) => apiExtend[key](...args, originFunc); })(); } return wxx; })(wx); // lib/api-extend const Func = require('./Func'); (function (exports) { // 需要扩展的api(类似于config) // 获取权限 exports.authorize = function (opts, done) { // 当调用为"确认授权方法时"直接执行,避免死循环 if (opts.$callee === 'isCheckAuthApiSetting') { console.log('optsopts', opts); done(opts); return; } Func.isCheckAuthApiSetting(opts.scope, () => done(opts)); }; // 选择地址 exports.chooseAddress = function (opts, done) { Func.isCheckAuthApiSetting('scope.address', () => done(opts)); }; // 获取位置信息 exports.getLocation = function (opts, done) { Func.isCheckAuthApiSetting('scope.userLocation', () => done(opts)); }; // 保存到相册 exports.saveImageToPhotosAlbum = function (opts, done) { Func.isCheckAuthApiSetting('scope.writePhotosAlbum', () => done(opts)); } // ...more })(module.exports); [代码] 更多的玩法 可以看到我们无论后续扩展任何的微信api,都只需要在lib/api-extend.js 配置即可,这里不仅仅局限于授权,也可以做一些日志,传参的调整,例如: [代码] // 读取本地缓存(同步) exports.getStorageSync = (key, done) => { let storage = null; try { storage = done(key); } catch (e) { wx.$logger.error('getStorageSync', {msg: e.type}); } return storage; }; [代码] 这样是不是很方便呢,至于Func.isCheckAuthApiSetting这个方法具体实现,为了节省文章行数请自行去git仓库里查看吧 关于音频授权 录音授权略为特殊,以wx.getRecorderManager为例,它并不能直接调起录音授权,所以并不能直接用上述的这种方法,不过我们可以曲线救国,达到类似的效果,还记得我们对于wx.authorize的包装么,本质上我们是可以直接使用它来进行授权的,比如将它用在我们已经封装好的录音管理器的start方法进行校验 [代码]wx.authorize({ scope: 'scope.record' }); [代码] 实际上,为方便统一管理,Func.isCheckAuthApiSetting方法其实都是使用wx.authorize来实现授权的 [代码]exports.isCheckAuthApiSetting = async function(type, cb) { // 简单的类型校验 if(!type && typeof type !== 'string') return; // 声明 let err, result; // 获取本地配置项 [err, result] = await to(getSetting()); // 这里可以做一层缓存,检查缓存的状态,如果已授权可以不必再次走下面的流程,直接return出去即可 if (err) { return cb('fail'); } // 当授权成功时,直接执行 if (result.authSetting[type]) { return cb('success'); } // 调用获取权限 [err, result] = await to(authorize({scope: type, $callee: 'isCheckAuthApiSetting'})); if (!err) { return cb('success'); } } [代码] 关于用户授权 用户授权极为特殊,因为微信将wx.getUserInfo升级了一版,没有办法直接唤起了,详见《公告》,所以需要单独处理,关于这里会拆出单独的一篇文章来写一些有趣的玩法 总结 最后稍微总结下,通过上述的方案,我们解决了最开始目标的同时,也为wx这个对象上的方法提供了统一的装饰接口(lib/api-extend文件),便于后续其他行为的操作比如埋点,日志,参数校验 还是那么一句话吧,小程序不管和web开发有多少不同,本质上都是在js环境上进行开发的,希望小程序的社区环境更加活跃,带来更多有趣的东西
2019-06-14 - 一个通用request的封装
小程序内置了[代码]wx.request[代码],用于向后端发送请求,我们先来看看它的文档: wx.request(OBJECT) 发起网络请求。使用前请先阅读说明。 OBJECT参数说明: 参数名 类型 必填 默认值 说明 最低版本 url String 是 - 开发者服务器接口地址 - data Object/String/ArrayBuffer 否 - 请求的参数 - header Object 否 - 设置请求的 header,header 中不能设置 Referer。 - method String 否 GET (需大写)有效值:OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT - dataType String 否 json 如果设为json,会尝试对返回的数据做一次 JSON.parse - responseType String 否 text 设置响应的数据类型。合法值:text、arraybuffer 1.7.0 success Function 否 - 收到开发者服务成功返回的回调函数 - fail Function 否 - 接口调用失败的回调函数 - complete Function 否 - 接口调用结束的回调函数(调用成功、失败都会执行) - success返回参数说明: 参数 类型 说明 最低版本 data Object/String/ArrayBuffer 开发者服务器返回的数据 - statusCode Number 开发者服务器返回的 HTTP 状态码 - header Object 开发者服务器返回的 HTTP Response Header 1.2.0 这里我们主要看两点: 回调函数:success、fail、complete; success的返回参数:data、statusCode、header。 相对于通过入参传回调函数的方式,我更喜欢promise的链式,这是我期望的第一个点;success的返回参数,在实际开发过程中,我只关心data部分,这里可以做一下处理,这是第二点。 promisify 小程序默认支持promise,所以这一点改造还是很简单的: [代码]/** * promise请求 * 参数:参考wx.request * 返回值:[promise]res */ function requestP(options = {}) { const { success, fail, } = options; return new Promise((res, rej) => { wx.request(Object.assign( {}, options, { success: res, fail: rej, }, )); }); } [代码] 这样一来我们就可以使用这个函数来代替wx.request,并且愉快地使用promise链式: [代码]requestP({ url: '/api', data: { name: 'Jack' } }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); [代码] 注意,小程序的promise并没有实现finally,Promise.prototype.finally是undefined,所以complete不能用finally代替。 精简返回值 精简返回值也是很简单的事情,第一直觉是,当请求返回并丢给我一大堆数据时,我直接resolve我要的那一部分数据就好了嘛: [代码]return new Promise((res, rej) => { wx.request(Object.assign( {}, options, { success(r) { res(r.data); // 这里只取data }, fail: rej, }, )); }); [代码] but!这里需要注意,我们仅仅取data部分,这时候默认所有success都是成功的,其实不然,wx.request是一个基础的api,fail只发生在系统和网络层面的失败情况,比如网络丢包、域名解析失败等等,而类似404、500之类的接口状态,依旧是调用success,并体现在[代码]statusCode[代码]上。 从业务上讲,我只想处理json的内容,并对json当中的相关状态进行处理;如果一个接口返回的不是约定好的json,而是类似404、500之类的接口异常,我统一当成接口/网络错误来处理,就像jquery的ajax那样。 也就是说,如果我不对[代码]statusCode[代码]进行区分,那么包括404、500在内的所有请求结果都会走[代码]requestP().then[代码],而不是[代码]requestP().catch[代码]。这显然不是我们熟悉的使用方式。 于是我从jquery的ajax那里抄来了一段代码。。。 [代码]/** * 判断请求状态是否成功 * 参数:http状态码 * 返回值:[Boolen] */ function isHttpSuccess(status) { return status >= 200 && status < 300 || status === 304; } [代码] [代码]isHttpSuccess[代码]用来决定一个http状态码是否判为成功,于是结合[代码]requestP[代码],我们可以这么来用: [代码]return new Promise((res, rej) => { wx.request(Object.assign( {}, options, { success(r) { const isSuccess = isHttpSuccess(r.statusCode); if (isSuccess) { // 成功的请求状态 res(r.data); } else { rej({ msg: `网络错误:${r.statusCode}`, detail: r }); } }, fail: rej, }, )); }); [代码] 这样我们就可以直接resolve返回结果中的data,而对于非成功的http状态码,我们则直接reject一个自定义的error对象,这样就是我们所熟悉的ajax用法了。 登录 我们经常需要识别发起请求的当前用户,在web中这通常是通过请求中携带的cookie实现的,而且对于前端开发者是无感知的;小程序中没有cookie,所以需要主动地去补充相关信息。 首先要做的是:登录。 通过[代码]wx.login[代码]接口我们可以得到一个[代码]code[代码],调用后端登录接口将code传给后端,后端再用code去调用微信的登录接口,换取[代码]sessionKey[代码],最后生成一个[代码]sessionId[代码]返回给前端,这就完成了登录。 [图片] 具体参考微信官方文档:wx.login [代码]const apiUrl = 'https://jack-lo.github.io'; let sessionId = ''; /** * 登录 * 参数:undefined * 返回值:[promise]res */ function login() { return new Promise((res, rej) => { // 微信登录 wx.login({ success(r1) { if (r1.code) { // 获取sessionId requestP({ url: `${apiUrl}/api/login`, data: { code: r1.code, }, method: 'POST' }) .then((r2) => { if (r2.rcode === 0) { const { sessionId } = r2.data; // 保存sessionId sessionId = sessionId; res(r2); } else { rej({ msg: '获取sessionId失败', detail: r2 }); } }) .catch((err) => { rej(err); }); } else { rej({ msg: '获取code失败', detail: r1 }); } }, fail: rej, }); }); } [代码] 好的,我们做好了登录并且顺利获取到了sessionId,接下来是考虑怎么把sessionId通过请求带上去。 sessionId 为了将状态与数据区分开来,我们决定不通过data,而是通过header的方式来携带sessionId,我们对原本的requestP稍稍进行修改,使得它每次调用都自动在header里携带sessionId: [代码]function requestP(options = {}) { const { success, fail, } = options; // 统一注入约定的header let header = Object.assign({ sessionId: sessionId }, options.header); return new Promise((res, rej) => { ... }); } [代码] 好的,现在请求会自动带上sessionId了; 但是,革命尚未完成: 我们什么时候去登录呢?或者说,我们什么时候去获取sessionId? 假如还没登录就发起请求了怎么办呢? 登录过期了怎么办呢? 我设想有这样一个逻辑: 当我发起一个请求的时候,如果这个请求不需要sessionId,则直接发出; 如果这个请求需要携带sessionId,就去检查现在是否有sessionId,有的话直接携带,发起请求; 如果没有,自动去走登录的流程,登录成功,拿到sessionId,再去发送这个请求; 如果有,但是最后请求返回结果是sessionId过期了,那么程序自动走登录的流程,然后再发起一遍。 其实上面的那么多逻辑,中心思想只有一个:都是为了拿到sessionId! 我们需要对请求做一层更高级的封装。 首先我们需要一个函数专门去获取sessionId,它将解决上面提到的2、3点: [代码]/** * 获取sessionId * 参数:undefined * 返回值:[promise]sessionId */ function getSessionId() { return new Promise((res, rej) => { // 本地sessionId缺失,重新登录 if (!sessionId) { login() .then((r1) => { res(r1.data.sessionId); }) .catch(rej); } } else { res(sessionId); } }); } [代码] 好的,接下来我们解决第1、4点,我们先假定:sessionId过期的时候,接口会返回[代码]code=401[代码]。 整合了getSessionId,得到一个更高级的request方法: [代码]/** * ajax高级封装 * 参数:[Object]option = {},参考wx.request; * [Boolen]keepLogin = false * 返回值:[promise]res */ function request(options = {}, keepLogin = true) { if (keepLogin) { return new Promise((res, rej) => { getSessionId() .then((r1) => { // 获取sessionId成功之后,发起请求 requestP(options) .then((r2) => { if (r2.rcode === 401) { // 登录状态无效,则重新走一遍登录流程 // 销毁本地已失效的sessionId sessionId = ''; getSessionId() .then((r3) => { requestP(options) .then(res) .catch(rej); }); } else { res(r2); } }) .catch(rej); }) .catch(rej); }); } else { // 不需要sessionId,直接发起请求 return requestP(options); } } [代码] 留意req的第二参数keepLogin,是为了适配有些接口不需要sessionId,但因为我的业务里大部分接口都需要登录状态,所以我默认值为true。 这差不多就是我们封装request的最终形态了。 并发处理 这里其实我们还需要考虑一个问题,那就是并发。 试想一下,当我们的小程序刚打开的时候,假设页面会同时发出5个请求,而此时没有sessionId,那么,这5个请求按照上面的逻辑,都会先去调用login去登录,于是乎,我们就会发现,登录接口被同步调用了5次!并且后面的调用将导致前面的登录返回的sessionId过期~ 这bug是很严重的,理论上来说,登录我们只需要调用一次,然后一直到过期为止,我们都不需要再去登录一遍了。 ——那么也就是说,同一时间里的所有接口其实只需要登录一次就可以了。 ——也就是说,当有登录的请求发出的时候,其他那些也需要登录状态的接口,不需要再去走登录的流程,而是等待这次登录回来即可,他们共享一次登录操作就可以了! 解决这个问题,我们需要用到队列。 我们修改一下getSessionId这里的逻辑: [代码]const loginQueue = []; let isLoginning = false; /** * 获取sessionId * 参数:undefined * 返回值:[promise]sessionId */ function getSessionId() { return new Promise((res, rej) => { // 本地sessionId缺失,重新登录 if (!sessionId) { loginQueue.push({ res, rej }); if (!isLoginning) { isLoginning = true; login() .then((r1) => { isLoginning = false; while (loginQueue.length) { loginQueue.shift().res(r1); } }) .catch((err) => { isLoginning = false; while (loginQueue.length) { loginQueue.shift().rej(err); } }); } } else { res(sessionId); } }); } [代码] 使用了isLoginning这个变量来充当锁的角色,锁的目的就是当登录正在进行中的时候,告诉程序“我已经在登录了,你先把回调都加队列里去吧”,当登录结束之后,回来将锁解开,把回调全部执行并清空队列。 这样我们就解决了问题,同时提高了性能。 封装 在做完以上工作以后,我们都很清楚的封装结果就是[代码]request[代码],所以我们把request暴露出去就好了: [代码]function request() { ... } module.exports = request; [代码] 这般如此之后,我们使用起来就可以这样子: [代码]const request = require('request.js'); Page({ ready() { // 获取热门列表 request({ url: 'https://jack-lo.github.io/api/hotList', data: { page: 1 } }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); // 获取最新信息 request({ url: 'https://jack-lo.github.io/api/latestList', data: { page: 1 } }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); }, }); [代码] 是不是很方便,可以用promise的方式,又不必关心登录的问题。 然而可达鸭眉头一皱,发现事情并不简单,一个接口有可能在多个地方被多次调用,每次我们都去手写这么一串[代码]url[代码]参数,并不那么方便,有时候还不好找,并且容易出错。 如果能有个地方专门记录这些url就好了;如果每次调用接口,都能像调用一个函数那么简单就好了。 基于这个想法,我们还可以再做一层封装,我们可以把所有的后端接口,都封装成一个方法,调用接口就相对应调用这个方法: [代码]const apiUrl = 'https://jack-lo.github.io'; const req = { // 获取热门列表 getHotList(data) { const url = `${apiUrl}/api/hotList` return request({ url, data }); }, // 获取最新列表 getLatestList(data) { const url = `${apiUrl}/api/latestList` return request({ url, data }); } } module.exports = req; // 注意这里暴露的已经不是request,而是req [代码] 那么我们的调用方式就变成了: [代码]const req = require('request.js'); Page({ ready() { // 获取热门列表 req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); // 获取最新信息 req.getLatestList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); } }); [代码] 这样一来就方便了很多,而且有一个很大的好处,那就是当某个接口的地址需要统一修改的时候,我们只需要对[代码]request.js[代码]进行修改,其他调用的地方都不需要动了。 错误信息的提炼 最后的最后,我们再补充一个可轻可重的点,那就是错误信息的提炼。 当我们在封装这么一个[代码]req[代码]对象的时候,我们的promise曾经reject过很多的错误信息,这些错误信息有可能来自: [代码]wx.request[代码]的fail; 不符合[代码]isHttpSuccess[代码]的网络错误; getSessionId失败; … 等等的一切可能。 这就导致了我们在提炼错误信息的时候陷入困境,到底catch到的会是哪种[代码]error[代码]对象? 这么看你可能不觉得有问题,我们来看看下面的例子: [代码]req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); [代码] 假如上面的例子中,我想要的不仅仅是[代码]console.log(err)[代码],而是想将对应的错误信息弹窗出来,我应该怎么做? 我们只能将所有可能出现的错误都检查一遍: [代码]req.getHotList({ page: 1 }) .then((res) => { if (res.code !== 0) { // 后端接口报错格式 wx.showModal({ content: res.msg }); } }) .catch((err) => { let msg = '未知错误'; // 文本信息直接使用 if (typeof err === 'string') { msg = err; } // 小程序接口报错 if (err.errMsg) { msg = err.errMsg; } // 自定义接口的报错,比如网络错误 if (err.detail && err.detail.errMsg) { msg = err.detail.errMsg; } // 未知错误 wx.showModal({ content: msg }); }); [代码] 这就有点尴尬了,提炼错误信息的代码量都比业务还多几倍,而且还是每个接口调用都要写一遍~ 为了解决这个问题,我们需要封装一个方法来专门做提炼的工作: [代码]/** * 提炼错误信息 * 参数:err * 返回值:[string]errMsg */ function errPicker(err) { if (typeof err === 'string') { return err; } return err.msg || err.errMsg || (err.detail && err.detail.errMsg) || '未知错误'; } [代码] 那么过程会变成: [代码]req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { const msg = req.errPicker(err); // 未知错误 wx.showModal({ content: msg }); }); [代码] 好吧,我们再偷懒一下,把wx.showModal也省去了: [代码]/** * 错误弹窗 */ function showErr(err) { const msg = errPicker(err); console.log(err); wx.showModal({ showCancel: false, content: msg }); } [代码] 最后就变成了: [代码]req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch(req.showErr); [代码] 至此,一个简单的wx.request封装过程便完成了,封装过的[代码]req[代码]比起原来,使用上更加方便,扩展性和可维护性也更好。 结尾 以上内容其实是简化版的[代码]mp-req[代码],介绍了[代码]mp-req[代码]这一工具的实现初衷以及思路,使用[代码]mp-req[代码]来管理接口会更加的便捷,同时[代码]mp-req[代码]也提供了更加丰富的功能,比如插件机制、接口的缓存,以及接口分类等,欢迎大家关注mp-req了解更多内容。 以上最终代码可以在这里获取:req.js。
2020-08-04