模版消息推送是微信小程序采用的通知形式,用戶本人在小程序頁(yè)面有交互行為后,可觸發(fā)下發(fā)通知,通過(guò)微信聊天列表中的服務(wù)通知可快捷進(jìn)入查看消息。此外,點(diǎn)擊查看詳情還能跳轉(zhuǎn)到下發(fā)消息的小程序的指定頁(yè)面。但是為 ...
模版消息推送是微信小程序采用的通知形式, 用戶本人在小程序頁(yè)面有交互行為后,可觸發(fā)下發(fā)通知 ,通過(guò)微信聊天列表中的服務(wù)通知可快捷進(jìn)入查看消息。此外,點(diǎn)擊查看詳情還能跳轉(zhuǎn)到下發(fā)消息的小程序的指定頁(yè)面。但是為了避免這種通知被濫用,帶來(lái)不好的用戶體驗(yàn),小程序也對(duì)模板消息推送做了相應(yīng)的限制。為了更好的優(yōu)化小打卡小程序的打卡通知功能,我在開發(fā)的過(guò)程中自行摸索了一套突破推送限制的解決方案??梢詫?shí)現(xiàn) 7天內(nèi)向用戶推送多條模板消息,甚至向用戶群發(fā)消息的功能 。
消息通知是一個(gè)很重要的功能,如QQ空間的回復(fù)狀態(tài)通知,QQ郵箱的郵件通知,微信支付成功提通知等。這種常規(guī)的 服務(wù)跟蹤類 消息,便于用戶掌握產(chǎn)品對(duì)自身服務(wù)的進(jìn)度,方便客戶獲取必要的信息,提高效率;保證用戶的知情權(quán),讓用戶有安全感。同時(shí),對(duì)于產(chǎn)品本身來(lái)說(shuō),可以引導(dǎo)用戶進(jìn)行下一步行為, 增加了產(chǎn)品的曝光率,便于用戶留存,增強(qiáng)用戶粘性。

如上圖,呈現(xiàn)在微信聊天列表的 服務(wù)通知 ,收納了各個(gè)小程序向用戶推送模板消息,這個(gè)服務(wù)通知是用戶查看模板消息的入口,用戶點(diǎn)擊服務(wù)通知后可以查看到通知列表頁(yè)面,每條通知以卡片的形式呈現(xiàn),包括小程序的logo、名稱、通知時(shí)間、通知內(nèi)容等信息。
所謂『模板消息』,就如上面的通知卡片,首先通知卡片形式樣子是固定的,其實(shí)卡片中的通知內(nèi)容部分,可以看到每天通知的內(nèi)容都具備日程描述、日程主題、日程時(shí)間等要素,通知之間不同的地方在于這些要素后面的文案,將這些通知要素制作成模板,每次針對(duì)不同的通知內(nèi)容 只需要填充每條要素對(duì)應(yīng)的具體的文本 即可推送給用戶。上面圖中兩條模板消息的日程主題和時(shí)間不一樣,其他的信息要素保持一致,這就是模板消息。
提到模板消息的好處,第一印象是 "多、快、好、省" 的特點(diǎn)。
"快"即快捷,體現(xiàn)在微信用戶側(cè)的通知體驗(yàn),由于在微信客戶端服務(wù)通知在聊天列表中,保留了用戶以往處理聊天通知的習(xí)慣,所以用戶可以很 便捷地觸及服務(wù)通知 ,查看小程序推送的模板消息。
"好"即效果好,小程序的模板消息具備 跳轉(zhuǎn)直達(dá)小程序特定頁(yè)面 的能力,這樣用戶接收消息后,查看消息的通知就能便捷地回到小程序進(jìn)行相應(yīng)的業(yè)務(wù)處理、信息查看等后續(xù)操作,一定程度上提升了用戶的活躍度,小打卡小程序的近30天訪問(wèn)來(lái)源數(shù)據(jù)顯示,有20%左右的用戶通過(guò)模板消息這個(gè)入口進(jìn)入小打卡,在各種來(lái)源中排名第三位,可以見模板消息是用戶使用你的小程序的重要入口。
"省"即省錢唄,有了模板推送,自然 降低了消息通知的成本 ,節(jié)省費(fèi)用。消息通知優(yōu)先通過(guò)模板消息這種方式來(lái)推送給指定用戶,只有才無(wú)法觸及用戶的情況下,才使用傳統(tǒng)的付費(fèi)短信推送等形式。
"多"呢?上面提到"無(wú)法觸及用戶的情況",其實(shí)是因?yàn)樾〕绦虿痪邆?quot;多"的特點(diǎn)。物以稀為貴,模板消息雖好,但是微信小程序官方為了保證用戶體驗(yàn), 平衡通知和騷擾行為 ,對(duì)模板推送做了相應(yīng)限制。接下來(lái)就聊聊這個(gè)限制。

