我相信很多开发朋友都像我一样遇到过这样的需求,尤其是开发过IM对实时性要求比较高的朋友。一定遇到过老板或者产品经理发出来的要求我们做到像微信,钉钉等等这些大厂应用一样保持应用一直保活,能够实时接收到推送或者消息的功能需求。然后了解情况的人其实都知道,Android系统现在每个版本升级在应用保活这块已经被限制的很死了,6.0以前的那一套保活方法已经基本失效了。目的就是降低系统功耗,降低安全风险,避免流氓软件。但是产品经理可不管这些,他们的要求是:微信有的我也要有,微信能做出来我也要,至于如何去实现我管不着,但我就是要这么个功能。(此刻程序员的心里活动:杀人不犯法的话,你已经是一个死人了!),人家微信是怎么做到的呢?是呀,人家微信是怎么做到的呢?(程序员心里活动:人家微信的员工干一个月顶我干半年你怎么不说。)生气归生气,但是作为程序员,我们确实需要问一句:人家微信是怎么做到的呢?除了和手机厂商合作这一条路之外,还能有什么办法做到类似微信一样的保活策略吗?本章节我们就来研究一下到底能不能有折中的方案实现这样的需求?注意:我们这里说的保活,只是降低应用被强杀的可能,以及尽可能做到应用被杀后拉活。好了,废话不多说,直接进入正题!
我们都知道Android 系统对应用进程是有等级划分的。那么这个等级是如何划分的呢?
系统出于体验和性能的考虑,app在退到后台时系统并不会真正立马kill掉当前进程。而是先将其缓存起来。这就造成手机打开的应用越多,系统缓存的进程也就越多。但是系统缓存进程也是有个度的,总不能无限量的去缓存。例如,系统内存不足时,就需要腾出空间来给新的开的应用进程。于是就产生了一套根据不同的进程优先级去回收进程的机制。而这套机制就是大名鼎鼎的Low Memory Killer机制。那么Android对进程又分为哪些等级呢?这里我就直接按照优先级从高到低列出来了。
为了确定在内存不足时应该终止哪些进程,Android 会根据每个进程中运行的组件以及这些组件的状态,将它们放入“重要性层次结构”。这些进程类型包括(按重要性排序):
1、 前台进程:是用户目前执行操作所需的进程。在不同的情况下,进程可能会因为其所包含的各种应用组件而被视为前台进程。如果以下任一条件成立,则进程会被认为位于前台:
它正在用户的互动屏幕上运行一个 Activity(其 onResume() 方法已被调用)。它有一个 BroadcastReceiver 目前正在运行(其 BroadcastReceiver.onReceive() 方法正在执行)。它有一个 Service 目前正在执行其某个回调(Service.onCreate()、Service.onStart() 或 Service.onDestroy())中的代码。系统中只有少数此类进程,而且除非内存过低,导致连这些进程都无法继续运行,才会在最后一步终止这些进程。通常,此时设备已达到内存分页状态,因此必须执行此操作才能使用户界面保持响应。
2、可见进程:正在进行用户当前知晓的任务,因此终止该进程会对用户体验造成明显的负面影响。在以下条件下,进程将被视为可见:
它正在运行的 Activity 在屏幕上对用户可见,但不在前台(其 onPause() 方法已被调用)。举例来说,如果前台 Activity 显示为一个对话框,而这个对话框允许在其后面看到上一个 Activity,则可能会出现这种情况。它有一个 Service 正在通过 Service.startForeground()(要求系统将该服务视为用户知晓或基本上对用户可见的服务)作为前台服务运行。系统正在使用其托管的服务实现用户知晓的特定功能,例如动态壁纸、输入法服务等。相比前台进程,系统中运行的这些进程数量较不受限制,但仍相对受控。这些进程被认为非常重要,除非系统为了使所有前台进程保持运行而需要终止它们,否则不会这么做。
3、服务进程:包含一个已使用 startService() 方法启动的 Service。虽然用户无法直接看到这些进程,但它们通常正在执行用户关心的任务(例如后台网络数据上传或下载),因此系统会始终使此类进程保持运行,除非没有足够的内存来保留所有前台和可见进程。
4、缓存进程:是目前不需要的进程,因此,如果其他地方需要内存,系统可以根据需要自由地终止该进程。在正常运行的系统中,这些是内存管理中涉及的唯一进程:运行良好的系统将始终有多个缓存进程可用(为了更高效地切换应用),并根据需要定期终止最早的进程。只有在非常危急(且具有不良影响)的情况下,系统中的所有缓存进程才会被终止,此时系统必须开始终止服务进程。
这些进程通常包含用户当前不可见的一个或多个 Activity 实例(onStop() 方法已被调用并返回)。只要它们正确实现其 Activity 生命周期(详情请见 Activity),那么当系统终止此类流程时,就不会影响用户返回该应用时的体验,因为当关联的 Activity 在新的进程中重新创建时,它可以恢复之前保存的状态。
这些进程保存在伪 LRU 列表中,列表中的最后一个进程是为了回收内存而终止的第一个进程。此列表的确切排序政策是平台的实现细节,但它通常会先尝试保留更多有用的进程(比如托管用户的主屏幕应用、用户最后看到的 Activity 的进程等),再保留其他类型的进程。还可以针对终止进程应用其他政策:比如对允许的进程数量的硬限制,对进程可持续保持缓存状态的时间长短的限制等
注意:上面这段对各个等级的描述可不是我自己编的,而是官网上的表述。https://developer.android.google.cn/guide/components/activities/process-lifecycle?hl=zh-cn。有兴趣的同学可以自行去官网看看。但是官网现在已经将我们的后台进程和空进程统一划分到了缓存进程。这里大家知道这点就行了。并不是我有多牛逼,自己搞了个后台进程和空进程的名词出来。
通过上面官网的表述我们可以知道,当系统内存不足时,系统就会根据需要杀死对应优先级的进程。列如:当可用内存小于315MB的时候,就会杀死空进程。但是系统是根据一个什么样的标准去判断是否要杀死某个进程的呢?这里就要提到oom_adj这样的一个参数(不同手机的oom_adj值可能不一样)。系统就是通过这个值来判断当前进程的优先级。每个进程的oom_adj值都存储在/proc/进程ID/oom_adj文件中。且这个值会随着进程的不同状态发生变化。我们可以通过如下命令来参看某个进程的oom_adj值:cat proc/进程ID/oom_adj
如下表是不同的进程优先级,所对应的oom_adj值,在不同的手机中同一个优先级的oom_adj值可能会有所差异。但整体规律相同。
ADJ级别
取值
解释
UNKNOWN_ADJ
16
一般指将要被缓存的进程,无法获取确定值
CACHED_APP_MAX_ADJ
15
不可见进程的adj最大值1
CACHED_APP_MIN_ADJ
9
不可见进程的adj最小2
SERVICE_B_AD
8
B List中的Service(较老的,使用可能性更小)
PREVIOUS_APP_ADJ
7
上个APP的进程(往往通过按返回键退出的进程)
HOME_APP_ADJ
6
Home进程
SERVICE_ADJ
5
服务进程
HEAVY_WEIGHT_APP_ADJ
4
后台重量级进程,system/rootdir/init.rc文件中设置
BACKUP_APP_ADJ
3
备份进程
PERCEPTIBLE_APP_ADJ
2
可感知进程,如后台音乐播放
VISIBLE_APP_ADJ
1
可见进程
FOREGROUND_APP_ADJ
0
前台进程
PERSISTENT_SERVICE_ADJ
-11
关联着系统活或persistent进程
PERSISTENT_PROC_ADJ
-12
系统persistent进程,如:telephony
SYSTEM_ADJ
-16
系统进程
NATIVE_ADJ
-17
native进程(不被系统管理)
这里的adj数值越大优先级就越低。低于0值的进程都是系统进程(蓝色区间)。通常情况下3~0(绿色区间)这个区间的adj值进程我们就可以认为该进程是处于安全状态的进程(即:不会被系统强杀的进程),而红色区间的进程就属于不安全的进程,有很大的可能性会被系统强杀。
讲了这么多的adj值表述,那么系统到底如何来计算一个进程的adj值的呢?我们前面也说了,应用进程的adj值是会跟着当前应用的生命周期状态不断发生着变化的。这就需要系统有一套计算每个应用的adj值的算法。这个算法的核心其实就在我们的AMS源码中。
AMS源码中我们会找到如下三个方法,而着三个方法就是我们的adj算法核心。
(1)updateOomAdjLocked:更新adj,当目标进程为空或者别杀则返回false,否则返回true。
(2)computeOomAdjLocked:计算adj值,返回计算后的RawAdj值。
(3)applyOomAdjLocked:使用adj,当需要杀掉目标进程则返回false,否则返回true。
整个计算过程是相当复杂的,具体的逻辑我这里就不一一讲解了,我觉得以爱阅读源码的同学,估计也很难看懂,所以我们知道有这么个算法在就行了。有兴趣的同学可以去抠一下ActivtyManagerService源码。
讲到现在才是今天的重点,一切为了解决问题!相信遇到过类似需求的朋友已经有过研究了,目前通用的一些保活方案有如下几种:
什么是一像素保活法?前面我们说了,应用的adj值会跟着应用的状态发生变化,通常情况下,当应用在前台显示时的adj值为0。当系统息屏时,应用的adj值就会立马发生变化,且值会变大,也就是优先级会变低。这时候应用进程就由安全进程变成了不安全进程了(对照上面的进程优先级表)。而一像素保活法就是解决系统息屏情况下确保应用adj值不发生变化,且亮屏时不影响应用使用的的方案。他的原理就是:手机息屏时偷偷创建一个Activity,让应用成为前台进程。亮屏时关闭该Activity。。具体实现代码如下:
首先我们需要监听灭屏和亮屏事件 public class KeepReceiver extends BroadcastReceiver { private static final String TAG = "KeepReceiver"; @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); Log.e(TAG, "onReceive: " + action); if (TextUtils.equals(action, Intent.ACTION_SCREEN_OFF)) { // 关闭屏幕时 开启1像素activity KeepManager.getInstance().startKeep(context); } else if (TextUtils.equals(action, Intent.ACTION_SCREEN_ON)) { // 打开屏幕时 关闭1像素activity KeepManager.getInstance().finishKeep(); } } } 灭屏启动一像素Activity进行提权,亮屏结束一像素Activity public class KeepActivity extends Activity { private static final String TAG = "KeepActivity"; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.e(TAG, "启动keep"); Window window = getWindow(); //放在左上角 window.setGravity(Gravity.START | Gravity.TOP); WindowManager.LayoutParams params = window.getAttributes(); //设置宽高 params.width = 1; params.height = 1; //设置起始坐标 params.x = 0; params.y = 0; window.setAttributes(params); // KeepActivity 创建一个弱引用 KeepManager.getInstance().setKeep(this); } @Override protected void onDestroy() { super.onDestroy(); Log.e(TAG, "关闭keep"); } }这里需要注意一点:这个一像素的Activity主题需要改成全透明主题。
<!-- 请原封不动的抄袭--> <style name="KeepTheme"> <!--背景--> <item name="android:windowBackground">@null</item> <!--是否透明--> <item name="android:windowIsTranslucent">true</item> <item name="android:windowFrame">@null</item> <item name="android:windowNoTitle">true</item> <item name="android:windowIsFloating">true</item> <item name="android:windowContentOverlay">@null</item> <item name="android:backgroundDimEnabled">false</item> <item name="android:windowAnimationStyle">@null</item> <item name="android:windowDisablePreview">true</item> <item name="android:windowNoDisplay">false</item> </style>且禁止Activity出现在最近使用的应用栈中,一像素Activity需要单独启用一个堆栈。
<activity android:name=".activity.KeepActivity" android:excludeFromRecents="true" android:taskAffinity="com.enjoy.daemon.keep" android:theme="@style/KeepTheme" />前台服务我相信大家都不陌生,它会让我们的应用有个Notification在系统通知栏中常驻。但是产品或者老板一定会觉得这个东西一直杵在哪里难看死了,让你去掉。这里我们的方案就是去掉通知栏的前台服务如何实现:
在Android4.3以前我们在设置一个服务为前台服务时,可以通过传入一个什么都没有的Notification对象来达到我们要的效果。
//将service设置成前台服务,并且不显示通知栏消息 startForeground(SERVICE_ID, new Notification());但是Android4.3之后Google工程师堵上了这条路。但是牛逼的中国人,还是通过找到Google工程师的bug来实现这样的功能需求。即Android8.0之前。我们可通过先设置前台服务让通知栏显示,然后立马在另起一个服务来关闭前台服务的常驻通知。
//将service设置成前台服务 public void setForeground() { //如果API大于18,需要弹出一个可见通知 if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { startForeground(NOTICE_ID, getNotification()); // 如果觉得常驻通知栏体验不好 // 可以通过启动CancelNoticeService,将通知移除,oom_adj值不变 Intent intent = new Intent(this, CancelNoticeService.class); startService(intent); } else { startForeground(NOTICE_ID, new Notification()); } }//立马启动另外一个服务来,删除通知栏消息
package com.single.code.keepalive.service; import android.app.Notification; import android.app.NotificationManager; import android.app.Service; import android.content.Intent; import android.os.Build; import android.os.IBinder; import android.os.SystemClock; import androidx.annotation.Nullable; import com.single.code.keepalive.R; public class CancelNoticeService extends Service { @Nullable @Override public IBinder onBind(Intent intent) { return null; } @Override public void onCreate() { super.onCreate(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { startForeground(ForegroundDaemonService.NOTICE_ID,getNotification()); // 开启一条线程,去移除DaemonService弹出的通知 new Thread(new Runnable() { @Override public void run() { // 延迟1s SystemClock.sleep(1000); // 取消CancelNoticeService的前台 stopForeground(true); // 移除DaemonService弹出的通知 NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); if (manager != null) { manager.cancel(ForegroundDaemonService.NOTICE_ID); //8.0之后这样依旧不能够去掉通知栏,那么怎么办呢?有办法,NotifyManager这个类大家都不陌生吧。它有个一劳永逸的方法cancelAll。啥意思呢?意思就是清除当前应用所有的通知栏 manager.cancelAll(); } // 任务完成,终止自己 stopSelf(); } }).start(); } return super.onStartCommand(intent, flags, startId); } private Notification getNotification(){ NotificationUtils notificationUtils = new NotificationUtils(this); Notification notification = notificationUtils.getNotification("", "", R.mipmap.ic_launcher); return notification; } @Override public void onDestroy() { super.onDestroy(); } }具体实现代码可移步github。
个人觉得最不靠谱的一个方案,严重损耗性能,而且会导致系统按键音消失等一系列问题。所以这里就不将该方案纳入我们的保活框架里面。
前面我们说了,保活只能够降低应用被杀死的可能性,但一旦应用被杀死了,保活方案也就无效了。但是说实话产品经理是不管你这么多的,他只知道应该被杀死了,你还是没做到应用永生不死!于是拉活方案就这样诞生了。
所谓的广播拉活也就是注册静态广播监听器,来监听特定广播事件,即可在监听到特定广播事件时拉活特定应用。这里的广播事件可以是系统广播也可以是自定义广播。但是我们都知道,随着Android版本的更新迭代,Android系统对系统广播这一块的限制越来越严格。我们可以看看,如今能够提供给我们静态注册监听的敏感系统广播越来越少。https://developer.android.google.cn/guide/components/broadcast-exceptions.html
于是大厂的牛人们就想到了一个办法,“全家桶”拉活。有多个app在用户设备上安装,只要开启其中一个就可以将其他的app也拉活。比如手机里装了手Q、QQ空间、兴趣部落等等,那么打开任意一个app后,其他的app也都会被唤醒。
但是很多人说,我们是小公司,没有大厂那么多应用能够相互拉活怎么办?唯一的办法就是看你能不能找到大厂应用间拉活的这个广播,然后你也去注册这个广播,然后来实现你想要的功能。当然这是比较难的,至少我还没找到这样一条广播,如果有找到的朋友可以吱一声。
onStartCommand这个方法不知道大家了解不,他是Service里面的方法,但是很多人并不知道它是用来干嘛的。其实它就是Service系统提供给我们的一个拉活方案。(但是说实话,在高版本系统真心没啥用)。这个方法有如下四种返回值,而每一种返回值都是一种策略。默认返回START_STICKY。
“粘性”。如果service进程被kill掉,保留service的状态为开始状态,但不保留递送的intent对象。随后系统会尝试重新创建service,由于服务状态为开始状态,所以创建服务后一定会调用onStartCommand(Intent,int,int)方法。如果在此期间没有任何启动命令被传递到service,那么参数Intent将为null。
“非粘性的”。使用这个返回值时,如果在执行完onStartCommand后,服务被异常kill掉,系统不会自动重启该服务。
重传Intent。使用这个返回值时,如果在执行完onStartCommand后,服务被异常kill掉,系统会自动重启该服务,并将Intent的值传入。
START_STICKY的兼容版本,但不保证服务被kill后一定能重启。
系统具体是如何通过这些值来实现拉活的呢?通过源码跟踪我们最终能够在ActiveServices中看到如下一段逻辑:
void serviceDoneExecutingLocked(ServiceRecord r, int type, int startId, int res) { boolean inDestroying = mDestroyingServices.contains(r); if (r != null) { if (type == ActivityThread.SERVICE_DONE_EXECUTING_START) { // This is a call from a service start... take care of // book-keeping. r.callStart = true; switch (res) { case Service.START_STICKY_COMPATIBILITY: case Service.START_STICKY: {//如果是START_STICKY或者START_STICKY_COMPATIBILITY将停止杀死该服务。 // We are done with the associated start arguments. r.findDeliveredStart(startId, false, true); // Don't stop if killed. r.stopIfKilled = false; break; } case Service.START_NOT_STICKY: {//非粘性,直接杀死 // We are done with the associated start arguments. r.findDeliveredStart(startId, false, true); if (r.getLastStartId() == startId) { // There is no more work, and this service // doesn't want to hang around if killed. r.stopIfKilled = true; } break; } case Service.START_REDELIVER_INTENT: { // We'll keep this item until they explicitly // call stop for it, but keep track of the fact // that it was delivered. ServiceRecord.StartItem si = r.findDeliveredStart(startId, false, false); if (si != null) { si.deliveryCount = 0; si.doneExecutingCount++; // Don't stop if killed. r.stopIfKilled = true; } break; } case Service.START_TASK_REMOVED_COMPLETE: { // Special processing for onTaskRemoved(). Don't // impact normal onStartCommand() processing. r.findDeliveredStart(startId, true, true); break; } default: throw new IllegalArgumentException( "Unknown service start result: " + res); } if (res == Service.START_STICKY_COMPATIBILITY) { r.callStart = false; } } else if (type == ActivityThread.SERVICE_DONE_EXECUTING_STOP) { // This is the final call from destroying the service... we should // actually be getting rid of the service at this point. Do some // validation of its state, and ensure it will be fully removed. if (!inDestroying) { // Not sure what else to do with this... if it is not actually in the // destroying list, we don't need to make sure to remove it from it. // If the app is null, then it was probably removed because the process died, // otherwise wtf if (r.app != null) { Slog.w(TAG, "Service done with onDestroy, but not inDestroying: " + r + ", app=" + r.app); } } else if (r.executeNesting != 1) { Slog.w(TAG, "Service done with onDestroy, but executeNesting=" + r.executeNesting + ": " + r); // Fake it to keep from ANR due to orphaned entry. r.executeNesting = 1; } } final long origId = Binder.clearCallingIdentity(); serviceDoneExecutingLocked(r, inDestroying, inDestroying); Binder.restoreCallingIdentity(origId); } else { Slog.w(TAG, "Done executing unknown service from pid " + Binder.getCallingPid()); } }通过 这段逻辑我们能够清楚的看到,除了START_STICKY这种返回值,其他几种都是直接杀死服务。但是某些ROM 系统不会拉活。并且经过测试,Service 第一次被异常杀死后很快被重启,第二次会比第一次慢,第三次又会比前一次慢,一旦在短时间内 Service 被杀死4-5次,则系统不再拉起。
这种拉活方式,我想可能很多人都没有听过或者见过吧。Android 手机的设置里面都有一个Account栏。很多人不清楚这个是用来干嘛的。实际上我们的Android手机会每过一段时间就会去同步我们手机里面被加入到Account中的应用的账户。而我们的账户同步拉活就是利用这个系统的定时刷新账户的原理来实现的。具体实现可移步GitHub。这种拉活方式的优点是系统拉活,比较稳定,但是缺点是这个同步账户的时间我们无法掌控。
允许在特定状态与特定时间间隔周期执行任务。可以利用它的这个特点完成保活的功能,效果即开启一个定时器,与普通定时器不同的是其调度由系统完成。
同样在某些ROM可能并不能达到需要的效果
@SuppressLint("NewApi") public class ZJobService extends JobService { private static final String TAG = "MyJobService"; public static void startJob(Context context) { JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); //setPersisted 在设备重启依然执行 // 需要增加权限 RECEIVE_BOOT_COMPLETED JobInfo.Builder builder = new JobInfo.Builder(8, new ComponentName(context.getPackageName(), ZJobService.class.getName())).setPersisted(true); // 小于7.0 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { // 每隔 1s 执行一次 job // 版本23 开始 进行了改进,最小周期为 5s builder.setPeriodic(1000); } else { // 延迟执行任务 builder.setMinimumLatency(1000); } jobScheduler.schedule(builder.build()); } @Override public boolean onStartJob(JobParameters params) { Log.e(TAG, "onStartJob"); // 如果7.0以上 轮询 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { startJob(this); } return false; } @Override public boolean onStopJob(JobParameters params) { return false; } }启动两个运行在不同进程的Service。并相互绑定。一方被杀另一方进行拉活。但是这样方案在Android7.0以后好像就已经失效了。因为高版本的Android每次杀死进程都是杀死一个进程组。而一个应用内的进程都属于同一个进程组。
主要管理任务请求和任务队列,将WorkRequest加入任务队列。 通过WorkManager来调度任务,以分散系统资源的负载。它是一个系统级别的API。只要任务队列里面有任务,即使应用被杀死了,还是会去执行该任务。而我们要的就是它这种由系统强制拉活执行任务的能力。
以上方案,经模拟器运行在Android10手机上依然能够达到相应效果。在真机上运行目前8.0手机上同样能达到想要的效果,但更高版本的真机,目前未验证过。方案再多也有终结的一天,所以想要永生不死,终极方案还是找厂商合作,加入白名单。当然了,这对于绝大部分公司来说都是不可能的,不是因为你不给钱,而是因为你体量太小了,不够格,厂商鸟都不鸟你!最后附上源码:https://github.com/279154451/KeepAlive。拿走不谢。祝各位都能实现老板的需求!