天天看點

App保活實作原理

App保活實作原理​

一直以來,App 程序保活都是各大廠商,特别是頭部應用開發商永恒的追求。畢竟App 程序死了,就什麼也幹不了了;一旦 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 程序保活分為兩個方面:

  1. 保持程序不被系統殺死。
  2. 程序被系統殺死之後,可以重新複活。

随着 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. 黑科技雖強,但是它不該存在于這世上。沒有規矩,不成方圓。黑科技黑的了一時,黑不了一世。要提升産品的存活率,終歸要落到産品本身上面來,尊重使用者,提升體驗方是正途。

================= End