微信小程序允許下發(fā)模板消息的條件分為兩類, 支付或者提交表單 。
目前支付的限制有所放開,即1次支付可以下發(fā)3條模板消息。通過(guò)提交表單來(lái)下發(fā)模板消息的方式限制為一次的觸發(fā)行為,7天內(nèi)可以向用戶推送一條模板消息。 這種消息的控制放的太寬的話,很容易對(duì)用戶的體驗(yàn)造成很大沖擊,給用戶帶來(lái)一定的騷擾。
但是,用戶1次觸發(fā)、7天內(nèi)推送1條通知明顯是不夠用的,比如小打卡小程序利用模板消息的推送來(lái)提醒用戶每天打卡,只能在用戶前一天打卡的情況下,獲取一次推送模板消息的機(jī)會(huì),然后用于第二天向用戶發(fā)送打卡通知。但是很多情況下,用戶如果某一天忘記打卡,小打卡便 失去了提醒用戶的權(quán)限,和用戶斷開了聯(lián)系 。
在小打卡中還有一個(gè)迫切需要多條模板消息推送的場(chǎng)景,比如打卡活動(dòng)每次有新的成員進(jìn)入,需要通知管理員進(jìn)行審核,這種情況也需要及時(shí)地通知管理員,以便管理員快速響應(yīng),處理成員的審核請(qǐng)求并通知成員審核結(jié)果。
注意到下發(fā)條件中,每次觸發(fā)的到的 推送碼可以在未來(lái)7天內(nèi)使用,多次提交觸發(fā)下發(fā)的消息條數(shù)獨(dú)立,相互不影響 ,那能不能突破模板消息的發(fā)送限制,更好地優(yōu)化打卡提醒功能呢?
微信小程序官方最近已經(jīng)透露出可能對(duì)模板消息進(jìn)一步放寬限制的信號(hào),不過(guò)在這之前,我們可以在遵守官方相關(guān)運(yùn)營(yíng)規(guī)范、保證用戶體驗(yàn)的情況下,倒騰一個(gè) "讓用戶一次觸發(fā)、多次推送,甚至群發(fā)模板消息" 的解決方案。
其實(shí)仔細(xì)分析消息下發(fā)條件"1次提交表單可下發(fā)1條,多次提交下發(fā)條數(shù)獨(dú)立,相互不影響",突破口就明顯了,只需 收集到足夠推送碼 ,即每次提交表單時(shí)獲取到的formId就是我們所需的“推送權(quán)限”。它是一次性的,代表著開發(fā)者有向當(dāng)前用戶推送模板消息的權(quán)限。
為了打造這樣一個(gè)突破限制的模版消息推送功能,做到7天內(nèi)任性推送,我們將小程序前后端的工作明確一下,小程序前端,即運(yùn)行在用戶微信上的小程序負(fù)責(zé) 收集推送碼 ,小程序后端,即運(yùn)行在服務(wù)器上的應(yīng)用程序負(fù)責(zé)將推送碼 存儲(chǔ)到數(shù)據(jù)庫(kù) 中,并在需要推送的模版消息的時(shí)候從中取出推送碼formId判斷有效性并加以運(yùn)用。整個(gè)方案的前后端業(yè)務(wù)流程如下:

