先定一个小目标,比如写一个QQ
项目简介
本项目是即时通讯的示例项目,使用了MVP模式,集成了环信SDK和Bmob后端云,展示了即时通讯基本功能的实现,包括注册登录,退出登录,联系人列表,添加好友,删除好友,收发消息,消息提醒等功能。
使用的开源项目
学习目标
环信集成[list=1]注册并创建应用下载SDKSDK的导入SDK初始化 so文件夹1. 放在jniLibs2. 也可以放在libs目录下,不过需要在模块下的配置文件中配置
服务器端的MVC
Android中MVCAndroid中并没有清晰的MVC框架,如果把Activity当做Controller,根据我们实际开发经验,里面会有大量的UI操作,所以V和C就傻傻分不清了。
MVVMMVVM主要应用于WPF, Silverlight, Caliburn, nRoute等。[list=1] Model: 获取数据的业务逻辑,网络操作,数据库操作 View: UI ViewModel: 将View和Model绑定
Android中MVVM 软件架构的核心思想
参考
Andrioid6.0对权限进行了分组,涉及到用户敏感信息的权限只能动态的去获取。当应用的targetSdkVersion小于23时,会默认采用以前的权限管理机制,当targetSdkVersion大于等于23时并且运行在Andorid6.0系统上,它才会采用这套新的权限管理机制。参考 动态获取写磁盘权限环信SDK内部维护了一个数据库来存储聊天记录等数据,需要读写磁盘的权限,所以我们在用户登录之前要申请该权限。类似的,很多同学在集成百度地图或者高德地图时常常不能正常定位,往往是忽略了在Android6.0上需要获取位置的权限。
功能需求[list=1]用户名的长度必须是3-20位,首字母必须为英文字符,其他字符则除了英文外还可以是数字或者下划线。密码必须是3-20位的数字。密码和确认密码一致MVP实现
云数据库 Bmob集成Bmob开发文档
底部导航条 主界面底部是一个Tab导航条,实现的方法可以有很多种,我们可以使用RadioGroup或者FragmentTabHost, 或者我们可以自定义一个控件,为了不重复造轮子,本Demo使用了第三方导航条BottomBar。BottomBar的使用请参考其Github地址。第三方导航条Fragment的切换
MVP实现
MVP实现
运用模块化的思想,将联系人的列表项抽取成一个独立的自定义组合式控件ContactListItemView。ContactListItemView只需传入一个与之对应的数据模型ContactListItem即可完成渲染。联系人是否在同一个组
当多个联系人的首字符相同时,只有第一个ContactListItemView显示首字符,后续首字符相同的ContactListItemView均不显示首字符。在ContactListItem中声明一个布尔型变量showFirstLetter来标记是否显示首字符。该变量在创建ContactListItem时赋值。
CardView继承自FrameLayout, 是Material Design里面的卡片设计,带有圆角和阴影效果。CardView效果非常好看,但也不能滥用,比如:
更多的使用规范,请参考Material Design的设计规范Material Design Cards。这里我们给ContactListItemView的布局里面包了一层CardView,实际上是不可取的做法,这里只是为了展示CardView的使用姿势,实际项目中请大家遵循Material Design的设计规范。参考SwipeRefreshLayout的使用
MVP实现
MVP实现
发送一条消息 返回消息的类型 根据是发送的消息还是接受的消息,来创建不同的item。
MVP实现
加载所有会话
未读消息计数更新
环信官方文档
会话列表中未读消息的更新
BottomBar badge的更新
标记消息已读
加载聊天数据时标记已读
在聊天界面收到新消息时将消息标记已读
消息通知
通知
判断app是否在前台
如果app在后台则弹出Notification
初始化SoundPool
播放音效
环信官方文档
github地址:https://github.com/uncleleonfan/QQDemo
本项目是即时通讯的示例项目,使用了MVP模式,集成了环信SDK和Bmob后端云,展示了即时通讯基本功能的实现,包括注册登录,退出登录,联系人列表,添加好友,删除好友,收发消息,消息提醒等功能。
使用的开源项目
学习目标
- 环信SDK的集成与使用
- MVP模式的运用
- ORM数据库的集成与使用
- 模块化思想的运用
允许两人或多人使用网络即时的传递文字讯息、档案、语音与视频交流。相关产品
- 鼻祖 ICQ
- 国内主流 QQ 微信 陌陌 YY等
- 国外主流 Facebook Messenger WhatsApp Skype Instagram Line
环信集成[list=1]注册并创建应用下载SDKSDK的导入SDK初始化 so文件夹1. 放在jniLibs2. 也可以放在libs目录下,不过需要在模块下的配置文件中配置
android { sourceSets { main { jniLibs.srcDirs = ['libs'] } } }
注意事项运行出错:Didn't find class "com.hyphenate.chat.adapter.EMACallSession",原因是hyphenatechat_3.2.0.jar包内没有该类。解决办法:导入Demo源码中EaseUI库里面的hyphenatechat_3.2.0.jar替换。软件架构MVCMVC应用于Ruby on Rails, Spring Framework, iOS开发和 ASP.NET等。- Model: 获取数据的业务逻辑,网络操作,数据库操作
- View: UI
- Controller: 操作Model层获取数据传递给UI
服务器端的MVC
Android中MVCAndroid中并没有清晰的MVC框架,如果把Activity当做Controller,根据我们实际开发经验,里面会有大量的UI操作,所以V和C就傻傻分不清了。
- Model:Java Bean, NetworkManager, DataBaseHelper
- View: xml res
- Controller: Activity Fragment
- ArrayList-ListView-Adapter(MVC)
- Model: 获取数据的业务逻辑,网络操作,数据库操作
- View: UI
- Presenter: 操作Model层获取数据传递给UI
MVVMMVVM主要应用于WPF, Silverlight, Caliburn, nRoute等。[list=1] Model: 获取数据的业务逻辑,网络操作,数据库操作 View: UI ViewModel: 将View和Model绑定
Android中MVVM 软件架构的核心思想
分层分模块
参考
- MVC,MVP和MVVM模式如何选择
- 教你认清MVC,MVP和MVVM
- android architectureUnderstanding MVC, MVP and MVVM Design Patterns
- Android Data Binding
- Clean Architecture
- adapter 存放适配器
- app 存放常量类,Application类以及一些app层级的全局类
- database 数据库相关类
- event EventBus使用的事件类
- factory 工厂类
- model 数据模型
- presenter MVP模型中的Presenter类
- ui 存放activity和fragment
- utils 工具类
- view MVP模型中的View类
- widget 自定义控件
- BaseActivity
- BaseFragment
- SplashView
- SplashPresenter
@Overridepublic void checkLoginStatus() { if (EMClient.getInstance().isLoggedInBefore() && EMClient.getInstance().isConnected()) { mSplashView.onLoggedIn(); } else { mSplashView.onNotLogin(); }}登录界面功能需求[list=1]点击登录按钮或者点击虚拟键盘上的Action键都能发起登录操作点击新用户,跳转到注册界面。MVP实现
- LoginView
- LoginPresenter
android:imeOptions="actionNext"//下一个android:imeOptions="actionGo"//启动android:imeOptions="actionDone"//完成android:imeOptions="actionPrevious"//上一个android:imeOptions="actionSearch"//搜索android:imeOptions="actionSend"//发送监听软键盘Action事件,发起登录
private TextView.OnEditorActionListener mOnEditorActionListener = new TextView.OnEditorActionListener() { @Override public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { if (actionId == EditorInfo.IME_ACTION_GO) { startLogin(); return true; } return false; }};EMCallBack的适配器 EMCallBack是环信的一个请求回调接口,包括请求成功的回调onSuccess,请求失败的回调onError和请求进度回调onProgress,但在实际使用过程中,通常只使用到请求成功和失败的回调,请求进度回调通常留在那里成了一个空方法,对于一个有代码洁癖的搬砖师来说,这是很难受的。所以我们可以创建一个适配器类实现这个接口,使用时用适配器类来替换EMCallBack接口,这样只需要覆写我们想覆写的方法就可以了。
//EMCallBack接口的适配器public class EMCallBackAdapter implements EMCallBack{ @Override public void onSuccess() { } @Override public void onError(int i, String s) { } @Override public void onProgress(int i, String s) { }}//EMCallBack适配器的使用private EMCallBackAdapter mEMCallBack = new EMCallBackAdapter() { @Override public void onSuccess() { ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { mLoginView.onLoginSuccess(); } }); } @Override public void onError(int i, String s) { ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { mLoginView.onLoginFailed(); } }); }};Android6.0动态权限管理
Andrioid6.0对权限进行了分组,涉及到用户敏感信息的权限只能动态的去获取。当应用的targetSdkVersion小于23时,会默认采用以前的权限管理机制,当targetSdkVersion大于等于23时并且运行在Andorid6.0系统上,它才会采用这套新的权限管理机制。参考 动态获取写磁盘权限环信SDK内部维护了一个数据库来存储聊天记录等数据,需要读写磁盘的权限,所以我们在用户登录之前要申请该权限。类似的,很多同学在集成百度地图或者高德地图时常常不能正常定位,往往是忽略了在Android6.0上需要获取位置的权限。
/** * 是否有写磁盘权限 */private boolean hasWriteExternalStoragePermission() { int result = ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); return result == PermissionChecker.PERMISSION_GRANTED;}/** * 申请权限 */private void applyPermission() { String permissions = {Manifest.permission.WRITE_EXTERNAL_STORAGE}; ActivityCompat.requestPermissions(this, permissions, REQUEST_WRITE_EXTERNAL_STORAGE);}/** * 申请权限回调 */@Overridepublic void onRequestPermissionsResult(int requestCode, @NonNull String permissions, @NonNull int grantResults) { switch (requestCode) { case REQUEST_WRITE_EXTERNAL_STORAGE: if (grantResults[0] == PermissionChecker.PERMISSION_GRANTED) { login(); } else { toast(getString(R.string.not_get_permission)); } break; }}
注册界面功能需求[list=1]用户名的长度必须是3-20位,首字母必须为英文字符,其他字符则除了英文外还可以是数字或者下划线。密码必须是3-20位的数字。密码和确认密码一致MVP实现
- RegisterView
- RegisterPresenter
private static final String USER_NAME_REGEX = "^[a-zA-Z]\\w{2,19}$";//用户名的正则表达式private static final String PASSWORD_REGEX = "^[0-9]{3,20}$";//密码的正则表达式
- ^ 匹配输入字符串的开始位置
- [a-zA-Z] 字符范围。匹配指定范围内的任意字符。
- \w 匹配包括下划线的任何单词字符。等价于'[A-Za-z0-9_]'。
- $ 匹配输入字符串的结束位置
云数据库 Bmob集成Bmob开发文档
- 注册创建应用
- 下载SDK
- 导入SDK
- 初始化SDk
protected void hideSoftKeyboard() { if (mInputMethodManager == null) { mInputMethodManager = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); } mInputMethodManager.hideSoftInputFromWindow(getCurrentFocus().getWindowToken(), 0);}注册用户到BmobBmob用户管理
private void registerBmob(final String userName, final String pwd) { User user = new User(userName, pwd); user.signUp(new SaveListener() { @Override public void done(User user, BmobException e) { if (e == null) { registerEaseMob(userName, pwd); } else { notifyRegisterFailed(e); } } });}注册到环信
private void registerEaseMob(final String userName, final String pwd) { ThreadUtils.runOnBackgroundThread(new Runnable() { @Override public void run() { try { EMClient.getInstance().createAccount(userName, pwd); ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { mRegisterView.onRegisterSuccess(); } }); } catch (HyphenateException e) { e.printStackTrace(); ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { mRegisterView.onRegisterError(); } }); } } });}用户名已注册的处理当我们注册一个用户名到Bmob云数据库后,使用相同的用户名再次注册,Bmob会返回错误码202,信息为username '%s' already taken。所以我们应该处理该错误,通知用户换一个用户名注册。Bmob错误码
private void notifyRegisterFailed(BmobException e) { if (e.getErrorCode() == Constant.ErrorCode.USER_ALREADY_EXIST) { mRegisterView.onResisterUserExist(); } else { mRegisterView.onRegisterError(); }}主界面
底部导航条 主界面底部是一个Tab导航条,实现的方法可以有很多种,我们可以使用RadioGroup或者FragmentTabHost, 或者我们可以自定义一个控件,为了不重复造轮子,本Demo使用了第三方导航条BottomBar。BottomBar的使用请参考其Github地址。第三方导航条Fragment的切换
private OnTabSelectListener mOnTabSelectListener = new OnTabSelectListener() { @Override public void onTabSelected(@IdRes int tabId) { FragmentTransaction fragmentTransaction = mFragmentManager.beginTransaction(); fragmentTransaction.replace(R.id.fragment_container, FragmentFactory.getInstance().getFragment(tabId)).commit(); }};动态界面
MVP实现
- DynamicView
- DynamicPresenter
@Overridepublic void logout() { mDynamicView.onStartLogout(); EMClient.getInstance().logout(true, mEMCallBackAdapter);}联系人界面
MVP实现
- ContactView
- ContactPresenter
- LinearLayoutManager 线性布局管理器 (类似ListView效果)
- GridLayoutManager 网格布局管理器 (类似GridView效果)
- StaggeredGridLayoutManager 分散网格布局管理器(瀑布流效果)
private void initRecyclerView() { mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));//设置布局管理器 mRecyclerView.setHasFixedSize(true);//设置true如果列表内容不会影响RecyclerView的大小 mContactListAdapter = new ContactListAdapter(getContext(), mContactPresenter.getContactList()); mContactListAdapter.setOnItemClickListener(mOnItemClickListener); mRecyclerView.setAdapter(mContactListAdapter);}ContactListAdapter的实现 ContactListItemView
运用模块化的思想,将联系人的列表项抽取成一个独立的自定义组合式控件ContactListItemView。ContactListItemView只需传入一个与之对应的数据模型ContactListItem即可完成渲染。联系人是否在同一个组
当多个联系人的首字符相同时,只有第一个ContactListItemView显示首字符,后续首字符相同的ContactListItemView均不显示首字符。在ContactListItem中声明一个布尔型变量showFirstLetter来标记是否显示首字符。该变量在创建ContactListItem时赋值。
/** * 获取联系人列表数据 * @throws HyphenateException */private void startGetContactList() throws HyphenateException { List contacts = EMClient.getInstance().contactManager().getAllContactsFromServer(); DatabaseManager.getInstance().deleteAllContacts(); if (!contacts.isEmpty()) { for (int i = 0; i < contacts.size(); i++) { ContactListItem item = new ContactListItem(); item.userName = contacts.get(i); if (itemInSameGroup(i, item)) { item.showFirstLetter = false; } mContactListItems.add(item); saveContactToDatabase(item.userName); } }}/** * 当前联系人跟上个联系人比较,如果首字符相同则返回true * @param i 当前联系人下标 * @param item 当前联系人数据模型 * @return true 表示当前联系人和上一联系人在同一组 */private boolean itemInSameGroup(int i, ContactListItem item) { return i > 0 && (item.getFirstLetter() == mContactListItems.get(i - 1).getFirstLetter());}CardView的使用
CardView继承自FrameLayout, 是Material Design里面的卡片设计,带有圆角和阴影效果。CardView效果非常好看,但也不能滥用,比如:
更多的使用规范,请参考Material Design的设计规范Material Design Cards。这里我们给ContactListItemView的布局里面包了一层CardView,实际上是不可取的做法,这里只是为了展示CardView的使用姿势,实际项目中请大家遵循Material Design的设计规范。参考SwipeRefreshLayout的使用
//设置SwipeRefreshLayout的颜色mSwipeRefreshLayout.setColorSchemeResources(R.color.qq_blue, R.color.qq_red);//设置SwipeRefreshLayout的监听器mSwipeRefreshLayout.setOnRefreshListener(mOnRefreshListener);自定义控件SlideBar分类字符数组
rivate static final String SECTIONS = {"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L" , "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"};绘制居中文本FontMetrics
@Overrideprotected void onSizeChanged(int w, int h, int oldw, int oldh) { mTextSize = h * 1.0f / SECTIONS.length;//计算分配给字符的高度 Paint.FontMetrics fontMetrics = mPaint.getFontMetrics(); float mTextHeight = fontMetrics.descent - fontMetrics.ascent;//获取绘制字符的实际高度 mTextBaseline = mTextSize / 2 + mTextHeight/2 - fontMetrics.descent;//计算字符居中时的baseline}@Overrideprotected void onDraw(Canvas canvas) { float x = getWidth() * 1.0f / 2; float baseline = mTextBaseline; for(int i = 0; i < SECTIONS.length; i++) { canvas.drawText(SECTIONS[i], x, baseline, mPaint); baseline += mTextSize; }}[/i]在ContactFragment里面监听SlideBar的事件 当用户在SlideBar上ACTION_DOWN和ACTION_MOVE时,如果用户触摸到的首字符发生变化,则会回调监听器onSectionChange,当ACTION_UP时回调onSlidingFinish()。
private SlideBar.OnSlideBarChangeListener mOnSlideBarChangeListener = new SlideBar.OnSlideBarChangeListener() { @Override public void onSectionChange(int index, String section) { mSection.setVisibility(View.VISIBLE);//显示中间绿色背景的首字符 mSection.setText(section); scrollToSection(section);//滚动RecyclerView到用户滑到的首字符 } @Override public void onSlidingFinish() { mSection.setVisibility(View.GONE);//隐藏中间绿色背景的首字符 }};联系人点击事件当单击联系人时跳转到聊天界面,当长按联系人时弹出Dialog询问用户是否删除好友。
private ContactListAdapter.OnItemClickListener mOnItemClickListener = new ContactListAdapter.OnItemClickListener() { /** * 单击跳转到聊天界面 * @param name 点击item的联系人名字 */ @Override public void onItemClick(String name) { startActivity(ChatActivity.class, Constant.Extra.USER_NAME, name); } /** * 长按删除好友 * @param name 点击item的联系人名字 */ @Override public void onItemLongClick(final String name) { showDeleteFriendDialog(name); }};添加好友界面
MVP实现
- AddFriendView
- AddFriendPresenter
@Overridepublic void searchFriend(final String keyword) { mAddFriendView.onStartSearch(); //注:模糊查询只对付费用户开放,付费后可直接使用。 BmobQuery query = new BmobQuery(); query.addWhereContains("username", keyword).addWhereNotEqualTo("username", EMClient.getInstance().getCurrentUser()); query.findObjects(new FindListener() { @Override public void done(List list, BmobException e) { processResult(list, e); } });}greenDAOgreenDAO是Android SQLite数据库ORM框架的一种。ORM即对象关系映射, object/relational mapping, 将Java对象映射成数据库的表。我们需要存储联系人数据到数据库,在添加好友界面,需要判断搜索出来的用户是否已经是联系人,如果已经是好友,则应显示“已添加”。其他ORM框架 参考 创建实体类
@Entitypublic class Contact { @Id public Long id; public String userName;}初始化
public void init(Context context) { DaoMaster.DevOpenHelper devOpenHelper = new DaoMaster.DevOpenHelper(context, Constant.Database.DATABASE_NAME, null); SQLiteDatabase writableDatabase = devOpenHelper.getWritableDatabase(); DaoMaster daoMaster = new DaoMaster(writableDatabase); mDaoSession = daoMaster.newSession();}保存联系人
public void saveContact(String userName) { Contact contact = new Contact(); contact.setUsername(userName); mDaoSession.getContactDao().save(contact);}查询联系人
public List queryAllContacts() { List list = mDaoSession.getContactDao().queryBuilder().list(); ArrayList contacts = new ArrayList(); for (int i = 0; i < list.size(); i++) { String contact = list.get(i).getUsername(); contacts.add(contact); } return contacts;}删除所有联系人
public void deleteAllContacts() { ContactDao contactDao = mDaoSession.getContactDao(); contactDao.deleteAll();}发送好友请求 AddFriendItemView里面处理添加好友的点击事件
@OnClick(R.id.add)public void onClick() { String friendName = mUserName.getText().toString().trim(); String addFriendReason = getContext().getString(R.string.add_friend_reason); AddFriendEvent event = new AddFriendEvent(friendName, addFriendReason);//通过EventBus发布添加好友事件 EventBus.getDefault().post(event);}AddFriendPresenterImpl实现发送好友请求
@Subscribe(threadMode = ThreadMode.BACKGROUND)public void addFriend(AddFriendEvent event) { try { EMClient.getInstance().contactManager().addContact(event.getFriendName(), event.getReason()); ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { mAddFriendView.onAddFriendSuccess(); } }); } catch (HyphenateException e) { e.printStackTrace(); ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { mAddFriendView.onAddFriendFailed(); } }); }在联系人列表中监听联系人变化当新增好友或者被好友删除时刷新联系人列表。
private EMContactListenerAdapter mEMContactListener = new EMContactListenerAdapter() { @Override public void onContactAdded(String s) { mContactPresenter.refreshContactList(); } @Override public void onContactDeleted(String s) { mContactPresenter.refreshContactList(); }};聊天界面
MVP实现
- ChatView
- ChatPresenter
mEdit.addTextChangedListener(mTextWatcher);private TextWatcherAdapter mTextWatcher = new TextWatcherAdapter() { @Override public void afterTextChanged(Editable s) { mSend.setEnabled(s.length() != 0); }};动画文件
- anim文件夹:存放补间动画
- animator文件夹:存放属性动画
- drawable文件夹:存放帧动画
- animator文件夹:存放属性动画
<?xml version="1.0" encoding="utf-8"?>9文件制作官方说明
发送一条消息 返回消息的类型 根据是发送的消息还是接受的消息,来创建不同的item。
@Overridepublic int getItemViewType(int position) { EMMessage message = mMessages.get(position); return message.direct() == EMMessage.Direct.SEND ? ITEM_TYPE_SEND_MESSAGE : ITEM_TYPE_RECEIVE_MESSAGE;}是否显示时间戳的判断
/** * 如果两个消息之间的时间太近,就不显示时间戳 */private boolean shouldShowTimeStamp(int position) { long currentItemTimestamp = mMessages.get(position).getMsgTime(); long preItemTimestamp = mMessages.get(position - 1).getMsgTime(); boolean closeEnough = DateUtils.isCloseEnough(currentItemTimestamp, preItemTimestamp); return !closeEnough;}更新消息的状态处理三种消息状态,正在发送中INPROGRESS,发送成功SUCCESS,发送失败FAIL。
private void updateSendingStatus(EMMessage emMessage) { switch (emMessage.status()) { case INPROGRESS: mSendMessageProgress.setVisibility(VISIBLE); mSendMessageProgress.setImageResource(R.drawable.send_message_progress); AnimationDrawable drawable = (AnimationDrawable) mSendMessageProgress.getDrawable(); drawable.start(); break; case SUCCESS: mSendMessageProgress.setVisibility(GONE); break; case FAIL: mSendMessageProgress.setImageResource(R.mipmap.msg_error); break; }}接收一条消息 监听消息的接收,当收到一条消息后,通知MessageListAdapter进行刷新,并且滚动RecyclerView到底部。
private EMMessageListenerAdapter mEMMessageListener = new EMMessageListenerAdapter() { @Override public void onMessageReceived(final List list) { ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { final EMMessage emMessage = list.get(0); mChatPresenter.makeMessageRead(mUserName); mMessageListAdapter.addNewMessage(emMessage); smoothScrollToBottom(); } }); }};初始化聊天记录
@Overridepublic void loadMessages(final String userName) { ThreadUtils.runOnBackgroundThread(new Runnable() { @Override public void run() { EMConversation conversation = EMClient.getInstance().chatManager().getConversation(userName); if (conversation != null) { //获取此会话的所有消息 List messages = conversation.getAllMessages(); mEMMessageList.addAll(messages); //指定会话消息未读数清零 conversation.markAllMessagesAsRead(); } ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { mChatView.onMessagesLoaded(); } }); } });}加载更多聊天记录
@Overridepublic void loadMoreMessages(final String userName) { if (hasMoreData) { ThreadUtils.runOnBackgroundThread(new Runnable() { @Override public void run() { EMConversation conversation = EMClient.getInstance().chatManager().getConversation(userName); EMMessage firstMessage = mEMMessageList.get(0); //SDK初始化加载的聊天记录为20条,到顶时需要去DB里获取更多 //获取startMsgId之前的pagesize条消息,此方法获取的messages SDK会自动存入到此会话中,APP中无需再次把获取到的messages添加到会话中 final List messages = conversation.loadMoreMsgFromDB(firstMessage.getMsgId(), DEFAULT_PAGE_SIZE); hasMoreData = (messages.size() == DEFAULT_PAGE_SIZE); mEMMessageList.addAll(0, messages); ThreadUtils.runOnUiThread(new Runnable() { @Override public void run() { mChatView.onMoreMessagesLoaded(messages.size()); } }); } }); } else { mChatView.onNoMoreData(); }}会话界面
MVP实现
- ConversationView
- ConversationPresenter
加载所有会话
@Override
public void loadAllConversations() {
ThreadUtils.runOnBackgroundThread(new Runnable() {
@Override
public void run() {
Map conversations = EMClient.getInstance().chatManager().getAllConversations();
mEMConversations.addAll(conversations.values());
//根据最后一条消息的时间进行排序
Collections.sort(mEMConversations, new Comparator() {
@Override
public int compare(EMConversation o1, EMConversation o2) {
return (int) (o2.getLastMessage().getMsgTime() - o1.getLastMessage().getMsgTime());
}
});
ThreadUtils.runOnUiThread(new Runnable() {
@Override
public void run() {
mConversationView.onAllConversationsLoaded();
}
});
}
});
}
未读消息计数更新
环信官方文档
会话列表中未读消息的更新
private EMMessageListenerAdapter mEMMessageListenerAdapter = new EMMessageListenerAdapter() {
@Override
public void onMessageReceived(List list) {
ThreadUtils.runOnUiThread(new Runnable() {
@Override
public void run() {
toast(getString(R.string.receive_new_message));
//当收到一个新消息时,重新加载会话列表。
mConversationPresenter.loadAllConversations();
}
});
}
};
BottomBar badge的更新
private EMMessageListenerAdapter mEMMessageListenerAdapter = new EMMessageListenerAdapter() {
//该回调在子线程中调用
@Override
public void onMessageReceived(List list) {
updateUnreadCount();
}
};
private void updateUnreadCount() {
ThreadUtils.runOnUiThread(new Runnable() {
@Override
public void run() {
BottomBarTab bottomBar = mBottomBar.getTabWithId(R.id.conversations);
int count = EMClient.getInstance().chatManager().getUnreadMsgsCount();
bottomBar.setBadgeCount(count);
}
});
}
标记消息已读
加载聊天数据时标记已读
//指定会话消息未读数清零
conversation.markAllMessagesAsRead();
在聊天界面收到新消息时将消息标记已读
private EMMessageListenerAdapter mEMMessageListener = new EMMessageListenerAdapter() {聊天界面返回时更新未读badge
@Override
public void onMessageReceived(final List list) {
ThreadUtils.runOnUiThread(new Runnable() {
@Override
public void run() {
final EMMessage emMessage = list.get(0);
if (emMessage.getUserName().equals(mUserName)) {
//标记消息已读
mChatPresenter.makeMessageRead(mUserName);
mMessageListAdapter.addNewMessage(emMessage);
smoothScrollToBottom();
}
}
});
}
};
@Override
protected void onResume() {
super.onResume();
updateUnreadCount();
}
消息通知
通知
判断app是否在前台
public boolean isForeground() {
ActivityManager am = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
List runningAppProcesses = am.getRunningAppProcesses();
if (runningAppProcesses == null) {
return false;
}
for (ActivityManager.RunningAppProcessInfo info :runningAppProcesses) {
if (info.processName.equals(getPackageName()) && info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
return true;
}
}
return false;
}
如果app在后台则弹出Notification
private void showNotification(EMMessage emMessage) {声音
String contentText = "";
if (emMessage.getBody() instanceof EMTextMessageBody) {
contentText = ((EMTextMessageBody) emMessage.getBody()).getMessage();
}
Intent chat = new Intent(this, ChatActivity.class);
chat.putExtra(Constant.Extra.USER_NAME, emMessage.getUserName());
PendingIntent pendingIntent = PendingIntent.getActivity(this, 1, chat, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationManager notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
Notification notification = new Notification.Builder(this)
.setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.avatar1))
.setSmallIcon(R.mipmap.ic_contact_selected_2)
.setContentTitle(getString(R.string.receive_new_message))
.setContentText(contentText)
.setPriority(Notification.PRIORITY_MAX)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build();
notificationManager.notify(1, notification);
}
初始化SoundPool
private void initSoundPool() {
mSoundPool = new SoundPool(2, AudioManager.STREAM_MUSIC, 0);
mDuanSound = mSoundPool.load(this, R.raw.duan, 1);
mYuluSound = mSoundPool.load(this, R.raw.yulu, 1);
}
播放音效
private EMMessageListenerAdapter mEMMessageListenerAdapter = new EMMessageListenerAdapter() {多设备登录
@Override
public void onMessageReceived(List list) {
if (isForeground()) {
mSoundPool.play(mDuanSound, 1, 1, 0, 0, 1);
} else {
mSoundPool.play(mYuluSound, 1, 1, 0, 0, 1);
showNotification(list.get(0));
}
}
};
环信官方文档
private EMConnectionListener mEMConnectionListener = new EMConnectionListener() {
@Override
public void onConnected() {
}
@Override
public void onDisconnected(int i) {
if (i == EMError.USER_LOGIN_ANOTHER_DEVICE) {
ThreadUtils.runOnUiThread(new Runnable() {
@Override
public void run() {
startActivity(LoginActivity.class);
toast(getString(R.string.user_login_another_device));
}
});
}
}
};
github地址:https://github.com/uncleleonfan/QQDemo