首頁>科技>

1、引言

上個月在知乎上發表的由“袁輝輝”分享的關於TIM程序永生方面的文章(即時通訊網重新整理後的標題是:《史上最強Android保活思路:深入剖析騰訊TIM的程序永生技術》),短時間內受到大量關注,可惜在短短的幾十個小時後,就在一股神祕力量的干預下被強行刪除了。。。

在《史上最強Android保活思路:深入剖析騰訊TIM的程序永生技術》一文重新整理髮布後的數小時內,作者田維術(部落格名:Weishu)快速響應,針對TIM程序永生這個話題,對Android程序永生技術進行了終極揭密,從Android系統原始碼層面,通俗易懂地講解了Andorid程序被殺的底層原理(也即是本文將要分享的內容),並詳細探討APP如何對抗系統被殺的技巧實踐(並同時提供了參考實現程式碼)。

(本文同步釋出於:/file/2020/02/27/20200227111845_14233.jpg python, rails, C++, node。現專攻Android,業餘Haskell。

作者部落格:http://weishu.me

作者Github:https://github.com/tiann

3、混亂的程序保活,一個黑暗的時代

一直以來,App 程序保活都是各大廠商,特別是頭部應用開發商永恆的追求。畢竟App 程序死了,就什麼也幹不了了。一旦 App 程序死亡,那就再也無法在使用者的手機上開展任何業務,所有的商業模型在使用者側都沒有立足之地了。

早期的 Android 系統不完善,導致 App 側有很多空子可以鑽,因此它們有著有著各種各樣的姿勢進行保活。

▲ 這臺手機,應該能勾起很多老Android程式設計師的回憶

譬如說在 Android 5.0 以前,App 內部通過 native 方式 fork 出來的程序是不受系統管控的,系統在殺 App 程序的時候,只會去殺 App 啟動的 Java 程序。因此誕生了一大批“毒瘤”,他們通過 fork native 程序,在 App 的 Java 程序被殺死的時候通過 am命令拉起自己從而實現永生。

那時候的 Android 可謂是魑魅橫行,群魔亂舞,系統根本管不住應用,因此長期以來被人詬病耗電、卡頓。

比如以下這幾篇中介紹的Android保活方法:

《應用保活終極總結(一):Android6.0以下的雙程序守護保活實踐》

《應用保活終極總結(二):Android6.0及以上的保活實踐(程序防殺篇)》

《應用保活終極總結(三):Android6.0及以上的保活實踐(被殺復活篇)》

同時,系統的軟弱導致了 Xposed 框架、阻止執行、綠色守護、黑域、冰箱等一系列管制系統後臺程序的框架和 App 出現。

4、限制程序保活,大勢所趨

不過,隨著 Android 系統的發展,這一切都在往好的方向演變。

Android 5.0 以上,系統殺程序以 uid 為標識,通過殺死整個程序組來殺程序,因此 native 程序也躲不過系統的法眼。

Android 6.0 引入了待機模式(doze),一旦使用者拔下裝置的電源插頭,並在螢幕關閉後的一段時間內使其保持不活動狀態,裝置會進入低電耗模式,在該模式下裝置會嘗試讓系統保持休眠狀態。

Android 7.0 加強了之前雞肋的待機模式(不再要求裝置靜止狀態),同時對開啟了 Project Svelte。Project Svelte 是專門用來優化 Android 系統後臺的專案,在 Android 7.0 上直接移除了一些隱式廣播,App 無法再通過監聽這些廣播拉起自己。

Android 8.0 進一步加強了應用後臺執行限制:一旦應用進入已快取狀態時,如果沒有活動的元件,系統將解除應用具有的所有喚醒鎖。另外,系統會限制未在前臺執行的應用的某些行為,比如說應用的後臺服務的訪問受到限制,也無法使用 Mainifest 註冊大部分隱式廣播。

Android 9.0 進一步改進了省電模式的功能並加入了應用待機分組,長時間不用的 App 會被打入冷宮。另外,系統監測到應用消耗過多資源時,系統會通知並詢問使用者是否需要限制該應用的後臺活動。

然而,道高一尺,魔高一丈。系統在不斷演進,保活方法也在不斷髮展。

大約在 4 年前出現過一個 MarsDaemon,這個庫通過雙程序守護的方式實現保活,一時間風頭無兩。不過好景不長,進入 Android 8.0 時代之後,這個庫就逐漸消亡。

這篇《全面盤點當前Android後臺保活方案的真實執行效果(截止2019年前)》,盤點了那些經典的保活方法的有效情況。

而這篇《2020年了,Android後臺保活還有戲嗎?看我如何優雅的實現!》,則直接放棄了曾今的保活的黑科技,轉而順應Android系統的變化。

5、程序永生技術,後Andriod保活時代的產物

一般來說,Android 程序保活分為兩個方面:

1)保持程序不被系統殺死;