接下來(lái)我們?cè)O(shè)計(jì)一個(gè)能夠突破當(dāng)前模板消息推送限制的方案。結(jié)合 小程序前端界面、小程序邏輯層、服務(wù)器程序、數(shù)據(jù)庫(kù)、異步任務(wù)系統(tǒng) 各自分工,來(lái)實(shí)現(xiàn)將小程序模板消息推送所需的推送碼收集、上報(bào)、存儲(chǔ)、調(diào)用。最終做到7日內(nèi)更好地推送模板消息、觸及用戶。
每次表單提交可以觸發(fā)一次下發(fā)模版消息的機(jī)會(huì),表單組件
Page({
formSubmit: function(e) {
let formId = event.detail.formId;
console.log('form發(fā)生了submit事件,推送碼為:', formId)
}
})
/*wxss*/
/*修改按鈕樣式,使其能夠包裹其他組件*/
.btn {
border:none;
text-align:left;
padding:0;
margin:0;
line-height:1.5;
}
//js
Page({
formSubmit: function(e) {
let formId = e.detail.formId;
this.dealFormIds(formId); //處理保存推送碼
let type = e.detail.target.dataset.type;
//根據(jù)type的值來(lái)執(zhí)行相應(yīng)的點(diǎn)擊事件
//...
},
dealFormIds: function(formId) {
let formIds = app.globalData.gloabalFomIds;//獲取全局?jǐn)?shù)據(jù)中的推送碼gloabalFomIds數(shù)組
if (!formIds) formIds = [];
let data = {
formId: formId,
expire: parseInt(new Date().getTime() / 1000)+604800 //計(jì)算7天后的過(guò)期時(shí)間時(shí)間戳
}
formIds.push(data);//將data添加到數(shù)組的末尾
app.globalData.gloabalFomIds = formIds; //保存推送碼并賦值給全局變量
},
})
上面的代碼主要實(shí)現(xiàn)了模擬表單提交事件來(lái)取代原來(lái)的點(diǎn)擊事件,用戶在點(diǎn)擊界面進(jìn)行交互的同時(shí),能夠獲得多個(gè)推送碼保存app.js的全局變量globalData中,等待用戶下一次發(fā)起網(wǎng)絡(luò)請(qǐng)求時(shí),即可將gloabalFomIds數(shù)組數(shù)據(jù)發(fā)送給服務(wù)器。

