網易云信 IM 推送保障及網絡優化實踐
今天,我想和大家分享的是云信在保障 IM 推送和移動網絡優化方面的一些實踐經驗。
對于移動 App 來說,除了簡單的工具類 App 外,IM 功能可謂非常重要,它能夠創建起人與人之間的連接。社交類產品中,用戶與用戶之間的溝通可以產生出更好的用戶粘性。電商類產品,用戶與商家的連接能極大的促進溝通效率,降低溝通成本。教育類產品,學生與老師的連接讓在線教育猶如課堂。醫療類 App,醫生與患者的溝通讓醫生把脈更加準確。
IM 應用如此之廣泛,那么,IM 是什么,有些什么要求呢?IM 由兩個字組成:Instant,Messaging。即時性要求有新消息時能夠立即收到,如果程序在后臺,則要能立即收到推送通知。而通信則要求穩定可靠,系統不宕機,程序不崩潰,安全,傳遞消息時不會被攔截監聽,消息不丟,順序不亂,不重復,如果包含音視頻聊天,則要求延遲低,流暢不卡頓。這兩點說起來容易,但是,要真正做出一套穩定可靠的商用級IM系統,挑戰非常之多。下面,我會挑選影響 IM 即時性和消息穩定性最核心的兩個問題,來看看都有哪些障礙,以及云信是如何去實踐克服這些障礙的。
首先第一個問題是消息推送,在 iOS 端有 APNS 做推送,相當穩定。Android 本身也有 GCM 可以用,但是在國內,我們有一個叫作“墻”的東西,直接就把 GCM 等 Google 服務全部擋在外面了,導致國內根本無法運用。對于 IM,當 App 退到后臺,是必須還能夠收到新消息提醒的,沒有 GCM,怎么辦?唯一能做的,就是后臺運行了。Android 從設計上,就是支持真后臺運行的,后臺運行的特性也是 Android 現在能如此成功的原因之一。但另一面,Android 長久以來一直擺脫不了的卡頓、耗電等壞名聲,后臺運行也拖不了干系。因此,系統對于后臺運行也不會放任自流。App 想要在后臺運行,需要面對不少的障礙。
第一個障礙是 Android 的 Low Memory Killer 機制。手機的內存畢竟是有限的,當后臺運行的進程越來越多,內存剩余量也就隨之減少。當有一個新的 App 想要啟動,如果此時內存不夠了,LMK 機制就會啟動,從正在運行的進程中挑選一個清理掉,釋放出空間,然后新的 App 就可以云信了。這個挑選過程顯然不會隨機拼人品,LMK 有兩個尺度去評判。一個是進程優先級,優先級越低,被清理的可能性越大,另一個是內存占用,占的內存越多,被清理的權重自然也越大。
因為 LMK 機制的存在,雖然 App 允許在后臺運行,但同樣也面臨隨時被清理的風險。因此,我們需要在被清理后及時的重新啟動。常規的,有 4 種方式能夠做到:
- sticky service,就是在 Service 的 onStartCommand 中返回 sticky flag,這樣當 service 被 kill 掉后,系統會將它加入重啟的 pending 列表,在后面合適的時機再把 service 重啟;
- alarm,鬧鐘,有循環鬧鐘和一次性鬧鐘兩種,在鬧鐘觸發后啟動對應的組件;
- 在 Manifest 文件中靜態注冊的 Receiver,通過監聽各種系統事件,比如開機、網絡變化、mount/unmounts 等。在這些事件發生時啟動組件,因為這種方式會造成在這些事件發生時系統容易卡頓,在7.0里面,Android增加了限制;
- JobScheduler,這是在 5.0 里面新增的,允許 App 在特定事件發生時做一些動作,比如充電、切換到 Wi-Fi 等。
雖說無論怎么做,App 終究免不了一死,但通過對照 LMK 的評判準則,我們還是可以降低 App 被清理的概率的。第一個就是降低進程的內存占用。如果采用單進程的模式,由于進程中包含了 UI、Webview、各種圖片緩存等內容,內存必然會居高不下,降不下來。IM 軟件一般都會采用雙進程甚至多進程的策略,將 push 進程獨立出來,在 push 進程里只處理網絡連接和 push 業務,不參與任何其他業務邏輯,更不包含任何 UI。
我們來看一下云信 Demo 的進程內存占用情況。上面一個是主進程,看第四列 PSS 的數據,內存占用是 50M 左右,下面一個是 push 進程,內存占用只有 10M 左右。當處于后臺時,push 進程被清理概率比 UI 主進程低很多。
降低被清理概率的第二個手段是提升進程優先級。我們先看這個例子,這是綠色守護的一個截圖,我們評價其他 App 的行為,也不打廣告,所以這里對 icon 和名字做了模糊處理。看最上面,這一組是“暫不自動休眠”,因為這里列出的兩個 App 的狀態都是工作中,對應的進程優先級是“可視進程”。但這兩個 App 并沒有提供桌面小部門在運行,也沒有指示前臺服務的常駐通知欄提醒,事實上,它們就只是在后臺運而已。通常進程退到后臺后,其進程優先級類型就變成了較低的后臺進程,而不是這樣的“可視進程”,它們是通過什么方法來提升優先級,降低被清理概率的呢?
Android 在設計前臺服務上有一個漏洞,通過兩個服務配合,我們就能創建一個隱形的前臺服務。這里有兩個已經啟動的 service: A 和 B。先在 A 中調用 startForeground,提供一個 NOTIFY_ID, 然后 A 就變成前臺服務了,同時有了一個 ID 為 NOTIFY_ID 的常駐通知欄提醒,然后我們在 B 中也調用 startForeground,提供相同的 NOTIFY_ID, B 也變成了前臺服務,因為兩個通知 ID 相同,因此這一次就不會創建新的通知欄提醒了。然后再在 A 中調用 stopForeground,A 的前臺屬性被取消,同時,常駐通知欄提醒也會被移除,但是,service B 并不會受到任何影響,還是前臺服務,這是再把 A 停掉,進程就只剩下前臺服務 B 了,進程也變成了前臺進程,但用戶不會有任何感知。
正常來說,做了上面三步之后,我們的進程就能夠比較穩定地在后臺運行了。但是后來發現,在有些情況下,我們的推送進程卻永遠起不來。跟蹤之后發現,除了系統能夠殺掉后臺運行的進程外,用戶也一樣是可以殺死進程的。用戶殺掉進程的方式有兩種,一種是在最近任務列表中將 App 劃掉,這種方式和系統殺掉進程效果相同。另外一種就是通過這里的 force stop,這種方式比系統清理更加徹底。不但 App 正在運行的進程會被清理,App 當前在重啟列表中的待重啟服務,注冊的各種鬧鐘、事件監聽組件等都會被移除,除非用戶在主動點擊或者系統重啟等外力,App 沒法再自己重新爬起來了。
我們后來還發現,在有些國內的像 MIUI 一類的 ROM 上,用戶從最近任務列表中將 App 移除,效果竟然也是 force stop。正常來說,如果是用戶主動操作,我們 App 本身也不應該再重啟了。但有些時候這個并不是用戶本意。況且,對于 IM 軟件來說,消息提送是一定要得到保障的,否則不明正確的吃瓜群眾們會覺得是我們軟件不行,連消息推送都做不好。因此,這時候還是應該想辦法繼續維持后臺運行。那么,又有哪些辦法呢?
第一個是通過兩次 fork 加上 exec 的方式。兩個 fork 后,第一次 fork 的進程退出,第二次 fork 出來的進程就會被 init 進程領養。用戶此時再 force stop,因為這個進程父進程是 init,而不是 Zygote,因此不會被清理。由于這個進程還是從 Android 進程 fork 出來的,帶有 Android 運行時環境以及父進程的資源,所以內存會比較大。這里可以再通過 exec 命令,打開一個純 Linux 的可執行文件,開啟一個 daemon 進程,其內存占用大概只有100K+,對用戶也就完全無感了。利用這個后臺進程,可以定時地將 push 進程拉起來。此種方式只在 5.0 以下的系統中有效,在 4.4 及以上系統中,SELinux 特性是強制開啟的,exec 沒有權限執行,同時在 5.0 之后,ActivityManager 在做 force stop 以及移除任務時,只要是具有相同的 uid 的進程,就會全部清理掉,不再漏掉沒有虛擬機環境的進程。
最后一個后臺保活的手段是一個大殺器,也是帶有強烈的中國特色。因為前面所列的所有保活手段都不是那么保險,因此想出來這么一個互相保活的方式。當一個 App 進程起來后,它就去掃描已安裝的應用列表,看看有沒有自己的兄弟姐妹。比如說同一個長的 App,或者是集成了同一個 SDK 的 App,如果有,就把這些 App 都拉起來。這也就是現在比較出名的“全家桶”方案。雖說這種方法確實能夠帶來較高的后臺存活率,特別是那些大廠和應用廣泛的 SDK,但是這種方式對于用戶的傷害也非常大,如果有后臺推送的必要性,且不會對用戶體驗造成太大傷害時,此方式還可以使用,但如果只是為了推廣告,則會對用戶造成傷害,反過來,也可能會導致用戶直接卸載 App。
現在,因為“全家桶”實在是太令人討厭,現在各種手機管理軟件都會對這種喚醒方式做限制,特別是在 Root 過的機器上,可以做到完全切斷這些喚醒路徑。同時,很多 ROM 也會自帶管理軟件,限制后臺運行和后臺喚醒,以便給設備換取更長的續航。在目前國內的 Android 生態環境中,無論采用什么方式,想要一直在后臺運行時越來越難了,我們需要重新想另外的辦法來保障消息推送。另一方面,我們作為開發者,也有義務為用戶提供更好體驗的軟件,而不是無休止的在后臺浪費用戶的資源。
其實,對于 IM 來說,及時的消息推送和較低的電量消耗也并非不可兼得。在傳統上,每個 IM 客戶端都會各自維護一條與服務器的長連接,自己的消息和信令都在這條長連接上傳遞,每個 App 也獨自去心跳,斷線重連等事情。這種模式比較簡單,不同的 App 也是完全隔離的,不會互相影響。但它的缺點也非常明顯,首先是做了很多重復的事情,造成了流量和電量的無謂消耗,第二是要保證所有的進程都能在后臺運行很難。優化的方向也就非常明顯了,那就是共享連接,現在絕大部分推送 SDK 也是這么做的。
從這些 App 里選出一個當前正在運行的,或者是被殺概率最低的 App 作為總代理,只由這個代理和服務器建立連接,一個手機上的所有其他 App 都通過這個代理中轉與服務器通信。但是,IM有一個很基本的要求在這種模式下無法得到滿足:安全。所有 App 的消息都經過代理中轉,代理到服務器的連接是加密、安全的,但到了代理這里,消息都被解開了,因此代理理論上可以看到其他所有 App 的來往消息。因此,這種共享長連接的方式并不適用于 IM。
長連接+推送
雖然共享長連接方式不合適,但仍然給我們提供了一個優化的思路。在此基礎上,我們想到了另外一個可以脫敏共享連接的方式:安全長連接加推送連接模式。每個 App 在使用和真正傳遞數據時,仍然獨立使用自己的安全長連接。而當 App 退到后臺一段時間之后,則斷開長連接,然后每個 App 開啟一個推送代理,并選擇其中一個和云信的推送服務器建立連接,之后當 App 有新消息時,就通過這個推送連接傳遞。 App 可以自己控制發出的推送消息的安全級別,可以是包含說話人和消息內容,可以只包含說話人,或者只是一條簡單的有新消息到達的提醒文案。推送到達后,如果是代理 App 自己的消息,直接傳遞給代理 App 即可。如果是其他 App 消息,前面說到過,直接喚醒可能會失敗,而且會導致無謂的電量消耗,所以這里并不直接將提醒傳遞給目標 App ,而是由帶來發出一條通知欄提醒。等用戶去點擊通知欄提醒后,才會把目標 App 喚醒。
系統推送
現在國內的ROM中,華為和小米的系統本來是帶有推送系統,且開放給了第三方 App 的。在這兩個系統上,使用系統的推送通道明顯會更加穩定,也更加節省資源。因此在MIUI上,從長連接到推送通道的切換流程仍然和前面的一樣,只是不再使用自己的推送連接,而是將消息轉發到MIUI的推送服務器,然后轉給MIUI系統的推送代理,然后傳遞給云信的 App 。華為的推送系統流程也是一樣。不過現在華為和MIUI在推送實現上有一些區別,例如MIUI的通知欄提醒是在自己的推送代理里完成的,而華為卻是將提醒通知交給 App 自己去完成的,另外,他們的通知欄提醒的管理接口也有很多區別。在 App 沒有被禁用的情況下,兩者都可以收到推送,而如果 App 已經被禁用了,MIUI的通知欄提醒方式還可以將推送送達,而其他的推送方式則不能送達了。
以上就是在保障消息推送方面我們所能夠做的所有事情了。如果以后有更多的系統開放自己的推送系統,我們也可以選擇逐步接入,以提高推送到達即時性,減少資源消耗。不過相應的,我們也要承受不斷加入各種系統的推送SDK,增大發布包體積的缺點。期望Android擁有統一推送平臺的那一天早點到來吧。
相對于PC的網絡環境,我總結的手機網絡有三個特點:
- 第一個是 慢 ,尤其是2G,3G網絡,慢的令人發指。當我們收發圖片視頻這類比較大的文件時,就會看到蛋疼的菊花一圈一圈不停的轉。
- 第二個是 斷 ,手機跟著人不停的移動,網絡也不停的在切換,從 Wi-Fi 到移動網絡,從一個基站到另一個基站,從有信號到沒信號,都可能導致網絡中斷。有些制式的網絡,接打電話也會導致數據網絡斷開。另外,移動基站還有 NAT 超時,到一個連接上長時間空閑后,基站就會默默的將連接斷開,沒有任何通知。
- 第三個是 貴 ,這個就不用多說,看中國移動每天凈賺一個億就知道了。
在云信整個通信系統中,我們有三種類型的連接:TCP,UDP,HTTP。雖說這三個并不是同一層的協議,不過畢竟都在我們的應用的更下層,因此這么劃分也無妨。3種類型的協議對應了不同的業務應用。TCP主要是用戶長連接,也就是普通IM消息和信令的傳輸,UDP用于傳輸實時音視頻數據流,而HTTP則主要用在音頻,圖片等文件的上傳下載上。對于不同的業務,我們的優化的關注點會有一些不相同。
長連接是云信所有業務的基礎,使用量也是最大的,因此優化也是從基礎開始。 在這里我們舉兩個例子。
第一個是協議的選擇。前面說,長連接的使用量是最大,選擇一個合適的協議至關重要。如果是剛開始接觸IM開發,一般會選擇一些開源的協議,比如XMPP,SIP等。這是XMPP協議的一個請求樣例,可以看到是一段XML格式的文本數據。這是基于SIP的SIMPLE協議的一個請求樣例,可以看到是一段類似HTTP協議的文本數據。這些協議的優勢在于開源,有成熟的解決方案可以使用,擴展性好,甚至還可以和其他系統互聯互通,協議的可讀性也非常好。但是在普遍比較臃腫,冗余字段很多,在昂貴的移動網絡里面用起來會讓人覺肉疼。云信采用的是私有的二進制協議,這是一個請求的數據樣例,這里是把二進制數據轉為了16進制顯示出來,每個字節這里顯示為兩個字符。可以看到二進制協議的特點在于完全失去了可讀性,但是,卻帶來極高的表達效率,相對于文本協議,可以節省非常多的數據流量。
另一個例子是登錄的優化。由于移動網絡經常斷開,所以登錄常常是心跳之外交互最多的協議了。使用量越大,優化就越有意義。一般而言,登錄會經過這么幾步。
第一步是LBS。這里的LBS不是經常說的基于地址位置的服務,在不同的廠商可能也有不同的叫法,反正作用都是獲取服務器的IP地址。像云信這種需要提供全球服務的系統,在世界各地都要部署服務器,用戶登錄時,肯定要選擇一臺最優的服務器接入服務。通過lbs,客戶端可以獲取離自己最近,連通性最好的服務器連接機IP地址,服務器也可以據此做負載均衡。
拿到服務器連接機IP后,客戶端就去連接該服務器。
連接成功,需要有一次握手。這個握手不是TCP的三次握手,而是為了建立安全連接,同服務器協商加密算法和加密密鑰。
然后就發送登錄請求,這里會帶上用戶認證信息,本機設備信息等數據。
登錄成功之后,就是同步數據,包括離線消息,用戶信息,群組信息等。一般而言,這里不會去做全量同步,而是采用基于時間戳的增量同步。
在移動網絡上,每一次交互都需要比較長的時間,同時,每一次網絡請求電量消耗也是很大的。所以,優化的方向就是盡量減少交互次數,而方法則是合并請求,并行操作以及省略請求。
LBS和連接這兩個步驟是可以并行完成的。如果前面已經獲取過LBS,這里可以有之前的緩存地址,如果沒有,可以先連一個默認地址。
其次是握手和登錄也可以并行操作。在握手包中,就可以把加密后的登錄包直接帶上去了。如果是斷線重連,我們還可以簡化登錄,直接帶上上一次登錄的會話ID,一來減少服務器鑒權壓力,二則可以直接帶回在斷線期間是否有未讀消息等數據,如果沒有,則能直接將同步這一步省略掉。如果有,同步也可以只做部分同步,只去拉去離線消息即可。等到 App 切換到前臺,才去同步其他的信息。
通過這些優化,登錄時間可以降為原來的1/2到1/3,登錄的流量消耗也可以節省30%左右。
實時音視頻對實時性要求很高,但可以容忍一定的丟包,所以我們選擇 UDP 私有協議來作為底層的傳輸協議。如果只是普通的IM消息,對網絡情況其實不是太敏感,最多也就是慢一點,菊花轉得久一點。但對于這種視頻電話,如果網絡差了,發生了經常性卡頓,或者是延遲很高,圖像出現花屏,音視頻不同步了,這個功能其實也就相當于廢棄了。而且,音視頻數據量本身也比較大,在弱網環境下發生問題的概率就更大了。
UDP 協議是不可靠,為了提高弱網下的實時音視頻的通話效果,需要使用相關方案來做 QoS 保障:主要包括了基于 UDP 協議的擁塞控制、前向糾錯 FEC 技術及相關的重傳技術。同時網絡層需要能夠實時的探測到網絡狀態,作為底層調整 QoS 策略的依據,同時需要回調上層,來動態調整音視頻的碼率,做到音視頻碼率自適應。通過上面的 QoS 保障,我們實際測試在 20% 的隨機丟包弱網環境下,音視頻通話還能夠正常進行。
第二是音頻,我們的音頻編解碼主要以Opus為主,它具備高音質,高壓縮率,高抗丟包等特性,非常適合移動網絡。我們使用智能的jitterbuffer算法來平滑由于網絡抖動引起的聲音卡頓和延遲累計問題。配合PLC丟包補償算法,來降低音頻丟包后的爆音。同時,我們使用自研的高性能降噪算法,配合回聲消除、自動增益和舒適噪音等音頻處理算法來進一步保證音頻部分的質量。
對于視頻,我們使用時域分層的H264視頻編碼器,來降低丟包對視頻流暢性的影響,同時支持動態幀率和動態分辨率,方便上層根據業務需求進行切換。現在用戶對于視頻的清晰度要求越來越高,我們的實時通話系統當前能夠支持720p。720p下純軟件編解碼對CPU開銷過大,因此在可以開啟硬件編解碼的機器上,對于需要720p清晰度的都盡量使用硬件編解碼。
由于音視頻的網絡優化如果全部細說,恐怕再加1個小時也講不完,所以這里我只提了一些優化的方向供大家參考,就不一一展開了。
下面再來看看對于HTTP的優化。圖片語音是IM的必需元素,而且本身數據比較大。在弱網環境下,快速的上傳下載,更少的等待時間可以帶來更好的用戶體驗。
斷點續傳可以減少因網絡原因導致的重復傳輸,減少傳輸時間,節省流量。
圖片預加載技術可以根據不用網絡情況,在收到消息后,就加載不同素質的預覽圖片,甚至直接將原圖預加載,做到用戶點開即看。
上面兩個是比較基礎的優化措施,下面兩個則比較高級一點。
圖片和語音這種文件我們并沒有通過長連接收發,而是通過HTTP去做上傳下載。傳統上通過HTTP上傳時,文件會分為一片一片,傳完一片,收到回包,才會穿下一個分片,一直到最終傳輸完成。可以看到,服務器返回ack這段時間,上傳通道其實是空閑的,如果把這段時間利用起來,可以節約不少上傳時間。Pipeline就是為此而來。通過重疊利用http請求的響應等待時間,加快傳輸速度。使用pipeline,需要修改HttpClient,同時還需要服務器提供支持。視網絡具體情況,使用pipeline后,一次上傳可以減少20%至30%的時間。
常規發送語音消息需要這幾步,先錄音,然后計算hash值,然后上傳,上傳完畢后,服務器計算一下校驗和,通過后語音消息發送成功。在前面錄制語音時,網絡其實也是空閑的。把這段時間利用起來,則可以減少后面上傳步驟的時間。優化后,流程就變成這樣。在錄制的過程中,每錄完一段,就作為一個分片直接上傳。直到最后錄完,計算好hash,再把最后一個分片帶上hash信息上傳。這里除了客戶端的改動,也是需要服務器支持。服務器在開始接收時,很多信息都不明確,需要開辟緩存來記錄整次上傳過程。對于比較差的網絡,邊錄邊傳的效果會更好,畢竟純語音的比特率并不高,基本都能做到錄完就傳完。
以上就是我今天分享的全部內容。提升消息推送達到率和到達速度,優化網絡利用效率,節省系統資源一直都是Android開發的核心和基礎,新技術,新方法都在不停的涌現,也歡迎大家一起討論,進步,謝謝大家。
來自:http://geek.csdn.net/news/detail/106242