2)程序被系統殺死之後,可以重新復活。

隨著 Android 系統變得越來越完善,單單通過自己拉活自己逐漸變得不可能了。

因此,後面的所謂「保活」基本上是兩條路:

1)提升自己程序的優先順序,讓系統不要輕易弄死自己;

2)App 之間互相結盟,一個兄弟死了其他兄弟把它拉起來。

當然,還有一種終極方法,那就是跟各大系統廠商建立 PY 關係,把自己加入系統記憶體清理的白名單——比如說國民應用微信。當然這條路一般人是沒有資格走的。

大約一年以前,大神袁輝輝(gityuan)在其部落格上公佈了 TIM 使用的一種可以稱之為「終極永生術」的保活方法(即即時通訊網重新整後的《史上最強Android保活思路:深入剖析騰訊TIM的程序永生技術》一文)。

這種方法在當前 Android 核心的實現上可以大大提升程序的存活率。本文作者研究了這種保活思路的實現原理,並且提供了一個參考實現:https://github.com/tiann/Leoric。而這些,正是本文接下來要分享的內容。

6、Android保活的底層技術原理

知己知彼,百戰不殆。既然我們想要保活,那麼首先得知道我們是怎麼死的。

一般來說,系統殺程序有兩種方法,這兩個方法都通過 ActivityManagerService 提供:

1)killBackgroundProcesses;

2)forceStopPackage。

不過國內各廠商以及一加三星等 ROM 現在一般使用第二種方法。因為第一種方法太過溫柔,根本治不住想要搞事情的應用。第二種方法就比較強力了,一般來說被 force-stop 之後,App 就只能乖乖等死了。

因此,要實現保活,我們就得知道 force-stop 到底是如何運作的。

既然如此,我們就跟蹤一下系統的 forceStopPackage 這個方法的執行流程。

首先是 ActivityManagerService裡面的 forceStopPackage 這方法:

public void forceStopPackage(final String packageName, int userId) {

// .. 許可權檢查,省略

long callingId = Binder.clearCallingIdentity();

try{

IPackageManager pm = AppGlobals.getPackageManager();

synchronized(this) {

int[] users = userId == UserHandle.USER_ALL ? mUserController.getUsers() : newint[] { userId };

for(intuser : users) {

// 狀態判斷,省略..

intpkgUid = -1;

try{

pkgUid = pm.getPackageUid(packageName,MATCH_DEBUG_TRIAGED_MISSING, user);

} catch(RemoteException e) {

}

if(pkgUid == -1) {

Slog.w(TAG, "Invalid packageName: "+ packageName);

continue;

}

try{

pm.setPackageStoppedState(packageName, true, user);

} catch(RemoteException e) {

} catch(IllegalArgumentException e) {

Slog.w(TAG, "Failed trying to unstop package "

+ packageName + ": "+ e);

}

if(mUserController.isUserRunning(user, 0)) {

// 根據 UID 和包名殺程序

forceStopPackageLocked(packageName, pkgUid, "from pid "+ callingPid);

finishForceStopPackageLocked(packageName, pkgUid);

}

}

}

} finally{

Binder.restoreCallingIdentity(callingId);

}

}

在這裡我們可以知道,系統是通過 uid 為單位 force-stop 程序的,因此不論你是 native 程序還是 Java 程序,force-stop 都會將你統統殺死。

我們繼續跟蹤forceStopPackageLocked 這個方法:

