android简单即时聊天sdk
- 切换用户登录的实现
- 联系人列表的实现
- 聊天页的实现
- 消息缓存与排序
- 消息接收和分发——数据库队列和投递队列
- 有序列表的维护
切换用户登录的实现
- 由于不同的登录用户需要有不同的联系人以及聊天记录等数据,而切换用户之后再重新登回时也应尽量保持与之前体验一致,所以比较恰当的方式是每一个登录用户建立一个以自己用户id为尾缀的database。每次用户登录成功后即可通过该登录用户的userid去本地读取该用户数据库相关的数据(egg. IMDB_123456)
- 安卓提供了一个比较方便的数据库抽象类SqliteOpenHelper,自己实现一个IMDBHelper类继承SqliteOpenHelper,并提供一个根据用户id获取实例的方法,我的代码如下
public IMDBHelper getIMDBHelper(long userid) {
if (userid > 0 && dbhelpers == null) {
dbhelpers = new Hashtable<>();
}
String dbname = getDBName(userid);
if (!dbhelpers.containsKey(dbname)) {
synchronized (dbhelpers) {
IMDBHelper helper = new IMDBHelper(IMClient.getInstance().getContext(), dbname);
dbhelpers.put(dbname, helper);
}
}
return dbhelpers.get(dbname);
}
联系人列表的实现
- 每次进入联系人列表的时候需要先去服务度拉取最新联系人版本号,并与本地对照,如果大于本地版本号,则要去服务端拉取增量数据(这是联系人列表的增量更新逻辑,要求不高也可每次全量更新即可);拉取数据回来后,如果是有新增/删除联系人的操作则需要先新增/删除联系人表的相关记录,然后新增/删除以该联系人id为尾缀的对应的消息表。
- 联系人列表需要包含的元素有三部分
- 1、联系人数据(若为单聊联系人即用户头像、id等用户相关数据);
- 2、未读消息数
- 3、最后一条消息(消息摘要、消息时间)
- 联系人表结构比较简单,表名固定t_contacts即可,lastMsg、newMsgTime、newMsgNum、userid四个字段就能完整描述了,userid就是对应的联系人id,真正的用户数据放到另外的user表里面,需要用时通过userid去取出即可。
- 联系人列表的ui实现也比较简单,用Listview或者RecyclerView都可以,重点是每次新增/删除联系人、有新消息到来时都需要对列表排序,但是大量的排序肯定是很消耗性能的,所以我想到的比较科学的方式是在本地维护一个有序的列表,这样只要在每次插入的时候进行一次o(n)甚至是o(1)复杂度的运算就可以实现了。列表实现代码在文章最后面贴。下面为在新增联系人记录时新建消息表的代码。
public synchronized long insert(ContactEntity entity,
TableOperator<IMSimpleUser> operator) {
// 插入用户表
if (entity.getContact() != null) {
if (operator == null) {
operator = new UserTable().attachTable();
}
operator.insert(entity.getContact());
}
// 插入或更新contact表
List<ContactEntity> users = attachTable().query(USERID,
entity.getContact().getUserid() + "");
if (users != null && users.size() > 0) {
return attachTable().update(USERID, entity.getContact().getUserid() + "", entity);
} else {
// 如果是插入,需要创建聊天记录表
long id = attachTable().insert(entity);
new MessageTable(entity.getContact()).attachTable();
return id;
}
}
聊天页的实现
- 聊天页的底层主要基于一个以会话id为尾缀的消息表,消息表是在联系人表发生变动(新增/删除)时来动态建立/删除的。会在缓存初始化时从表中加载数据,会在新消息产生(发送)或者是新消息到来(接收)时在对应的消息表中做新增、更新等操作。(notes:发送消息的时候聊天界面上可能会有不同的状态展示,例如发送中、发送失败等的状态显示,可以在新增消息后将该消息的自增id返回保存到缓存中,以便下次更新数据状态时精确定位到具体消息)。
- 聊天界面的ui会相对比较复杂,会有不同的消息类型展示,会有表情、大表情等入口,可能还会有跟自己业务相关的消息类型,具体实现可以参考环信demo的easeui来做。
- 消息表名类似:t_msg_123445;字段大致包括如下即可大致描述清楚一条消息:uid(消息发送者id)、tuid(消息接收者id)、time、msgid、direct(消息走向:send/receive)、message(消息具体内容)、deliverStatus(消息投递状态:发送消息时用到);
- 消息表的封装类会提供三个方法以实现初始化数据拉取,加载历史消息,以及消息窗口移动的功能。
public List<IMSimpleMessage> loadFirst(int size) {
return attachTable().query(null, null, size + "", null, TIME + " DESC");
}
public List<IMSimpleMessage> loadFront(long time, int start, int size) {
return attachTable().query(new String[] {
TIME
}, new String[] {
time + ""
}, start + "," + size, "<", TIME + " DESC");
}
public List<IMSimpleMessage> loadBack(long time, int start, int size) {
return attachTable().query(new String[] {
TIME
}, new String[] {
time + ""
}, start + "," + size, ">", TIME + " ASC");
}
- 聊天页的展示与交互基本是基于对缓存的操作与接收缓存变动通知来实现。
消息缓存与排序
- 为什么会需要一个常驻内存的消息缓存呢,主要是新消息到来与发送的频率是有可能很高,且实时性要求高,而且在联系人列表以及聊天页面都需要用到消息相关数据。同时,如果没有消息缓存,当较早发送的消息由于网络延时或者其他原因比较晚的消息更晚到达时,可能联系人列表的最后一条消息就会显示的并不是真正的最后一条消息。鉴于此,很有必要维护一个统一的缓存;由于联系人列表需要用到,所以缓存的存在周期应与联系人列表的存在周期相同。
- 消息缓存的初始化,在联系人列表数据获取成功后完成,初始化时从各个响应的消息表中取出第一页需要取出的数据
- 加载更多历史消息,聊天页下拉加载更多时会从表中分页加载更多数据进入缓存列表,当缓存窗口未达到限定值时直接将数据加入到列表(每次数据加入缓存时会自动插入恰当的时序位置以实现有序列表)。当缓存窗口达到了限定值的时候,如果是加载较早时间方向的消息,则会先移除较晚时间方向的消息,然后在入列表。若方向相反则移除数据同样相反。
- 当缓存发生变动时(插入、删除、初始化加载)都会触发缓存变动事件,这个事件会被传递到上层,上层收到事件通知后,会来调用缓存的获取列表接口并刷新显示。
private void notifyReceive() {
if (sendCallbacks != null && sendCallbacks.size() > 0) {
for (MessageCallback callback : sendCallbacks) {
callback.onRecieve();
}
}
}
private void notifyLoadEnd(int flag, int size) {
if (sendCallbacks != null && sendCallbacks.size() > 0) {
for (MessageCallback callback : sendCallbacks) {
callback.onLoadEnd(flag, size);
}
}
}
- 发送/收到新消息时会先将新消息加入到缓存中,再走不同流程。如果是发送新消息会先加入缓存,然后加入数据库队列,数据库队列将其插入数据库后后再将其加入到投递队列,投递队列完成了,更改缓存状态,更改数据库状态,通知ui缓存变动。
- 如果是接收到新消息,也会先加入到缓存,然后会通知ui缓存变动,再加入到数据库队列。
public void sendMessage(IMSimpleMessage message) {
if (MessageUtils.checkSend(message)) {
putIntoList(message);
Deliver.getInstance().deliverMessage(message, this);
}
}
public void saveMessage(IMSimpleMessage message) {
if (message != null) {
putIntoList(message);
DBWorker2.getInstance().insertOrUpdate(this, message);
}
}
public void receiveMessage(IMSimpleMessage message) {
if (MessageUtils.checkReceive(message)) {
putIntoList(message);
DBWorker2.getInstance().insertOrUpdate(this, message);
}
}
消息接收和分发——数据库队列和投递队列
- 由于存数据库和投递消息(我们使用http请求来发送消息)都是阻塞型的事件,所以都会单独建立一个异步消息处理线程来处理消息相关的操作。
- 每个消息操作(插入、修改、加载)都会被封装成一个数据库操作任务被加入到数据库线程的队列中。
public static class ConversationEntity {
public interface Task {
int LOAD_FIRST = 0X01;
int LOAD_FRONT = 0X02;
int LOAD_BACK = 0X03;
int INSERT_OR_UPDATE = 0X04;
}
public int loadStart;
public int loadSize;
public String conversationid;
List<IMSimpleMessage> messages;
public DBCallback callback;
public int taskid;
public long loadTime;
public DeliverCallback deliverCallback;
public ConversationEntity(String conversationid, long loadTime, int loadStart, int loadSize,
int taskid, DBCallback callback) {
this.conversationid = conversationid;
this.taskid = taskid;
this.callback = callback;
this.loadStart = loadStart;
this.loadSize = loadSize;
this.loadTime = loadTime;
}
public ConversationEntity(String conversationid, int taskid, DBCallback callback,
List<IMSimpleMessage> messages) {
this.conversationid = conversationid;
this.taskid = taskid;
this.callback = callback;
this.messages = messages;
}
}
- 同理每个需要被投递的消息也会被封装成一个投递任务被发布到投递线程的队列中。
public static class DeliverEntity {
public DeliverEntity(IMSimpleMessage message, DeliverCallback callback) {
this.message = message;
this.callback = callback;
}
DeliverCallback callback;
IMSimpleMessage message;
}
有序列表的维护
- 消息缓存的方案我网上找了一下,看到了一些通用数据结构的缓存方案,例如FIFO、Lru等等,但貌似与我们所需要的并不相符,我需要的是一个以时间为轴的自动排序列表,所以我自己基于链表实现了一个简单的。
public class TimeOrderCache<T extends ITimeOrder> implements ITimeOrderCache<T> {
String key;
LinkedList<T> cache;
final int MAX_SIZE = 1024;
public TimeOrderCache(String key) {
this.key = key;
cache = new LinkedList<>();
}
@Override
public String getKey() {
return key;
}
@Override
public void putIntoList(T entity) {
if (entity != null) {
synchronized (cache) {
if (cache.size() > MAX_SIZE) {
cache.removeLast();
}
Iterator<T> iterator = cache.descendingIterator();
int index = cache.size();
boolean needInsert = true;
while (iterator.hasNext()) {
T t = iterator.next();
if (entity.equalsMessage(t)) {
needInsert = false;
break;
}
if (entity.getTimeOrder() > t.getTimeOrder()) {
// 插入该元素的下一个位置
break;
}
index--;
}
if (needInsert) {
if (index < 0)
index = 0;
cache.add(index, entity);
}
}
}
}
@Override
public T getLast() {
if (cache.size() > 0)
return cache.getLast();
return null;
}
@Override
public T getFirst() {
if (cache.size() > 0)
return cache.getFirst();
return null;
}
@Override
public T removeLast() {
synchronized (cache) {
if (cache.size() > 0)
return cache.removeLast();
return null;
}
}
@Override
public T removeFirst() {
synchronized (cache) {
if (cache.size() > 0)
return cache.removeFirst();
return null;
}
}
@Override
public int size() {
return cache.size();
}
public List<T> getCache() {
// 使用Collections.unmodifiableList发现ListView数据更换后无法通过notifyDatasetChanged刷新
synchronized (cache) {
if (cache.size() > 0) {
Iterator<T> iterator = cache.iterator();
ArrayList<T> caches = new ArrayList<>();
while (iterator.hasNext()) {
T t = iterator.next();
caches.add(t);
}
return caches;
}
}
return null;
}
@Override
public void clear() {
synchronized (cache) {
cache.clear();
}
}
}
ITimeOrder是每一个存入该缓存的数据实体必须实现的简单接口
/**
* 用于获取排序的时间值 Created by walljiang on 2017/11/20.
*/
public interface ITimeOrder {
long getTimeOrder();
boolean equalsMessage(ITimeOrder entity);
}