ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微信小程序开发避坑实录:导航栏适配、订阅消息与蓝牙定位

微信小程序开发避坑实录:导航栏适配、订阅消息与蓝牙定位 在微信小程序开发这件事上我的习惯是每做完一个阶段就写一篇笔记这是第三篇。前两篇主要写基础框架和组件用法这一篇记录的是最近几个月真实业务里踩过的一批坑顶部导航栏高度适配、列表加载更多、订阅消息弹框、蓝牙定位、防截屏、打包超限……单个问题拿出来都不算难但凑在一起几乎就是开发一个新小程序生命周期里最容易卡壳的环节。写的时候我尽量把每个问题的“为什么”也讲清楚而不只是给一个解决方案。因为小程序开发有个特点同样一段代码在开发者工具、Android 真机、iPhone 真机上表现可以完全不一样如果你不理解背后的机制改来改去也只是碰运气。1. 页面骨架和高频组件导航栏、搜索框、单选框1.1 顶部导航栏高度怎么算才准自定义导航栏是很多项目的标配尤其要把页面做“沉浸式”或者让标题栏跟产品 UI 统一。要自定义就必须知道状态栏的高度和胶囊按钮的位置算出导航栏整体高度。我常用的取参方式是这样const windowInfo wx.getWindowInfo(); const menuButton wx.getMenuButtonBoundingClientRect(); const statusBarHeight windowInfo.statusBarHeight; const navBarHeight (menuButton.top - statusBarHeight) * 2 menuButton.height;有些同学会奇怪为什么导航栏高度要用这个公式算而不是直接量一个固定值因为这个公式里包含了“胶囊按钮垂直居中”这个隐含条件。微信胶囊按钮在导航栏里通常保持垂直居中所以胶囊顶部到状态栏底部的间距大致等于胶囊底部到导航栏底部的间距。导航栏总高度就是“上下留白 × 2 胶囊自身高度”。这个公式在 iPhone、Android 绝大多数机型上都是准的这也是社区里沉淀出的通用方案。两个细节需要注意第一wx.getSystemInfoSync()已经逐渐被wx.getWindowInfo()、wx.getDeviceInfo()替代。旧接口虽然还在兼容但在新基础库下会提示 deprecated建议新项目直接用新接口。第二取参的时机不要放在 App onLaunch 里一次取完就全局死存。真机上部分 Android 机型的窗口信息会在横竖屏切换或折叠屏展开后变化更稳妥的做法是在页面 onLoad 或 onShow 时重新读取一次。我踩过这个坑折叠屏展开后导航栏高度没刷新标题直接偏到一边去了。另外自定义导航栏时页面顶部要么留一个同等高度的占位 view要么把导航栏做成 fixed 并把页面 padding-top 设置为“状态栏高度 导航栏高度”否则页面内容会顶到最上面。用 fixed 方案时还要注意 safe-area比如env(safe-area-inset-top)只适合部分场景保险的做法还是动态取数后传给样式。1.2 搜索框聚焦后偏移的真相搜索框聚焦后页面偏移这个问题的根因基本都在 Android 的键盘弹出机制。Android 上键盘弹出会压缩 WebView 视口。如果你给 input 写的是position: fixed视口一旦被压缩元素位置就会跟着漂而 iOS 的键盘是浮层一般不改变视口大小所以“iOS 没事、Android 飘”就成了经典现象。解决思路分两层。第一层给 input 加上adjust-position{{false}}关闭默认顶起行为然后监听键盘高度变化wx.onKeyboardHeightChange手动给页面底部留白。如果你不希望键盘把页面整个挤上去这个属性是必须关的。第二层如果搜索框固定在页面顶部用top: 0的方式固定不要依赖 flex 布局的文档流位置。如果搜索框在 scroll-view 内部建议直接把它挪到 scroll-view 外面因为滚动容器 输入框的组合在真机上容易出现各种意外位移。cursor-spacing也值得留意。它控制光标与键盘之间的距离可以避免输入文字被键盘盖住。但实测这个属性在 iOS 和 Android 上的表现并不完全一致真机测试时一定要分别看一遍别只看开发者工具模拟器。1.3 单选框原生 radio 只适合最简单的场景小程序原生 radio 组件默认带边框和圆形样式和业务 UI 经常对不上。基础做法是radio-group包label再在里面放 radio 和 text这样点文字也能选中用户体验会好很多。radio-group bindchangeonRadioChange label wx:for{{options}} wx:keyvalue radio value{{item.value}} checked{{item.checked}} color#07c160 / text{{item.label}}/text /label /radio-groupcolor属性可以直接改选中态颜色这是最快的定制手段。但如果你要做“卡片式单选项”“左侧图标 右侧文字 边框高亮”这类复杂样式原生 radio 并不好使。主要原因是 radio 的默认样式在 Android 真机上渲染差异比较大尤其是边框、内圆的间距细节经常和设计稿差几个像素。我的做法是直接放弃 radio 组件用 view 模拟点击时更新 data 里的 selectedValue再根据选中值控制 class。这样视觉完全可控也不会有原生组件样式不一致的问题。这里有个小坑radio-group 的 change 事件里detail.value返回的是 radio 的 value是一个字符串不是 index。如果你拿它做数组下标或者对象查找记得显式转成目标类型否则会踩到类型不匹配的 bug。2. 列表加载与数据可视化分页加载、图表和地图那些事2.1 上拉加载更多的标准姿势列表页“加载更多”几乎是标配但很多人写出来的版本会有重复请求、白屏、没有更多提示混乱这三类问题。先看套路页面onReachBottom里触发 loadMoreloadMore 内部判断 isLoading 和 hasMore条件不满足就直接 return。我习惯把所有加载逻辑收敛到一个 loadList 方法里data: { list: [], pageNo: 1, pageSize: 10, hasMore: true, isLoading: false }, async loadList(append true) { if (this.data.isLoading) return; if (!this.data.hasMore append) return; this.setData({ isLoading: true }); try { const res await request({ url: /api/list, data: { pageNo: this.data.pageNo, pageSize: this.data.pageSize } }); const rows res.data.list || []; const newList append ? this.data.list.concat(rows) : rows; this.setData({ list: newList, pageNo: this.data.pageNo 1, hasMore: rows.length this.data.pageSize, isLoading: false }); } catch (e) { this.setData({ isLoading: false }); wx.showToast({ title: 加载失败, icon: none }); } }hasMore的判断有两种一种是根据“返回条数是否等于 pageSize”另一种是拿接口返回的 total 算pageNo * pageSize total。前者简单但对后端稳定性要求高如果后端明明没有更多数据了还返回固定条数页面会出现一次空请求后者需要后端额外返回 total更稳。一个容易忽略的点onReachBottom的触发条件是页面滚动内容必须超过一屏。如果列表只有几条数据根本不会触发用户会一直以为页面坏了。所以列表不满一屏时要么自动请求下一页要么直接展示“没有更多了”的占位这个逻辑要提前设计好不能只靠触底事件。2.2 下拉刷新与数据竞态下拉刷新的标准步骤是页面 json 里开启enablePullDownRefresh在onPullDownRefresh里重置 pageNo1调用 loadList(false)成功后调用wx.stopPullDownRefresh()。如果请求失败也要 stop否则下拉动画会一直转。这里最坑的是竞态用户先触底加载了第二页过程中又下拉刷新两次请求是异步的后返回的旧请求可能会把新数据覆盖或者把第二页数据追加到第一页后面。解决方案有两种。一种是给请求加序号每次刷新时生成一个 seq等结果返回后只有 seq 等于当前最新序号才执行 setData。这种方法能覆盖所有乱序场景但代码会稍微复杂。另一种是把请求串行化刷新时直接重置 isLoading 和相关状态避免旧请求在刷新后继续追加数据。对普通项目来说后一种已经够用。我一般用“刷新时置hasMore true、清空 list、重置 pageNo”的方式配合 isLoading 保证请求不会叠加。2.3 柱状图不是难在图表而是难在 canvas 环境小程序的 echarts 方案通常是 echarts-for-weixin 的 ec-canvas 组件。使用的时候有几个关键点第一ec-canvas 的宽高要显式设置用 wxss 写 rpx 或 px 都可以但不能只靠内容撑开。canvas 容器在 scroll-view 里尤其容易出现问题最常见的是高度为 0因为 canvas 本身是原生组件和普通 view 的布局逻辑不一样。第二如果图表数据是异步加载的建议ec属性走lazyLoad模式等拿到数据后再手动 init 并 setOption不要在 onReady 里急着初始化。ec-canvas idchart canvas-idbar ec{{ ec }} force-use-old-canvas{{true}}/ec-canvasimport * as echarts from ../../ec-canvas/echarts; function initChart(canvas, width, height, dpr) { const chart echarts.init(canvas, null, { width, height, devicePixelRatio: dpr }); canvas.setChart(chart); chart.setOption({ xAxis: { type: category, data: [Mon, Tue, Wed] }, yAxis: { type: value }, series: [{ type: bar, data: [120, 200, 150] }] }); return chart; }真机 canvas 在高分屏下如果不传devicePixelRatio图表会发虚但传了之后如果 canvas 容器尺寸变化还要重新 resize。另一个坑是频繁 setData 会导致 canvas 重绘卡顿可以把 option 直接更新到 chart 对象上再触发 setOption减少不必要的页面渲染。如果你只是需要一个非常简单的柱状图不建议上 echarts 完整包。体积大、初始化慢还会遇到 canvas 层级问题。可以考虑用原生 canvas 画一个柱状图或者用 F2 的轻量裁剪产物主包体积压力会小很多。2.4 天地图与坐标偏移先问清楚坐标系再谈集成在小程序里集成天地图最常见的需求是换底图或者叠加天地图的数据服务。天地图官方提供的是 Web 端 API 和瓦片服务小程序里直接用 map 组件时如果要强行把天地图瓦片铺进来实际上是利用地图组件本身支持的图层能力使用体验和配置复杂度都偏高。比起底图本身真正的坑是坐标系。天地图采用的是 CGCS2000 / WGS-84 坐标而微信小程序里的wx.getLocation返回坐标默认是 GCJ-02俗称火星坐标。如果你把 GCJ-02 坐标直接标到天地图上位置会偏。偏多少取决于地区市区能偏出去几百米在城市级地图上“漂移”非常明显。解决方案分两种一是wx.getLocation时指定type: wgs84拿到的是原始 GPS 坐标可以直接匹配天地图二是服务端统一做坐标转换把业务坐标转成 WGS-84 再返回。我曾在做类似“校园食堂订餐系统”项目时踩过这个坑。当时服务端返回的是高德坐标前端用天地图瓦片叠加所有店铺点位都飘了后来统一在服务端转成 WGS-84 才解决。做地图相关功能时第一个问题永远是你的数据是哪个坐标系的别上来就画 marker。3. 用户身份与生命周期登录、订阅消息与离开监听3.1 wx.login 与登录态别把 code 当 token 存wx.login是获取登录凭证的标准入口。它做的事情是静默拿到一个 code然后把 code 传给后端后端再通过微信接口换 openid 和 session_key。很多初学者直接把 code 当登录凭证存在本地这是不对的。code 有效期只有 5 分钟且只能用一次换完就失效。一个标准流程是这样的App onLaunch 时调用wx.login拿 code。将 code 发送给后端后端换取 openid、session_key 并生成自定义 token。前端把 token 存到 Storage所有后续请求带上 Authorization。如果 token 过期或后端返回 401重新wx.login再走一遍登录流程。wx.login({ success(res) { if (res.code) { wx.request({ url: https://your-api.com/login, data: { code: res.code }, success(r) { wx.setStorageSync(token, r.data.token); } }); } } });有一个容易被忽略的坑wx.login的 code 只能换取当前用户在当前小程序里的 openid。如果你做了“同一个用户在小程序端和网页端同步登录”的需求那是另一套体系通常要靠 unionid 或者手机号绑定来实现不能直接用 code 跨端互通。3.2 订阅消息授权弹框弹多了会被“永远拒绝”订阅消息弹框的问题是“弹了用户不点点了后又不能发”。先说机制。一般业务能申请到的是“一次性订阅”用户每授权一次只能给你发一条消息。如果需要发多条就要让用户多次订阅或者使用长期订阅但长期订阅通常只开放给特定类目普通项目基本申请不到。编码层的要点有几个第一wx.requestSubscribeMessage的tmplIds可以传多个模板 ID一次性弹出多个授权卡片。用户点允许后在 success 回调里通过res[templateId]判断结果是 accept、reject 还是 ban。wx.requestSubscribeMessage({ tmplIds: [模板ID1, 模板ID2], success(res) { // res[templateId] 可能是 accept / reject / ban } });第二不要在页面 onLoad 时直接调用这个 API。微信对弹窗时机有限制放在用户点击“订阅提醒”这种按钮的同步回调里调用成功率最高。如果你在异步回调里调用可能会直接弹不出来。第三如果订阅成功但后端发消息时报错 43101通常就是用户订阅次数已经用完。排查时先确认该模板 ID 是否配置正确以及该用户最近是否已经收到过同模板消息。还有一个产品层面的预期管理问题如果用户选择了“总是保持以上选择”再次调用订阅接口也不会弹窗业务上要做兼容。产品如果期望“每次点击都弹窗”那就要解释清楚这是平台机制限制不是代码 bug。3.3 监听用户离开小程序其实没有“直接事件”“监听用户离开小程序”这个需求听起来简单实际要分清楚场景。如果只是监听“页面离开”Page.onHide 就够。但要监听“用户切后台、切到别的 App、甚至关闭小程序”就要在 App 级别处理。App 实例上有 onHide 和 onShow 全局生命周期。用户从前台切到后台会触发 onHide回到小程序触发 onShow。这个场景经常被用来做登录态过期校验、重新验 token、或者标记“用户是否在支付流程中途退出”。很多开发者以为 onUnload 会覆盖“关闭小程序”但实际上微信小程序关闭时并不会可靠触发 onUnload。通常的做法是在 App.onHide 里记录一个时间戳App.onShow 回来时对比时间差超过阈值就认为用户“离开过一段时间”再执行相应的业务处理比如订单倒计时、活动过期提醒。不要试图去监听“用户主动关闭小程序”这种事件平台层面没有开放这个能力。跟产品沟通时也要把预期说清楚不然这个需求会变成无底洞。3.4 “微信登录有好几个名字怎么删除”到底是咋回事这个其实是授权登录场景里非常常见的困惑。你在一个界面点登录时微信弹出来的列表里出现了好几个“同一个小程序”或几个相似名字的账号很多人以为是小程序代码的问题其实多数是微信客户端历史授权记录或者同一 AppID 下不同账号残留。处理方式按场景分如果是微信侧的历史授权列表让用户去“微信 - 设置 - 个人信息与权限 - 授权管理”里找到对应小程序解除授权即可。如果是小程序自己的登录页列出了多个账号名大概率是后端把 openid 映射成了多个 user 记录要查后端用户表里是否有多条记录指向同一个小程序。如果只是本地缓存残留可以在小程序里提供“退出登录”按钮退出时清掉 Storage 里的 token 和用户信息。项目里遇到这种问题先别急着改代码让用户打开“授权管理”清一次授权再看情况很多情况下这一步就能解决。4. 硬件与多媒体蓝牙定位、防截屏、直播全屏与视频保存4.1 蓝牙定位与近场打卡权限才是最大的坑蓝牙定位在小程序里最常见的是室内定位、近场打卡、或者连接外设。标准流程是wx.openBluetoothAdapter打开蓝牙适配器再wx.startBluetoothDevicesDiscovery开始搜索附近设备通过wx.onBluetoothDeviceFound监听发现设备然后连接目标设备wx.createBLEConnection再拿服务和特征值做数据读写。开发者工具对蓝牙 API 的模拟非常有限基本只能在真机上调试。Android 上需要在系统里开启定位服务并授权定位权限否则扫描不到设备iOS 侧则是蓝牙权限弹窗用户拒绝后要引导到设置页重新开启。这个权限差异很现实我第一次调试时在 Android 上什么设备都搜不到排查了一天才发现是定位服务没开。如果你要根据信号强度估算距离可以参考这个简化公式d 10 ^ ((abs(rssi) - A) / (10 * n))其中 A 是距离 1 米时的信号强度通常取 -60 左右n 是环境衰减因子空旷环境取 2 左右室内复杂环境取 2.5 到 4。这个公式只能给一个量级参考实际环境受到人体遮挡、墙壁、多径效应影响非常大。做“区域判断”还行做高精度定位基本不现实。如果只是做“蓝牙测距打卡”可以结合 RSSI 阈值做多轮采样取中位数再判断。做过这个功能的人都知道蓝牙信号波动堪比心跳千万不要用单次采样做判定。4.2 防截屏先降低预期再谈实现“苹果防截屏”在小程序里没有真正的系统级禁止方案iOS 没有向第三方 App 开放禁止截屏的能力。所以遇到产品提“这个页面不能截图”第一件事是把预期拉回来我们能做的是“提高截屏成本”和“截屏后可追溯”而不是物理上禁止。目前可行的工程手段有几个用wx.onUserCaptureScreen监听截屏事件触发后弹提示或者记录日志。敏感信息用 canvas 绘制而不是真实文本节点展示能避免文本被复制但截图照样能截到画面。Android 端可以使用wx.setVisualEffectOnCapture设置visualEffectType为 none让部分安卓机型截出来的图变空白。注意这个 API 有一定基础库版本要求iOS 不生效。给页面加动态水印水印内容带上用户标识和当前时间方便事后溯源。实际项目里我通常建议做“水印 截屏监听 敏感区域 canvas 化”三重组合。如果有人截图泄露了至少能定位到是谁在什么时间截的。这已经是在平台限制下能做到的上限。4.3 live-player 全屏在 PC 端以及视频保存live-player 在 PC 端小程序里默认全屏能力经常不生效主要原因是 PC 端对直播组件的原生全屏支持不完整。我的应对方式是判断当前运行环境是不是 PC可以通过wx.getDeviceInfo()里的平台字段来判断。如果是 PC 端就隐藏原生全屏按钮自己做一个自定义全屏按钮点击后把 live-player 外层容器设为 fixed 并铺满窗口再处理横竖屏样式。这里要注意PC 端上 fixed 铺屏跟移动端全屏的行为还是会有差别至少要处理好 Esc 退出、尺寸重算、播放器比例自适应这三个点。如果只是想要“全屏按钮能用”也可以尝试用page-container组件实现一个相对简单的自定义全屏。视频下载保存是另一个高频需求。流程是先wx.downloadFile下载网络视频得到临时文件然后wx.saveVideoToPhotosAlbum保存到相册。wx.downloadFile({ url: https://your-cdn.com/video.mp4, success(res) { if (res.statusCode 200) { wx.saveVideoToPhotosAlbum({ filePath: res.tempFilePath, success() { wx.showToast({ title: 已保存到相册, icon: success }); }, fail(err) { console.log(save fail, err); } }); } } });两个前提下载域名必须配置在合法downloadFile域名里真机上才能通保存相册前要拿相册授权用户拒绝过的话需要引导去设置页重新打开。一个容易忽略的问题downloadFile 返回的临时文件生命周期很短保存操作最好在下载回调里立刻执行不要留着 tempFilePath 下次再用。另外有些 CDN 地址不带.mp4后缀saveVideoToPhotosAlbum会直接失败这种情况下要先把临时文件复制成带正确后缀的路径再保存。这个细节救过我一次。5. 工程化与发布启动项目、试用分发、报错排查与包体积控制5.1 项目到底怎么启动怎么发给别人试用“项目如何启动”这个问题其实藏在很多新手提问里。微信开发者工具导入一个项目时需要项目目录、AppID 和语言类型。如果是原生小程序直接导入即可但如果是 uni-app 或 Taro 这类跨端项目第一步并不是用开发者工具打开源码目录而是先在终端安装依赖、执行构建命令把产物生成到 dist 目录再导入这个 dist 目录。很多新手第一次接触 uni-app 项目时直接导入源码目录看到一堆 App.vue、main.js 完全不知道怎么运行就是这个原因。让别人“试用收集反馈”有三个层级最快的方案开发者工具点“预览”生成预览二维码。但预览二维码有有效期而且频繁点击预览会提示预览次数上限。正式一点的方案在开发者工具里点“上传”代码到微信小程序后台把对应版本设为体验版再把测试同学的微信号加进体验成员列表。体验版的二维码长期有效可以反复扫码。如果要收集几天试用反馈可以在小程序里内置反馈入口或者引导扫码填问卷。这里有个容易踩的坑体验版访问的是真实线上接口所以服务器域名、HTTPS 证书都必须提前配好否则体验版扫进去就是一片红什么都加载不出来用户只会给你反馈“打不开”。5.2 10002 到底是什么问题开发小程序时真机或体验版里 request 报错 fail错误码 10002 很多时候都是网络请求相关的问题。典型场景是开发者工具里勾选了“不校验合法域名”一切正常一换到真机或体验版就挂。排查顺序建议按下面来先看开发者工具里请求是否成功。如果都能通把“不校验合法域名”关掉看是否报错。如果是url not in domain list说明 request 域名没有加进小程序后台的“服务器域名”白名单。如果域名已配置看协议是不是 HTTPS证书是否有效是否被微信校验拒绝。如果用的是 IP 地址或局域网地址生产环境必然不行只能用于本地开发模式。还有一个小概率问题域名配置后不是即时生效微信后台可能有一小段时间生效延迟。实测遇到过配置完成后等了几分钟才正常。10002 不一定是单一原因排查时要结合控制台完整错误信息和 errMsg 联合定位不要只看错误码就下结论。5.3 uni-app 打包超过 2MB分包是第一优先级uni-app 打包微信小程序时容易遇到类似source size 2612kb exceed max limit 2mb的提示这是主包超过 2MB工具直接不允许预览或上传。最有效的方案是分包。把非首屏、非 tabBar 的页面挪到分包目录下页面路由正常写路径。uni-app 项目通常在 pages.json 里配置 subPackages{ subPackages: [ { root: packageOrder, pages: [ { path: order-list/order-list } ] } ] }分包有几个限制要先知道tabBar 页面必须放在主包分包不能包含 tabBar 页面分包之间不能互相跳转只能跳到主包或对应分包内的页面每个分包本身也有大小限制超了还要再拆。削减包体积的办法还有用图片压缩工具把项目里的静态图片压一遍去掉不必要的 UI 图。组件库按需引入不要在 app.json 里一次性注册所有组件。大的 JS 库或图表库尽量按需引入不要全局挂在 main.js。字体文件如果只是个别字可以用工具子集化一个几 MB 的字体压到几十 KB。分包完记得跑一下微信开发者工具里的代码包分析看看是哪个目录占了空间。定向优化比盲目压缩效率高很多。如果你只想快速绕过这个报错可以先压缩图片临时解决但“临时解决”的后果是后续迭代后再次超限不如第一次就把分包规划好。6. 写在最后的话整理这一批热搜词的时候我发现绝大多数问题都不是“API 不会用”而是对平台差异、生命周期、权限机制的理解不够。蓝牙要真机调、截屏要看系统能力、订阅消息要理解次数规则、坐标系要先问清楚来源。这些听起来都是小点但每一个都能让一个功能从“Demo 能跑”变成“上线能用”。如果你正在做微信小程序我的建议是不管是原生、uni-app 还是 Taro先把微信官方文档里“能力”和“平台差异”两个板块通读一遍再开始写业务逻辑会比边写边查省很多返工。遇到报错多看重试信息和 errMsg遇到功能异常先怀疑基础库版本再怀疑自己的代码。这套习惯已经帮我少加了很多次班。这次笔记就先写到这里。个人体会是小程序开发真正的瓶颈往往在“边界情况”上不同系统的权限、不同设备的屏幕、不同用户的授权选择。把这些边界情况处理好上线就能踏实很多。
返回列表