final boolean forceStopPackageLocked(String packageName, int appId,

boolean callerWillRestart, boolean purgeCache, boolean doit,

boolean evenPersistent, boolean uninstalling, int userId, String reason) {

int i;

// .. 狀態判斷,省略

boolean didSomething = mProcessList.killPackageProcessesLocked(packageName, appId, userId,

ProcessList.INVALID_ADJ, callerWillRestart, true/* allowRestart */, doit,

evenPersistent, true /* setRemoved */,

packageName == null? ("stop user "+ userId) : ("stop "+ packageName));

didSomething |= mAtmInternal.onForceStopPackage(packageName, doit, evenPersistent, userId);

// 清理 service

// 清理 broadcastreceiver

// 清理 providers

// 清理其他

return didSomething;

}

這個方法實現很清晰:先殺死這個 App 內部的所有程序,然後清理殘留在 system_server 內的四大元件資訊。

我們關心程序是如何被殺死的,因此繼續跟蹤killPackageProcessesLocked,這個方法最終會呼叫到 ProcessList 內部的 removeProcessLocked 方法,removeProcessLocked 會呼叫 ProcessRecord 的 kill 方法。

我們看看這個kill:

void kill(String reason, boolean noisy) {

if(!killedByAm) {

Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "kill");

if(mService != null && (noisy || info.uid == mService.mCurOomAdjUid)) {

mService.reportUidInfoMessageLocked(TAG,"Killing "+ toShortString() + " (adj "+ setAdj + "): "+ reason, info.uid);

}

if(pid > 0) {

EventLog.writeEvent(EventLogTags.AM_KILL, userId, pid, processName, setAdj, reason);

Process.killProcessQuiet(pid);

ProcessList.killProcessGroup(uid, pid);

} else{

pendingStart = false;

}

if(!mPersistent) {

killed = true;

killedByAm = true;

}

Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);

}

}

這裡我們可以看到,首先殺掉了目標程序,然後會以uid為單位殺掉目標程序組。如果只殺掉目標程序,那麼我們可以通過雙程序守護的方式實現保活。

關鍵就在於這個killProcessGroup,繼續跟蹤之後發現這是一個 native 方法,它的最終實現在 libprocessgroup中。

程式碼如下:

int killProcessGroup(uid_t uid, int initialPid, int signal) {

return KillProcessGroup(uid, initialPid, signal, 40/*retries*/);

}

注意這裡有個奇怪的數字:40。

我們繼續跟蹤:

static int KillProcessGroup(uid_t uid, int initialPid, int signal, int retries) {

// 省略

int retry = retries;

int processes;

while((processes = DoKillProcessGroupOnce(cgroup, uid, initialPid, signal)) > 0) {

LOG(VERBOSE) << "Killed "<< processes << " processes for processgroup "<< initialPid;

if(retry > 0) {

std::this_thread::sleep_for(5ms);

--retry;

} else{

break;

}

}

// 省略

}

瞧瞧我們的系統做了什麼騷操作?迴圈 40 遍不停滴殺程序,每次殺完之後等 5ms,迴圈完畢之後就算過去了。

看到這段程式碼,我想任何人都會蹦出一個疑問:假設經歷連續 40 次的殺程序之後,如果 App 還有程序存在,那不就僥倖逃脫了嗎?

7、APP對抗被殺的實現思路

那麼,如何實現逃脫被殺呢?我們看這個關鍵的 5ms。

假設:App 程序在被殺掉之後,能夠以足夠快的速度(5ms 內)啟動一堆新的程序,那麼系統在一次迴圈殺掉老的所有程序之後,sleep 5ms 之後又會遇到一堆新的程序。

如此迴圈 40 次,只要我們每次都能夠拉起新的程序,那我們的 App 就能逃過系統的追殺,實現永生。

是的:煉獄般的 200ms,只要我們熬過 200ms 就能渡劫成功,得道飛昇。不知道大家有沒有玩過打地鼠這個遊戲,整個過程非常類似,按下去一個又冒出一個,只要每次都能足夠快地冒出來,我們就贏了。

現在問題的關鍵就在於:如何在 5ms 內啟動一堆新的程序?

