在很多移动应用中,特别是即时通信类项目中,保活是一个永远无法避免的一个话题。保活,按照我的理解,主要包含两部分:
网络连接保活:如何保证消息接收实时性。
进程保活:尽量保证应用的进程不被Android系统回收。
在很早以前,谈Android的保活都会涉及到进程常驻内存,如何进行性能优化等话题,今天就这些话题,做一个简单的总结。
Android进程
在讨论这个问题之前,我们首先来看一些现象级APP的进程。
搞Android的同学都知道,每一个Android应用启动后至少对应一个进程,有的则有多个进程,大多数主流APP都会包含多个进程,因为除了主要的进程之外,还有诸如长连接、推送等进程。
查看进程
对于任何一个进程,我们都可以通过adb shell ps|grep 的方式来查看。具体方式如下:
上图的具体含义如下:
值 | 解释 |
---|---|
u0_a16 | USER 进程当前用户 |
3881 | 进程ID |
873024 | 进程的虚拟内存大小 |
37108 | 实际驻留”在内存中”的内存大小 |
进程划分
Android系统按重要性从高到低把进程的划为了如下几种(严格来说是6种)。
1,前台进程
此种进程指用户正在使用的程序,一般系统是不会杀死前台进程的,除非用户强制停止应用或者系统内存不足等极端情况会杀死。
主要场景:
- 某个进程持有一个正在与用户交互的Activity,并且该Activity正处于resume的状态。
- 某个进程持有一个Service,并且该Service与用户正在交互的Activity绑定。
- 某个进程持有一个Service,并且该Service调用startForeground()方法使之位于前台运行。
- 某个进程持有一个Service,并且该Service正在执行它的某个生命周期回调方法,比如onCreate()、onStart()或onDestroy()。
- 某个进程持有一个BroadcastReceiver,并且BroadcastReceiver正在执行其onReceive()方法。
2,可见进程
用户正在使用,看得到,但是摸不着,没有覆盖到整个屏幕,只有屏幕的一部分可见进程不包含任何前台组件,一般系统也是不会杀死可见进程的,除非要在资源吃紧的情况下,要保持某个或多个前台进程存活。
主要场景:
- 拥有不在前台、但仍对用户可见的 Activity(已调用onPause())。
- 拥有绑定到可见(或前台)Activity 的 Service。
3,服务进程
在内存不足以维持所有前台进程和可见进程同时运行的情况下,服务进程会被杀死。
主要场景:
- 某个进程中运行着一个Service且该Service是通过startService()启动的,与用户看见的界面没有直接关联。
4,后台进程
后台进程,系统可能随时终止它们,用以回收内存。
主要场景:
- 在用户按了”back”或者”home”后,程序本身看不到了,但是其实还在运行的程序,比如Activity调用了onPause方法。
空进程
某个进程不包含任何活跃的组件时该进程就会被置为空进程,完全没用,杀了它只有好处没坏处,第一个干它。
内存阈值
上面主要讲的是进程,那么进程是怎么被杀的呢?这不得不提主要的一个原因:内存。在移动设备中内存往往是有限的,打开的应用越多,后台缓存的进程也越多。在系统内存不足的情况下,系统开始依据自身的一套进程回收机制来判断要kill掉哪些进程。在Android的内存回收机制中有一个重要的概念:Low Memory Killer。
我们可以使用cat /sys/module/lowmemorykiller/parameters/minfree来查看某个手机的内存阈值。
注意这些数字的单位是page(1 page = 4 kb)。上面的六个数字对应的就是(MB): 72,90,108,126,144,180,这些数字也就是对应的内存阀值,内存阈值在不同的手机上不一样,一旦低于该值,Android便开始按顺序关闭进程. 因此Android开始结束优先级最低的空进程,即当可用内存小于180MB(46080*4/1024)。
读到这里,你或许有一个疑问,假设现在内存不足,空进程都被杀光了,现在要杀后台进程,但是手机中后台进程很多,难道要一次性全部都清理掉?当然不是的,进程是有它的优先级的,这个优先级通过进程的adj值来反映,它是linux内核分配给每个系统进程的一个值,代表进程的优先级,进程回收机制就是根据这个优先级来决定是否进行回收,adj值定义在com.android.server.am.ProcessList类中,这个类路径是${android-sdk-path}\sources\android-23\com\android\server\am\ProcessList.java。oom_adj的值越小,进程的优先级越高,普通进程oom_adj值是大于等于0的,而系统进程oom_adj的值是小于0的,我们可以通过cat /proc/进程id/oom_adj可以看到当前进程的adj值。
看到adj值是0,0就代表这个进程是属于前台进程,我们再按下Back键,将应用至于后台,再次查看。
adj值变成了8,8代表这个进程是属于不活跃的进程。关于oom_adj进程的相关内容可以参考下表:
adj级别 | 值 | 解释 |
---|---|---|
UNKNOWN_ADJ | 16 | 预留的最低级别,一般对于缓存的进程才有可能设置成这个级别 |
CACHED_APP_MAX_ADJ | 15 | 缓存进程,空进程,在内存不足的情况下就会优先被kill |
CACHED_APP_MIN_ADJ | 9 | 缓存进程,也就是空进程 |
SERVICE_B_ADJ | 8 | 不活跃的进程 |
PREVIOUS_APP_ADJ | 7 | 切换进程 |
HOME_APP_ADJ | 6 | 与Home交互的进程 |
SERVICE_ADJ | 5 | 有Service的进程 |
HEAVY_WEIGHT_APP_ADJ | 4 | 高权重进程 |
BACKUP_APP_ADJ | 3 | 正在备份的进程 |
PERCEPTIBLE_APP_ADJ | 2 | 可感知的进程,比如那种播放音乐 |
VISIBLE_APP_ADJ | 1 | 可见进程,如当前的Activity |
FOREGROUND_APP_ADJ | 0 | 前台进程 |
PERSISTENT_SERVICE_ADJ | -11 | 重要进程 |
PERSISTENT_PROC_ADJ | -12 | 核心进程 |
SYSTEM_ADJ | -16 | 系统进程 |
NATIVE_ADJ | -17 | 系统起的Native进程 |
说明:上表的数字可能在不同系统会有一定的出入。
下面按照网络保活和进程保活来给大家介绍保活的一些策略。
网络连接保活
网络保活,业界主要手段有:
a. GCM;
b. 公共的第三方push通道(信鸽等);
c. 自身跟服务器通过轮询,或者长连接;
GCM即Google Cloud Messaging,主要用于消息推送的,即使在应用没有起来的情况下,客户端也能通过GCM收到来自服务器的消息。GCM支持Android、IOS和Chrome。由于GCM需要google service支持,在国内基本不能用,经常会断线。
push很多也是基于长连接实现的,早年的微信,直接通过Java socket 实现。所以后面我们直接谈长连接。
长连接实现包括几个要素:
a. 网络切换或者初始化时 server ip 的获取。
b. 连接前的 ip筛选,出错后ip 的抛弃。
c. 维护长连接的心跳。
d. 服务器通过长连notify。
e. 选择使用长连通道的业务。
f. 断开后重连的策略。
今天,我们讨论重点即时聊天中的心跳和 notify 机制。
1,心跳机制
通过定期的数据包,对抗NAT超时(一般会设置为5-10秒)。以下是部分地区网络NAT 超时统计。
心跳的实现过程如下:
说明:
a. 连接后主动到服务器Sync拉取一次数据,确保连接过程的新消息。
b. 心跳周期的Alarm 唤醒后,一般有几秒的cpu 时间,无需wakelock。
c. 心跳后的Alarm防止发送超时,如服务器正常回包,该Alarm 取消。
d. 如果服务器回包,系统通过网络唤醒,无需wakelock。
流程基于两个系统特性:
a. Alarm唤醒后,足够cpu时间发包。
b. 网络回包可唤醒机器。
特别是b项,假如Android封堵该特性,那就只能用GCM了。API level >= 23的doze就关闭所有的网络, alarm等。Google也最终在6.0版本加入REQUEST_IGNORE_BATTERY_OPTIMIZATIONS权限。
2,动态心跳
4.5min心跳周期是稳定可靠的,但无法确定是最大值。通过终端的尝试,可以获取到特定用户网络下,心跳的最大值。引入该特性的背景:
a. 运营商的信令风暴
b. 运营商网络换代,NAT超时趋于增大
c. Alarm耗电,心跳耗流量。
动态心跳引入下列状态:
a. 前台活跃态:亮屏,微信在前台, 周期minHeart (4.5min) ,保证体验。
b. 后台活跃态:微信在后台10分钟内,周期minHeart ,保证体验。
c. 自适应计算态:步增心跳,尝试获取最大心跳周期(sucHeart)。
d. 后台稳定态:通过最大周期,保持稳定心跳。
下面是自适应计算态流程:
在自适应态:
a. curHeart初始值为minHeart , 步增(heartStep)为1分钟。
b. curHeart 失败5次, 意味着整个自适应态最多只有5分钟无法接收消息。
c. 结束后,如果sucHeart > minHeart,会减去10s(避开临界),为该网络下的稳定周期。
d. 进入稳定态时,要求连接连续三次成功minHeart心跳周期,再使用sucHeart。
3,notify机制
网络保活的意义在于消息实时。通过长连接,即时通信类产品有下列机制保证消息的实时。
Sync:
通过Sync CGI直接请求后台数据。Sync 通过后台和终端的seq值对比,判断该下发哪些消息。终端正常处理消息后,seq更新为最新值。
Sync 的主要场景:
a. 长连无法建立时,通过Sync 定期轮询;
b. 微信切到前台时,触发Sync(保命机制);
c. 长连建立完成,立即触发Sync,防止连接过程漏消息;
d. 接收到Notify 或者 gcm 后,终端触发Sync 接收消息。
Notify:
类似于GCM。通过长连接,后台发出仅带seq的小包,终端根据seq决定是否触发Sync拉取消息。
NotifyData:
在长连稳定, Notify机制正常的情况下(保证seq的同步)。后台直接推送消息内容,节省1个RTT (Sync) 消息接收时间。终端收到内容后,带上seq回应NotifyAck,确认成功。这里会出现Notify和NotifyData状态互相切换的情况:
如NotifyData 后,服务器在没收到NotifyAck,而有新消息的情况下,会切换回到Notify,Sync可能需要冗余之前NotifyData的消息。终端要保证串行处理NotifyData和Sync ,否则seq可能回退。
GCM:
只要机器上有GMS ,启动时就尝试注册GCM,并通知后台。服务器会根据终端是否保持长连,决定是否由GCM通知。GCM主要针对国外比较复杂的网络环境。
进程保活
在Android系统里,进程被杀的原因通常为以下几个方面:
a. 应用Crash;
b. 系统回收内存;
c. 用户触发;
d. 第三方root权限app。
下面分享几个微信和qq关于进程保活的几个方法:
1,进程拆分
俗话说,鸡蛋不能放一个篮子里面,那么为了保活,我们也可以将进程拆分为几个。
例如,上图是微信应用的几个进程:
a. push主要用于网络交互,没有UI
b. worker就是用户看到的主要UI
c. tools主要包含gallery和webview
这样,进程通过拆分之后,单个进程被回收了并不影响其他的进程。拆分网络进程,确实就是为了减少进程回收带来的网络断开。
可以看到push的内存要远远小于worker。而且push的工作性质稳定,内存增长会非常少。这样就可以保证,尽量的减少push 被杀的可能。为了提高线程存活的概率,这里启动一个纯C/C++ 的进程,而不是Java run time。
2,及时拉起
系统回收不可避免,及时重新拉起的手段主要依赖系统特性。从上图看到, push有AlarmReceiver, ConnectReceiver,BootReceiver。这些receiver 都可以在push被杀后,重新拉起。特别AlarmReceiver ,结合心跳逻辑,微信被杀后,重新拉起最多一个心跳周期。
而对于worker,除了用户UI操作启动。在接收消息,或者网络切换等事件, push也会通过LocalBroadcast,重新拉起worker。这种拉起的worker ,大部分初始化已经完成,也能大大提高用户点击微信的启动速度。
历史原因,我们在push和worker通信使用Broadcast和AIDL。实际上,我一直不喜欢这里的实现,AIDL代码冗余多, broadcast效率低。欢迎大家分享更好的思路或者方法。
3,进程优先级
前面说过Low Memory Killer机制,Low Memory Killer 机制决定是否杀进程除了内存大小,还有进程优先级。这个前面也说过。从这个原理来说,我们可以通过提高进程的优先级来保活。
值得注意的是,Android 的前台service机制。但该机制的缺陷是通知栏保留了图标。
对于 API level < 18 :调用startForeground(ID, new Notification()),发送空的Notification ,图标则不会显示。
对于 API level >= 18:在需要提优先级的service A启动一个InnerService,两个服务同时startForeground,且绑定同样的 ID。Stop 掉InnerService ,这样通知栏图标即被移除。