詳解App保活實現原理
概述
早期的 Android 系統不完善,導致 App 側有很多空子可以鉆,因此它們有著有著各種各樣的姿勢進行保活。譬如說在 Android 5.0 以前,App 內部通過 native 方式 fork 出來的進程是不受系統管控的,系統在殺 App 進程的時候,隻會去殺 App 啟動的 Java 進程;因此誕生瞭一大批“毒瘤”,他們通過 fork native 進程,在 App 的 Java 進程被殺死的時候通過am命令拉起自己從而實現永生。那時候的 Android 可謂是魑魅橫行,群魔亂舞;系統根本管不住應用,因此長期以來被人詬病耗電、卡頓。同時,系統的軟弱導致瞭 Xposed 框架、阻止運行、綠色守護、黑域、冰箱等一系列管制系統後臺進程的框架和 App 出現。
不過,隨著 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 進程保活分為兩個方面:
- 保持進程不被系統殺死。
- 進程被系統殺死之後,可以重新復活。
隨著 Android 系統變得越來越完善,單單通過自己拉活自己逐漸變得不可能瞭;因此後面的所謂「保活」基本上是兩條路:1. 提升自己進程的優先級,讓系統不要輕易弄死自己;2. App 之間互相結盟,一個兄弟死瞭其他兄弟把它拉起來。
當然,還有一種終極方法,那就是跟各大系統廠商建立 PY 關系,把自己加入系統內存清理的白名單;比如說國民應用微信。當然這條路一般人是沒有資格走的。
大約一年以前,大神 gityuan 在其博客上公佈瞭 TIM 使用的一種可以稱之為「終極永生術」的保活方法;這種方法在當前 Android 內核的實現上可以大大提升進程的存活率。筆者研究瞭這種保活思路的實現原理,並且提供瞭一個參考實現Leoric。接下來就給大傢分享一下這個終極保活黑科技的實現原理。
保活的底層技術原理
知己知彼,百戰不殆。既然我們想要保活,那麼首先得知道我們是怎麼死的。一般來說,系統殺進程有兩種方法,這兩個方法都通過 ActivityManagerService 提供:
1.killBackgroundProcesses
2.forceStopPackage
在原生系統上,很多時候殺進程是通過第一種方式,除非用戶主動在 App 的設置界面點擊「強制停止」。不過國內各廠商以及一加三星等 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() : new int[] { userId }; for (int user : users) { // 狀態判斷,省略.. int pkgUid = -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 還有進程存在,那不就僥幸逃脫瞭嗎?
實現方法
那麼,如何實現這個目的呢?我們看這個關鍵的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 的進程同屬一個進程組,一個被殺之後會觸發另外一個進程被殺,從而被文件鎖感知。
具體來說,創建 2 個進程 p1, p2,這兩個進程通過文件鎖互相關聯,一個被殺之後拉起另外一個;同時 p1 經過 2 次 fork 產生孤兒進程 c1,p2 經過 2 次 fork 產生孤兒進程 c2,c1 和 c2 之間建立文件鎖關聯。這樣假設 p1 被殺,那麼 p2 會立馬感知到,然後 p1 和 c1 同屬一個進程組,p1 被殺會觸發 c1 被殺,c1 死後 c2 立馬感受到從而拉起 p1,因此這四個進程三三之間形成瞭鐵三角,從而保證瞭存活率。
分析到這裡,這種方案的大致原理我們已經清晰瞭。基於以上原理,我寫瞭一個簡單的 PoC,代碼在這裡:https://github.com/tiann/Leoric有興趣的可以看一下。
改進空間
本方案的原理還是比較簡單直觀的,但是要實現穩定的保活,還需要很多細節要補充;特別是那與死神賽跑的 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。
如何應對
今天我把這個實現原理公開,並且提供 PoC 代碼,並不是鼓勵大傢使用這種方式保活,而是希望各大系統廠商能感知到這種黑科技的存在,推動自己的系統徹底解決這個問題。
兩年前我就知道瞭這個方案的存在,不過當時鮮為人知。最近一個月我發現很多 App 都使用瞭這種方案,把我的 Android 手機折騰的慘不忍睹;畢竟本人手機上安裝瞭將近 800 個 App,假設每個 App 都用這個方案保活,那這系統就沒法用瞭。
系統如何應對
如果我們把系統殺進程比喻為斬首,那麼這個保活方案的精髓在於能快速長出一個新的頭;因此應對之法也很簡單,隻要我們在斬殺一個進程的時候,讓別的進程老老實實呆著別搞事情就 OK 瞭。具體的實現方法多種多樣,不贅述。
用戶如何應對
在廠商沒有推出解決方案之前,用戶可以有一些方案來緩解使用這個方案進行保活的流氓 App。這裡推薦兩個應用給大傢:
- 冰箱
- Island
通過冰箱的凍結和 Island 的深度休眠可以徹底阻止 App 的這種保活行為。當然,如果你喜歡別的這種“凍結”類型的應用,比如小黑屋或者太極的陰陽之門也是可以的。
其他不是通過“凍結”這種機制來壓制後臺的應用理論上對這種保活方案的作用非常有限。
總結
1.對技術來說,黑科技沒有什麼黑的,不過是對系統底層原理的深入瞭解從而反過來對抗系統的一種手段。很多人會說,瞭解系統底層有什麼用,本文應該可以給出一個答案:可以實現別人永遠也無法實現的功能,通過技術推動產品,從而產生巨大的商業價值。
2.黑科技雖強,但是它不該存在於這世上。沒有規矩,不成方圓。黑科技黑的瞭一時,黑不瞭一世。要提升產品的存活率,終歸要落到產品本身上面來,尊重用戶,提升體驗方是正途。
以上就是詳解App保活實現原理的詳細內容,更多關於App保活實現原理的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- Android 圖文詳解Binder進程通信底層原理
- Android中關於Binder常見面試問題小結
- 淺談Android ANR的信息收集過程
- 解析Android AIDL的實例與原理
- Android音視頻開發Media FrameWork框架源碼解析