再回過頭來看原來的保活方式:它們拉起程序最開始通過am命令,這個命令實際上是一個 java 程式,它會經歷啟動一個程序然後啟動一個 ART 虛擬機器,接著獲取 ams 的 binder 代理,然後與 ams 進行 binder 同步通訊。這個過程實在是太慢了,在這與死神賽跑的 5ms 裡,它的速度的確是不敢恭維。

後來:MarsDaemon 提出了一種新的方式,它用 binder 引用直接給 ams 傳送 Parcel,這個過程相比 am命令快了很多,從而大大提高了成功率。

其實這裡還有改進的空間,畢竟這裡還是在 Java 層呼叫,Java 語言在這種實時性要求極高的場合有一個非常令人詬病的特性:垃圾回收(GC)。

雖然我們在這 5ms 內直接碰上 gc 引發停頓的可能性非常小,但是由於 GC 的存在,ART 中的 Java 程式碼存在非常多的 checkpoint。想象一下你現在是一個信使有重要軍情要報告,但是在路上卻碰到很多關隘,而且很可能被勒令暫時停止一下,這種情況是不可接受的。

因此,最好的方法是通過 native code 給 ams 傳送 binder 呼叫。當然,如果再底層一點,我們甚至可以通過 ioctl 直接給 binder 驅動傳送資料進而完成呼叫,但是這種方法的相容性比較差,沒有用 native 方式省心。

通過在 native 層給 ams 傳送 binder 訊息拉起程序,我們算是解決了「快速拉起程序」這個問題。但是這個還是不夠。

還是回到打地鼠這個遊戲,假設你摁下一個地鼠,會冒起一個新的地鼠,那麼你每次都能摁下去最後獲取勝利的概率還是比較高的;但如果你每次摁下一個地鼠,其他所有地鼠都能冒出來呢?這個難度係數可是要高多了。如果我們的程序能夠在任意一個程序死亡之後,都能讓把其他所有程序全部拉起,這樣系統就很難殺死我們了。

新的黑科技保活中通過 2 個機制來保證程序之間的互相拉起:

1)2 個程序通過互相監聽檔案鎖的方式,來感知彼此的死亡;

2)通過 fork 產生子程序,fork 的程序同屬一個程序組,一個被殺之後會觸發另外一個程序被殺,從而被檔案鎖感知。

具體來說:

1)建立 2 個程序 p1、p2,這兩個程序通過檔案鎖互相關聯,一個被殺之後拉起另外一個;

2)同時 p1 經過 2 次 fork 產生孤兒程序 c1,p2 經過 2 次 fork 產生孤兒程序 c2,c1 和 c2 之間建立檔案鎖關聯。

這樣假設 p1 被殺,那麼 p2 會立馬感知到,然後 p1 和 c1 同屬一個程序組,p1 被殺會觸發 c1 被殺,c1 死後 c2 立馬感受到從而拉起 p1,因此這四個程序三三之間形成了鐵三角,從而保證了存活率。

分析到這裡,這種方案的大致原理我們已經清晰了。基於以上原理,我寫了一個簡單的驗證性程式碼(程式碼在下方)有興趣的可以看一下。

本文內容所涉及的驗證性程式碼演示下載地址:

主地址:https://github.com/tiann/Leoric

備地址:https://github.com/52im/Leoric

8、對抗被殺技術實現的改進空間

上節技術方案的原理還是比較簡單直觀的,但是要實現穩定的保活,還需要很多細節要補充。特別是那與死神賽跑的 5ms,需要不計一切代價去優化才能提升成功率。

具體來說,就是當前的實現是在 Java 層用 binder 呼叫的,我們應該在 native 層完成。筆者曾經實現過這個方案,但是這個庫本質上是有損使用者利益的,因此並不打算公開程式碼。這裡簡單提一下實現思路供大家學習。

如何在 native 層進行 binder 通訊?

libbinder 是 NDK 公開庫,拿到對應標頭檔案,動態連結即可。

難點:依賴繁多,剝離標頭檔案是個體力活。

如何組織 binder 通訊的資料?

通訊的資料其實就是二進位制流,具體表現就是 (C++/Java) Parcel 物件。native 層沒有對應的 Intent Parcel,相容性差。

可行的方案:

