錯(cuò):TAP gesture與異步調(diào)用排查詳解)
前陣子聯(lián)調(diào)小程序訂閱消息測試在頁面里點(diǎn)了一個(gè)按鈕等了幾秒才調(diào)wx.requestSubscribeMessage控制臺直接彈出一行紅字requestSubscribeMessage:fail can only be invoked by user TAP gesture。我當(dāng)時(shí)第一反應(yīng)是是不是按鈕沒綁好還是模板 ID 傳錯(cuò)了后來排了一圈才發(fā)現(xiàn)問題根本不在按鈕交互而在調(diào)用時(shí)機(jī)和調(diào)用棧。這個(gè)報(bào)錯(cuò)幾乎每個(gè)做過訂閱消息的開發(fā)者都會遇到但很多人被卡住的原因都一樣——沒理解微信到底在限制什么。這篇文章不繞彎子直接把這個(gè)報(bào)錯(cuò)的完整排查鏈路、底層約束、可用方案和我在真機(jī)上踩過的邊界情況都寫出來。不管你是剛接手小程序開發(fā)還是已經(jīng)寫過一陣子訂閱消息都能照著排查。1. 先看這個(gè)報(bào)錯(cuò)的真實(shí)現(xiàn)場不是按鈕沒綁好而是調(diào)用棧斷了1.1 三種最容易觸發(fā)的寫法can only be invoked by user TAP gesture這句話里最關(guān)鍵的是TAP gesture也就是“用戶的點(diǎn)擊手勢”。微信要求訂閱彈窗必須由一次真實(shí)點(diǎn)擊直接觸發(fā)整個(gè)調(diào)用鏈路上不能斷。最容易觸發(fā)這個(gè)報(bào)錯(cuò)的寫法我列幾個(gè)典型場景你可以對照一下自己代碼里有沒有在onLoad或onShow里直接調(diào)用訂閱。這種做法很常見尤其是剛接觸訂閱消息的開發(fā)者覺得只要頁面一進(jìn)來就彈訂閱用戶看到的概率最大。結(jié)果一運(yùn)行報(bào)錯(cuò)基本都是這個(gè)TAP gesture。在wx.showModal的確認(rèn)回調(diào)里調(diào)用訂閱。這是另一個(gè)高頻翻車點(diǎn)。按鈕點(diǎn)擊后先彈了一個(gè)showModal用戶點(diǎn)“確認(rèn)”后再去調(diào)訂閱。雖然用戶確實(shí)點(diǎn)了屏幕但showModal的回調(diào)已經(jīng)脫離了最初那個(gè)按鈕的tap事件調(diào)用棧微信不認(rèn)。在異步請求的success回調(diào)里調(diào)用訂閱。比如用戶點(diǎn)擊按鈕 → 發(fā)一個(gè)登錄請求 → 登錄成功后再調(diào)訂閱。這是業(yè)務(wù)上最常見的寫法但也是報(bào)錯(cuò)率最高的。因?yàn)閣x.request的返回是在網(wǎng)絡(luò)事件中觸發(fā)的根本不是用戶手勢的同步調(diào)用棧。我接手過一個(gè)項(xiàng)目訂閱邏輯寫在login()的Promise.then里每次進(jìn)頁面都報(bào)這個(gè)錯(cuò)。后來把訂閱調(diào)用從then里挪出來直接放在用戶點(diǎn)擊按鈕的那個(gè)同步函數(shù)里問題立刻消失。1.2 報(bào)錯(cuò)日志背后的含義這個(gè)報(bào)錯(cuò)的完整信息是requestSubscribeMessage:fail can only be invoked by user TAP gesture拆開看就是requestSubscribeMessage 調(diào)用失敗失敗原因是只能由用戶點(diǎn)擊手勢調(diào)用。微信的校驗(yàn)并不復(fù)雜它不會去判斷你的按鈕長什么樣也不管你的頁面是不是自定義組件它只關(guān)心一件事——當(dāng)前這個(gè)調(diào)用棧里有沒有一個(gè)真實(shí)的tap事件在鏈路上。也就是說你不用非得用button你用一個(gè)view綁定bindtap只要在點(diǎn)擊回調(diào)里同步調(diào)wx.requestSubscribeMessage同樣可以成功。反過來就算你用的是官方按鈕只要你在點(diǎn)擊回調(diào)里先await了某個(gè)異步請求再調(diào)訂閱一樣會失敗。這一點(diǎn)特別容易誤解。很多人以為是按鈕類型不對換成open-typesubscribe就好其實(shí)換了也有可能繼續(xù)報(bào)錯(cuò)因?yàn)閱栴}出在“調(diào)用棧是否同步”而不是“按鈕是不是官方組件”。2. 為什么微信要把訂閱請求“鎖”在用戶點(diǎn)擊這個(gè)動(dòng)作上2.1 TAP gesture 到底校驗(yàn)的是什么從開發(fā)者的角度看這個(gè)限制顯得很不講道理用戶明明已經(jīng)點(diǎn)了按鈕我也確實(shí)是在點(diǎn)按鈕之后才彈訂閱的為什么不算問題就出在“點(diǎn)按鈕之后”和“點(diǎn)按鈕的那個(gè)瞬間”之間的時(shí)間差。微信的TAP gesture校驗(yàn)本質(zhì)上是在檢查wx.requestSubscribeMessage這個(gè)調(diào)用是否發(fā)生在點(diǎn)擊事件的同步執(zhí)行過程里。用戶手指按下去系統(tǒng)觸發(fā)一個(gè)tap事件從事件分發(fā)到事件處理函數(shù)執(zhí)行結(jié)束這一整段是同步的。在這段同步鏈路里調(diào)用訂閱才算合法。一旦你在這個(gè)函數(shù)里開了異步操作比如發(fā)請求、寫定時(shí)器、甚至setTimeout(..., 0)那么訂閱調(diào)用實(shí)際發(fā)生的時(shí)間點(diǎn)已經(jīng)不在tap事件的處理過程內(nèi)了。微信無法確認(rèn)這次訂閱是否真的由用戶點(diǎn)擊觸發(fā)于是直接拒絕??梢岳斫獬勺詣?dòng)售貨機(jī)你投幣之后貨品掉出來需要一小段時(shí)間。但微信不允許你把錢先投進(jìn)去然后過了五分鐘再回來取貨。它要求的是“投幣”和“出貨”必須發(fā)生在同一個(gè)連續(xù)動(dòng)作里中間不能中斷。2.2 微信真正防的是三種壞體驗(yàn)這個(gè)限制看著不近人情但如果你站在平臺的角度想就合理了。第一防止頁面加載即騷擾。如果開發(fā)者可以在onLoad里直接彈訂閱用戶每次打開小程序都會被訂閱彈窗糊臉。這個(gè)體驗(yàn)比授權(quán)彈窗還煩人因?yàn)橛嗛喯⑹强梢蚤L期觸達(dá)用戶的。第二防止誘導(dǎo)和誤導(dǎo)。有些業(yè)務(wù)會把訂閱時(shí)機(jī)放在異步回調(diào)里比如“請求失敗后彈訂閱”用戶根本不知道為什么彈。微信要求訂閱必須由主動(dòng)點(diǎn)擊觸發(fā)就是希望用戶面對彈窗時(shí)清楚自己正在做一個(gè)“允許后續(xù)消息推送”的決定。第三防止繞過用戶主動(dòng)意愿。如果允許異步調(diào)用開發(fā)者完全可以做出“用戶點(diǎn)了一個(gè)無關(guān)按鈕下一秒訂閱彈窗突然出現(xiàn)”的效果用戶會覺得莫名其妙。微信要求訂閱彈窗必須在點(diǎn)擊的瞬間出現(xiàn)用戶能看到“我按了這個(gè)按鈕所以彈了這個(gè)窗口”因果關(guān)系清晰。理解了這一點(diǎn)你就不會再去糾結(jié)“為什么我明明綁了bindtap還是報(bào)錯(cuò)”而是會主動(dòng)檢查點(diǎn)擊回調(diào)里面訂閱調(diào)用前面有沒有await有沒有setTimeout有沒有wx.request有沒有wx.showModal的回調(diào)等待。只要這條鏈路是同步的基本就不會出現(xiàn)這個(gè)報(bào)錯(cuò)。3. 把訂閱調(diào)用穩(wěn)穩(wěn)“掛”在用戶點(diǎn)擊鏈路里方案與代碼3.1 最穩(wěn)妥的寫法普通 button 同步調(diào)用我試過幾種方案最穩(wěn)的還是普通button加上bindtap在回調(diào)里同步調(diào)wx.requestSubscribeMessage。WXML 部分button typeprimary bindtaponSubscribeTap 訂閱開獎(jiǎng)提醒 /buttonJS 部分Page({ data: { tmplIds: [模板ID1, 模板ID2] }, onSubscribeTap() { // 這里不要加 await不要在前面塞任何異步請求 wx.requestSubscribeMessage({ tmplIds: this.data.tmplIds, success(res) { console.log(訂閱結(jié)果, res); // res[模板ID1] 可能是 accept / reject / ban }, fail(err) { console.error(訂閱失敗, err); } }); } });這段代碼在用戶點(diǎn)擊按鈕的瞬間同步發(fā)起訂閱請求。微信會立刻彈出訂閱授權(quán)窗口整個(gè)過程沒有任何異步中斷所以不會觸發(fā)TAP gesture報(bào)錯(cuò)。官方還提供了一種button open-typesubscribe的按鈕類型適合模板 ID 固定不變、不需要在代碼里動(dòng)態(tài)拼接模板的場景。但是如果你想更靈活地控制模板 ID或者需要在訂閱前讀取一些本地配置普通buttonwx.requestSubscribeMessage的方式更實(shí)用排查問題也更容易。3.2 有異步前置邏輯時(shí)怎么調(diào)整順序很多業(yè)務(wù)場景有一個(gè)矛盾訂閱接口要求同步調(diào)用但業(yè)務(wù)上需要先拿到服務(wù)端下發(fā)的模板 ID或者先完成登錄、獲取用戶狀態(tài)。我之前踩過的坑就是為了拿模板 ID先發(fā)了一個(gè)請求請求回來后才調(diào)訂閱。這個(gè)邏輯在開發(fā)工具里偶爾能通過真機(jī)上幾乎必掛。解決思路不是“想辦法繞過同步限制”而是重新排列事件的先后順序。如果你的模板 ID 是動(dòng)態(tài)的把獲取模板 ID 的請求提前放在進(jìn)入頁面時(shí)就去拉存到data里。等用戶真正點(diǎn)擊按鈕時(shí)模板 ID 早就準(zhǔn)備好了點(diǎn)擊回調(diào)里不需要任何網(wǎng)絡(luò)請求直接同步訂閱。代碼結(jié)構(gòu)是這樣的Page({ data: { tmplIds: [] }, onLoad() { // 提前向服務(wù)端獲取模板 ID this.fetchTemplateIds(); }, fetchTemplateIds() { wx.request({ url: https://api.example.com/get-template-ids, success: (res) { this.setData({ tmplIds: res.data.tmplIds }); } }); }, onSubscribeTap() { if (this.data.tmplIds.length 0) { wx.showToast({ title: 模板配置加載中請稍后再試, icon: none }); return; } // 到這里時(shí)data 里已經(jīng)有模板 ID沒有異步等待 wx.requestSubscribeMessage({ tmplIds: this.data.tmplIds, success(res) { /* 處理結(jié)果 */ }, fail(err) { /* 處理失敗 */ } }); } });如果登錄狀態(tài)也是前置條件同樣把它提前。進(jìn)入頁面時(shí)就開始靜默登錄用戶點(diǎn)擊訂閱按鈕時(shí)登錄態(tài)已經(jīng)有了點(diǎn)擊回調(diào)里不需要再await登錄。一句話總結(jié)把所有異步準(zhǔn)備都放在用戶點(diǎn)擊之前點(diǎn)擊回調(diào)里只留同步訂閱這一件事。這個(gè)方法在真機(jī)上實(shí)測穩(wěn)定基本沒有再觸發(fā)過TAP gesture報(bào)錯(cuò)。3.3 處理訂閱結(jié)果與用戶拒絕后的狀態(tài)訂閱彈窗點(diǎn)完之后回調(diào)里拿到的結(jié)果是一個(gè)對象字段名就是模板 ID值有三種返回值含義建議處理方式accept用戶同意訂閱記錄狀態(tài)后續(xù)按業(yè)務(wù)推送reject用戶拒絕本次訂閱不要立刻再次彈窗等待用戶下一次主動(dòng)點(diǎn)擊ban用戶拒絕次數(shù)過多被平臺限制引導(dǎo)用戶到設(shè)置頁手動(dòng)開啟很多人只處理accept忽略了ban。ban狀態(tài)下你再調(diào)用訂閱按鈕彈窗也不會出現(xiàn)微信直接返回失敗或返回ban。用戶不是不想訂而是被之前頻繁的彈窗搞煩了選擇了“總是保持以上選擇”并拒絕結(jié)果這個(gè)模板就被拉黑了。遇到ban比較合理的做法是彈一個(gè)自定義引導(dǎo)層告訴用戶“訂閱消息已關(guān)閉點(diǎn)擊按鈕去設(shè)置頁開啟”。代碼示例handleBan() { wx.showModal({ title: 訂閱消息已被關(guān)閉, content: 請?jiān)谠O(shè)置頁中重新開啟訂閱消息以便接收提醒, confirmText: 去設(shè)置, success(res) { if (res.confirm) { wx.openSetting(); } } }); }wx.openSetting()會打開小程序的設(shè)置頁里面包含訂閱消息的開關(guān)項(xiàng)。用戶手動(dòng)打開后再回來點(diǎn)擊訂閱按鈕就能正常彈出授權(quán)窗了。4. 真機(jī)、開發(fā)工具、彈窗頻率這些邊界情況我全踩過4.1 開發(fā)工具和真機(jī)表現(xiàn)為何不一致開發(fā)工具里有時(shí)候你在異步回調(diào)里調(diào)訂閱它不報(bào)錯(cuò)或者報(bào)錯(cuò)了但偶爾還能彈出來。真機(jī)上則非常穩(wěn)定地報(bào)TAP gesture。這種差異會讓開發(fā)者在調(diào)試階段誤以為代碼沒問題一到體驗(yàn)版就翻車。原因在于開發(fā)工具對事件調(diào)用鏈的模擬沒有真機(jī)那么嚴(yán)格。工具畢竟跑在 PC 瀏覽器環(huán)境里對tap的判定和真機(jī)小程序運(yùn)行時(shí)不一致。所以我的建議是訂閱消息這種與手勢強(qiáng)相關(guān)的能力一定要以真機(jī)為準(zhǔn)。你把開發(fā)工具當(dāng)成看布局、調(diào)樣式的工具就行涉及訂閱調(diào)用、掃碼、支付這類依賴系統(tǒng)能力的功能老老實(shí)實(shí)上真機(jī)測。4.2 用戶勾選“總是保持以上選擇”后按鈕失靈了怎么辦訂閱彈窗里有一個(gè)“總是保持以上選擇”的選項(xiàng)。用戶一旦勾選并選擇“拒絕”這個(gè)模板在后續(xù)調(diào)用中會直接進(jìn)入ban狀態(tài)不再彈窗。很多產(chǎn)品經(jīng)理看后臺數(shù)據(jù)發(fā)現(xiàn)訂閱轉(zhuǎn)化率越來越低其實(shí)不是入口不夠明顯而是用戶已經(jīng)進(jìn)入ban狀態(tài)彈窗根本不會出現(xiàn)了。這時(shí)候單純優(yōu)化按鈕文案是無效的要做的是在訂閱入口前增加一個(gè)“是否還有訂閱資格”的前置判斷。我的做法是在進(jìn)入頁面時(shí)用wx.getSetting配合本地緩存判斷一下是否還能調(diào)起訂閱。如果判斷到可能已經(jīng)ban就先展示引導(dǎo)文案而不是直接讓用戶點(diǎn)訂閱按鈕。checkSubscribeStatus() { // 這里用本地緩存記錄用戶之前的訂閱結(jié)果 const subscribeStatus wx.getStorageSync(subscribe_status); if (subscribeStatus ban) { this.setData({ showSubscribeGuide: true }); } }不過要注意ban是模板維度的狀態(tài)而且微信沒有提供直接查詢某個(gè)模板是否ban的公開接口。保守的做法是本地記錄上次訂閱結(jié)果如果上次是ban下次就先引導(dǎo)不強(qiáng)彈。4.3 訂閱結(jié)果里的 accept / reject / ban 該怎么處置單次訂閱的reject不等于永久拒絕。用戶這次拒絕下次點(diǎn)擊訂閱按鈕時(shí)彈窗仍然會出現(xiàn)。真正危險(xiǎn)的是連續(xù)多次reject之后觸發(fā)ban因?yàn)閎an狀態(tài)下彈窗就不再出現(xiàn)了。為了避免把用戶“拒到 ban”我一般在用戶拒絕后不會立刻在同一個(gè)頁面再次誘導(dǎo)訂閱。至少等用戶完成某個(gè)關(guān)鍵操作后再提供一個(gè)自然觸達(dá)的訂閱入口。比如用戶提交訂單后再問“是否訂閱物流提醒”這時(shí)候用戶意愿更強(qiáng)拒絕率也會低一些。還有個(gè)細(xì)節(jié)wx.requestSubscribeMessage一次最多傳 3 個(gè)模板 ID。如果你一次傳 4 個(gè)接口不會按你預(yù)期地彈 4 個(gè)模板而是直接失敗。不要在這個(gè)參數(shù)上省請求次數(shù)業(yè)務(wù)上真正高價(jià)值的模板一次推給用戶一個(gè)就夠太多模板反而讓用戶反感。5. 同類“時(shí)機(jī)/權(quán)限”報(bào)錯(cuò)的排查套路從隱私協(xié)議到能力封禁5.1 隱私協(xié)議聲明的報(bào)錯(cuò)api scope is not declared訂閱消息只是微信小程序里一堆“權(quán)限報(bào)錯(cuò)”的一種。實(shí)際開發(fā)里還有一類報(bào)錯(cuò)也經(jīng)常出現(xiàn)比如chooseImage:fail api scope is not declared in the privacy agreement這個(gè)和TAP gesture完全是兩碼事但很多新手會混在一起排查。它的意思是你的小程序調(diào)用了wx.chooseImage但在后臺的“用戶隱私保護(hù)指引”里沒有聲明這個(gè)接口的用途。微信要求小程序在收集用戶信息前先聲明使用了哪些隱私接口。chooseImage會讀取相冊權(quán)限所以必須在小程序管理后臺的“設(shè)置-服務(wù)內(nèi)容聲明-用戶隱私保護(hù)指引”里勾選對應(yīng)接口說明用途。代碼層能做的補(bǔ)救是在調(diào)用前申請隱私授權(quán)wx.requirePrivacyAuthorize({ success() { // 用戶同意隱私協(xié)議后再調(diào)用 chooseImage wx.chooseImage({ /* ... */ }); }, fail() { wx.showToast({ title: 需要同意隱私協(xié)議才能使用, icon: none }); } });但這種接口聲明類的配置問題最終還是要回到小程序后臺去完善光改代碼解決不了根本問題。5.2 scan denied、半屏小程序 banned權(quán)限和封禁其實(shí)是兩回事熱搜詞里還出現(xiàn)了兩類典型的權(quán)限報(bào)錯(cuò)scanqdenied大概率是wx.scanCode的掃碼權(quán)限被拒絕。這和訂閱消息的reject/ban類似用戶拒絕過一次后續(xù)調(diào)用可能直接被denied。排查方向是檢查用戶授權(quán)狀態(tài)必要時(shí)引導(dǎo)到設(shè)置頁打開掃碼權(quán)限。openembeddedminiprogram:fail banned這是wx.openEmbeddedMiniProgram這個(gè)“半屏小程序”能力被平臺封禁。這種banned通常和小程序自身的違規(guī)記錄、類目資質(zhì)有關(guān)也可能是該能力對某些類目不開放。代碼再怎么改都沒用要去小程序后臺查看能力狀態(tài)或者聯(lián)系平臺處理。很多人一看到denied和banned就以為是代碼問題其實(shí)這兩個(gè)詞的側(cè)重點(diǎn)完全不同報(bào)錯(cuò)關(guān)鍵詞常見原因排查方向denied用戶拒絕授權(quán)檢查wx.getSetting授權(quán)狀態(tài)引導(dǎo)重新授權(quán)banned平臺或能力被封禁檢查小程序后臺能力狀態(tài)、類目、違規(guī)記錄not declared隱私協(xié)議未聲明接口后臺完善“用戶隱私保護(hù)指引”can only be invoked by user TAP gesture調(diào)用時(shí)機(jī)不在用戶點(diǎn)擊的同步鏈路內(nèi)調(diào)整代碼調(diào)用時(shí)機(jī)避免異步調(diào)用5.3 一個(gè)通用的報(bào)錯(cuò)排查順序把訂閱消息、掃碼、隱私協(xié)議、能力封禁這幾種報(bào)錯(cuò)放一起看其實(shí)能總結(jié)出一套排查順序。遇到這類奇怪的fail提示我一般按這個(gè)順序來。先看調(diào)用時(shí)機(jī)。報(bào)錯(cuò)信息里有沒有user TAP gesture、can only be invoked、show()之類的字眼如果有先去查代碼調(diào)用鏈路看看有沒有await、setTimeout、異步回調(diào)嵌套。這類問題優(yōu)先級最高因?yàn)樗谴a邏輯層面的問題改起來最快。再看用戶授權(quán)狀態(tài)。如果報(bào)錯(cuò)里有denied、reject、ban就要去查wx.getSetting確認(rèn)用戶是不是之前拒絕過。這個(gè)階段一般需要配合引導(dǎo)文案而不是反復(fù)強(qiáng)彈。然后看權(quán)限配置。如果報(bào)錯(cuò)里有is not declared、privacy agreement去小程序后臺完善隱私保護(hù)指引確認(rèn)所有用到的接口都聲明了用途。最后看能力狀態(tài)。如果報(bào)錯(cuò)里有banned、not allowed并且代碼和配置都查過沒問題大概率是平臺側(cè)對該小程序的能力限制。這時(shí)候需要去后臺看是否有站內(nèi)信、違規(guī)記錄或者直接提交工單確認(rèn)。這個(gè)排查順序能解決小程序里百分之八九十的“不明原因失敗”。很多人卡在TAP gesture這種報(bào)錯(cuò)上好幾天就是因?yàn)橐簧蟻砭退汛a、改按鈕完全沒意識到問題出在調(diào)用棧的同步性上。我個(gè)人在實(shí)際項(xiàng)目里的習(xí)慣是凡是涉及用戶主動(dòng)觸發(fā)的系統(tǒng)能力都寫一個(gè)統(tǒng)一的“用戶手勢入口”組件把點(diǎn)擊回調(diào)、狀態(tài)判斷、結(jié)果處理全部封裝在一起業(yè)務(wù)代碼里不會再有散落的異步訂閱調(diào)用。這樣既能讓排查路徑清晰也能避免測試在真機(jī)上反復(fù)提交同一個(gè)報(bào)錯(cuò)。如果你現(xiàn)在還在被這個(gè)報(bào)錯(cuò)折磨別急著換方案先去看一眼你的訂閱調(diào)用是不是被某個(gè)異步邏輯“切了一刀”。把那刀切掉問題大概率就自己消失了。