上圖以小打卡的打卡詳情頁(yè)為例,用戶在這個(gè)頁(yè)面的點(diǎn)擊操作可以很快收集到多個(gè)formId,所以將界面上用戶高頻點(diǎn)擊的事件用表單的形式重新封裝后,可以靜默、快速收集到所需的"模板消息推送權(quán)限" 。
Page({
onLoad:function(){
this. saveFormIds();
},
saveFormIds: function(){
var formIds = app.globalData.gloabalFomIds; // 獲取gloabalFomIds
if (formIds.length) {//gloabalFomIds存在的情況下 將數(shù)組轉(zhuǎn)換為JSON字符串
formIds = JSON.stringify(formIds);
app.globalData.gloabalFomIds = '';
}
wx.request({//通過(guò)網(wǎng)絡(luò)請(qǐng)求發(fā)送openId和formIds到服務(wù)器
url: 'https://www.x.com',
method: 'GET',
data: {
openId: 'openId',
formIds: formIds
},
success: function(res) {
}
});
},
})
在小程序的邏輯層中,通過(guò)全局變量gloabalFomIds收集到多個(gè)formId后,可以在新頁(yè)面載入時(shí),在onLoad生命周期函數(shù)中發(fā)送網(wǎng)絡(luò)請(qǐng)求獲取數(shù)據(jù), gloabalFomIds不為空時(shí),把gloabalFomIds數(shù)組格式化為字符串發(fā)送到服務(wù)器,并清空當(dāng)前的gloabalFomIds ,以便繼續(xù)獲取新的formId。
因?yàn)檫@個(gè)保存是一個(gè)高頻IO的操作,我們 后端以PHP結(jié)合高性能的key-value數(shù)據(jù)庫(kù)Redis來(lái)實(shí)現(xiàn)推送碼的存儲(chǔ) 。相關(guān)關(guān)鍵代碼如下,簡(jiǎn)單表達(dá)了思路,針對(duì)不同的后端環(huán)境和開發(fā)語(yǔ)言,你可能需要做相應(yīng)的調(diào)整。
//關(guān)鍵代碼
public function saveFormIds(){
$openId = $_GET['openId'];
$formIds = $_GET['formIds'];;//獲取formIds數(shù)組
if($formIds){
$formIds = json_decode($formIds,TRUE);//JSON解碼為數(shù)組
$this -> _saveFormIdsArray($openId,$formIds);//保存
}
}
private function _get($openId){
$cacheKey = md5('user_formId'.$openId);
$data = $this->cache->redis->get($cacheKey);//修改為你自己的Redis調(diào)用方式
if($data)return json_decode($data,TRUE);
else return FALSE;
}
private function _save($openId,$data){
$cacheKey = md5('user_formId'.$openId);
return $this->cache->redis->save($cacheKey,json_encode($data),60*60*24*7);//修改為你自己的Redis調(diào)用方式
}
private function _saveFormIdsArray($openId,$arr){
$res = $this->_get($openId);
if($res){
$new = array_merge($res, $arr);//合并數(shù)組
return $this->_save($openId,$new);
}else{
$result = $arr;
return $this->_save($openId,$result);
}
}
這一步主要是構(gòu)建服務(wù)器程序高效存儲(chǔ)用戶的推送碼formId,這下推送機(jī)會(huì)有了,接下來(lái)我們考慮如何 利用后端程序來(lái)想特定用戶發(fā)送模板消息 ,考慮怎樣去合理運(yùn)用推送機(jī)會(huì)。
構(gòu)建高性能的服務(wù)器端異步任務(wù)推送,可以滿足 模板消息的群發(fā)、以及定時(shí)發(fā)送 的需求,如小打卡就采用了高性能分布式內(nèi)存隊(duì)列系統(tǒng) BEANSTALKD,來(lái)實(shí)現(xiàn)模板消息的異步定時(shí)推送。實(shí)現(xiàn)發(fā)送模板消息的群發(fā)、定時(shí)發(fā)送分為2個(gè)步驟:
普通的模板消息的發(fā)送就不贅述了,可參考 官方文檔中的模板消息功能 一步步進(jìn)行操作,我們重點(diǎn)來(lái)看高性能異步任務(wù)推送的實(shí)現(xiàn)方法。涉及到的關(guān)鍵代碼如下:
//設(shè)置異步任務(wù)
public function put_task($data,$priority=2,$delay=3,$ttr=60){//任務(wù)數(shù)據(jù)、優(yōu)先級(jí)、時(shí)間定時(shí)、任務(wù)處理時(shí)間
$pheanstalk = new Pheanstalk('127.0.0.1:11300');
return $pheanstalk ->useTube('test') ->put($data,$priority,$delay,$ttr);
}
//執(zhí)行異步任務(wù)
public function run() {
while(1) {
$job = $this->pheanstalk->watch('test')->ignore('default')->reserve();//監(jiān)聽任務(wù)
$this->send_notice_by_key($job->getData());//執(zhí)行模板消息的發(fā)送
$this->pheanstalk->delete($job);//刪除任務(wù)
$memory = memory_get_usage();
usleep(10);
}
}
//1.取出一個(gè)可用的用戶openId對(duì)應(yīng)的推送碼
public function getFormId($openId){
$res = $this->_get($openId);
if($res){
if(!count($res)){
return FALSE;
}
$newData = array();
$result = FALSE;
for($i = 0;$i < count($res);$i++){
if($res[$i]['expire'] > time()){
$result = $res[$i]['formId'];//得到一個(gè)可用的formId
for($j = $i+1;$j < count($res);$j++){//移除本次使用的formId
array_push($newData,$res[$j]);//重新獲取可用formId組成的新數(shù)組
}
break;
}
}
$this->_save($openId,$newData);
return $result;
}else{
return FALSE;
}
}
//2.拼裝模板,創(chuàng)建通知內(nèi)容
private function create_template($openId,$formId,$content){
$templateData['keyword1']['value'] = '打卡即將開始';
$templateData['keyword1']['color'] = '#d81e06';
$templateData['keyword2']['value'] = '打卡名稱';
$templateData['keyword2']['color'] = '#1aaba8';
$templateData['keyword3']['value'] = '05:00';
$templateData['keyword4']['value'] = '備注說(shuō)明';
$data['touser'] = $openId;
$data['template_id'] = '模板id';
$data['page'] = 'pages/detail/detail?id=1000';//用戶點(diǎn)擊模板消息后的跳轉(zhuǎn)頁(yè)面
$data['form_id'] = $formId;
$data['data'] = $templateData;
return json_encode($data);
}
//3.執(zhí)行模板消息發(fā)布
public function send_notice($key){
$openId = '用戶openId';
$formId = $this -> getFormId($openId);//獲取formId
$access_token = '獲取access_token';
$content='通知內(nèi)容';//可通過(guò)$key作為鍵來(lái)獲取對(duì)應(yīng)的通知數(shù)據(jù)
if($access_token){
$templateData = $this->create_template($openId,$formId,$content);//拼接模板數(shù)據(jù)
$res = json_decode($this->http_post('https://api.weixin.qq.com/cgi-bin/message/wxopen/template/send?access_token='.$access_token,$templateData));
if($res->errcode == 0){
return $res;
}else{
return false;
}
}
}
Beanstalkd是一個(gè) 高性能、輕量級(jí)的分布式內(nèi)存隊(duì)列系統(tǒng) ,我們通過(guò)Beanstalkd將模板消息推送任務(wù)的創(chuàng)建以及任務(wù)的執(zhí)行分開進(jìn)行。
在創(chuàng)建推送任務(wù)時(shí), 設(shè)置任務(wù)的執(zhí)行時(shí)間以及定義推送消息的類型和通知內(nèi)容等數(shù)據(jù) 。
在任務(wù)執(zhí)行時(shí),通過(guò)Beanstalkd的任務(wù)監(jiān)聽函數(shù)來(lái)捕獲任務(wù)。通過(guò)預(yù)先在創(chuàng)建任務(wù)時(shí)標(biāo)記的數(shù)據(jù)來(lái)確定模板消息的具體推送內(nèi)容,比如用戶openId,通過(guò)用戶openId獲取一個(gè)可用的推送碼formId,獲取推送內(nèi)容等,最后在調(diào)用微信小程序模板消息下發(fā)接口完成推送。
getFormId函數(shù)主要實(shí)現(xiàn)每次取出一個(gè)未過(guò)期可用的推送碼formId,并且刪除不可用的邀請(qǐng)碼和當(dāng)前已選中的邀請(qǐng)碼,以保證一定數(shù)額的推送碼formId在未來(lái)一周內(nèi)可用。
關(guān)于Beanstalkd的使用介紹,可用參考一下文章,深入研究。
高性能分布式內(nèi)存隊(duì)列系統(tǒng)beanstalkd(轉(zhuǎn))
beanstalkd消息隊(duì)列使用最后總結(jié)一下,整個(gè)方案涉及到的關(guān)鍵詞有 表單、按鈕、formId、模板消息、Redis、Beanstalkd 等,涉及了多項(xiàng)技術(shù)的組合,包括 前端開發(fā)、后端開發(fā)、數(shù)據(jù)庫(kù)技術(shù) 等,且前后端分工明確,共同支撐整個(gè)方案地實(shí)現(xiàn)。

正如我之前文章里所說(shuō)的, 微信小程序開發(fā)的難點(diǎn)不在于小程序本身,小程序開發(fā)技術(shù)是前后端一系列的技術(shù)的組合,開發(fā)者需要持續(xù)學(xué)習(xí),掌握、提升更多的相關(guān)開發(fā)技術(shù),來(lái)更好地支撐產(chǎn)品的功能實(shí)現(xiàn) 。最后,這個(gè)方案可以在用戶最后一次使用小程序后的7天內(nèi),對(duì)用戶發(fā)送多條模板消息喚回用戶,但是請(qǐng) 一定要在遵循微信官方的運(yùn)營(yíng)規(guī)范的前提下 ,合理使用這樣的模板消息推送功能。