1)Java 層建立 Parcel (含 Intent),拿到 Parcel 物件的 mNativePtr(native peer),傳到 Native 層;

2)native 層直接把 mNativePtr 強轉為結構體指標;

3)fork 子程序,建立管道,準備傳輸 parcel 資料;

4)子程序讀管道,拿到二進位制流,重組為 parcel。

9、如何應對本文探討的程序永生技術?

今天我把這個實現原理公開,並且提供驗證程式碼,並不是鼓勵大家使用這種方式保活,而是希望各大系統廠商能感知到這種黑科技的存在,推動自己的系統徹底解決這個問題。

兩年前我就知道了這個方案的存在,不過當時鮮為人知。最近一個月我發現很多 App 都使用了這種方案,把我的 Android 手機折騰的慘不忍睹。畢竟本人手機上安裝了將近 800 個 App,假設每個 App 都用這個方案保活,那這系統就沒法用了。

系統如何應對?

如果我們把系統殺程序比喻為斬首,那麼這個保活方案的精髓在於能快速長出一個新的頭;因此應對之法也很簡單,只要我們在斬殺一個程序的時候,讓別的程序老老實實呆著別搞事情就 OK 了。具體的實現方法多種多樣,不贅述。

使用者如何應對?

在廠商沒有推出解決方案之前,使用者可以有一些方案來緩解使用這個方案進行保活的流氓 App。

這裡推薦兩個應用給大家:

1)冰箱;

2)Island。

通過冰箱的凍結和 Island 的深度休眠可以徹底阻止 App 的這種保活行為。當然,如果你喜歡別的這種“凍結”型別的應用,比如小黑屋或者太極的陰陽之門也是可以的。

其他不是通過“凍結”這種機制來壓制後臺的應用理論上對這種保活方案的作用非常有限。

10、本文小結

對技術來說,黑科技沒有什麼黑的,不過是對系統底層原理的深入了解從而反過來對抗系統的一種手段。很多人會說,了解系統底層有什麼用,本文應該可以給出一個答案:可以實現別人永遠也無法實現的功能,通過技術推動產品,從而產生巨大的商業價值。

黑科技雖強,但是它不該存在於這世上。沒有規矩,不成方圓。黑科技黑的了一時,黑不了一世。要提升產品的存活率,終歸要落到產品本身上面來,尊重使用者,提升體驗方是正途。

(本文原題“Android 黑科技保活實現原理揭祕”,由即時通訊網重新整理、排版和釋出,感謝原作者田維術的無私分享。)

附錄:有關IM/推送的程序保活/網路保活方面的文章彙總

《應用保活終極總結(一):Android6.0以下的雙程序守護保活實踐》

《應用保活終極總結(二):Android6.0及以上的保活實踐(程序防殺篇)》

《應用保活終極總結(三):Android6.0及以上的保活實踐(被殺復活篇)》

《Android程序保活詳解:一篇文章解決你的所有疑問》

《Android端訊息推送總結:實現原理、心跳保活、遇到的問題等》

《深入的聊聊Android訊息推送這件小事》

《為何基於TCP協議的移動端IM仍然需要心跳保活機制?》

《Android P正式版即將到來:後臺應用保活、訊息推送的真正噩夢》

《全面盤點當前Android後臺保活方案的真實執行效果(截止2019年前)》

《一文讀懂即時通訊應用中的網路心跳包機制:作用、原理、實現思路等》

《融雲技術分享:融雲安卓端IM產品的網路鏈路保活技術實踐》

《正確理解IM長連線的心跳及重連機制,並動手實現(有完整IM原始碼)》

《2020年了,Android後臺保活還有戲嗎?看我如何優雅的實現!》

《史上最強Android保活思路:深入剖析騰訊TIM的程序永生技術》

《Android程序永生技術終極揭密:程序被殺底層原理、APP對抗被殺技巧》

>> 更多同類文章 ……

(本文同步釋出於:/file/2020/02/27/20200227111846_14234.jpg

最新評論
  • 整治雙十一購物亂象,國家再次出手!該跟這些套路說再見了
  • 淘寶賣家設定郵費999元防誤拍,假如有人下單,直接發貨會怎樣?