「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!
这是即时通讯系列文章的第一篇,正式开始对IM开发技术的讲解之前,我们先来谈谈客户端在完整聊天系统中所扮演的角色,为此,我们必须先明确客户端的职责。
现今主流的IM应用几乎都是采用服务器中转的方式来进行消息传输的,为的是更好地支持离线、群组等业务。在这种模式下,所有客户端都需连接到服务端,服务端将不同客户端发给自己的消息根据消息里携带的用户标识进行转发或广播。
因此,作为消息收发的终端设备,客户端的重要职责之一就是保持与服务端的连接,该连接的稳定性直接决定消息收发的实时性和可靠性。而在上篇文章我们讲过,移动设备是资源受限的,这对连接的稳定性提出了极大的挑战,具体可体现在以下两个方面:
- 为了维持多任务环境的正常运行,Android为每个应用的堆大小设置了硬性上限,不同设备的确切堆大小取决于设备的总体可用RAM大小,如果应用在达到堆容量上限后尝试分配更多内容,则可能引发OOM。
- 当用户切换到其他应用时,系统会将原有应用的进程保留在缓存中,稍后如果用户返回该应用,系统就会重复使用该进程,以便加快应用切换速度。但当系统资源(如内存)不足时,系统会考虑终止占用最多内存的、优先级较低的进程以释放RAM。
虽然ART和Dalvik虚拟机会例行执行垃圾回收任务,但如果应用存在内存泄漏问题,并且只有一个主进程,势必会随着应用使用时间的延长而逐步增大内存使用量,从而增加引发OOM的概率和缓存进程被系统终止的风险。
因此,为了保证连接的稳定性,可考虑将负责连接保持工作的消息服务放入一个独立的进程中,分离之后即使主进程退出、崩溃或者出现内存消耗过高等情况,该服务仍可正常运行,甚至可以在适当的时机通过广播等方式重新唤起主进程。
但是,给应用划分进程,往往就意味着需要编写额外的进程通讯代码,特别是对于消息服务这种需要高度交互的场景。而由于各个进程都运行在相对独立的内存空间,因而是无法直接通讯的。为此,Android提供了AIDL(Android Interface Definition Language,Android接口定义语言)用于实现进程间通信,其本质就是实现对象的序列化、传输、接收和反序列化,得到可操作的对象后再进行常规的方法调用。
接下来,就让我们来一步步实现跨进程的通讯吧。
Step1 创建服务
由于连接保持的工作是需要在后台执行长时间执行的操作,通常不提供操作界面,符合这个特性的组件就是Service了,因此我们选用Service作为与远程进程进行进程间通信(IPC)的组件。创建Service的子类时,必须实现onBind回调方法,此处我们暂时返回空实现。
class MessageAccessService : Service() {
override fun onBind(intent: Intent?): IBinder? {
return null
}
}
另外使用Service还有一个好处就是,我们可以在适当的时机将其升级为前台服务,前台服务是用户主动意识到的一种服务,进程优先级较高,因此在内存不足时,系统也不会考虑将其终止。
使用前台服务唯一的缺点就是必须在抽屉式通知栏提供一条不可移除的通知,对于用户体验极不友好,但是我们可以通过定制通知样式进行协调,后续的文章中会讲到。
step2 指定进程
默认情况下,同一应用的所有组件均在相同的进程中运行。如需控制某个组件所属的进程,可通过在清单文件中设置android:process属性实现:
<manifest ...>
<application ...>
<service
android:name=".service.MessageAccessService"
android:exported="true"
android:process=":remote" />
</application>
</manifest>
另外,为使其他进程的组件能调用服务或与之交互,还需设置android:exported属性为true。
step3 创建.aidl 文件
让我们重新把目光放回onBind回调方法,该方法要求返回IBinder对象,客户端可使用该对象定义好的接口与服务进行通信。IBinder是远程对象的基础接口,该接口描述了与远程对象交互的抽象协议,但不建议直接实现此接口,而应从Binder扩展。通常做法是是使用.aidl文件来描述所需的接口,使其生成适当的Binder子类。
那么,这个最关键的.aidl文件该如何创建,又该定义哪些接口呢?
创建.aidl文件很简单,Android Studio本身就提供了创建AIDL文件方法:项目右键 -> New -> AIDL -> AIDL File
前面讲过,客户端是消息收发的终端设备,而接入服务则是为客户端提供了消息收发的出入口。客户端发出的消息经由接入服务发送到服务端,同时客户端会委托接入服务帮忙收取消息,当服务端有消息推送过来时通知自己。
如此一来便很清晰了,我们要定义的接口总共有三个,分别为:
- 发送消息
- 注册消息接收器
- 反注册消息接收器
MessageCarrier.aidl
package com.xxx.imsdk.comp.remote;
import com.xxx.imsdk.comp.remote.bean.Envelope;
import com.xxx.imsdk.comp.remote.listener.MessageReceiver;
interface MessageCarrier {
void sendMessage(in Envelope envelope);
void registerReceiveListener(MessageReceiver messageReceiver);
void unregisterReceiveListener(MessageReceiver messageReceiver);
}
这里解释一下上述接口中携带的参数的含义:
Envelope ->
解释这个参数之前,得先介绍Envelope.java这个类,该类是多进程通讯中作为数据传输的实体类。AIDL支持的数据类型除了基本数据类型、String和CharSequence,还有就是实现了Parcelable接口的对象,以及其中元素为以上几种的List和Map。
Envelope.java
**
* 用于多进程通讯的信封类
* <p>
* 在AIDL中传递的对象,需要在类文件相同路径下,创建同名、但是后缀为.aidl的文件,并在文件中使用parcelable关键字声明这个类;
* 但实际业务中需要传递的对象所属的类往往分散在不同的模块,所以通过构建一个包装类来包含真正需要被传递的对象(必须也实现Parcelable接口)
*/
@Parcelize
data class Envelope(val messageVo: MessageVo? = null,
val noticeVo: NoticeVo? = null) : Parcelable {
}
另外,在AIDL中传递的对象,需要在上述类文件的相同包路径下,创建同名、但是后缀为.aidl的文件,并在文件中使用parcelable关键字声明这个类,Envelope.aidl就是对应Envelope.java而创建的;
Envelope.aidl
package com.xxx.imsdk.comp.remote.bean;
parcelable Envelope;
两个文件对应的路径比较如下:
那为什么是Envelope类而不直接是MessageVO类(消息视图对象)呢?这是由于考虑到实际业务中需要传递的对象所属的类往往分散在不同的模块(MessageVO从属于另外一个模块,需要被其他模块引用),所以通过构建一个包装类来包含真正需要被传递的对象(该对象必须也实现Parcelable接口),这也是该类命名为Envelope(信封)的含义。
MessageReceiver ->
跨进程的消息收取回调接口,用于将消息接入服务收取到的服务端消息传递到客户端。但这里使用的回调接口有点不一样,在AIDL中传递的接口,不能是普通的接口,只能是AIDL接口,因此我们还需要新建多一个.aidl文件:
MessageReceiver.aidl
package com.xxx.imsdk.comp.remote.listener;
import com.xxx.imsdk.comp.remote.bean.Envelope;
interface MessageReceiver {
void onMessageReceived(in Envelope envelope);
}
包目录结构如下图:
step4 返回IBinder接口
构建应用时,Android SDK会生成基于.aidl 文件的IBinder接口文件,并将其保存到项目的gen/目录中。生成文件的名称与.aidl 文件的名称保持一致,区别在于其使用.java 扩展名(例如,MessageCarrier.aidl 生成的文件名是 MessageCarrier .java)。此接口拥有一个名为Stub的内部抽象类,用于扩展 Binder 类并实现 AIDL 接口中的方法。
/** 根据MessageCarrier.aidl文件自动生成的Binder对象,需要返回给客户端 */
private val messageCarrier: IBinder = object : MessageCarrier.Stub() {
override fun sendMessage(envelope: Envelope?) {
}
override fun registerReceiveListener(messageReceiver: MessageReceiver?) {
remoteCallbackList.register(messageReceiver)
}
override fun unregisterReceiveListener(messageReceiver: MessageReceiver?) {
remoteCallbackList.unregister(messageReceiver)
}
}
override fun onBind(intent: Intent?): IBinder? {
return messageCarrier
}
step5 绑定服务
组件(例如 Activity)可以通过调用bindService方法绑定到服务,该方法必须提供ServiceConnection 的实现以监控与服务的连接。当组件与服务之间的连接建立成功后, ServiceConnection上的 onServiceConnected()方法将被回调,该方法包含上一步返回的IBinder对象,随后便可使用该对象与绑定的服务进行通信。
/**
* ## 绑定消息接入服务
* 同时调用bindService和startService, 可以使unbind后Service仍保持运行
* @param context 上下文
*/
@Synchronized
fun setupService(context: Context? = null) {
if (!::appContext.isInitialized) {
appContext = context!!.applicationContext
}
val intent = Intent(appContext, MessageAccessService::class.java)
// 记录绑定服务的结果,避免解绑服务时出错
if (!isBound) {
isBound = appContext.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
startService(intent)
}
/** 监听与服务连接状态的接口 */
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
// 取得MessageCarrier.aidl对应的操作接口
messageCarrier = MessageCarrier.Stub.asInterface(service)
...
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}
可以同时将多个组件绑定到同一个服务,但当最后一个组件取消与服务的绑定时,系统会销毁该服务。为了使服务能够无限期运行,可同时调用startService()和bindService(),创建同时具有已启动和已绑定两种状态的服务。这样,即使所有组件均解绑服务,系统也不会销毁该服务,直至调用 stopSelf() 或 stopService() 才会显式停止该服务。
/**
* 启动消息接入服务
* @param intent 意图
* @param action 操作
*/
private fun startService(
intent: Intent = Intent(appContext, MessageAccessService::class.java),
action: String? = null
) {
// Android8.0不再允许后台service直接通过startService方式去启动,将引发IllegalStateException
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !ProcessUtil.isForeground(appContext)
) {
if (!TextUtils.isEmpty(action)) intent.action = action
intent.putExtra(KeepForegroundService.EXTRA_ABANDON_FOREGROUND, false)
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
}
}
/**
* 停止消息接入服务
*/
fun stopService() {
// 立即清除缓存的WebSocket服务器地址,防止登录时再次使用旧的WebSocket服务器地址(带的会话已失效),导致收到用户下线的通知
GlobalScope.launch {
DataStoreUtil.writeString(appContext, RemoteDataStoreKey.WEB_SOCKET_SERVER_URL, "")
}
unbindService()
appContext.stopService(Intent(appContext, MessageAccessService::class.java))
}
/**
* 解绑消息接入服务
*/
@Synchronized
fun unbindService() {
if (!isBound) return // 必须判断服务是否已解除绑定,否则会报java.lang.IllegalArgumentException: Service not registered
// 解除消息监听接口
if (messageCarrier?.asBinder()?.isBinderAlive == true) {
messageCarrier?.unregisterReceiveListener(messageReceiver)
messageCarrier = null
}
appContext.unbindService(serviceConnection)
isBound = false
}
总结
通过以上代码的实践,最终我们得以将应用拆分为主进程和远程进程。主进程主要负责用户交互、界面展示,而远程进程则主要负责消息收发、连接保持等。由于远程进程仅保持了最小限度的业务逻辑处理,内存增长相对稳定,因此会大大降低系统内存紧张时远端进程被终止的概率,即使主进程因为意外情况退出了,远程进程仍可保持运行,从而保证连接的稳定性。
「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!
参考
WebSocket详解(一):初步认识WebSocket技术
http://www.52im.net/thread-331-1-1.html
内存管理概览
https://developer.android.google.cn/topic/performance/memory-overview
进程和应用生命周期
https://developer.android.google.cn/guide/components/activities/process-lifecycle
服务概览
https://developer.android.google.cn/guide/components/services
绑定服务概览
https://developer.android.google.cn/guide/components/bound-services
Android 接口定义语言 (AIDL)