這篇文章是我最早釋出公司內部km,後來在bugly和騰訊大講堂上被轉載過的一篇文章。記錄了2017年開發收款到賬語音提醒功能的一些細節,關於iOS13上的變更我在最後也有提到。原文連結
一、背景
為了解決小商戶老闆們在頻繁交易中不方便核對、確認到賬的痛點,產品MM提出了新版本需要支援收款到賬語音提醒功能。這篇文章總結了開發過程中遇到的坑和一些小技巧。
二、技術方案
後臺喚醒App
收款到賬語音提醒需要收款方在收到款後,播放一段TTS合成語音播報金額,微信在前臺時可以通過模板訊息將需要播報的金額帶下來,再請求TTS資料並播放,但是app在掛起或者被kill掉的情況下要如何請求語音資料並播放呢? iOS提供了兩種方式喚醒處於掛起或已經被kill掉的app。分別是Silent Notification和VoIP Push Notification,客戶端在被喚醒之後將獲得30s的後臺執行時間,這段執行時間足以請求合成語音資料並播放。
- Silent Notification: Silent Notification在iOS7以上便可以支援,但是每小時能推送的Silent Notification次數有限制。
- VoIP Push Notification VoIP Push Notification則是在iOS8以上才支援的新Push型別,相比於Silent Notification,VoIP Push具有高優先順序、低延遲的優勢,並且沒有次數限制。 對比這兩種技術方案,VoIP Push Notification明顯更適合用於收款到賬語音提醒的喚醒方案。
TTS合成語音
TTS語音合成方案分為離線合成方案和線上合成方案,離線合成方案省去網路請求,合成速度更快,節省網路流量,但是合成音的聽起來比較機械,語速和停頓的處理較差一些。如果對合成音的效果要求不是特別高,可以考慮採用iOS自帶的AVSpeechSynthesis框架,免去語音庫的合入,減少安裝包大小。
線上合成方案的效果則相對更像人聲,富有感情。考慮到產品體驗,我們採用了搜尋產品部提供的線上語音合成方案(公眾開發平臺也有提供給第三方開發的api介面)。合成音格式支援wav,mp3,silk,amr,speex,對比後發現,在合成相同文字的情況下,amr的壓縮率最高,但是能聽到音質下降明顯。silk格式壓縮率次高,且能保持相對清晰的音質,單條合成語音大小在2KB左右。
2019年更新:微信的支付語音播報目前已更換成離線合成的方式,且已經上線。說個題外話,龍哥雖然在公開場合曾經提過對人工智慧表達過一些質疑,但實際上微信有著很強的深度學習研究團隊,在語音方面的研究也是非常的深入。
喚醒後播放音訊檔案
在請求到合成語音後,要在後臺或者鎖屏狀態下播放音訊檔案,AVAudio Session的Category值需要使用AVAudioSessionCategoryPlayback
或是AVAudioSessionCategoryPlayAndRecord
,CategoryOptions根據實際需要可選擇MixWithOthers
(與其他聲音混音)或是DuckOthers
(調低其他聲音的音量)。
會話型別 | 說明 | 是否遵從靜音鍵 |
---|---|---|
AVAudioSessionCategoryAmbient | 混音播放,可以與其他音訊應用同時播放 | 是 |
AVAudioSessionCategorySoloAmbient | 獨佔播放 | 是 |
AVAudioSessionCategoryPlayback | 支援後臺播放 | 否 |
AVAudioSessionCategoryRecord | 錄音模式 | 否 |
AVAudioSessionCategoryPlayAndRecord | 支援後臺播放,可以播放也可以錄音 | 否 |
AVAudioSessionCategoryAudioProcessing | 硬體解碼音訊,此時不能播放和錄製 | 否 |
AVAudioSessionCategoryMultiRoute | 多種輸入輸出,例如可以耳機、USB裝置同時播放 | 否 |
需要注意的是,只有iOS10以上才支援app被喚醒後在後臺/鎖屏狀態下播放音訊。所以iOS10以下的裝置,在收到VoIP Push後只能在local push上設定一段固定鈴聲,這也是為什麼iOS10以下只有“微信支付收款到賬”,而沒有後面具體的金額數值。
三、靜音開關檢測
不幸的是,在產品釋出後沒多久就受到了某網際網路大佬的吐槽。
從產品體驗上來說,收款到賬的金額播報是隨著local push的彈出一起播放的,更像是一種特殊的push鈴聲,而蘋果對push鈴聲的處理是受到靜音開關控制的,所以講道理,這個吐槽是合理的。然而前面提到App在被VoIP Push喚醒之後,需要將AudioSessionCategory設定為AVAudioSessionCategoryPlayback
或AVAudioSessionCategoryPlayAndRecord
才可以在後臺播放音訊檔案,這兩種模式是不受靜音開關控制的。要實現這個需求,就必須獲取當前靜音開關的狀態。而蘋果在iOS5之後並沒有明確地提供一種方式讓開發獲取靜音開關的狀態,這就陷入了一個尷尬的局面。
蘋果在iOS5之前可以使用以下方式監聽靜音鍵開關
- (BOOL)isMuted
{
CFStringRef route;
UInt32 routeSize = sizeof(CFStringRef);
OSStatus status = AudioSessionGetProperty(kAudioSessionProperty_AudioRoute, &routeSize, &route);
if (status == kAudioSessionNoError)
{
if (route == NULL || !CFStringGetLength(route))
return YES;
}
return NO;
}
複製程式碼
蘋果在iOS5之後便禁止了使用這種方式監聽靜音按鍵,背後的原因應該是蘋果希望開發者使用AVAudioSession來提供統一的音訊播放效果。
最後我在Reddit 上找到了一種曲線救國的方式,實現起來也不復雜:使用AudioServicesPlaySystemSound播放一段0.2s的空白音訊,並監聽音訊播放完成事件,如果從開始播放到回撥完成方法的間隔時間小於0.1s,則意味當前靜音開關為開啟狀態。
void SoundMuteNotificationCompletionProc(SystemSoundID ssID,void* clientData){
MMSoundSwitchDetector* detecotr = (__bridge MMSoundSwitchDetector*)clientData;
[detecotr complete];
}
- (instancetype)init {
self = [super init];
if (self) {
NSURL *pathURL = [[NSBundle mainBundle] URLForResource:@"mute" withExtension:@"caf"];
if (AudioServicesCreateSystemSoundID((__bridge CFURLRef)pathURL, &_soundId) == kAudioServicesNoError){
AudioServicesAddSystemSoundCompletion(self.soundId, CFRunLoopGetMain(), kCFRunLoopDefaultMode, SoundMuteNotificationCompletionProc,(__bridge void *)(self));
UInt32 yes = 1;
AudioServicesSetProperty(kAudioServicesPropertyIsUISound, sizeof(_soundId),&_soundId,sizeof(yes), &yes);
} else {
MMErrorWithModule(LOGMODULE, @"Create Sound Error.");
_soundId = 0;
}
}
return self;
}
- (void)checkSoundSwitchStatus:(CheckSwitchStatusCompleteBlk)completHandler {
if (self.soundId == 0) {
completHandler(YES);
return;
}
self.completeHandler = completHandler;
self.beginTime = CACurrentMediaTime();
AudioServicesPlaySystemSound(self.soundId);
}
- (void)complete {
CFTimeInterval elapsed = CACurrentMediaTime() - self.beginTime;
BOOL isSwitchOn = elapsed > 0.1;
if (self.completeHandler) {
self.completeHandler(isSwitchOn);
}
}
複製程式碼
四、設定聲音閾值
另外一個使用者反饋較多的問題是聽不到播報聲音,通過檢視日誌發現是觸發語音播報時,使用者設定的系統音量過小所導致。首先想到的解決方案是直接設定AVAudioPlayer的volume(或者是AudioQueue中的kAudioQueueParam_Volume),然而實驗過後發現這樣行不通,volume屬性受制於系統音量(比如系統volume是0.5,AVAudioPlayer的音量是0.6,則最終的音量為0.5*0.6 =0.3)。要解決音量過小的問題,還是需要通過調節系統音量。最終的解決方案借鑑了進入收付款展示二維碼時自動調節螢幕亮度的方案:如果螢幕亮度未達到閾值,則調高螢幕亮度到閾值,離開頁面時,將亮度設回原亮度。同理,播放提示音時,若使用者設定的系統音量小於閾值,則調節到閾值。提示音播放完畢後,將提示音調回原音量。
控制系統音量有兩種方式:
- 方式一:通過MPMusicPlayerController設定音量
MPMusicPlayerController *mpc = [MPMusicPlayerController applicationMusicPlayer];
//This property is deprecated -- use MPVolumeView for volume control instead.
mpc.volume = 0; //0.0~1.0
複製程式碼
第一種方式簡單粗暴,在設定的時候會彈出系統音量提示框,如果使用者在使用app的過程突然彈出音量框,會對使用者造成困擾,不建議使用這種方式,並且蘋果在iOS7.0以後已將該屬性標為deprecated。
- 方式二:通過MPVolumeView設定音量
第二種方式則是將一個看不見的MPVolumeView新增到當前檢視上,系統音量提示框就不會顯示了 需要注意的是,在調節完系統音量需要將MPVolumeView移除,否則後續使用者手動調節音量會出現系統音量提示框不顯示的情況。
調節音量的方式,則是先取到MPVolumeView中名為MPVolumeSlider的子View,並對其傳送模擬使用者操作的事件。
- (void)setSystemVolume:(float)volume {
UISlider* volumeViewSlider = nil;
for (UIView *view in [self.m_privateVoulmeView subviews]){
if ([view.class.description isEqualToString:@"MPVolumeSlider"]){
volumeViewSlider = (UISlider*)view;
break;
}
}
if (volumeViewSlider != nil) {
[volumeViewSlider setValue:volume animated:NO];
//通過send
[volumeViewSlider sendActionsForControlEvents:UIControlEventTouchUpInside];
}
}
複製程式碼
五、關於iOS13無法再使用PushKit做語音播報功能
蘋果在iOS13中不再允許voip push應用在非voip電話的功能,在session707中有提到這是出於解決電池續航、效能和使用者隱私的考慮,減少後臺任務的濫用。如果使用pushkit的話需要接入callkit的介面,導致收到Voip Push會拉起一個接打電話的全屏介面。
iOS13 Voip Push變更點:
1.NSData的description方法有改變,不能再用[[NSString alloc] initWithFormat:@"%@", self.m_token]這種方法傳token給後臺。
2.需要在didReceiveIncomingPushWithPayload方法結束前,呼叫call kit的[CXProvider reportNewIncomingCallWithUUID:]方法。否則會出現以下的crash:
killing app because it never posted an incoming call to system after receiving a PushKit Voip push CallBack
並在收到若干次voip push後,會被系統禁止接收voip push。
3.呼叫call kit的[CXProvider reportNewIncomingCallWithUUID]會導致出現一個全螢幕的接電話介面,這個介面暫時沒有辦法去掉,所以這導致了iOS13中無法在使用者無感知的情況下在拉起App執行後臺任務。
iOS13下要如何實現語音播報功能?
沒有了VoipPush之後是不是就沒法實現這個功能呢?答案是否定的,使用Notification Service Extension同樣也能實現類似的功能,目前我正在遷移這部分功能,等專案正式上線後,我會再寫一篇文章介紹詳細的實現細節以及過程中遇到的坑。