环信移动客服v5.13发布——支持接收并播放微信小视频,支持向工单系统提交工单
支持接收并播放微信小视频
移动客服系统支持接收并播放微信小视频。客服与微信网友聊天时,若收到对方发来的微信小视频,可以直接点击视频进行播放。视频支持全屏切换。
视频消息可以在历史会话等页面查看,并支持导出和下载(导出文件中包含视频消息的下载地址)。
注:目前仅网页版客服工作台支持播放微信小视频,且视频来源为微信授权模式集成的微信公众号。
支持向工单系统提交工单
环信已推出功能强大的支持多人协作的在线工单系统,用于处理邮件、网页、电话渠道提交的工单。环信移动客服系统提供“工单融合”功能,与工单系统连通。
当客户咨询的问题需要后续跟进处理时,客服可以在“工单”页面为客户创建工单,由工单系统的专家继续为客户解答问题。创建工单后,客服可以在工单详情页查看工单进度。
环信工单系统采用私有部署的方式,如需开通“工单融合”功能(增值服务),请联系环信商务经理。
管理员模式
新增权限管理,支持自定义角色和权限
新增权限管理功能,允许管理员自定义角色,并设置该角色可以使用的管理员模式和客服模式的页面。
操作方法:
1. 添加角色。进入“管理员模式 > 设置 > 权限管理”页面,点击“添加角色”按钮,输入角色名称,并保存。
2. 设置自定义角色的权限。点击新添加的角色,在权限页面勾选该角色可以使用的页面,包括管理员模式和客服模式的页面,并保存。
注:如果允许该角色查看“客服模式 > 客户中心”页面,需要同时在“管理员模式 > 设置 > 系统开关”页面打开“客服可以使用访客中心”开关。
3. 设置客服的角色。进入“管理员模式 > 成员管理 > 客服”页面,设置客服的角色。
移动客服系统与工单系统融合
环信已推出功能强大的支持多人协作的在线工单系统,用于处理邮件、网页、电话渠道提交的工单。环信移动客服系统提供“工单融合”功能,与工单系统连通。
在“管理员模式 > 工单”页面,管理员可以为客户创建工单,并查看移动客服系统中已提交的所有工单及其进度。
环信工单系统采用私有部署的方式,如需开通“工单融合”功能(增值服务),请联系环信商务经理。
机器人自定义菜单支持导入/导出
机器人自定义菜单提供菜单模版,并支持导入、导出菜单,便于对菜单进行批量维护。
注:在菜单中添加的多媒体文件不支持导出。
Web插件(访客端)
当前版本:V43.14
显示当前排队人数
网页端的客户联系客服时,支持在网页端聊天窗口显示当前排队人数。桌面聊天窗口和H5网页均支持。
显示当前排队人数为旗舰版功能,如需开通,请提供租户ID并联系环信商务经理。
环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.13
环信移动客服登陆地址http://kefu.easemob.com/ 收起阅读 »
【公告】全新的环信移动客服访客端SDK正式发布
全新的环信移动客服访客端SDK正式发布,功能全面升级,性能大幅优化:
- 支持消息传输双通道。当主通道意外受阻时,第二通道自动启用,成倍提升了系统的可靠性,确保消息必达
- 基于MSync协议,更快的连接速度,更小的流量消耗
- 支持消息回撤
- 封装了头像昵称显示,留言,机器人菜单消息/转人工按钮,指定技能组等客服业务功能,极简集成
我们将基于新的移动客服访客端SDK陆续推出消息预知、多端消息漫游、设备信息获取等功能,为您提供最完美的客服体验!
请您尽快升级!
集成指南:
- 安卓:http://docs.easemob.com/cs/300visitoraccess/androidsdk
- iOS: http://docs.easemob.com/cs/300visitoraccess/iossdk
API文档:
- 安卓:http://docs.easemob.com/cs/300visitoraccess/androidsdkapi
- iOS:http://docs.easemob.com/cs/300visitoraccess/iossdkapi
开源的客服Demo:
- 安卓:https://github.com/easemob/kefu-android-demo
- iOS:https://github.com/easemob/helpdeskdemo-ios
如您在集成过程中碰到问题,可以联系我们的支持人员,我们将提供及时周到的支持服务。
敬请您及时更新,享受更优质的服务,谢谢! 收起阅读 »
集成环信demo
2:要记得新建lib文件夹
3:下载libHyphenateFullSDk.a并将其拖入新建的lib文件中,
4:到这步基本ok了,你可以开始浪起~
5:感谢江南孤鹜!
环信移动客服v5.12发布——留言页面改版,支持批量分配留言
留言页面改版,支持批量分配留言
优化留言页面,分类显示未处理、处理中、已解决、未分配的留言;支持选择多个留言,并对留言进行批量分配。
设置方法:
- 勾选多个留言后,点击页面右上角的“处理”按钮,对留言进行批量分配。
- 点击任意一条留言,可以查看留言详情,回复留言、分配留言、修改留言状态。
【优化】进行中会话列表提示转接的会话当客服收到机器人或其他客服转接的会话时,进行中会话列表显示转接标志,提示客服这是一条转接的会话。
【优化】历史会话支持根据技能组进行筛选支持根据技能组筛选历史会话,筛选结果为路由或转接到该技能组并由该技能组的客服结束的会话。【优化】客户中心支持根据客户ID模糊查询在客户中心页面,支持按客户ID模糊查询,即输入客户ID的关键字段,即可查询到该客户的详细资料。注:若在客服模式下显示“客户中心”页面,需要管理员在“管理员模式 > 设置 > 系统开关”页面打开“客服可以使用访客中心功能”开关。【优化】消息中心区分管理员通知和系统消息消息中心增加分类:管理员通知、系统消息,方便客服快速查找管理员通知。【优化】允许客服查看自己的“平均会话时长”“统计数据”页面增加“平均会话时长”指标,允许客服查看自己的“平均会话时长”。 管理员模式留言页面改版,支持批量分配留言优化留言页面,分类显示未处理、处理中、已解决、全部留言;支持选择多个留言,并对留言进行批量分配。设置方法:
- 勾选多个留言后,点击页面右上角的“处理”按钮,对留言进行批量分配。
- 点击任意一条留言,可以查看留言详情,回复留言、分配留言、修改留言状态。
新增访客标签、访客资料、坐席信息变更等“自定义事件”
自定义事件推送新增以下事件:添加访客标签、删除访客标签、更改访客资料、坐席信息变更。当移动客服系统中出现以上事件时,可以实时推送到客户的服务器。
设置方法:进入“管理员模式 > 设置 > 自定义事件推送”页面,创建事件推送,设置服务器地址,勾选需要推送的事件,并保存。
自定义事件推送为增值服务,如需开通,请提供租户ID并联系环信商务经理。
【优化】历史会话支持根据技能组进行筛选
支持根据技能组筛选历史会话,筛选结果为路由或转接到该技能组并由该技能组的客服结束的会话。
【优化】客户中心支持根据客户ID模糊查询
在客户中心页面,支持按客户ID模糊查询,即输入客户ID的关键字段,即可查询到该客户的详细资料。
【优化】消息中心区分管理员通知和系统消息
消息中心增加分类:管理员通知、系统消息,方便客服快速查找管理员通知。
【优化】工作量报表增加指标“接起次数”
“统计查询 > 工作量”页面的客服/技能组工作量详情里,增加“接起次数”,表示客服或技能组在接起的会话中的服务次数。
例如:一条会话被客服A接起,转接至客服B,再次转接至客服A,并由客服A结束。那么,客服A的接起会话数为1,接起次数为2;客服B的接起会话数为1,接起次数为1。
【优化】工作质量报表增加无效人工会话明细
“统计查询 > 工作质量”页面的客服/技能组工作质量详情里,增加无效人工会话明细:客服无消息、访客无消息、均无消息的无效会话数。
iOS客服工作台
当前版本:V2.1.3
支持编辑客户资料中的“自定义字段”。
注:如需添加“自定义字段”,请登录网页版客服工作台,进入“管理员模式 > 设置 > 客户资料自定义”页面进行设置。
关于更多iOS客服工作台的更新日志,请查看iOS 客服工作台 更新日志。
PC客服工作台
当前版本:V2.0.2017.02284
支持消息提示、弹窗和闪烁分开控制,保存文件时支持选择存储路径。
关于更多PC客服工作台的更新日志,请查看PC 客服工作台 更新日志。
Web插件(访客端)
当前版本:V43.13
新增消息预知功能,与网页端客户聊天时,在会话面板显示客户的输入状态及正在输入的内容(下图),使客服能够更高效地解答客户的疑问。
消息预知功能为增值服务,如需开通,请提供租户ID并联系环信商务经理。
关于Web插件的集成说明,请查看网页渠道集成。
环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.12
环信移动客服登陆地址http://kefu.easemob.com/ 收起阅读 »
【环信征文】做一个IOS聊天APP如何实现发送/预览文件功能
导语
在实际项目开发中,用户之间经常需要在聊天窗口发送文件。环信官方IOS版Demo功能很强大,本文主要介绍在IOS版APP中,如何结合iCloud Drive一步步实现【发送文件】和【预览文件】的功能。
一、认识iCloud Drive
iCloud官方文档
这里可以看到iCloud的一些官方介绍以及使用方式,刚开始暂时不必要深入了解。
iCloud Drive, 各类文件,在你的各种设备呈现
http://www.apple.com/cn/icloud/icloud-drive/
iCloud Drive 常见问题
https://support.apple.com/zh-cn/HT201104
为什么我们要用iCloud Drive
由于受ios系统的限制(越狱的iphone当然不受限制),app并不能直接访问系统中的文件,所以只能通过iCloud Drive选取文件。
二、配置项目支持iCloud Drive
我们以环信官方Demo项目为例进行示范操作,V3.3.0版Demo完整源码下载地址:
http://www.easemob.com/download/im
1.下载完项目后,用Xcode打开ChatDemo-UI3.0.xcodeproj,然后更改项目的【Bundle Identifier】为【com.easemob.enterprise.demo.ui.dabiaoge】(自己设置一个独一无二的),并且选择相应的开发证书:
为项目设置一个独一无二的【Bundle Identifier】,才能确保在appstore开发者账户下启用iCloud功能。
2.授权APP使用iCloud服务:选中【Capabilities】标签,点击开关启用【iCloud】服务,勾选【Services】组中的【iCloud Documents】项,下面的容器【Containers】项会自动选上,如下图所示:
授权与容器
容器是存放在服务器的保存所有app数据的一个概念性位置,分为公有数据库与私有数据库。
3.在plist文件中增加配置项:用【Source Code】方式打开项目中的【ChatDemo-UI3.0-Info.plist】文件,在文件末尾新增如下配置:
<key>com.apple.developer.icloud-container-identifiers</key>三、实现【发送文件】功能
<array>
<string>iCloud.$(CFBundleIdentifier)</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
1.显示【发送文件】按钮:在聊天窗口的扩展面板中增加【发送文件】的按钮。
(1).在EaseChatBarMoreView.h增加如下代码(具体代码可下载源码参考,文章底下有下载链接):
(3).编译运行项目,进入单聊页面,打开页面底下的扩展面板,就可以看到【发送文件】的按钮已经可以显示出来了。
2.点击【发送文件】按钮,使用UIDocumentPickerViewController打开iCloud文档页面:
文件选择控制器(UIDocumentPickerViewController)可以让用户在程序外访问程序的沙盒。是app间共享文件的一种简单方式。它也支持一些复杂方式,比如用户可能在多个app中编辑同一个文件。
文件选择器可以访问多个文件提供者的文件。比如,iClound可以让你访问其他app存储在iClound的文件,第三方开发者也可以提供文件
(注意:在mac或者windows系统上往icloud drive传文件时,有时候iphone上不能马上显示最新的文件列表,这时候只要在iphone上注销icloud账号重新登录即可)
(1).在EaseMessageViewController.m页面增加如下代码:
// 第1435行
-(void)moreViewFileTransferAction:(EaseChatBarMoreView *)moreView{
// 具体代码,请参考文章提供的源码项目....
....
}
// 选中icloud里的pdf文件
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url {
// 具体代码,请参考文章提供的源码项目....
....
}
// 第2083行
- (void)sendFileMessageWithURL:(NSURL *)url displayName:(NSString*)displayName
{
// 具体代码,请参考文章提供的源码项目....
....
}
在EaseSDKHelper.m文件中添加如下代码:
在EaseSDKHelper.h添加如下代码:
在EaseBubbleView+File.m文件中添加如下代码:
编译运行,效果如下图所示:
四、实现【预览文件】功能
点击聊天窗口中的文件类型消息,使用UIDocumentInteractionController打开文件预览窗口查看文件内容。
文件交互控制器(UIDocumentInteractionController类的实例)为用户提供可接收程序来处理文件,使用起来非常灵活,功能也比较强大。它除了支持同设备上app之间的文档共享外,还可以实现文档的预览、打印、发邮件以及复制。
要使用一个文件交互控制器(UIDocumentInteractionController类的实例),需要以下步骤:
为每个你想打开的文件创建一个UIDocumentInteractionController类的实例;
实现UIDocumentInteractionControllerDelegate代理;
显示预览窗口/显示菜单。
在EaseMessageViewController.m页面增加如下代码:
// 第52行
UIDocumentInteractionController *_fileInteractionController;
// 第872行
// 打开文件
- (void)_fileMessageCellSelected:(id<IMessageModel>)model
{
// 具体代码,请参考文章提供的源码项目....
....
}
// 打开文件
-(void)openFileViewController:(NSString *) file_url {
// 具体代码,请参考文章提供的源码项目....
....
}
- (UIViewController *)documentInteractionControllerViewControllerForPreview:(UIDocumentInteractionController *)controller {
return self;
}
- (UIView *)documentInteractionControllerViewForPreview:(UIDocumentInteractionController *)controller {
return self.view;
}
- (CGRect)documentInteractionControllerRectForPreview:(UIDocumentInteractionController *)controller {
return self.view.frame;
}
// 第1308行
[self _fileMessageCellSelected:model];
// 第45行改为
@interface EaseMessageViewController ()<EaseMessageCellDelegate,UIDocumentPickerDelegate,UIDocumentInteractionControllerDelegate>
点击聊天窗口后,查看文件的效果如下图所示:
这样,一个简单的发送、预览文件功能就完成了。
技巧:如何参考代码实现功能
在百度网盘中下载本项目的完整源码,然后在xcode中打开项目,全局搜索【add by martin】,即可找到作者增加的相关代码。
https://pan.baidu.com/s/1c269Znq
如有问题,请加入【环信互帮互助群】(群号:340452063)提问。
完整源码可参考简书版文章:http://www.jianshu.com/p/034480a08714
相关文章参考
- android中如何显示开发者服务器上的昵称和头像http://www.imgeek.org/article/825307856
- IOS中如何显示开发者服务器上的昵称和头像http://www.imgeek.org/article/825307855
- IOS快速集成环信IM - 基于官方的Demo优化,5分钟集成环信IM功能http://www.imgeek.org/article/825307886
- 草草们的忧伤:环信IM昵称和头像 http://www.imgeek.org/article/825308536
- IOS中环信聊天窗口如何实现文件发送和预览的功能http://www.imgeek.org/question/6260
- 一言不合你就用环信搞个直播APPhttp://blog.csdn.net/mengmakies/article/details/51794248
收起阅读 »
环信稿酬计划
环信稿酬计划,恭喜你,环信能够与你相遇,算得上是走了(狗shi)运了。
想走向人生巅峰先看这里
1、投稿文章内容必须是关于“环信”、“移动开发”和“人工智能”之类的。这里的“环信”可以是即时通讯云,也可以是“环信移动客服”,当然也可以是“大数据、人工智能”等等,智者见智,希望看到你脑洞大开的良心佳作。
2、文章字数在1000至5000为宜。大神作品另论。显然这里“大神”指的是江南孤鹜,午夜狂魔以及李理等作者,或者自认为技术水平超过他们的,也请不吝赐教。
3、文章名字统一规定为《【环信征文】|XXXXXXX》,方便审稿以及打赏。“环信”两个字还是要写在题目上的,万一通过了呢!)
4、文章投稿至我要上周刊。被收录即代表你已通过。
5、投稿文章数量不限。但文章必须是2017年3月之后创作的作品。没错,我们就是这么喜新厌旧。优秀作品会被立即推至环信官网资讯首页,并且有机会被收录至环信开发者周刊、环信博客、环信微博以及环信公众号等平台。
以下内容并不重要,忙的朋友可以退出本文。
文章审核通过,将获得50~500人民币现金奖励(注:具体金额根据文章阅读数和评委评分比重各占一半!阅读数是重要的参考信息,最终决定权依然在评委手中。)
收起阅读 »
java使用Jersey调用rest api实例
我用的版本是1.19,需要以下几个jar包:
首先要写一个网络访问类,这里举了post和delete的例子,其他的方法基本相同:
import java.util.HashMap;使用的例子(创建群组方法,注意这里面使用了设置参数和Header的方法):
import java.util.Map;
import java.util.Set;
import com.google.gson.Gson;
import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.WebResource;
import com.sun.jersey.api.client.WebResource.Builder;
public class HXRequest {
private String url;
private Map<String, Object> param = new HashMap<String, Object>();
private Map<String, String> header = new HashMap<String, String>();
public HXRequest(String url){
this.url = url;
}
public void setParam(String key, Object value) {
param.put(key, value);
}
public void setHeader(String key, String value) {
header.put(key, value);
}
/**
* 向指定URL发送POST方法的请求
*/
public String sendPost() {
Client client = Client.create();
WebResource resource = client.resource(url);
Builder builder = resource.header("Content-Type", "application/json");
Set<String> keys = header.keySet();
if(keys != null && keys.size() > 0){
for (String key : keys) {
builder = builder.header(key, header.get(key));
}
}
try {
String result = builder.entity(new Gson().toJson(param)).post(String.class);
System.out.println(result);
return result;
} catch(Exception e){
e.printStackTrace();
return null;
}
}
/**
* 向指定URL发送DELETE方法的请求
*/
public String sendDelete() {
Client client = Client.create();
WebResource resource = client.resource(url);
Builder builder = resource.header("Content-Type", "application/json");
Set<String> keys = header.keySet();
if(keys != null && keys.size() > 0){
for (String key : keys) {
builder = builder.header(key, header.get(key));
}
}
try {
String result = builder.entity(new Gson().toJson(param)).delete(String.class);
System.out.println(result);
return result;
} catch(Exception e){
e.printStackTrace();
return null;
}
}
}
/**收起阅读 »
* 创建群组
* @param hxname
* @return
*/
public static HXResult<HXGroup> createGroup(String hxname, String groupName, String token){
HXRequest request = new HXRequest(createGroupUrl);
request.setParam("groupname", groupName);
request.setParam("desc", groupName);
request.setParam("public", true);
request.setParam("approval", false);
request.setParam("owner", hxname);
request.setHeader("Authorization", "Bearer " + token);
String result = request.sendPost();
if(result == null)
return null;
HXResult<HXGroup> group = new Gson().fromJson(result,
new TypeToken<HXResult<HXGroup>>(){}.getType());
return group;
}
一言不合他就花2小时写了一个Slack的聊天机器人(猛戳下载源码)
2016年作为人工智能元年,聊天机器人(Chatbot)在各大行业的应用方兴未艾。据统计,2016年有超过3万个聊天机器人品牌和6千个相关技能涌入市场。国外包括Facebook Messenger、Slack等均引入了聊天机器人,所以毋庸置疑,聊天机器人将成为我们未来生活中不可或缺的一部分。近日,IMGEEK开源社区热心开发者&朝阳区群众“晨星桑”一言不合他就花2小时写了一个Slack的聊天机器人,猛戳“阅读原文”下载GitHub源码。
简介
什么是Slack
Slack是一个团队沟通的平台,在这里你可以群聊、单聊、甚至打电话。还可以通过简单的拖动,进行文件分享。甚至可以跟Github、Travis、Twitter等等工具和网站进行集成。如果这还不能满足需求,也可以定制自己的APP。Slack也支持强大的搜索功能,所有的消息、通知、文件都可以搜索。
Slack App & Slack Bot
Slack Apps是能提高工作效率的工具,这里已经有很多很好的工具,比如To-do bot,跟他聊天便可以轻松的定制计划任务,在指定的时间做你要求他做的事情。
当你添加To-do bot这个APP之后,你就可与To-do bot的机器人todobot聊天了,在左侧的DIRECT MESSAGES中找到todobot,如果没找到,点击加号,添加todobot,如下图
什么是环信移动客服
环信移动客服是一款国内领先的全媒体智能SaaS客服产品,支持全媒体接入,包括网页在线客服、社交媒体客服(微博、微信)、APP内置客服和呼叫中心等多种渠道均可一键接入。
初始化你的Slack
什时候需要把Slack和客服集成?
星巴克想在Slack上卖咖啡,而Slack的用户都是Team内部的,不可能在每个Team内都安插一个星巴克的服务员。这样就需要把Slack上用户发的消息转到一个集中地方处理,于是我就想到了环信移动客服,消息传递到移动客服,Slack用户可以跟某个客服聊天,并且通过一些定制开发能够看见Slack用户的基本信息(比如:昵称、电话、团队名称等),并且可以二维码支付。
创建你的APP
打开 https://api.slack.com/apps 页面,点击 Create New App 按钮
填写你的 App Name 并选择开发者的Team,你就可以点击Create App按钮了,出现下面界面的时候,你的App就创建好啦
初始化设置你的App
点击OAuth & Permissions页面,在下面会有Permission Scope,这里我们搜索bot,然后选择并 Save Changes
在上面的Redirect URLS中填写OAuth认证成功之后的回调地址,比如 https://xxx.xxx/oauth/callback,当然这会儿你可能也不知道你的地址是什么呢,记得之后会用得上
点击Event Subscriptions,这是设置Slack 事件订阅的,有了它,我们就可以接收到用户在Slack上发的消息了。
进入页面后打开开关,在下面的Subscribe to Team Events中,我们搜索并添加message.channel和message.im,分别是群聊和单聊的消息事件订阅
当然,光订阅是不行的,我们还要设置订阅的地址,在上面的Request Url中设置好你的订阅地址就可以李,比如 https://xxx.xxx/events/callback
创建Bot User,在BotUsers页面,创建一个BotUser
然后你就可以在OAuth & Permissions页面,点击Install App to Team按钮,把App安装到你的Team了
Hello World
Step 1:事件订阅初始化
在设置事件订阅地址的时候,Slack会尝试进行一次检查,需要你他们的请求中携带的challenge原封不动的返回给服务器
Slack进行事件订阅验证的请求体Step 2: 处理消息的事件
{
"type": "url_verification",
"challenge": "xxxxxxxxxxxxxx",
"token": "slack verify token"
}
当我们想处理用户发来的消息的时候,我们需要处理消息的事件订阅。
最外层的type为event_callback,event为事件内容,根据event中的type能区分event的类型,channel为消息投递的channel id,user为发送人的id,text为消息的内容
要小心:如果是机器人发送的消息,event中会有bot_id,如果处理不当会导致消息循环发送(不要问我怎么知道的……)
Step 3: 把消息发送到移动客服
Step 3.1: 获取发消息的Token
方法:POSTStep 3.2:发送文本消息
地址:https://a1.easemob.com/{org}/{app}/token
请求体:
{
"client_id": "client_id",
"client_secret": "client_secret",
"grant_type": "client_credentials"
}
响应体:
{
"accessToken": "ABCDEFG"
}
from为发送消息的人,目前以slackTeamId_slackUserId_slackChannelId为格式
方法:POSTStep 4: 让Slack App接收移动客服消息
地址:https://a1.easemob.com/{org}/{app}/messages
请求头:
Authorization: Bearer accessToken
请求体:
{
"from": "teamId_userid_channelId",
"targetType": "users",
"target": ["imServiceNumber"],
"msg": {
"msg": "你好",
"type": "txt"
},
"ext": {
"weichat": {
"visitor": {
"userNickname": "这里可以填写昵称,也可为空",
"companyName": "这里填写公司名称,也可以为空"
}
}
}
}
如果你想使用移动客服回调的方式接收消息,你需要跟你的移动客服客户经理申请开通;当然你也可以使用环信即时通讯云的SDK开发,使用长连接接收消息。
以下以回调模式举例,简单的集成只需要关心如下几个字段
- eventType应该为chat
- from 消息的发送人,应该就是imServiceNumber,要跟集成移动客服的保持一致
- payload是消息的内容,简单的可以先支持txt类型的
- to是接收消息的人,目前以slackTeamId_slackUserId_slackChannelId为格式
消息回调的bodyStep 5: 处理OAuth回调
{
"callId": "xxxxxxxx#xxxxxx_305833766880810084",
"chat_type": "chat",
"eventType": "chat",
"from": "im-channel",
"msg_id": "305833766880810084",
"payload": {
"bodies": [
{
"msg": "啊啊啊",
"type": "txt"
}
],
"ext": {
"weichat": {
"ack_for_msg_id": null,
"agent": {
"avatar": "",
"userNickname": "Admin"
}
}
}
},
"security": "xxxxxxxxxxxxxxxxx",
"timestamp": 1488772272814,
"to": "teamId_userid_channelId"
}
OAuth回调的时候,slack会传给我们一个code,这个code相当于一个临时令牌,来换取accessToken等信息,下面的API就是如何使用code来获取这些信息
方法:POSTStep 6: 把收到的消息发给Slack 用户
地址:https://slack.com/api/oauth.access
请求参数:
client_id
client_secret
code
响应体:
{
"access_token": "xoxp-139740849892-139751631381-146013991078-XXXXXXXXXXXXXXXXXXXXXXX",
"bot": {
"bot_access_token": "xoxb-144002817952-XXXXXXXXXXXXXXXXXXXXXXX",
"bot_user_id": "UXXXXXXXXX"
},
"ok": true,
"scope": "identify,bot",
"team_id": "TXXXXXXX",
"team_name": "91chenxing",
"user_id": "UXXXXXXX"
}
发消息需要以SlackBot的身份发送,需要根据teamId获取到SlackBot,获取bot的accessToken。什么时候能获取到SlackBot信息呢?在Slack用户安装App的时候,进行完OAuth认证,Slack Bot信息就会通过 OAuth 回调传给我们了
方法:GET至此你就可以在移动客服中和Slack中的用户聊天啦
地址:https://slack.com/api/chat.postMessage
请求参数:
token botAccessToken
channel 填写用户名中的channelId
text 消息内容
as_user 设置为true,就会以bot身份显示,而不是app
如果还需要获取User、Team的信息,可以使用Slack Api获取
如果需要的Api权限不够,则需要用户在授权的时候给予更多的权限
注意
- 一定要注意消息收发,一不小心会导致消息循环发送,形成死循环
- Slack的中Channel可以分为3中:Public Channel、Direct Message Channel、Private Channel,可以根据channel的第一个字母进行区分,C开头的是Public Channel,D开头的是Direct MessageChannel、G开头的是Private Channel
项目源码:https://github.com/sheepstarli/slack2easemobkefu 收起阅读 »
炸窝了,苹果禁止使用热更新
有开发者质疑可能是项目中使用了JSPatch、weex以及ReactNative等热更新技术。对于修复bug提交审核的开发者来说,热更新技术可以帮开发者避免长时间的审核等待以及多次被拒造成的成本开销。但也给黑客留了后门,也就违反了苹果的安全和隐私政策。
不过这次苹果只是对使用热更新的应用进行了警告,并没有开发者反应产品因此问题被下架。
对此,开发者表示:
舞小月:苹果注重的就是流畅性和用户体验,混编做的东西肯定没有native的流畅,这就违背了苹果本来的意愿,被禁也是正常的,而且苹果自己的蛋糕为何要分给竞争对手?以前没混编的时候你该怎么做不还是做了,现在没有,不代表以后没有,就像之前没有混编,后来有了混编。新的框架苹果自然也会去完善,苹果既然做了这个决定,他肯定会优化自己的东西。
Gilbertat:苹果爸爸会不会在自己的生态中搞死js啊
luohui8891:我们也是昨天收到的,目前没有什么对策。我们的APP只是用JSPatch做热修复,并不修改应用的功能行为等(但我觉得Apple并不care这个)。
lsllsllsl:没用RN没用JSPatch,同样收到警告。
luohui8891:@tcathy 根据邮件里说是你下次提交前请去掉这样远程下载代码运行的机制。所以应该就是下个版本如果不删除就reject
Loooren:早上收到邮件,itunesconnect站内信,电话通知....用到了weex
xiaofuyesnew:昨天晚上微软发布了Visual Studio 2017,自带基于React Native的iOS开发功能。鉴于微软这两年来开源的力度,发布这一功能似乎是在抢占开发者的市场,基于vs2017,在非苹果上开发ios应用更容易了。所以,苹果在这个节骨眼发出这个警告邮件,就有点威胁现有开发者的意思。暗地里想跟微软互怼。
对于那些已经在学习RN、weex、JSPatch的同学来说,这是个悲惨的故事
从苹果的角度看,禁止应用使用热更新技术更多是为了保护用户隐私、数据安全以及其全力打造的生态圈。对于用户来说,出于安全起见,应谨慎授予应用权限;对于开发者来说,为了审核以及长远的用户体验考虑,不要轻易触碰苹果拉的那条红线。
以上内容来源于CocoaChina,GitHub 收起阅读 »
Android ios V3.3.0 SDK 已发布,增加群组、聊天室管理员权限
Android V3.3.0 2017-03-07
新功能:
- 群组和聊天室改造:增加管理员权限,新增禁言,增减管理员的功能,支持使用分批的方式获取成员,禁言,管理员列表,支持完善的聊天室功能。新增加API请查看链接3.3.0 api修改
- 优化dns劫持时的处理
- 增加EMConversation.latestMessageFromOthers,表示收到对方的最后一条消息
- 增加EMClient.compressLogs,压缩log,Demo中增加通过邮件发送log的示例
- libs.without.audio继续支持armeabi,解决armeabi-v5te的支持问题
bug 修订:
- 修复2.x升级3.x消息未读数为0的bug
- Demo在视频通话时,主叫方铃声没有播放的问题
- Demo在视频通话时,主叫方在建立连接成功后,文字提示不正确
- Demo在聊天窗口界面,清空消息后,收到新的消息,返回会话列表,未读消息数显示不正确
- 修复在Oppo和Vivo手机上出现的JobService报错。
- EMGroupManager.createGroup成员列表数超过512产生的overflow错误
- 修复部分手机在网络切换时发消息慢的bug
ios V3.3.0 2017-03-07
新功能:
- 新增:群组改造,增加一系列新接口,具体查看iOS iOS 3.3.0 api修改
- 新增:获取SDK日志路径接口,将日志文件压缩成.gz文件,返回gz文件路径,[EMClient getLogFilesPath:]
- 更新:使用视频通话录制功能时,必须在开始通话之前调用[EMVideoRecorderPlugin initGlobalConfig]
优化:
- 优化DNS劫持时的处理
- 切换网络时,减小消息重发的等待时间
修复:
- 音视频通话丢包率(以前返回的是丢包数)
- IOS动态库用H264编码在iPhone6s上崩溃
- 实时音视频新旧版互通崩溃
版本历史:Android SDK更新日志 ios SDK更新日志
下载地址:SDK下载 收起阅读 »
环信完成C轮1.03亿元融资,深耕BI和AI层!
3月8日,企业级软件服务提供商环信今日对外宣布,完成C轮103000000元人民币融资,本轮融资由经纬领投,银泰嘉禾跟投。环信创始人、CEO刘俊彦表示:“本轮融资资金将用于环信BI和AI层的产品打磨、完善生态圈建设以及提升垂直行业解决方案能力。”
环信CEO宣布完成由经纬领投的C轮1.03亿元融资
1. 顶级风投持续看好,资本、客户规模、产品、大客户等核心维度均领先行业。
一直将环信视为中国企业级服务潜在“独角兽”公司的经纬中国合伙人左凌烨表示:“客服是企业服务软件的最大市场之一。随着用户体验和技术演进,对客服产品提出更多挑战,包括全渠道,实时性,移动化和AI辅助等等。环信作为该领域的绝对领先的创业公司,率先满足这些需求,推出并不断优化其一流产品。先后获得泰康,中信证券,中意人寿,国美在线等多家国内500强标杆客户,年收入保持250%以上的增长,取得骄人的市场认可和业务增长。经纬非常看好客服市场在中国的前景,并坚信环信将保持势头,成为该领域的领军企业。”
环信目前有三条产品线,包括环信即时通讯云、环信移动客服和环信人工智能。在2013年成立之初,环信推出PaaS通讯能力平台“环信即时通讯云”,用即时通讯“连接人与人”。2015年环信推出SaaS客服云产品“环信移动客服”,用即时通讯“连接人与商业”。随后,环信移动客服又拓展包括微信、微博、网页端、呼叫中心等全渠道客服接入能力,帮助企业和消费者多点接触。
移动互联时代,当用户能够在任何时间、任意地点,跨渠道、跨媒体、跨平台联系商家获得服务以后,客服请求必然激增。“我们希望能通过人工智能解决商家日益增长的客服成本和海量客服请求之间的天然矛盾,人工智能显然是绝佳的手段。”刘俊彦这样评价人工智能产品线。从最初的连接人与人,到连接人与商业,再到如今的人工智能,环信的主线关键词一直都是“连接”。
截至2016年底,环信即时通讯云共服务了130176家APP客户。环信移动客服共服务了58541家企业客户,包括泰康在线、中意人寿、中信证券、国美在线、优信二手车、新东方、新浪微博、链家、58到家、神州专车等。
2. SaaS公司四个竞争层面,深耕细作BI和AI层,深度连接人与商业。
环信CEO刘俊彦认为如果从核心竞争力的角度,可以将SaaS企业的发展划分为四个层面。分为工具层、BI层(数据层)、生态圈和AI层。SaaS产品在工具层面的竞争将越来越难以差异化。BI层(数据层)是将工作流里产生的数据和知识变成产品。一旦用户的个性化数据和知识也变成了产品的一部分,用户的迁移成本将变得更高,这时企业才开始有了自己独特的竞争壁垒。SaaS企业的终极竞争层面是AI层,这是一个大趋势。
对于产品的优势,刘俊彦表示环信已经做到了工具层面的领先,在第二个层面即BI层面,环信也推出了相应的产品,如环信客户声音。环信客户声音是基于人工智能和大数据挖掘的客户体验透析产品。Gartner联合环信发布的《下一代客户服务软件趋势》报告显示:“客户声音(VOC)是企业有关客户体验管理(CEM)战略需要考量的核心维度。全媒体客服的最佳体验不仅是多渠道的接入,更重要的是跨渠道环境下的用户体验保证。”环信认为理解客户声音是保证客户体验的最重要一环,环信客户声音运用NLP(自然语言解析),深度学习等人工智能技术,对来自多种渠道的非结构化数据源进行客服业务的特征提取,主题聚类解析,情感分析建模。从而帮助企业挖掘和分析客户服务中的热点话题,发现服务运营问题,寻找畅销或问题产品,洞察销售机会。
在第四层即AI层面,环信推出了环信智能客服机器人和环信智能质检。环信客服机器人基于机器学习技术和自然语言处理,辅助、替代人工客服回答常见、高频的问题,从而降低人力成本。环信智能质检则是基于环信在线客服积累的各个领域的海量用户对话,提取出数百个客服对话特征,并用这些特征训练得到的几十种常见通用质检模型,从而将质检从过去人工、抽样,转变为自动、全面的工作。刘俊彦认为:“软件正在吃掉世界,而AI正在吃掉软件。客户服务行业因为其劳动力密集,有海量数据等特点,是现阶段AI能够真实落地并能产生巨大价值的几个行业之一。”
环信在客户互动这一主线上,从连接消费者,到客服的效率工具,已经完成主要的布局。下一步是紧紧把握住客户服务全面转向移动端,以及对话经济(Conversation As A Commerce)的新趋势,产品继续深耕细作,深度连接人与商业,全力发展BI和AI,引导SaaS客服行业的发展进入到一个新的层面。
3. 完善生态圈,推出五大垂直行业智能客户互动解决方案,服务好大型客户。
SaaS企业的核心竞争力的第三层是生态圈,只有建立生态圈,SaaS企业才能真正筑起足够高的竞争门槛。Salesforce目前拥有上百家企业在其force.com平台上进行软件、插件的开发,已形成自己的生态,其他公司基本无法与之抗衡。比如要颠覆Salesforce,就不再只是颠覆掉Salesforce的产品本身,还要同时颠覆Salesforce生态中的几百家合作伙伴公司。
只有完善生态圈推出行业垂直解决方案才能更好的服务大客户。环信已经陆续推出了针对五大垂直行业的智能客户互动解决方案,同时大客户战略的执行也初见成效。2016年环信平均客单价已经达到6.2万左右,2017年将会持续提升,预计将进入10万到20万区间。同时,在金融、证券、银行、教育等行业均实现了大客户的重要突破,签约了众多500强行业标杆客户,标志着中国的新兴SaaS企业,开始和中科软,恒生电子这样的传统大型IT系统供应商同场竞技,获得了主流大型传统企业的认可。
4. 诗和远方是中国的Salesforce,这一代明星SaaS公司是下代中国SaaS创业者的天花板。
中国企业级软件服务市场和北美市场最大的不同就是,在北美除了Salesforce和Oracle等巨头外,大部分SaaS企业只能做巨头看不上的中小客户市场,上升空间天花板明显。中国企业级服务市场既没有本土巨头,又因为国外巨头企业服务公司进入中国有天然政策壁垒,可谓既无内忧也无外患。所以包括客服云、销售云、HR云、财务云、协同云等核心企业级服务赛道这几年都在野蛮生长,各赛道格局也已初定,成长起来了一批明星公司。
环信的诗和远方是中国的Salesforce,中国这一代各个赛道的领先SaaS企业因为没有既有的天花板,都有可能最终成为中国的Salesforce,这一批企业也将成为下一代中国SaaS创业者的天花板。 收起阅读 »
李理:详解卷积神经网络
作者:李理
目前就职于环信,即时通讯云平台和全媒体智能客服平台,在环信从事智能客服和智能机器人相关工作,致力于用深度学习来提高智能机器人的性能。
相关文章:
李理:从Image Caption Generation理解深度学习(part I)
李理:从Image Caption Generation理解深度学习(part II)
李理:从Image Caption Generation理解深度学习(part III)
李理:自动梯度求解 反向传播算法的另外一种视角
李理:自动梯度求解——cs231n的notes
李理:自动梯度求解——使用自动求导实现多层神经网络
接下来介绍一种非常重要的神经网络——卷积神经网络。这种神经网络在计算机视觉领域取得了重大的成功,而且在自然语言处理等其它领域也有很好的应用。深度学习受到大家的关注很大一个原因就是Alex等人实现的AlexNet(一种深度卷积神经网络)在LSVRC-2010 ImageNet这个比赛中取得了非常好的成绩。此后,卷积神经网络及其变种被广泛应用于各种图像相关任务。
这里主要参考了Neural Networks and Deep Learning和cs231n的课程来介绍CNN,两部分都会有理论和代码。前者会用theano来实现,而后者会使用我们前一部分介绍的自动梯度来实现。下面首先介绍Michael Nielsen的部分(其实主要是翻译,然后加一些我自己的理解)。
前面的话
如果读者自己尝试了上一部分的代码,调过3层和5层全连接的神经网络的参数,我们会发现神经网络的层数越多,参数(超参数)就越难调。但是如果参数调得好,深的网络的效果确实比较浅的好(这也是为什么我们要搞深度学习的原因)。所以深度学习有这样的说法:“三个 bound 不如一个 heuristic,三个 heuristic 不如一个trick”。以前搞机器学习就是feature engineering加调参,现在就剩下调参了。网络的结构,参数的初始化,learning_rate,迭代次数等等都会影响最终的结果。有兴趣的同学可以看看Michael Nielsen这个电子书的相应章节,cs231n的Github资源也有介绍,另外《Neural Networks: Tricks of the Trade》这本书,看名字就知道讲啥的了吧。
不过我们还是回到正题“卷积神经网络”吧。
CNN简介
在之前的章节我们使用了神经网络来解决手写数字识别(MNIST)的问题。我们使用了全连接的神经网络,也就是前一层的每一个神经元都会连接到后一层的每一个神经元,如果前一层有m个节点,后一层有n个,那么总共有m*n条边(连接)。连接方式如下图所示:
具体来讲,对于输入图片的每一个像素,我们把它的灰度值作为对应神经元的输入。对于28×28的图像来说,我们的网络有784个输入神经元。然后我们训练这个网络的weights和biases来使得它可以正确的预测对应的数字。
我们之前设计的神经网络工作的很好:在MNIST手写识别数据集上我们得到了超过98%的准确率。但是仔细想一想的话,使用全连接的网络来识别图像有一些奇怪。因为这样的网络结构没有考虑图像的空间结构。比如,它对于空间上很近或者很远的像素一样的对待。这些空间的概念【比如7字会出现某些像素在某个水平方向同时灰度值差不多,也就是上面的那一横】必须靠网络从训练数据中推测出来【但是如果训练数据不够而且图像没有做居中等归一化的话,如果训练数据的7的一横都出现在图像靠左的地方,而测试数据把7写到右下角,那么网络很可能学不到这样的特征】。那为什么我们不能设计一直网络结构考虑这些空间结构呢?这样的想法就是下面我们要讨论的CNN的思想。
这种神经网络利用了空间结构,因此非常适合用来做图片分类。这种结构训练也非常的快,因此也可以训练更“深”的网络。目前,图像识别大都使用深层的卷积神经网络及其变种。
卷积神经网络有3个基本的idea:局部感知域(Local Recpetive Field),权值共享和池化(Pooling)。下面我们来一个一个的介绍它们。
局部感知域
在前面图示的全连接的层里,输入是被描述成一列神经元。而在卷积网络里,我们把输入看成28×28方格的二维神经元,它的每一个神经元对应于图片在这个像素点的强度(灰度值),如下图所示:
和往常一样,我们把输入像素连接到隐藏层的神经元。但是我们这里不再把输入的每一个像素都连接到隐藏层的每一个神经元。与之不同,我们把很小的相临近的区域内的输入连接在一起。
更加具体的来讲,隐藏层的每一个神经元都会与输入层一个很小的区域(比如一个5×5的区域,也就是25个像素点)相连接。隐藏对于隐藏层的某一个神经元,连接如下图所示:
输入图像的这个区域叫做那个隐藏层神经元的局部感知域。这是输入像素的一个小窗口。每个连接都有一个可以学习的权重,此外还有一个bias。你可以把那个神经元想象成用来分析这个局部感知域的。
我们然后在整个输入图像上滑动这个局部感知域。对于每一个局部感知域,都有一个隐藏层的神经元与之对应。为了具体一点的展示,我们首先从最左上角的局部感知域开始:
然后我们向右滑动这个局部感知域:
以此类推,我们可以构建出第一个隐藏层。注意,如果我们的输入是28×28,并且使用5×5的局部关注域,那么隐藏层是24×24。因为我们只能向右和向下移动23个像素,再往下移动就会移出图像的边界了。【说明,后面我们会介绍padding和striding,从而让图像在经过这样一次卷积处理后尺寸可以不变小】
这里我们展示了一次向右/下移动一个像素。事实上,我们也可以使用一次移动不止一个像素【这个移动的值叫stride】。比如,我们可以一次向右/下移动两个像素。在这篇文章里,我们只使用stride为1来实验,但是请读者知道其他人可能会用不同的stride值。
共享权值
之前提到过每一个隐藏层的神经元有一个5×5的权值。这24×24个隐藏层对应的权值是相同的。也就是说,对于隐藏层的第j,k个神经元,输出如下:
σ(b+∑l=04∑m=04wl,maj+l,k+m)这里,σ是激活函数,可以是我们之前提到的sigmoid函数。b是共享的bias,Wl,m 是5×5的共享权值。ax,y 是输入在x,y的激活。
【从这个公式可以看出,权值是5×5的矩阵,不同的局部感知域使用这一个参数矩阵和bias】
这意味着这一个隐藏层的所有神经元都是检测同一个特征,只不过它们位于图片的不同位置而已。比如这组weights和bias是某个局部感知域学到的用来识别一个垂直的边。那么预测的时候不管这条边在哪个位置,它都会被某个对于的局部感知域检测到。更抽象一点,卷积网络能很好的适应图片的位置变化:把图片中的猫稍微移动一下位置,它仍然知道这是一只猫。
因为这个原因,我们有时把输入层到隐藏层的映射叫做特征映射(feature map)。我们把定义特征映射的权重叫做共享的权重(shared weights),bias叫做共享的bias(shared bais)。这组weights和bias定义了一个kernel或者filter。
上面描述的网络结构只能检测一种局部的特征。为了识别图片,我们需要更多的特征映射。隐藏一个完整的卷积神经网络会有很多不同的特征映射:
在上面的例子里,我们有3个特征映射。每个映射由一个5×5的weights和一个biase确定。因此这个网络能检测3种特征,不管这3个特征出现在图像的那个局部感知域里。
为了简化,上面之展示了3个特征映射。在实际使用的卷积神经网络中我们会使用非常多的特征映射。早期的一个卷积神经网络——LeNet-5,使用了6个特征映射,每一个都是5×5的局部感知域,来识别MNIST数字。因此上面的例子和LeNet-5很接近。后面我们开发的卷积层将使用20和40个特征映射。下面我们先看看模型学习到的一些特征:
这20个图片对应了20个不同的特征映射。每个映射是一个5×5的图像,对应于局部感知域的5×5个权重。颜色越白(浅)说明权值越小(一般都是负的),因此对应像素对于识别这个特征越不重要。颜色越深(黑)说明权值越大,对应的像素越重要。
那么我们可以从这些特征映射里得出什么结论呢?很显然这里包含了非随机的空间结构。这说明我们的网络学到了一些空间结构。但是,也很难说它具体学到了哪些特征。我们学到的不是一个 Gabor滤波器 的。事实上有很多研究工作尝试理解机器到底学到了什么样的特征。如果你感兴趣,可以参考Matthew Zeiler 和 Rob Fergus在2013年的论文 Visualizing and Understanding Convolutional Networks。。
共享权重和bias的一大好处是它极大的减少了网络的参数数量。对于每一个特征映射,我们只需要 25=5×5 个权重,再加一个bias。因此一个特征映射只有26个参数。如果我们有20个特征映射,那么只有20×26=520个参数。如果我们使用全连接的神经网络结构,假设隐藏层有30个神经元(这并不算很多),那么就有784*30个权重参数,再加上30个bias,总共有23,550个参数。换句话说,全连接的网络比卷积网络的参数多了40倍。
当然,我们不能直接比较两种网络的参数,因为这两种模型有本质的区别。但是,凭直觉,由于卷积网络有平移不变的特性,为了达到相同的效果,它也可能使用更少的参数。由于参数变少,卷积网络的训练速度也更快,从而相同的计算资源我们可以训练更深的网络。
“卷积”神经网络是因为公式(1)里的运算叫做“卷积运算”。更加具体一点,我们可以把公式(1)里的求和写成卷积:$a^1 = \sigma(b + w * a^0)$。*在这里不是乘法,而是卷积运算。这里不会讨论卷积的细节,所以读者如果不懂也不要担心,这里只不过是为了解释卷积神经网络这个名字的由来。【建议感兴趣的读者参考colah的博客文章 《Understanding Convolutions》】
池化(Pooling)
除了上面的卷积层,卷积神经网络也包括池化层(pooling layers)。池化层一般都直接放在卷积层后面池化层的目的是简化从卷积层输出的信息。
更具体一点,一个池化层把卷积层的输出作为其输入并且输出一个更紧凑(condensed)的特征映射。比如,池化层的每一个神经元都提取了之前那个卷积层的一个2×2区域的信息。更为具体的一个例子,一种非常常见的池化操作叫做Max-pooling。在Max-Pooling中,这个神经元选择2×2区域里激活值最大的值,如下图所示:
注意卷积层的输出是24×24的,而池化后是12×12的。
就像上面提到的,卷积层通常会有多个特征映射。我们会对每一个特征映射进行max-pooling操作。因此,如果一个卷积层有3个特征映射,那么卷积加max-pooling后就如下图所示:
我们可以把max-pooling看成神经网络关心某个特征在这个区域里是否出现。它忽略了这个特征出现的具体位置。直觉上看,如果某个特征出现了,那么这个特征相对于其它特征的精确位置是不重要的【精确位置不重要,但是大致的位置是重要的,比如识别一个猫,两只眼睛和鼻子有一个大致的相对位置关系,但是在一个2×2的小区域里稍微移动一下眼睛,应该不太影响我们识别一只猫,而且它还能解决图像拍摄角度变化,扭曲等问题】。而且一个很大的好处是池化可以减少特征的个数【2×2的max-pooling让特征的大小变为原来的1/4】,因此减少了之后层的参数个数。
Max-pooling不是唯一的池化方法。另外一种常见的是L2 Pooling。这种方法不是取2×2区域的最大值,而是2×2区域的每个值平方然后求和然后取平方根。虽然细节有所不同,但思路和max-pooling是类似的:L2 Pooling也是从卷积层压缩信息的一种方法。在实践中,两种方法都被广泛使用。有时人们也使用其它的池化方法。如果你真的想尝试不同的方法来提供性能,那么你可以使用validation数据来尝试不同池化方法然后选择最合适的方法。但是这里我们不在讨论这些细节。【Max-Pooling是用的最多的,甚至也有人认为Pooling并没有什么卵用。深度学习一个问题就是很多经验的tricks由于没有太多理论依据,只是因为最早的人用了,而且看起来效果不错(但可能换一个数据集就不一定了),所以后面的人也跟着用。但是过了没多久又被认为这个trick其实没啥用】
放到一起
现在我们可以把这3个idea放到一起来构建一个完整的卷积神经网络了。它和之前我们看到的结构类似,不过增加了一个有10个神经元的输出层,这个层的每个神经元对应于0-9直接的一个数字:
这个网络的输入的大小是28×28,每一个输入对于MNIST图像的一个像素。然后使用了3个特征映射,局部感知域的大小是5×5。这样得到3×24×24的输出。然后使用对每一个特征映射的输出应用2×2的max-pooling,得到3×12×12的输出。
最后一层是全连接的网络,3×12×12个神经元会连接到输出10个神经元中的每一个。这和之前介绍的全连接神经网络是一样的。
卷积结构和之前的全连接结构有很大的差别。但是整体的图景是类似的:一个神经网络有很多神经元,它们的行为有weights和biase确定。并且整体的目标也是类似的:使用训练数据来训练网络的weights和biases使得网络能够尽量好的识别图片。
和之前介绍的一样,这里我们仍然使用随机梯度下降来训练。不过反向传播算法有所不同。原因是之前bp算法的推导是基于全连接的神经网络。不过幸运的是求卷积和max-pooling的导数是非常简单的。如果你想了解细节,请自己推导。【这篇文章不会介绍CNN的梯度求解,后面实现使用的是theano,后面介绍CS231N的CNN是会介绍怎么自己来基于自动求导来求这个梯度,而且还会介绍高效的算法,感兴趣的读者请持续关注】
CNN实战
前面我们介绍了CNN的基本理论,但是没有讲怎么求梯度。这里的代码是用theano来自动求梯度的。我们可以暂时把cnn看出一个黑盒,试试用它来识别MNIST的数字。后面的文章会介绍theano以及怎么用theano实现CNN。
代码
首先得到代码: git clone
安装theano
参考这里 ;如果是ubuntu的系统,可以参考这里 ;如果您的机器有gpu,请安装好cuda以及让theano支持gpu。
默认的network3.py的第52行是 GPU = True,如果您的机器没有gpu,请把这一行改成GPU = False
baseline
首先我们实现一个baseline的系统,我们构建一个只有一个隐藏层的3层全连接网络,隐藏层100个神经元。我们训练时60个epoch,使用learning rate $\eta = 0.1$,batch大小是10,没有正则化:
$cd src
$ipython
>>> import network3
>>> from network3 import Network
>>> from network3 import ConvPoolLayer, FullyConnectedLayer, SoftmaxLayer
>>> training_data, validation_data, test_data = network3.load_data_shared()
>>> mini_batch_size = 10
>>> net = Network([
FullyConnectedLayer(n_in=784, n_out=100),
SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(training_data, 60, mini_batch_size, 0.1,
validation_data, test_data)
得到的分类准确率是97.8%。这是在test_data上的准确率,这个模型使用训练数据训练,并根据validation_data来选择当前最好的模型。使用validation数据来可以避免过拟合。读者运行时可能结果会有一些差异,因为模型的参数是随机初始化的。
改进版本1
我们首先在输入的后面增加一个卷积层。我们使用5 5的局部感知域,stride等于1,20个特征映射。然后接一个2 2的max-pooling层。之后接一个全连接的层,最后是softmax(仿射变换加softmax):
在这种网络结构中,我们可以认为卷积和池化层可以学会输入图片的局部的空间特征,而全连接的层整合全局的信息,学习出更抽象的特征。这是卷积神经网络的常见结构。
下面是代码:
>>> net = Network([【注意图片的大小,开始是(mini_batch_size, 1, 28 ,28),经过一个20个5 5的卷积池层后变成了(mini_batch_size, 20, 24,24),然后在经过2 2的max-pooling后变成了(mini_batch_size, 20, 12, 12),然后接全连接层的时候可以理解成把所以的特征映射展开,也就是20 12 12,所以FullyConnectedLayer的n_in是20 12 12】
ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28),
filter_shape=(20, 1, 5, 5),
poolsize=(2, 2)),
FullyConnectedLayer(n_in=20*12*12, n_out=100),
SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(training_data, 60, mini_batch_size, 0.1,
validation_data, test_data)
这个模型得到98.78%的准确率,这相对之前的97.8%是一个很大的提高。事实上我们的错误率减少了1/3,这是一个很大的提高。【准确率很高的时候就看错误率的减少,这样比较有成就感,哈哈】
如果要用gpu,可以把上面的命令保存到一个文件test.py,然后:
$THEANO_FLAGS=mode=FAST_RUN,device=gpu,floatX=float32 python test.py在这个网络结构中,我们吧卷积和池化层看出一个整体。这只是一种习惯。network3.py会把它们当成一个整体,每个卷积层后面都会跟一个池化层。但实际的一些卷积神经网络并不都要接池化层。
改进版本2
我们再加入第二个卷积-池化层。这个卷积层插入在第一个卷积层和全连接层中间。我们使用同样的5×5的局部感知域和2×2的max-pooling。代码如下:
>>> net = Network([【注意图片的大小,开始是(mini_batch_size, 1, 28 ,28),经过一个20个5 5的卷积池层后变成了(mini_batch_size, 20, 24,24),然后在经过2 2的max-pooling后变成了(mini_batch_size, 20, 12, 12)。然后是40个5*5的卷积层,变成了(mini_batch_size, 40, 8, 8),然后是max-pooling得到(mini_batch_size, 40, 4, 4)。然后是全连接的层】
ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28),
filter_shape=(20, 1, 5, 5),
poolsize=(2, 2)),
ConvPoolLayer(image_shape=(mini_batch_size, 20, 12, 12),
filter_shape=(40, 20, 5, 5),
poolsize=(2, 2)),
FullyConnectedLayer(n_in=40*4*4, n_out=100),
SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(training_data, 60, mini_batch_size, 0.1,
validation_data, test_data)
这个模型得到99.6%的准确率!
这里有两个很自然的问题。第一个是:加第二个卷积-池化层有什么意义呢?事实上,你可以认为第二个卷积层的输入是12*12的”图片“,它的”像素“代表某个局部特征。【比如你可以认为第一个卷积层识别眼睛鼻子,而第二个卷积层识别脸,不同生物的脸上面鼻子和眼睛的相对位置是有意义的】
这是个看起来不错的解释,那么第二个问题来了:第一个卷积层的输出是不同的20个不同的局部特征,因此第二个卷积层的输入是20 12 12。这就像我们输入了20个不同的”图片“,而不是一个”图片“。那第二个卷积层的神经元学到的是什么呢?【如果第一层的卷积网络能识别”眼睛“,”鼻子“,”耳朵“。那么第二层的”脸“就是2个眼睛,2个耳朵,1个鼻子,并且它们满足一定的空间约束。所以第二层的每一个神经元需要连接第一层的每一个输出,如果第二层只连接”眼睛“这个特征映射,那么只能学习出2个眼睛,3个眼睛这样的特征,那就没有什么用处了】
改进版本3
使用ReLU激活函数。ReLU的定义是:
ReLU(x)=max(0,x)
>>> from network3 import ReLU
>>> net = Network([
ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28),
filter_shape=(20, 1, 5, 5),
poolsize=(2, 2),
activation_fn=ReLU),
ConvPoolLayer(image_shape=(mini_batch_size, 20, 12, 12),
filter_shape=(40, 20, 5, 5),
poolsize=(2, 2),
activation_fn=ReLU),
FullyConnectedLayer(n_in=40*4*4, n_out=100, activation_fn=ReLU),
SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(training_data, 60, mini_batch_size, 0.03,
validation_data, test_data, lmbda=0.1)
使用ReLU后准确率从99.06%提高到99.23%。从作者的经验来看,ReLU总是要比sigmoid激活函数要好。
但为什么ReLU就比sigmoid或者tanh要好呢?目前并没有很好的理论介绍。ReLU只是在最近几年开始流行起来的。为什么流行的原因是经验:有一些人尝试了ReLU,然后在他们的任务里取得了比sigmoid好的结果,然后其他人也就跟风。理论上没有人证明ReLU是更好的激活函数。【所以说深度学习有很多tricks,可能某几年就流行起来了,但过几年又有人认为这些tricks没有意义。比如最早的pretraining,现在几乎没人用了。】
改进版本4
扩展数据。
深度学习非常依赖于数据。我们可以根据任务的特点”构造“新的数据。一种简单的方法是把训练数据里的数字进行一下平移,旋转等变换。虽然理论上卷积神经网络能学到与位置无关的特征,但如果训练数据里数字总是出现在固定的位置,实际的模型也不一定能学到。所以我们构造一些这样的数据效果会更好。
$ python expand_mnist.pyexpand_mnist.py这个脚本就会扩展数据。它只是简单的把图片向上下左右各移动了一个像素。扩展后训练数据从50000个变成了250000个。
接下来我们用扩展后的数据来训练模型:
>>> expanded_training_data, _, _ = network3.load_data_shared(这个模型的准确率是99.37%。扩展数据看起来非常trival,但是却极大的提高了识别准确率。
"../data/mnist_expanded.pkl.gz")
>>> net = Network([
ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28),
filter_shape=(20, 1, 5, 5),
poolsize=(2, 2),
activation_fn=ReLU),
ConvPoolLayer(image_shape=(mini_batch_size, 20, 12, 12),
filter_shape=(40, 20, 5, 5),
poolsize=(2, 2),
activation_fn=ReLU),
FullyConnectedLayer(n_in=40*4*4, n_out=100, activation_fn=ReLU),
SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(expanded_training_data, 60, mini_batch_size, 0.03,
validation_data, test_data, lmbda=0.1)
改进版本5
接下来还有改进的办法吗?我们的全连接层只有100个神经元,增加神经元有帮助吗? 作者尝试了300和1000个神经元的全连接层,得到了99.46%和99.43%的准确率。相对于99.37%并没有本质的提高。
那再加一个全连接的层有帮助吗?我们了尝试一下:
>>> net = Network([在第一个全连接的层之后有加了一个100个神经元的全连接层。得到的准确率是99.43%,把这一层的神经元个数从100增加到300个和1000个得到的准确率是99.48 %和99.47%。有一些提高但是也不明显。
ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28),
filter_shape=(20, 1, 5, 5),
poolsize=(2, 2),
activation_fn=ReLU),
ConvPoolLayer(image_shape=(mini_batch_size, 20, 12, 12),
filter_shape=(40, 20, 5, 5),
poolsize=(2, 2),
activation_fn=ReLU),
FullyConnectedLayer(n_in=40*4*4, n_out=100, activation_fn=ReLU),
FullyConnectedLayer(n_in=100, n_out=100, activation_fn=ReLU),
SoftmaxLayer(n_in=100, n_out=10)], mini_batch_size)
>>> net.SGD(expanded_training_data, 60, mini_batch_size, 0.03,
validation_data, test_data, lmbda=0.1)
为什么增加更多层提高不多呢,按说它的表达能力变强了,可能的原因是过拟合。那怎么解决过拟合呢?一种方法就是dropout。drop的详细解释请参考这里。简单来说,dropout就是在训练的时候随机的让一些神经元的激活“丢失”,这样网络就能学到更加鲁棒的特征,因为它要求某些神经元”失效“的情况下网络仍然能工作,因此就不会那么依赖某一些神经元,而是每个神经元都有贡献。
下面是在两个全连接层都加入50%的dropout:
>>> net = Network([使用dropout后,我们得到了99.60%的一个模型。
ConvPoolLayer(image_shape=(mini_batch_size, 1, 28, 28),
filter_shape=(20, 1, 5, 5),
poolsize=(2, 2),
activation_fn=ReLU),
ConvPoolLayer(image_shape=(mini_batch_size, 20, 12, 12),
filter_shape=(40, 20, 5, 5),
poolsize=(2, 2),
activation_fn=ReLU),
FullyConnectedLayer(
n_in=40*4*4, n_out=1000, activation_fn=ReLU, p_dropout=0.5),
FullyConnectedLayer(
n_in=1000, n_out=1000, activation_fn=ReLU, p_dropout=0.5),
SoftmaxLayer(n_in=1000, n_out=10, p_dropout=0.5)],
mini_batch_size)
>>> net.SGD(expanded_training_data, 40, mini_batch_size, 0.03,
validation_data, test_data)
这里有两点值得注意:
- 训练的epoch变成了40.因为dropout减少了过拟合,所以我们不需要60个epoch。
- 全连接层使用了1000个神经元。因为dropout会丢弃50%的神经元,所以从直觉来看1000个神经元也相当于只有500个。如果过用100个神经元感觉太少了点。作者经过验证发现有了dropout用1000个比300个的效果好。
改进版本6
ensemble多个神经网络。作者分别训练了5个神经网络,每一个都达到了99.6%的准确率,然后用它们来投票,得到了99.67%准确率的模型。
这是一个非常不错的模型了,10000个测试数据只有33个是错误的,我们把错误的图片都列举了出来:
图片的右上角是正确的分类,右下角是模型的分类。可以发现有些错误可能人也会犯,因为有些数字人也很难分清楚。
【为什么只对全连接的层使用dropout?】
如果读者仔细的阅读代码,你会发现我们只对全连接层进行了dropout,而卷积层没有。当然我们也可以对卷积层进行dropout。但是没有必要。因为卷积层本身就有防止过拟合的能力。原因是权值共享强制网络学到的特征是能够应用到任何位置的特征。这让它不太容易学习到特别局部的特征。因此也就没有必要对它进行的dropout了。
更进一步
感兴趣的读者可以参考这里,列举了MNIST数据集的最好结果以及对应的论文。目前最好的结果是99.79%
What’s Next?
接下来的文章会介绍theano,一个非常流行的深度学习框架,然后会讲解network3.py,也就是怎么用theano实现CNN。敬请关注。
收起阅读 »
两会专题报道:从北京到天津,他们改变了什么?看环信天津研发中心负责人赵贵斌的花样人生
年近40岁的北京人赵贵斌,去年10月带着组建环信天津研发中心的任务来到天津于家堡,和同事们一起,让已经在北京生根的环信延伸到天津。早在来天津之前,这名清华北大双料高材生就已经属于“外溢”的人才,曾跟随妻子在宁波工作过几年。他是跟随着工作走,也是跟随着自己的心走。 如今,环信正在快速地发展,招聘到更多的人才,是他面临的最大难题。
对于家堡这个地方,赵贵斌有着很高的评价。他和妻子居住在公寓,周围有购物中心、菜市场、高铁站,干什么都很方便。平时闲下来,赵贵斌还喜欢在河边散步、跑步。这周边人少,安静,不堵车,他说在这样的环境中,他能够很快地高效率地投入到工作,更能有许多独立思考问题的时间。
如今,赵贵斌的公司正在快速地发展,招聘到更多的人才,是他们面临的最大难题。赵贵斌希望,那些盲目地想要去“北漂”的年轻人,应该好好地算一笔账,说不定算明白之后就会发现,北京之外,还有更多更明智的选择。 (来自:腾讯大燕网·天津站) 收起阅读 »
环信移动客服v5.11发布——机器人渠道设置变更为路由规则“渠道指定”,管理员可以发通知给所有客服
【优化】历史会话支持查询会话转接情况
在“历史会话”页面可以查看会话是否经过转接,并且可以根据“是否转接”对会话进行筛选。
【优化】导出管理提供下载记录
在“导出管理”页面,可以查看每个导出文件的下载记录,包括客服名称、下载时间、IP地址。
管理员模式
机器人渠道设置变更为路由规则“渠道指定”
机器人渠道设置从“智能机器人 > 机器人设置”页面转移到“设置 > 会话分配规则”页面的路由规则“渠道指定”,并增加为渠道指定技能组。
数据迁移
版本更新后,原来的“机器人开关”中渠道设置将完全同步至“渠道指定”,工作时间设置变化为:
- “全天接会话”:对应“全天指定机器人”;
- “上班时间客服全忙以及下班时间接会话”和“仅下班时间接会话”:均对应“上班时间不指定”和“下班时间指定机器人”。
各路由规则说明如下:
- 渠道指定:为APP、网页、微信、微博这四种渠道分别指定机器人或技能组,支持全天指定和分上下班时间指定。
- 关联指定:为各个渠道内的关联分别指定机器人或技能组,支持全天指定和分上下班时间指定。
- 入口指定:通过网页和APP的访客端指定会话分配的技能组。
- 默认指定:将没有指定技能组的会话统一分配至未分组。
- 渠道指定、关联指定、入口指定这三种路由规则可以上下拖动,排在上方的路由规则优先级高;
- 当优先级高的路由规则指定机器人,优先级低的路由规则指定技能组,那么,机器人转人工时,分两种情况:
- 当优先级高的路由规则指定技能组,优先级低的路由规则指定机器人,那么,会话由指定的技能组接待,不会再转给机器人。
管理员可以发通知给所有客服
在管理员模式的“消息中心”页面,管理员可以向客服团队成员发布通知,内容可以是文字或附件,通知将展示在收件人的消息中心。
在“消息中心”页面,点击“发送新通知”,从右侧选择客服同事,并填写主题、内容或添加附件,点击“发送通知”,向收件人发送一条通知。
新增技术支持模块
移动客服系统新增“技术支持”页面,提供查看文档、常见问题的快捷入口,网络检测功能,以及联系环信官网客服的按钮。
【优化】机器人菜单增加“返回上一级”选项
支持创建4级机器人菜单,第2-4级菜单增加“返回上一级”选项,优化机器人接待时的用户体验。
【优化】增加开关控制仅机器人接待会话时是否自动发送满意度评价邀请
在“设置 > 系统开关”页面,当“会话结束自动发送满意度评价邀请”开关打开时,可以进一步选择仅机器人接待会话时,是否自动发送满意度评价邀请。
【优化】客户资料自定义中系统字段默认打开且不可关闭
在客户资料自定义页面,系统字段默认打开且不可关闭,避免误操作。原本处于关闭状态的系统字段会自动打开,并显示在“资料”页签。
说明:仍然可以手动控制自定义字段的“字段开关”。
【优化】客户中心导出文件包含“自定义字段”
在“客户中心”页面,点击“导出基本资料”按钮,可以导出客户的基本资料,导出文件中包含在“设置 > 客户资料自定义”页面添加并打开的自定义字段。
【优化】模糊搜索支持导出会话
在“搜索”页面,对会话进行搜索后,可以点击“导出”按钮,导出搜索结果。
生成导出文件后,请前往“导出管理”页面下载。
【优化】历史会话支持查询会话转接情况
在“历史会话”页面可以查看会话是否经过转接,并且可以根据“是否转接”对会话进行筛选。
管理员模式下,可以在会话详情页查看详细的转接记录。
【优化】导出管理提供下载记录
在“导出管理”页面,可以查看每个导出文件的下载记录,包括客服名称、下载时间、IP地址,以备企业内部安全审核。
Android客服工作台
当前版本:V2.7
新增客服可以发送所有类型的文件。
关于更多Android客服工作台的更新日志,请查看Android 客服工作台 更新日志。
PC客服工作台
当前版本:V2.0.2017.02150
新增下载导出功能,支持在“导出管理”页面下载导出文件。并且,修复消息提示音,支持播放语音消息。
关于更多PC客服工作台的更新日志,请查看PC 客服工作台 更新日志。
移动客服Android SDK
当前版本:V1.0.4
移动客服Android SDK支持对留言评论进行翻页查询。
关于移动客服Android SDK的集成说明,请查看移动客服 Android SDK 集成。
移动客服iOS SDK
当前版本:V1.0.0
移动客服iOS SDK发布!该iOS SDK基于IM SDK 3.x,登录、发消息速度更快。提供内置会话相关UI,集成后可立即给移动客服发送文本、语音、图片、文件消息。
支持双通道:已集成双通道功能,确保不丢消息;
极简集成:集成移动客服通用功能,只需5分钟。
关于移动客服iOS SDK的集成说明,请查看移动客服 iOS SDK 集成。
环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.11
环信移动客服登陆地址http://kefu.easemob.com/ 收起阅读 »
产品同质化?SaaS制胜之道本就不在工具层!
环信从2013年成立,从一开始的“即时通讯云”到移动客服,再到入选“Gartner 2016Cool Vendor”,环信的成绩着实令人惊诧。直到上周五,牛透社获取了来自Gartner和环信联合发布的《下一代客户服务软件趋势》报告,我们开始重新审视客服领域,重新审视带给我们诸多震撼的环信。
连接:从即时通讯云到人工智能,让连接价值更深入
“目前的环信,其实有三条产品线,分别是:即时通讯云、移动客服、人工智能。”当我问及环信当下的产品结构时,刘俊彦给出了这样的答案。
环信在2013年成立时,开始做即时通讯云,让企业的APP拥有像微信一样的聊天沟通能力,这个场景是“连接人和人”。
但当产品上线后,他们很快发现,IM其实天然的还适用在第二个场景——“连接人和商业”。最典型的例子就是淘宝旺旺。旺旺作为一款聊天工具,很好地连接了消费者和企业客服,这样的场景,用户黏性会更强,在商业上的价值也更高。这个场景离企业更近,也更适合商业化,环信移动客服应运而生,其核心就是用即时通讯工具连接人和商业。随后,环信又推出了全媒体客服,让用户可以通过各种通道联系到企业。
实际上,牛透社此前对于环信的印象就止步于此了,看起来,环信似乎就是做了这两件事情,并凭此声名大噪。但刘俊彦说,环信还有第三条产品线——环信人工智能。
当环信在做移动客服的时候,发现他们在一开始构建的“蓝图”其实还不够完美,有个最大的问题——当你真的将工具做得很出色,让任何一个消费者在任何地点都能很容易地与企业聊天沟通的时候,所有的压力和责任就会转移到商家身上。在手机时代,每个人都能24小时联系到商家,但商家的客服人员其实是有限的,用户需求的激增难以满足,所以回过头来看,这样的客服工具并没能真的提升用户体验,反而让商家倍感压力。
“我们希望能通过人工智能解决商家日益高涨的客服成本和用户需求不断增长的天然矛盾,人工智能显然是绝佳的手段。”刘俊彦这样评价道人工智能产品线。
环信创始人兼CEO 刘俊彦
从一开始的IM试图连接人和人,再到连接人和商业,打造了一款完整的客服产品,到如今的人工智能,环信的主线其实并没有发生任何变化——连接。以刘俊彦的话来说,环信的基因就是连接与对话,环信希望用对话的方式将人和人、人和商业连接在一起,这是环信公司的主线或者说愿景。
引领:结合“土壤”引领创新,缔造移动客服时代
牛透社早在去年7月份就得知了环信入选“Gartner 2016 Cool Vendor”,但仅通过过往的一些通稿并不能了解太多讯息,直到近期环信与Gartner联合发布的《下一代客户服务软件趋势》报告出炉,坐在环信的会议室里,和刘俊彦面对面交流,我们才从行业的角度,看到了一个更真实的环信。
“Gartner每年5月的Cool Vendor评选,都是从四五十家被不同分析师所提企业中筛选出的寥寥几家上榜企业,环信到底‘酷’在哪,能成为中国唯一入选的SaaS客服企业?”我抛出了牛透社对此最想了解的问题。
“在我看来,有两点非常关键——环信的创新和我们所扎根的‘土壤’。创新让我们始终走在行业前列,而‘土壤’则带来了更多契机。”刘俊彦这样答道,“Gartner几年前就作出预测,发生在移动端上的客户服务在未来将达到什么规模,在市场上占据多少比例,但实际的数据始终落后于预测,于是不断修正数据。从北美市场来看,不论是移动互联网的发展还是基于IM的商业文化,都还不足以支撑Gartner所预测的数据。但纵观全球,Gartner发现中国的市场做到了——不论是其预测的移动端客服的快速兴起还是客服机器人在客户服务中的大规模应用,美国都没能实现,但中国都实现了。而且Gartner发现,在中国引领世界的这一波客户服务的创新浪潮背后,主要的推动企业是一家叫环信的公司。”
环信达到了Gartner几年前预测的行业数据,做到了全球诸多企业都没能做到的事情,听起来有些不可思议,但Gartner以其报告严谨性享誉全球,这又是毋庸置疑的。
环信之所以能有这样的成绩,也正如刘俊彦所说:一方面是来源于环信不断的创新,这让环信始终保持着足够的敏锐度;另一方面,中国有着很独特的“土壤”,移动互联网的发展非常迅速,这是其它国家,包括美国在内都不能及的,而基于IM的商业文化、社交文化,也让环信移动客服获得了快速发展。
挑战:解析用户之难,让服务能力不止于工具
“当下的国内企业客服部门面临三大挑战:
一是移动化的挑战。过去的消费者都是通过电话、网页的方式联系客服,而现在则更多地转移到了微信公众号、APP上,许多企业的IT架构难以适应移动时代。
二是来自服务体验的挑战。过去以电话呼叫中心为主的客服部门,通常采用录音和抽样质检方式来监控服务质量。当用户服务请求激增,且服务请求来自微信、APP、网页、电话等多个渠道,每个渠道的数据格式都不一样,都是非结构化数据,且数据量极大,企业就不能像过去一样做抽样人工质检,也就失去了对服务体验最基本的监测。
三是客服人力成本与用户量激增的天然矛盾。当用户量越来越大,如何匹配足够的客服资源,这让许多公司头疼。”
当问及国内企业客服部门所面临的挑战,刘俊彦给出了这样的答案。
对于环信而言,专注在移动端即时通讯领域已经许久,所谓移动化的挑战,自然并不难应对。刘俊彦也表示,环信的全渠道客服,其移动端体验是颠覆性的,用户的痛点问题能得到很好的解决。
但他显然并不认为这是多末大的优势,在他看来,所谓全渠道客服,只是一个工具,一个相对专业的团队,有足够的资金,两年时间,基本都能将工具属性的全渠道客服做得很不错。
刘俊彦将客服领域的竞争分为三个层面:工具层面、BI层面、AI层面。
工具层面
在工具层面,诸多厂商的差距在变小,从表面来看,产品趋于同质化。
BI和AI层面
在这两个层面,是要通过数据分析和人工智能的能力,提供更好的用户体验。
全媒体客服的最佳体验不仅只是多渠道的接入,更重要的是用户跨渠道的体验和跟踪,在海量的数据中发现问题。而要做到这一点,企业首先需要理解客户到底体验到了什么。倾听客户声音的能力决定了他们在客户体验这个领域上的竞争力。
Gartner报告也指出:“VOC(客户声音)是企业有关客户体验管理(CEM)战略需要考量的核心维度。CEM是个很大的话题,覆盖了企业交付給用户的客户体验的方方面面,是未来五年全球CEO所关注的排名前三的重点领域之一。”
正是基于此,环信推出了“环信客户声音”——一款基于人工智能和大数据挖掘的客户体验透析产品。通过对多渠道的非结构化数据源进行客服业务的特征提取,发现服务运营的问题。通俗地来说,就是可以将系统中每天产生的数十万会话都转化成文本分析,主题关键词热度越高,说明用户关注度越高,加上对关键词做情感分析,了解用户对于某件事所带有的情绪。由此,企业就可以优先解决用户最关心、最影响体验的问题。
突围:纵观SaaS竞争格局,生态圈方为制胜之道
将目光聚焦在整个SaaS软件领域,刘俊彦认为,存在四个层面的竞争:
工具层竞争
所有SaaS软件的第一个竞争层面都是工具层,在工作流层面。也就说你做一个SaaS软件,主要是帮助企业实现它的工作流,比如客服的工作流,比如销售团队管理的工作流。目前大部分中国的SaaS企业公司都从工具开始起家。但在美国这个充分竞争的市场上,你只做第一个层面的SaaS企业,基本是没人会投资的。中国的SaaS行业还不像美国那样成熟,在这个层面还存在一些机会。而具体到客服领域来看,这一层面的竞争已经快结束了,开始进入第二层面的竞争。
数据层竞争
在这一层面,是数据的竞争,或者说知识的竞争。工具层面拉不开差距,而工作流中沉淀了大量数据,要把这些数据变为产品。
像环信现在做BI,竞争中我们不是比较谁的报表数量多,而是比较是否有探索式BI自定义报表的能力。想象一下,如果一个用户,在你的平台上自己生成了很多自定义的报表和BI数据,那么他就很难迁移走了。一旦用户的个性化数据和知识变成了产品的一部分,这将让用户的迁移成本变得更高,这时企业间的竞争才开始有了自己独特的壁垒。但中国目前能够做到这个层面的SaaS公司不多,因为第一层面的工具竞争还没有结束。
生态圈的竞争
最典型的例子就是Salesforce。Salesforce的成功并不仅仅是因为其产品很出色,还在于它的生态圈很完善。Salesforce目前拥有上百家企业在其force.com平台上进行软件、插件的开发,已形成自己的生态,其他公司基本无法与之抗衡。比如美国有一家著名的生命科技公司,viva,目前市值是17亿美金,但他自己没有底层平台,他把底层平台搭建在Salesforce上并基于此开发自己使用的软件。当你要颠覆Salesforce,就不再只是颠覆掉Salesforce的产品本身,还要同时颠覆Salesforce生态中的几百家合作伙伴公司。
AI层竞争
“SaaS企业的终极竞争层面是AI层,这是一个大趋势。环信的智能客服机器人、客户声音、环信智能质检,都是AI团队打造出来的。所有的数据、业务流程最终都以AI形式展现出来,这是最终决定所有SaaS公司生死的核心关键。”刘俊彦坚定地说到,“环信目前处于第二层到第三层的竞争阶段,对于第四层的AI也一直在努力。”
还是以客服行业为例子。客服行业在10年前,只有呼叫中心这一种形式。呼叫中心最初是解决基本沟通的问题,让消费者能找到我。所以第一阶段是解决沟通与通信问题,即通讯设备厂商阶段,出现Avaya、中兴、华为等销售通讯设备的企业;第二阶段是如何管理客服人员,促发了一批以提供管理和效率工具软件为主的客服企业。这阶段的主要挑战是如何使人像机器一样高效标准;第三阶段是机器替代人,由于不断提高的人力成本和不断增加的客户请求之间的不可调和的矛盾,我们只能用AI来代替人。这阶段的主要挑战是如何让机器像人一样智能、灵活。
“您认为中国SaaS市场会不会出现Salesforce这样的巨头企业?”临走前我问到。
“其实在中国企业服务的各主要赛道已经出现巨头了,格局也相对清晰,如果这些领头羊不出现什么重大失误,相信能一直居于前列。而当大家将第三层生态圈做好之后,或许就会是中国的Salesforce。”
收起阅读 »
一家SaaS客服企业要做AI,环信打的是什么算盘?
从SaaStr回来后,环信以CEO刘俊彦的口吻,连续对外做了几次观点发声,关于SaaS创业的9种正确姿势、AI正在吃掉软件……其中有些观点引起了笔者的兴趣,笔者也查找梳理了这家近两年在移动客服领域风生水起的创业企业的资料。全媒体客服、客服移动化、智能化、营销化是其突出的特点,然而在这一次与刘俊彦的采访中,他更想强调的是环信在数据层面的战略优势以及对AI的认知和布局。
笔者将环信所发出的观点和这次采访做了一些结合,以展示环信CEO刘俊彦以及环信对SaaS和自身发展的看法、布局。
从行业焦点看SaaS发展三大阶段
“在中国,不知道是不是因为SaaS企业有准确的数学模型,可以用一大串公式表达,直接戳中了资本的甜点,反正在过去1,2年的资本寒冬里,SaaS企业已经成为了很多资本寻求低风险高质量投资标的的热门选择。”这是一篇文章中提到的一句话,实质上也确实是前两年,尤其是2015下半年、2016上半年的SaaS市场行业状况,无论是哪个领域,无论是CEO、COO等CXO,还是市场经理、销售业务员等相对基层的员工,都会时不时拽出“CAC”“LTV”等高大上的词汇。
然而刘俊彦认为,这种现象正在逐渐削弱。他将近几年这一波SaaS的发展分为三个阶段,第一个是野蛮生长的阶段,SaaS浪潮涌起后,大波创业者入海,各自发展、野蛮生长;第二个是经典SaaS理论大行其道的阶段,CAC、LTV、续约率、客单价等等。美国SaaS企业已经发展了10多年,形成了一整套完整的理论体系,这为国内野蛮生长的市场打开了一扇经验之门,行业逐渐褪去虚热,开始转向理性阶段;然而,从去年下半年开始,很多SaaS创业公司开始发现美国经典SaaS理论并不完全适用,很多企业开始明确做大客户的思路,这就进入了SaaS发展的第三个阶段。
中国企业服务市场和北美市场存在很大的不同:在北美,除了Salesforce和Oracle等巨头外,大部分SaaS企业只能做巨头看不上的中小客户市场;而在中国,企业服务的6个核心赛道,客服云、市场云、销售云、HR云、财务云、协同云,都没有历史巨头。这就意味着中国的这一批SaaS企业都有可能成长为各自赛道上的巨头,都有机会做大客户。针对大客户的SaaS运营体系和针对中小企业的体系是不太一样的,而大家目前讨论比较多的经典SaaS理论体系主要针对中小客户,原因也很简单:在美国,绝大部分SaaS企业都是在做中小客户,愿意出来分享的也是这部分SaaS企业,而Oracle、Salesforce这样的巨头通常是不出来分享经验的。
从核心竞争力看SaaS的四级阶梯
如果从核心竞争力的角度,可以将SaaS企业的发展划分为四个阶段。
第一阶段的重点在工具层面,所有的产品都是为解决工作流的问题而开发的工具。工具的核心在于技术,技术本身并不是不可突破的壁垒,那么如果仅仅局限于工具层面,就很难让竞争产生差异化。
第二阶段的重点在数据层面,将工作流里产生的数据和知识变成产品。比如企业要做定制BI,数据源的挖掘聚合、数据清洗、数据的视图展现,这些部分往往要根据企业需求来进行定制。一旦用户的个性化数据和知识也变成了产品的一部分,用户的迁移成本将变得更高,这时企业才开始有了自己独特的竞争壁垒。
第三阶段的重点是生态圈的建设。只有建立生态圈,SaaS企业才能真正筑起足够高的竞争门槛。比如要颠覆Salesforce,就不再只是颠覆掉Salesforce的产品本身,还要同时颠覆Salesforce生态中的几百家合作伙伴公司。
第四阶段的重点是AI,严格来讲这是刘俊彦个人对SaaS未来技术发展方向的看法。“个人觉得SaaS的终极竞争在AI”,正如刘俊彦在谈论AI的文章中提到的,“AI正在吃掉软件,也正在深刻的影响着SaaS客服行业,在客服领域AI正逐渐发挥着重要的作用,有望成为一股颠覆性的力量从而被整个行业寄予厚望”。
AI很可能彻底颠覆SaaS客服软件
为什么这么说?刘俊彦以SaaS客服为例子,说明了为什么AI可能会彻底颠覆现在的SaaS客服软件。简单来讲,现在市场上所有SaaS客服软件的核心功能都是把一个服务请求按特定的规则分配给客服,然后给客服提供一个好用的效率工具,并提供各种报表来考核和管理客服的绩效。进入到智能客服机器人时代后,一个机器人可以秒级处理上百万的服务请求,所以不需要分配。机器人也不需要管理和发工资,所以也不再需要各种绩效管理和报表。那么目前市场上的这些传统SaaS客服软件还有存在的意义吗?
“当然,完全用客服机器人代替人,技术还不成熟,还需要5到10年时间,所以环信做SaaS客服软件,一二三层的能力还是要持续加强的”,刘俊彦补充到。基于这种思考和AI的发展趋势,刘俊彦将AI提到了环信的发展战略层面,并很早组建了AI团队。
进击二三四级技术力量,环信要做下一个Salesforce
回到环信,客服移动化、全媒体客服、客服智能化、客服营销化等,是环信移动客服的特点。对于产品的优势,刘俊彦表示环信已经做到了工具层面的领先,在第二个层面即数据层面,环信也推出了相应的数据产品,如环信客户声音。
环信客户声音是基于人工智能和大数据挖掘的客户体验透析产品。全媒体客服的最佳体验不仅是多渠道的接入,更重要的是跨渠道环境下,如何保证用户体验。环信认为,理解客户声音是保证客户体验的最重要一环。环信客户声音通过NLP(自然语言解析)、主题聚类、情感分析等技术手段,对来自多个渠道的非结构化文本数据进行挖掘和分析热点话题,发现服务运营问题,寻找畅销或者问题产品,洞察销售机会。
在第四层即AI层面,环信推出了环信智能客服机器人和环信智能质检。
环信客服机器人是环信基于自然语言处理和机器学习技术所推出的产品,其主要功能是辅助、替代人工客服回答常见、高频的问题,从而降低人力成本。
环信智能质检则是基于环信在线客服积累的各个领域的海量用户对话,提取出数百个客服对话特征,并用这些特征训练得到的几十种常见通用质检模型,从而将质检从过去人工、抽样,转变为自动、全面的工作。
令刘俊彦感到振奋的是,Gartner对于下一代客户服务软件的趋势预测和环信的实践是完全吻合的。Gartner报告指出“消费者对移动设备的偏好正在快速发展,到2019年,移动设备的使用将占到所有互联网交互的85%,如果不能改善移动客户服务,企业将遭受损失。” 。Gartner报告还指出,“VOC(客户声音)是企业有关客户体验管理(CEM)战略需要考量的核心维度。CEM是未来五年全球CEO所关注的排名前三的重点领域之一。”
应该说,这几条预测都在环信身上得到了有效地验证。相比于北美市场,中国有着很独特的“土壤”,移动互联网的发展非常迅速,这是包括美国在内的其它国家都不能及的,而基于IM的商业文化、社交文化,也让环信移动客服获得了快速发展。
2017年,环信的重点是加大二三四层核心竞争力的建设:将数据产品做得更好,在生态圈建设方面继续建设自身的PaaS平台,并在AI层面加大投入。
总的来说,刘俊彦认为客服是中国企业级服务市场六大核心赛道——客服云、市场云、销售云、HR云、财务云、协同云之一,环信希望能够在这一赛道上深耕细作并筑起足够高的竞争壁垒,成为像Salesforce一样的SaaS企业巨头。 收起阅读 »
Gartner联合环信发布《下一代客户服务软件趋势报告》
经过数十年的沉淀,客户中心正从话音呼叫中心、网页端客服向全媒体架构的统一客服平台升级,经过2015年的启动期,2017年全媒体客户中心将进入高速发展期,同时以环信为首的一批中国SaaS客服企业取得了井喷发展。在北美,2015年客服软件市场采购总额高达96亿美元,这个市场中孕育了多家备受业界关注和追捧的公司,如Salesforce和Zendesk。
作为中国全媒体智能客服的倡领者,环信CEO刘俊彦认为:“未来以移动端为核心的全媒体接入、跨媒体、跨渠道、跨部门的客户服务体验,以及智能客服机器人将成为下一代客服软件的三大核心驱动力。”客户中心经过多年的发展,从单一的语音服务渠道进化成为多介质的全媒体服务渠道,并最终将发展成整合传播服务、营销、销售和产品用户体验为一体的互动中心。未来客户中心将以“体验”为核心视角,描绘一副全媒体接入、人工智能驱动、大数据升华,参与企业全要素、全流程运作的服务蓝图。
Gartner 认为技术创新有时领先于客户需求,有时滞后于客户需求。在本报告中,我们采用步调分层这一方法从以下三个层次对现有和新兴技术要求做了分析:记录系统、创新系统、差异化系统。对于客户服务,我们划分了下列层次:
核心问题是:如何将步调层次中的技术转化为承载客户服务和支持的 IT 项目。需要着力解决的核心问题是组建合适的团队以开展企业级客户服务和支持。团队至少需要包括:
IT 架构师和应用设计师——配合核心客户服务。
一位业务主管——代表下列客户旅程: 从潜在客户显现需求至他们成为既有客 户,乃至增销和续订。
数据和分析专家——提出可用的分析方 案,说明如何创建相关的控制面板和基 准。他们了解如何捕捉有关客户服务和 支持的成功标准,并提高相关的分析成 熟度。
营销专业人士——分享他们在对业务的目 标新客户群体进行旅程分析和行为观察 方面所取得的经验。
我们提供基于客户交互的共同路线图。每个公司都应该使用相关的步调层次工具包来创建自己的三至五年路线图,以便从当前过渡到未来状态。虽然工具包并不产生路线图,但它确实可以为制定 IT 计划奠定良好的基础。
01·最佳实践:全媒体客服核心在于移动端接入,移动端客服最佳体验是基于IM
移动互联时代,客户正转移至移动端,服务需要紧跟客户步伐。Gartner报告指出:“消费者对移动设备的偏好正在快速发展,对于一些行业而言,到2019年,移动设备的使用将占到所有互联网交互的85%,如果不能改善移动客户服务,企业将遭受损失。”
因为技术门槛高,目前仅有部分大型企业能够在移动APP上提供端到端的、完整的客户服务支持能力,但是中小型企业的部署热情高涨。同时,在社交媒体上(如Facebook、微博、微信等)入驻的企业都已经开始在平台上提供客户服务能力,相比传统的网页客服和呼叫中心,社交媒体客服更是得到年轻用户的青睐。包括移动APP内置客服、社交媒体客服、网页客服/HTML5客服、传统呼叫中心等接入的全媒体客服已是大势所趋,而全媒体接入的核心在于移动端接入。
1、全媒体客服主流接入渠道特性
当前国内的主要接入渠道包括移动APP内置客服、网页客服/HTML5客服、社交媒体客服(微信、微博)和呼叫中心。由上表可见除开移动APP内置客服以外,其余三个主流的接入门槛较低,技术标准化且成熟,核心难点在于移动端接入。
2、支持移动APP内置客户服务的关键技术和最佳实践
2.1 移动APP内置客服帮助企业在移动端保持了品牌和服务的一致性
在移动APP中内置客户服务,使消费者不需要跳出APP就可以及时得到客户服务支持,而不再需要去寻求第三方比如呼叫中心等传统客服方式。这很好的解决了很多APP运营者,对消费者跳出APP后,可能不再返回APP的忧虑,同时企业保持了品牌和服务的一致性。
2.2 移动APP内置客服的最佳体验是基于IM(即时通讯)
随着IM(即时通讯)类APP如Whatsapp, 微信等在手机上的流行,IM已经被证明是在移动终端上最适合连接人与人的沟通方式。在客服领域,以环信为代表的一批移动APP内置客服技术提供商的成功,也证明了IM同样是移动终端上最适合连接人与服务的沟通方式。将IM方式用于消费者与客服人员沟通有几大优势:
支持富媒体消息,表现能力强。比如消费者可以发送位置,图片,订单消息等类型消息。这种类型的富媒体消息,往往很难通过电话描述。
IM沟通是典型的异步沟通方式。对客服坐席来说,使用IM,可以和最多几十个消费者同时沟通,相比电话这种传统的一对一同步沟通方式,效率有极大的提高。与此同时,对于消费者来说,使用IM沟通,更符合手机碎片化使用的特点。
使用IM客服,只要用户不卸载APP,即使用户离开APP,甚至杀死APP,客服也随时可以将消息以推送方式通知到手机。用户绝不会错过任何有价值的消息。
示例:国美在线APP通过环信提供的APP内置客服很好的服务了上亿用户。
2.3 移动APP内置IM(即时通讯)客服技术选型建议
附录:Gartner研究——移动端客户服务
02·跨渠道环境下的客户服务体验是客服行业面对全媒体客服新趋势的主要挑战
全媒体客服不仅只是多渠道的接入,更重要的是用户跨渠道的体验和跟踪,在海量的数据中发现问题。而要做到这一点,企业首先需要理解客户到底体验到了什么。
今天,全球来看,越来越多的企业正在通过构建一个有效的客户声音系统,来透析客户对企业产品和服务的准确体验,帮助企业识别和改善客户旅程的各个阶段。对企业而言,倾听客户声音的能力决定了他们在客户体验这个领域上的竞争力。
Gartner报告指出:“VOC是企业有关客户体验管理(CEM)战略需要考量的核心维度。CEM是个很大的话题,覆盖了企业交付給用户的客户体验的方方面面,是未来五年全球CEO所关注的排名前三的重点领域之一。”
附录:Gartner研究——客户声音
03·智能客服机器人是下一代客服的核心驱动力
随着全媒体客服的普及和广泛应用导致企业和消费者多点接触,同时用户体验得到了企业的重视,导致客服咨询量暴增,企业有限的客服人力资源与日益增加的客服请求之间的矛盾日益尖锐,如何用有限的客服资源服务不断增长的海量客服请求需要一个颠覆型的技术来解决。相比人工客服,智能客服机器人将提供极大的效率优势。
Gartner报告指出智能客服机器人(VCA-virtual customer assistance)的使用正处于临界点。大幅改进的自然语言处理技术,以聊天为中心的移动渠道与客户互动的应用,以及客户对机器人技术的接受程度,这些因素使得人们对VCA的兴趣越來越大。
从被动的被人类编程出来的,可以在结构化和非结构化内容库中找到问题答案的虚拟助手,到主动的有时候是机器学习得到的VCA的转变,其考察个人的特征并代表他们行动。虚拟助手正在经历从被动的被人类编程出来,在结构化和非结构化内容库中找到问题答案,到主动的通过机器学习能够理解用户个性化的需求,并且随之采取灵活应对行为的转变。
附录:Gartner研究——虚拟客户助手
环信移动客服——全媒体智能云客服倡领者,于2016年荣膺“Gartner 2016 Cool Vendor”。环信支持全媒体接入,包括网页在线客服、社交媒体客服(微博、微信)、APP内置客服和呼叫中心等多种渠道均可一键接入。基于环信业界领先的IM长连接技术保证消息必达,并通过智能客服机器人技术降低人工客服工作量。同时,基于人工智能和大数据挖掘的客户旅程透析产品"环信客户声音"能够帮助企业优化运营,提高跨渠道客服体验。典型用户包括国美在线、58到家、楚楚街、海尔、神州专车、新东方、链家、泰康在线、中信证券等众多标杆企业。如需阅读Gartner报告全文,请点击“阅读原文”查看详情。 收起阅读 »
【公告】原有的历史消息拉取接口会按照计划在3.1日正式下线
原有的历史消息拉取接口会按照计划在3.1日正式下线(具体可参照2016年12月28日发送的相关通知),3.1日后如需要拉取历史记录可以使用历史消息文件下载接口。通过后台扫描发现还有一部分用户在使用原有即将下线的接口,为了不影响您的正常使用请及时切换到新的历史文件下载接口。
具体接口说明可以参照如下连接地址:
http://docs.easemob.com/im/100serverintegration/30chatlog 收起阅读 »
淘宝购物车界面背后的逻辑及实现源码,欢迎Star!
ViewController: 购物车界面
整个界面就是TableView + 底部结账栏View组成
以店铺为section:商店下的商品为row和店铺名称组成一个 section
定制段头的View 把section的全选按钮、点击商品、编辑的三个按钮的方法用代理的方法。
-(UIView*)tableView:(UITableView*)tableView viewForHeaderInSection:(NSInteger)section;
建议使用Masonry进行cell适配
cell的创建就是和我们平常的一样,把要展示的样式代码编写或者xib都可以。再把数据源填充到我们所创建好的cell中和段头上。
创建好一个View添加在TableView的下方。View上写上全选及总金额等UI。每次我们选定的物品的增减都要调用该View赋值的方法,刷新金额等字段显示。
Cell:物品栏
创建两种cell,一个是正常的物品显示cell,另一个cell是编辑后的cell。
正常的cell:只说下label中划线的实现
//中划线
NSDictionary *attribtDic = @{NSStrikethroughStyleAttributeName: [NSNumber numberWithInteger:NSUnderlineStyleSingle]};
NSMutableAttributedString *attribtStr = [[NSMutableAttributedString alloc]initWithString:info[@"GoodsOldPrice"] attributes:attribtDic];
// 赋值
_Goods_OldPrice.attributedText = attribtStr;
编辑后的cell:
主要由三部分组成。商品数量 + 商品种类 + 删除
商品数量用的是第三方PPNumberButton,点击时改变model中该商品的实际数量;点击商品种类进行重新选择(方法未实现,原理一样);点击删除进行cell的删除及数据源的删除。
注释:这里的删除 及 修改 都是要对数据源进行修改在刷新的
Model:数据源的处理及购物车内各类按钮的判断
demo中的数据源我没有放到model中去处理,其实原理都一样,我把判断各类按钮的判断字段加到数据源中去了,如果用model模型的话,自己加上相对应的字段,并设置初始值。当进行model数据源的修改时,直接进行修改。
这是一种model处理方式,还有一种就是用JsonModel来处理,一层层的写下来,原理一样。
购物车逻辑及实现总结
逻辑整理:当我们把有购买意向的物品加到购物车后,我们在购物车中调用接口获取购物车中的物品信息。数据源格式大概是(感觉不怎么对,但是能理解就行)
[
{@“店铺信息”:[@{物品信息},@{物品信息},@{物品信息}]}, -------》组一
{@"店铺信息":[@{物品信息}]}, -------》组二
{@”店铺信息“:[@{物品信息},@{物品信息}]} -------》组三
]
把数据源用model装起来,把数据填充到tableview中去。
1.单个商品的选择、单个店铺内所有商品的选择、结账栏下的全选
如果做得很简单的话,可以直接用系统的单选和全选方法。
最重要的两句 !!!!
TableDemo.editing=YES; 编辑状态
TableDemo.allowsMultipleSelectionDuringEditing=YES; 编辑的时候多选
cell.tintColor= [UIColorredColor]; 选中后的颜色
选中和取消选中
-(void)tableView:(UITableView*)tableView didSelectRowAtIndexPath:(NSIndexPath*)indexPath 选中
-(void)tableView:(UITableView*)tableView didDeselectRowAtIndexPath:(NSIndexPath*)indexPath 取消选中
如果不用系统的话,则利用model来进行单选和全选的操作,选中和取消选中对model中的判断字段进行修改,刷新当前cell或者section。
//一个section刷新
NSIndexSet *indexSet=[[NSIndexSet alloc]initWithIndex:section];
[tableview reloadSections:indexSet
withRowAnimation:UITableViewRowAnimationAutomatic];
//一个cell刷新
NSIndexPath *indexPath=[NSIndexPath indexPathForRow:row inSection:section];
[tableView reloadRowsAtIndexPaths:[NSArray arrayWithObjects:indexPath,nil] withRowAnimation:UITableViewRowAnimationNone];
这里需要注意的是,每次单选和全选的时候,需要对底部结账栏进行数据的刷新,并且单选完整组后,需要对section上的按钮进行选中状态变化,当选中section时,同样需要对section下的row进行选中状态,如果全部商品选中,还得需要改变结账栏的状态。(这里其实不难,无非就是对model中各类字段进行改变,再刷新,同时判断选中数量的多少来进行按钮的状态变换)
2.删除单个商品
删除的话就相对容易了,直接对数据源删除对应的row,当只剩一个后,删除该数据后,记得删除该组section不然报错。删除后及时刷新底部结账栏金额显示。
3.编辑section
点击编辑按钮,修改model,展示编辑cell。编辑按钮上放了数量计数器、商品的信息、删除。
计数器:用到的是好友的一个库PPNumberButton 喜欢的大家可以去玩玩。点击后用代理方法把数量的变化跟新model。
商品信息:点击对商品的信息进行重新选择,同样修改数据源。
删除:代理出来进行model的修改。
因为这里用的是假数据,所以进行的都是对数据源的修改,正常情况下,原理都一样,可以在次基础上,如果接口成功,就对本地数据进行修改,最后提交的信息会和后台匹配一次的,如果有问题,可以自己修改一下。
有小伙伴提出demo中没有下拉刷新,其实下拉刷新不影响该demo。不过加上效果更好。
谢谢“爱在巴黎梦醒时”该小伙伴。
demo的bug注释:
因为demo中判断section的全选和编辑的按钮都是放在每个section的第一个row中的,所以删除section的第一个row后,会有全选和编辑的固定bug出来。特此声明,该bug不影响主体逻辑,如次bug影响小伙伴对逻辑思路的学习,那我后面再重新组织数据源。
demo纯代码编写的,只隔离了部分模块,因为我也是拿来练练手,所以如果有需要,后续我会把购物车模块化。如果内容有不妥和臃肿的地方,大家可以提出来,我及时学习并修改。如果大家有意见的可以@我1804094055qq.com。
项目源码Git地址https://github.com/zl645420646/-ZLShoppingCart
欢迎Star! 收起阅读 »
主流观点:呼叫中心还是客户中心?
近年来,呼叫中心常常被称为这个时代的“白领血汗工厂”。作为一名资深的客户服务代表、主管、经理、总监,当我第一次听到这种说法时,被深深地触动了。我熟悉的很多呼叫中心都把办公场所设在比较偏远的地方,在这些地方一般人很难把工作有效地完成做好,但客服工作人员却可以在电话里处理好各种任务。
想一想,传统的呼叫中心是什么样子呢?它们通常是由一些普通格子间组成,几乎没有什么可以发挥创造力的空间。通话时长和通话处理时长都是很重要的指标,这说明每一通电话都是在处理业务而不是单纯地交流互动。另外对于呼叫中心的工作人员来说,即使公司朝着蓬勃的方向发展,客服工作人员的发展空间也是极其有限的。
将呼叫中心转型为客户中心。有人会认为,以这样一种方式来加深企业与客户之间的关系,无异于痴人说梦。但事实真的是这样吗?其实只要做出一些调整,呼叫中心就可以变为客户中心。
第一步也是最简单的一步是将部门名称改为客户中心。如果我们希望员工成为客户关系的建立者,那么我们应该从部门的定位开始。在这个以交易时间、任务量和呼入量来微观管理的时代,我们应该时常这样反问自己:如果我们不以最好的方式来对待我们的员工,又怎能期望他们以最好的方式来对待我们最尊贵的客人?如果做不到最好,起码得一视同仁,只可惜我们连这样也做不到,我们只是一味地强调员工是企业与客户关系的建立者以及公司产品的形象代表,仅此而已。通常情况下,呼叫中心是企业与客户仅有的互动方式。客户愿意花时间打电话给我们,是因为遇到了各种类型的问题,他们需要我们提供帮助。难道我们不想让员工在成为公司政策执行者的同时还是客户问题的解决者吗?这样不仅不会让客户的投诉不断升级,也能更好地帮旅客解决问题。
如何管理比较合适呢?怎样能在让公司在付出最小代价的同时能够给员工最大的权限去帮客户解决问题?这就需要我们花时间培养员工提前预判的能力并在此基础上出色地完成任务,这样也能在机会来临时给客户带去惊喜。这其中的关键是需要准备,我们不能单纯地期望我们的员工认识到这些东西,也不能在一个问题上只告诉他们一次就期待他们能坚持,更不能在没有被加强训练的情况下期待他们保持正确的工作习惯。
解决方案:创建一个世界一流的服务体系。在这样一个体系中,所有员工都能发现并及时指出不足,坚持同样的标准,在把握机会的同时不断变得更好。为了能让员工更好地处理每天发生的不同情况,我们需要花更多的时间在幕后管理他们。这允许我们监督客服代表的活动以确保团队中的每个成员不在微观管理下都能恪尽职守。这让我们的客服代表们不会压力过大、负担过重进而保持员工数量的稳定性,因为我们没有足够的人员来雇佣和培训,这对呼叫中心来说是一大难题。这听起来很棒,但我们如何把它完成好呢?我建议从以下两个步骤开始,但两者都将需要投入一定的时间和人力成本。
第一步是让你的团队一起参与创造你的客户体验周期,这包括与团队一起研究确定客户的接触点。一旦确定了这些接触点,你就可以借此去剖析每一个人,找到可能出错的地方(服务缺陷),接下来就知道每个人和每通电话里需要做什么(操作和体验标准),同时我们能通过哪些方式来取悦客户(利用机会超越机会)。
通过这个项目,你和你的一线团队将会大开眼界,并产生新的使命感。你的团队会重现活力并能很好地完成工作。虽然这是一个很棒的开始,你不能指望这样一个开始在没有加强巩固的基础上就能保持不断发展的势头。接下来我们该做第二步了。
第二步是解决日常会议。在你说“这在这里永远都行不通,因为……”之前(我知道你会这样说,因为我听过所有的借口,而且我自己曾经也这样说过),可以思考下金牌服务的典范:丽思卡尔顿酒店。他们每天都会举行一个他们称之为“站起来”的会议。福来鸡也一样。这些公司已经忽略每个员工不能每天都出席会议的事实,因为他们一天有多种轮班制。他们能做的是利用好平台专注他们的服务价值,讨论并解决问题,庆祝每天成功的案例。
结论:这个过程在成就一个伟大的团建活动的同时也会不断提升团队成员的自主性和增强他们的归属感。创建属于自己客户体验周期,并在日常活动中不断加强,这有助于给团队带去新的使命感,并让员工变成真正的客户关系的建设者。随着日常会议深入,活动并不会因时间推移而逐渐消失,而是会变成一种超越规范并深入人心的文化。
本文刊载于《客户世界》2016年11月刊;作者Dave Murray为DiJulius集团高级客户体验顾问;译者皮晶晶为深圳航空营销委电子商务电话营销中心运营人员。 收起阅读 »
SaaStr 2017大会启示:SaaS创业最正确的十种姿势!
很多人感叹:“听过很多道理,却依然过不好这一生。听过很多创业鸡汤,却仍然没升职加薪...”那是因为你们还没有找到最正确的姿势。近日,环信CEO刘俊彦亲临美国SaaStr Annual 2017大会,帮你揭秘SaaS创业最正确的十种姿势!
SaaS行业最迷人之处之一就是经过10多年无数公司的探索,尤其是在北美,SaaS企业已经有了一个可以被精确计算和测量的模型。我们看到SaaS行业的人不管懂还是不懂,都在谈论着CAC, LTV, LTV>3CAC,续约率,平均客单价,MRR/ARR, inbound marketing, outbound prospecting...
在中国,不知道是不是因为SaaS企业有数学模型,可以用一大串公式表达,直接戳中了人民币资本的甜点,反正在过去1,2年的资本寒冬里,SaaS企业已经成为了很多资本寻求低风险高质量投资标的的热门选择。
同时,对中国的创业者来说,SaaS还是一个新鲜事物。当Salesforce已经上市了6年,享有560亿美金市值的时候,中国才刚刚有第一批SaaS企业进入ARR(年度可重复销售额)亿元俱乐部。
相信,我们这一批还稍显稚嫩但胸怀星辰大海的中国SaaS创业者们一定都想过,我现在SaaS创业的姿势到底对不对啊?如果5年后我可以重来一次,我会怎么做一个SaaS企业,我是否可以做的更好?
很幸运,在2017 SaaStr年会上,原Marketo创始人Jon Miller讲到了他在SaaS企业二次创业时的10点改进。
Marketo是一家做市场自动化软件的SaaS公司,于2006年创建,于2016以18亿美金的价格被Vista收购。Jon Miller随后离开了Marketo,创建了Engagio。 Engagio近来在硅谷可谓炙手可热,Engagio最牛的地方在于它从新定义了一个崭新的SaaS品类:ABM(Account based marketing)。
在Jon的演讲中,他分享了作为一个SaaS的二次创业者,他认为有10个方面在重新来过时,可以做的更好。
- 定义公司的愿景和核心价值观
Jon创建Engagio公司时,第一天做的第一件事就是定义公司的愿景和价值观。以前在Marketo,这个事情在公司成立2年后才开始做。
其实我很惊讶Jon把公司愿景和价值观放在第一。环顾四周,国内大部分创业公司是没有明确的公司愿景和价值观的。
以环信自己为例子,环信从做IM云到客服云到AI,虽然一直明确的以连接为主线,IM是连接人与人,客服是连接人与商业,但真正正式确定公司的愿景是在去年:“连接人与人,连接人与商业,用卓越的技术改变每个人的生活和工作”。同时也定下了核心价值观。但Jon是对的,当我做了环信的愿景和价值观后,我发现我确实希望我能做的更早。
- 建立有凝聚力的团队
我估计Jon应该是Patrick Lencioni 的粉丝,因为这页讲的完全就是“优势(The Advantage)”这本书的内容,即建立信任->掌控冲突->兑现承诺->承担责任->关注结果。这本书确实很好,尤其建议技术出身的CEO多看看,我每次坐飞机都带着,用来帮助睡眠。
- 用文化来驱动公司运行
这包括一整套公司运转流程:
招聘
员工福利
员工入职,学习,发展
绩效管理
办公环境
企业社会责任
我又一次被切中了痛点。我们的员工入职融入至今都还做的不够完美。招聘机器还有上升空间,想到我们可能每天都在错过优秀的人才,很忧虑。
员工的绩效考核和薪酬激励制度还需要继续完善。几十个人的公司时不觉得这些制度流程的完善有多么重要,变成几百人的公司后,再补课就发现晚了。
- 高效会议
这个就不用说了。好在环信这样的工程师文化的公司还没有太多会议,程序员都讲究“code wins”
- 融资策略
Jon在这一次重新创业的时候,希望能够用较少轮次的融资,较少的稀释,拿到以前的Marketo更多轮次融资同样金额的钱。这当然很好,但这是创二代的特权。
同时,Jon还说到,这一次创业,他会更重视财务管理,更重视盈利(少烧钱),他会希望B轮的钱一直用到公司盈利。
- 财务管理
更好的财务管理
- 竞争对手分析
一定要重视竞品分析。所以Jon特意画了一个密密麻麻的密集恐惧症患者无法直视的市场分析图,以表示他真的做了很透彻的市场研究。
Jon强调他创建Engagio的指导思想是要找到一个已经存在的市场,以避免去教育市场,同时还要找到一个竞争不是那么激烈的市场,以前Marketo的竞争实在太惨烈了。
对于这点我也深表同意。环信是从即时通讯云起家,即时通讯云这个名词以前是不存在的。我自己知道,为了教育这个市场,我们花了多少钱。
- 做更大的客户
Engagio的目标客单价是4万美金,目前已经做到了2.7万美金。而以前的Marketo只有5000美金的客单价.
这点就更不用说了。记得是北森的纪伟国先生说过一句话,“国内能转做大客户的都转做大客户了,没转做大客户的,是因为暂时能力不够,想转但转不了”。
- 做更多的outbound sales
我一直觉得美国的inbound marketing太热了,热的不太合理。hubspot,marketo,美国有几百家这样的公司。而且SaaStr上一半的内容都是在讲inbound marketing。inbound marketing对美国2B企业的销售真的这末重要吗?其实Jon创建Engagio已经说明了其中的秘密。
美国在中大型企业市场有Oracle,Salesforce等把持,创业公司是完全没有机会的。创业公司只能玩中小企业市场。所以才会有一大堆创业企业到处鼓吹inbound marketing。
inbound marketing是针对中小企业为主的,而Engagio的ABM(Account based marketing),是为中大型企业准备的。在二次创业的一开始,Jon就已经想好了,这次他要做大企业。
最后不得不感叹中国的SaaS创业者有多么幸运,中国在SaaS的各个核心赛道上,比如销售云,客服云,市场云,HR云等(财务和协同这2个赛道除外,你懂的)都没有本土巨头公司,Salesforce等海外巨头因为ICP牌照问题又进不了中国,可谓即无内忧,也无外患。这一代中国的SaaS创业者是没有天花板的,这一代中国SaaS创业者就是下一代创业者的天花板!
- 强迫症看了会沉默,处女座看了会流泪
为了照顾有强迫症的处女座创业者们,小编擅自加了最后这一条,凑齐了第十条。据说他们看完都默默点赞了...
环信成立于2013年4月,是一家国内领先的企业级软件服务提供商,于2016年荣膺“Gartner 2016 Cool Vendor”。产品包括国内上线最早规模最大的即时通讯云平台——环信即时通讯云,以及移动端最佳实践的全媒体智能云客服平台——环信移动客服。收起阅读 »
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现
环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0
环信官方Demo源码分析及SDK简单应用-LoginActivity
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面
环信官方Demo源码分析及SDK简单应用-EaseUI
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现
前言
手头工作上,正好需要在已有的两个App上集成IM功能。且迭代流程中是有开发详案这一项的。就分享给大家,边写开发详案边写代码。好吧,废话不多说,我们一起来学习如何集成和改造这款简单易用而又非常强大的环信SDK。
具体步骤
迭代点
需要做的功能点及工作
1.集成环信
2.围绕UE和UI进行编码
- 房源详情增加咨询按钮,点击进入咨询对话框,并且将房源信息带入对话框。
- 消息中心
- 根据UE和UI改造聊天窗口(EaseUI库)
环信的哥哥们已经帮我们实现了。但是根据要求呢,我没只需要删除会话,所以我们把第二项注释掉。
我们把对应处的判断代码和对应的menu文件em_delete_message中的标签给注释掉。看效果。
从房源详情页进入时,就返回房源详情页,从消息中心进入时,就返回消息中心。直接finish();显示当前咨询人的经纪人姓名,并显示当前咨询的对象的在线状态(在线/离线)官方的EaseUi是这么说的
我们来找下EaseTitleBar
我们来看下他的布局
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/root" android:layout_width="match_parent" android:layout_height="@dimen/height_top_bar" android:background="@color/top_bar_normal_bg" android:gravity="center_vertical" > <RelativeLayout android:id="@+id/left_layout" android:layout_width="50dip" android:layout_height="match_parent" android:background="@drawable/ease_common_tab_bg" android:clickable="true" > <ImageView android:id="@+id/left_image" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:scaleType="centerInside" /> </RelativeLayout> <TextView android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:textColor="#ffffff" android:textSize="20sp" /> <RelativeLayout android:id="@+id/right_layout" android:layout_width="50dp" android:layout_height="match_parent" android:layout_alignParentRight="true" android:background="@drawable/ease_common_tab_bg" > <ImageView android:id="@+id/right_image" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:scaleType="centerInside" /> </RelativeLayout></RelativeLayout>其实有title和rightview的。
我们来对title加入一个是否在线的状态1.获取token
MacBook:~ mli$ curl -X POST "https://a1.easemob.com/1177170104178912/demo/token" -d '{"grant_type":"client_credentials","client_id":"YXA6vcNInEeatzGVyK0tA","client_secret":"YXA6YACo7qumFfgYdWher3D3Cs"}'
{"access_token":"YWMtOT73nvcIEeaPCCuTQsCAAAVuOB_MQchxsIsxVJFXsW6lZ8f2l__xn8","expires_in":5168429,"application":"bd09c370-d227-11e6-adcc-65700322b4b4"}2.拿token获取用户状态
MacBook:~ mli$ curl -X GET -i -H "Authorization: Bearer YWMtOT73nvcIEeaPCCuTQsC6kwAAAVuOB_MQchxsIsxybVJFXsW6lZ8f2l__xn8" "https://a1.easemob.com/1177170104178912/demo/users/2/status"HTTP/1.1 200 OKServer: Tengine/2.0.3Date: Mon, 20 Feb 2017 05:24:00 GMTContent-Type: application/json;charset=UTF-8Transfer-Encoding: chunkedConnection: keep-aliveAccess-Control-Allow-Origin: *Set-Cookie: rememberMe=deleteMe; Path=/; Max-Age=0; Expires=Sun, 19-Feb-2017 05:24:00 GMT{ "action" : "get", "uri" : "http://a1.easemob.com/1177170104178912/demo/users/2/status", "entities" : [ ], "data" : { "2" : "offline" }, "timestamp" : 1487568240699, "duration" : 25, "count" : 0}MacBook:~ mli$ curl -X GET -i -H "Authorization: Bearer YWMtOT73nvcIEeaPCCuCkwAAAVuOB_MQchxsIJFXsW6lZ8f2l__xn8" "https://a1.easemob.com/1177170104178912/demo/users/1/status"HTTP/1.1 200 OKServer: Tengine/2.0.3Date: Mon, 20 Feb 2017 05:24:08 GMTContent-Type: application/json;charset=UTF-8Transfer-Encoding: chunkedConnection: keep-aliveAccess-Control-Allow-Origin: *Set-Cookie: rememberMe=deleteMe; Path=/; Max-Age=0; Expires=Sun, 19-Feb-2017 05:24:08 GMT{ "action" : "get", "uri" : "http://a1.easemob.com/1177170104178912/demo/users/1/status", "entities" : [ ], "data" : { "1" : "online" }, "timestamp" : 1487568248135, "duration" : 14, "count" : 0MacBook:~ mli$我们可以看到2是离线,1是在线的。注意一点
所以昵称是在咱自己的体系的。可以从现有的App里提取,如果有的话。我们知道从列表ConversationListFragment->ChatActivity->ChatFragment那么如何接受和发送自己与他人的头像和昵称呢?我们来玩这个ChatFragment
在OnSetMessageAttributes中,设置我们要发送时的消息扩展属性。那么接收怎么办呢,我们来看下DemoHelper中的getUserInfo()方法。
无聊的用鄙人蹩脚的英文写了一把注释。英文若是写的不对就不对吧。标题头中的电话按钮可以直接拨打电话修改删除按钮为打电话,并改动相关代码显示经纪人照片上传的照片,如果经纪人没有上传照片,就显示一个经纪人的占位图(要区别于用户的占位图)修改原demo当前用户头像默认显示当前用户的头像,如果没有头像,就显示一个默认的占位图修改原demo。聊天内容上长按可复制
自带了,后面我们可能需要去掉转发。发送的是手机号码时可以直接打电话。我们再长按后判断其是否为电话号码,如果是添加一项拨打电话。引用关系是这样的ChatFragment->ContextMenuActivity->em_context_menu_for_location.xml最后调回ChatFragment的onActivityResult我们来改em_context_menu_for_location.xml
<?xml version="1.0" encoding="UTF-8"?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginLeft="20dp" android:layout_marginRight="20dp" android:gravity="center_horizontal" android:orientation="vertical" > <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="1dp" android:background="@drawable/em_context_menu_item_bg" android:clickable="true" android:gravity="center_vertical" android:onClick="copy" android:padding="10dp" android:text="@string/copy_message" android:textColor="@android:color/black" android:textSize="20sp" /> <View android:layout_width="match_parent" android:layout_height="1px" android:background="@android:color/darker_gray" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/em_context_menu_item_bg" android:clickable="true" android:gravity="center_vertical" android:onClick="delete" android:padding="10dp" android:text="@string/delete_message" android:textColor="@android:color/black" android:textSize="20sp" /><!-- <View android:layout_width="match_parent" android:layout_height="1px" android:background="@android:color/darker_gray" /> <TextView android:id="@+id/forward" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/em_context_menu_item_bg" android:clickable="true" android:gravity="center_vertical" android:onClick="forward" android:padding="10dp" android:text="@string/forward" android:textColor="@android:color/black" android:textSize="20sp" />--> <View android:layout_width="match_parent" android:layout_height="1px" android:background="@android:color/darker_gray" /> <TextView android:id="@+id/call_phone" android:visibility="gone" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/em_context_menu_item_bg" android:clickable="true" android:gravity="center_vertical" android:onClick="call" android:padding="10dp" android:text="@string/call_phone" android:textColor="@android:color/black" android:textSize="20sp" /></LinearLayout>再来改ContextMenuActivity
/** * Copyright (C) 2016 Hyphenate Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */package com.hyphenate.chatuidemo.ui;import android.content.Intent;import android.os.Bundle;import android.text.TextUtils;import android.view.MotionEvent;import android.view.View;import android.widget.TextView;import com.easemob.redpacketsdk.constant.RPConstant;import com.hyphenate.chat.EMMessage;import com.hyphenate.chatuidemo.Constant;import com.hyphenate.chatuidemo.R;public class ContextMenuActivity extends BaseActivity { public static final int RESULT_CODE_COPY = 1; public static final int RESULT_CODE_DELETE = 2; public static final int RESULT_CODE_FORWARD = 3; public static final int RESUTL_CALL_PHONE = 4; String phoneNumber; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); EMMessage message = getIntent().getParcelableExtra("message"); boolean isChatroom = getIntent().getBooleanExtra("ischatroom", false); phoneNumber = getIntent().getStringExtra("phone_number"); int type = message.getType().ordinal(); if (type == EMMessage.Type.TXT.ordinal()) { if(message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VIDEO_CALL, false) || message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VOICE_CALL, false) //red packet code : 屏蔽红包消息、转账消息的转发功能 || message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_MESSAGE, false) || message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)){ //end of red packet code setContentView(R.layout.em_context_menu_for_location); }else if(message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_BIG_EXPRESSION, false)){ setContentView(R.layout.em_context_menu_for_image); }else{ //for text content setContentView(R.layout.em_context_menu_for_text); //for call phone number TextView callPhone = (TextView) findViewById(R.id.call_phone); if(!TextUtils.isEmpty(phoneNumber)){ callPhone.setVisibility(View.VISIBLE); callPhone.setText("拨打电话:" + phoneNumber); }else{ callPhone.setVisibility(View.GONE); } } } else if (type == EMMessage.Type.LOCATION.ordinal()) { setContentView(R.layout.em_context_menu_for_location); } else if (type == EMMessage.Type.IMAGE.ordinal()) { setContentView(R.layout.em_context_menu_for_image); } else if (type == EMMessage.Type.VOICE.ordinal()) { setContentView(R.layout.em_context_menu_for_voice); } else if (type == EMMessage.Type.VIDEO.ordinal()) { setContentView(R.layout.em_context_menu_for_video); } else if (type == EMMessage.Type.FILE.ordinal()) { setContentView(R.layout.em_context_menu_for_location); } if (isChatroom //red packet code : 屏蔽红包消息、转账消息的撤回功能 || message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_MESSAGE, false) || message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) { //end of red packet code View v = (View) findViewById(R.id.forward); if (v != null) { v.setVisibility(View.GONE); } } } @Override public boolean onTouchEvent(MotionEvent event) { finish(); return true; } public void copy(View view){ setResult(RESULT_CODE_COPY); finish(); } public void delete(View view){ setResult(RESULT_CODE_DELETE); finish(); } public void forward(View view){ setResult(RESULT_CODE_FORWARD); finish(); } public void call(View view) { Intent it = new Intent(); it.putExtra("phone_number",phoneNumber); setResult(RESUTL_CALL_PHONE,it); finish(); }}再来判断内容是否为电话号码
String phoneNumber=""; if(isPhoneNumber(content)){ phoneNumber = content; }// no message forward when in chat room startActivityForResult((new Intent(getActivity(), ContextMenuActivity.class)).putExtra("message",message) //if message's context is a phone number ,make it can be call it. .putExtra("ischatroom", chatType == EaseConstant.CHATTYPE_CHATROOM).putExtra("phone_number",phoneNumber), REQUEST_CODE_CONTEXT_MENU);onActivityResult部分
public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQUEST_CODE_CONTEXT_MENU) { //for Context MenuActivity Result switch (resultCode) { case ContextMenuActivity.RESULT_CODE_COPY: // copy clipboard.setPrimaryClip(ClipData.newPlainText(null, ((EMTextMessageBody) contextMenuMessage.getBody()).getMessage())); break; case ContextMenuActivity.RESULT_CODE_DELETE: // delete conversation.removeMessage(contextMenuMessage.getMsgId()); messageList.refresh(); break;// case ContextMenuActivity.RESULT_CODE_FORWARD: // forward// Intent intent = new Intent(getActivity(), ForwardMessageActivity.class);// intent.putExtra("forward_msg_id", contextMenuMessage.getMsgId());// startActivity(intent);//// break; case ContextMenuActivity.RESUTL_CALL_PHONE: Intent intent = new Intent(Intent.ACTION_DIAL); Uri callData = Uri.parse("tel:" +data.getStringExtra("phone_number")); intent.setData(callData); startActivity(intent); break; default: break; } }记住先提取字符串中的数字,再去匹配正则。
STM集成在本质上是相同的。不同的是一个是用户端,一个是经纪人端标注下需要注意的几个地方
- 头像和昵称的扩展互通,是SeeHouse和STM两边都需要做的。
- 因为有一条对于从房源详情进入时带入的房源详情类型的聊天条目,经纪人可以点击查看该房源在STM中的详情。是在STM中单独实现的。SeeHouse负责带入,STM负责点击跳转。
对于从房源详情进入时带入的房源详情类型的聊天条目,经纪人可以点击查看该房源在STM中的详情。
创建图文chatrow并设置对应点击事件代码。
集成至目标App
不需要的代码,我们只做注释,不删除,防止后面增加了,需要了。避免一系列麻烦。
剔除红包库
在ChatUIDemo3.0的build.gradle中注释编译红包依赖库。
各种编译,遇到报错就删除相关代码
剔除不需要的代码
注意EaseUI下有个SimpleDemo
目标App集成与调试
因为是公司的商业项目,这里就不贴出来了。接着完成需调试才能完成的功能点
总结
好了,至此,我们开发详案写完了,代码也写完了。因为本文写的时候UI还未出,所以后面就是根据UI改改的调整调整界面的小事情了。
有任何问题或者其他事宜请联系我: 5108168@qq.com,欢迎指正和勘误。 收起阅读 »
环信之Android修改圆形头像
然后只要在setUserAvatar这个方法里面稍作修改,可以看出用的是glide加载图片,于是我们可以写一个把图片转为圆形的类GlideCircleTransform, 代码如下:
public class GlideCircleTransform extends BitmapTransformation {然后稍加修改setUserAvatar方法,代码如下:
public GlideCircleTransform(Context context) {
super(context);
}
protected Bitmap transform(BitmapPool pool, Bitmap toTransform, int outWidth, int outHeight) {
return circleCrop(pool, toTransform);
}
private static Bitmap circleCrop(BitmapPool pool, Bitmap source) {
if (source == null) return null;
int size = Math.min(source.getWidth(), source.getHeight());
int x = (source.getWidth() - size) / 2;
int y = (source.getHeight() - size) / 2;
// TODO this could be acquired from the pool too
Bitmap squared = Bitmap.createBitmap(source, x, y, size, size);
Bitmap result = pool.get(size, size, Bitmap.Config.ARGB_8888);
if (result == null) {
result = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
}
Canvas canvas = new Canvas(result);
Paint paint = new Paint();
paint.setShader(new BitmapShader(squared, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP));
paint.setAntiAlias(true);
float r = size / 2f;
canvas.drawCircle(r, r, r, paint);
return result;
}
@Override
public String getId() {
return getClass().getName();
}
}
/**其中default_user_head_img是我项目中的默认头像,大家改为自己的即可。
* set user avatar
* @param username
*/
public static void setUserAvatar(Context context, String username, ImageView imageView){
EaseUser user = getUserInfo(username);
if(user != null && user.getAvatar() != null){
try {
int avatarResId = Integer.parseInt(user.getAvatar());
// Glide.with(context).load(avatarResId).into(imageView);
Glide.with(context).load(avatarResId).transform(new GlideCircleTransform(context)).into(imageView);
} catch (Exception e) {
//use default avatar
// Glide.with(context).load(user.getAvatar()).diskCacheStrategy(DiskCacheStrategy.ALL).placeholder(R.drawable.ease_default_avatar).into(imageView);
Glide.with(context).load(user.getAvatar()).diskCacheStrategy(DiskCacheStrategy.ALL). placeholder(R.drawable.default_user_head_img).transform(new GlideCircleTransform(context)).into(imageView);
}
}else{
// Glide.with(context).load(R.drawable.ease_default_avatar).into(imageView);
Glide.with(context).load(R.drawable.default_user_head_img).transform(new GlideCircleTransform(context)).into(imageView);
}
}
到现在EaseConversationListFragment中的头像就变成圆形的了,但是EaseChatFragment还要修改easeui布局文件, 文件列表如下:
我们在这些资源文件中可以用于显示头像的ImageView
把android:src="@drawable/ease_default_avatar"这行 删掉即可。
跑起来看看,是不是都是圆形头像了。
好了,教程结束。 收起阅读 »
环信官方Demo源码分析及SDK简单应用-EaseUI
环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0
环信官方Demo源码分析及SDK简单应用-LoginActivity
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面
环信官方Demo源码分析及SDK简单应用-EaseUI
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现
EaseUI
实际工作过程中,我们是用不了太多东西的,如果只是集成个im最多用到的就是聊天列表和聊天页面。
我们来看重头戏EaseUI这个库。
官方文档
其实官方的WiKi已经介绍的特别详细了。官方EaseUI文档
我们来看Demo
ChatActivity
// start chat acitivity
Intent intent = new Intent(getActivity(), ChatActivity.class);
if(conversation.isGroup()){
if(conversation.getType() == EMConversationType.ChatRoom){
// it's group chat
intent.putExtra(Constant.EXTRA_CHAT_TYPE, Constant.CHATTYPE_CHATROOM);
}else{
intent.putExtra(Constant.EXTRA_CHAT_TYPE, Constant.CHATTYPE_GROUP);
}
}
// it's single chat
intent.putExtra(Constant.EXTRA_USER_ID, username);
startActivity(intent);
我们来看看ChatActivity
package com.hyphenate.chatuidemo.ui;官方文档是这么说的
import android.content.Intent;
import android.os.Bundle;
import android.support.annotation.NonNull;
import com.hyphenate.chatuidemo.R;
import com.hyphenate.chatuidemo.runtimepermissions.PermissionsManager;
import com.hyphenate.easeui.ui.EaseChatFragment;
import com.hyphenate.util.EasyUtils;
/**
* chat activity,EaseChatFragment was used {@link #EaseChatFragment}
*
*/
public class ChatActivity extends BaseActivity{
public static ChatActivity activityInstance;
private EaseChatFragment chatFragment;
String toChatUsername;
@Override
protected void onCreate(Bundle arg0) {
super.onCreate(arg0);
setContentView(R.layout.em_activity_chat);
activityInstance = this;
//get user id or group id
toChatUsername = getIntent().getExtras().getString("userId");
//use EaseChatFratFragment
chatFragment = new ChatFragment();
//pass parameters to chat fragment
chatFragment.setArguments(getIntent().getExtras());
getSupportFragmentManager().beginTransaction().add(R.id.container, chatFragment).commit();
}
@Override
protected void onDestroy() {
super.onDestroy();
activityInstance = null;
}
@Override
protected void onNewIntent(Intent intent) {
// make sure only one chat activity is opened
String username = intent.getStringExtra("userId");
if (toChatUsername.equals(username))
super.onNewIntent(intent);
else {
finish();
startActivity(intent);
}
}
@Override
public void onBackPressed() {
chatFragment.onBackPressed();
if (EasyUtils.isSingleActivity(this)) {
Intent intent = new Intent(this, MainActivity.class);
startActivity(intent);
}
}
public String getToChatUsername(){
return toChatUsername;
}
@Override public void onRequestPermissionsResult(int requestCode, @NonNull String permissions,
@NonNull int grantResults) {
PermissionsManager.getInstance().notifyPermissionsChange(permissions, grantResults);
}
}
封装EaseChatFragment的ChatFragment
那么Demo中是做了一层封装的。
package com.hyphenate.chatuidemo.ui;判断是不是机器人及添加监听
import android.app.Activity;
import android.content.ClipData;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Bitmap.CompressFormat;
import android.media.ThumbnailUtils;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Toast;
import com.easemob.redpacketsdk.constant.RPConstant;
import com.easemob.redpacketui.utils.RPRedPacketUtil;
import com.easemob.redpacketui.utils.RedPacketUtil;
import com.easemob.redpacketui.widget.ChatRowRandomPacket;
import com.easemob.redpacketui.widget.ChatRowRedPacket;
import com.easemob.redpacketui.widget.ChatRowRedPacketAck;
import com.easemob.redpacketui.widget.ChatRowTransfer;
import com.hyphenate.chat.EMClient;
import com.hyphenate.chat.EMCmdMessageBody;
import com.hyphenate.chat.EMGroup;
import com.hyphenate.chat.EMMessage;
import com.hyphenate.chat.EMTextMessageBody;
import com.hyphenate.chatuidemo.Constant;
import com.hyphenate.chatuidemo.DemoHelper;
import com.hyphenate.chatuidemo.R;
import com.hyphenate.chatuidemo.domain.EmojiconExampleGroupData;
import com.hyphenate.chatuidemo.domain.RobotUser;
import com.hyphenate.chatuidemo.widget.ChatRowVoiceCall;
import com.hyphenate.easeui.EaseConstant;
import com.hyphenate.easeui.ui.EaseChatFragment;
import com.hyphenate.easeui.ui.EaseChatFragment.EaseChatFragmentHelper;
import com.hyphenate.easeui.widget.chatrow.EaseChatRow;
import com.hyphenate.easeui.widget.chatrow.EaseCustomChatRowProvider;
import com.hyphenate.easeui.widget.emojicon.EaseEmojiconMenu;
import com.hyphenate.util.EasyUtils;
import com.hyphenate.util.PathUtil;
import java.io.File;
import java.io.FileOutputStream;
import java.util.List;
import java.util.Map;
public class ChatFragment extends EaseChatFragment implements EaseChatFragmentHelper{
// constant start from 11 to avoid conflict with constant in base class
private static final int ITEM_VIDEO = 11;
private static final int ITEM_FILE = 12;
private static final int ITEM_VOICE_CALL = 13;
private static final int ITEM_VIDEO_CALL = 14;
private static final int REQUEST_CODE_SELECT_VIDEO = 11;
private static final int REQUEST_CODE_SELECT_FILE = 12;
private static final int REQUEST_CODE_GROUP_DETAIL = 13;
private static final int REQUEST_CODE_CONTEXT_MENU = 14;
private static final int REQUEST_CODE_SELECT_AT_USER = 15;
private static final int MESSAGE_TYPE_SENT_VOICE_CALL = 1;
private static final int MESSAGE_TYPE_RECV_VOICE_CALL = 2;
private static final int MESSAGE_TYPE_SENT_VIDEO_CALL = 3;
private static final int MESSAGE_TYPE_RECV_VIDEO_CALL = 4;
//red packet code : 红包功能使用的常量
private static final int MESSAGE_TYPE_RECV_RED_PACKET = 5;
private static final int MESSAGE_TYPE_SEND_RED_PACKET = 6;
private static final int MESSAGE_TYPE_SEND_RED_PACKET_ACK = 7;
private static final int MESSAGE_TYPE_RECV_RED_PACKET_ACK = 8;
private static final int MESSAGE_TYPE_RECV_TRANSFER_PACKET = 9;
private static final int MESSAGE_TYPE_SEND_TRANSFER_PACKET = 10;
private static final int MESSAGE_TYPE_RECV_RANDOM = 11;
private static final int MESSAGE_TYPE_SEND_RANDOM = 12;
private static final int REQUEST_CODE_SEND_RED_PACKET = 16;
private static final int ITEM_RED_PACKET = 16;
private static final int REQUEST_CODE_SEND_TRANSFER_PACKET = 17;
private static final int ITEM_TRANSFER_PACKET = 17;
//end of red packet code
/**
* if it is chatBot
*/
private boolean isRobot;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return super.onCreateView(inflater, container, savedInstanceState);
}
@Override
protected void setUpView() {
setChatFragmentListener(this);
if (chatType == Constant.CHATTYPE_SINGLE) {
Map<String,RobotUser> robotMap = DemoHelper.getInstance().getRobotList();
if(robotMap!=null && robotMap.containsKey(toChatUsername)){
isRobot = true;
}
}
super.setUpView();
// set click listener
titleBar.setLeftLayoutClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (EasyUtils.isSingleActivity(getActivity())) {
Intent intent = new Intent(getActivity(), MainActivity.class);
startActivity(intent);
}
onBackPressed();
}
});
((EaseEmojiconMenu)inputMenu.getEmojiconMenu()).addEmojiconGroup(EmojiconExampleGroupData.getData());
if(chatType == EaseConstant.CHATTYPE_GROUP){
inputMenu.getPrimaryMenu().getEditText().addTextChangedListener(new TextWatcher() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
if(count == 1 && "@".equals(String.valueOf(s.charAt(start)))){
startActivityForResult(new Intent(getActivity(), PickAtUserActivity.class).
putExtra("groupId", toChatUsername), REQUEST_CODE_SELECT_AT_USER);
}
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void afterTextChanged(Editable s) {
}
});
}
}
@Override
protected void registerExtendMenuItem() {
//use the menu in base class
super.registerExtendMenuItem();
//extend menu items
inputMenu.registerExtendMenuItem(R.string.attach_video, R.drawable.em_chat_video_selector, ITEM_VIDEO, extendMenuItemClickListener);
inputMenu.registerExtendMenuItem(R.string.attach_file, R.drawable.em_chat_file_selector, ITEM_FILE, extendMenuItemClickListener);
if(chatType == Constant.CHATTYPE_SINGLE){
inputMenu.registerExtendMenuItem(R.string.attach_voice_call, R.drawable.em_chat_voice_call_selector, ITEM_VOICE_CALL, extendMenuItemClickListener);
inputMenu.registerExtendMenuItem(R.string.attach_video_call, R.drawable.em_chat_video_call_selector, ITEM_VIDEO_CALL, extendMenuItemClickListener);
}
//聊天室暂时不支持红包功能
//red packet code : 注册红包菜单选项
if (chatType != Constant.CHATTYPE_CHATROOM) {
inputMenu.registerExtendMenuItem(R.string.attach_red_packet, R.drawable.em_chat_red_packet_selector, ITEM_RED_PACKET, extendMenuItemClickListener);
}
//red packet code : 注册转账菜单选项
if (chatType == Constant.CHATTYPE_SINGLE) {
inputMenu.registerExtendMenuItem(R.string.attach_transfer_money, R.drawable.em_chat_transfer_selector, ITEM_TRANSFER_PACKET, extendMenuItemClickListener);
}
//end of red packet code
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_CONTEXT_MENU) {
switch (resultCode) {
case ContextMenuActivity.RESULT_CODE_COPY: // copy
clipboard.setPrimaryClip(ClipData.newPlainText(null,
((EMTextMessageBody) contextMenuMessage.getBody()).getMessage()));
break;
case ContextMenuActivity.RESULT_CODE_DELETE: // delete
conversation.removeMessage(contextMenuMessage.getMsgId());
messageList.refresh();
break;
case ContextMenuActivity.RESULT_CODE_FORWARD: // forward
Intent intent = new Intent(getActivity(), ForwardMessageActivity.class);
intent.putExtra("forward_msg_id", contextMenuMessage.getMsgId());
startActivity(intent);
break;
default:
break;
}
}
if(resultCode == Activity.RESULT_OK){
switch (requestCode) {
case REQUEST_CODE_SELECT_VIDEO: //send the video
if (data != null) {
int duration = data.getIntExtra("dur", 0);
String videoPath = data.getStringExtra("path");
File file = new File(PathUtil.getInstance().getImagePath(), "thvideo" + System.currentTimeMillis());
try {
FileOutputStream fos = new FileOutputStream(file);
Bitmap ThumbBitmap = ThumbnailUtils.createVideoThumbnail(videoPath, 3);
ThumbBitmap.compress(CompressFormat.JPEG, 100, fos);
fos.close();
sendVideoMessage(videoPath, file.getAbsolutePath(), duration);
} catch (Exception e) {
e.printStackTrace();
}
}
break;
case REQUEST_CODE_SELECT_FILE: //send the file
if (data != null) {
Uri uri = data.getData();
if (uri != null) {
sendFileByUri(uri);
}
}
break;
case REQUEST_CODE_SELECT_AT_USER:
if(data != null){
String username = data.getStringExtra("username");
inputAtUsername(username, false);
}
break;
//red packet code : 发送红包消息到聊天界面
case REQUEST_CODE_SEND_RED_PACKET:
if (data != null){
sendMessage(RedPacketUtil.createRPMessage(getActivity(), data, toChatUsername));
}
break;
case REQUEST_CODE_SEND_TRANSFER_PACKET://发送转账消息
if (data != null) {
sendMessage(RedPacketUtil.createTRMessage(getActivity(), data, toChatUsername));
}
break;
//end of red packet code
default:
break;
}
}
}
@Override
public void onSetMessageAttributes(EMMessage message) {
if(isRobot){
//set message extension
message.setAttribute("em_robot_message", isRobot);
}
}
@Override
public EaseCustomChatRowProvider onSetCustomChatRowProvider() {
return new CustomChatRowProvider();
}
@Override
public void onEnterToChatDetails() {
if (chatType == Constant.CHATTYPE_GROUP) {
EMGroup group = EMClient.getInstance().groupManager().getGroup(toChatUsername);
if (group == null) {
Toast.makeText(getActivity(), R.string.gorup_not_found, Toast.LENGTH_SHORT).show();
return;
}
startActivityForResult(
(new Intent(getActivity(), GroupDetailsActivity.class).putExtra("groupId", toChatUsername)),
REQUEST_CODE_GROUP_DETAIL);
}else if(chatType == Constant.CHATTYPE_CHATROOM){
startActivityForResult(new Intent(getActivity(), ChatRoomDetailsActivity.class).putExtra("roomId", toChatUsername), REQUEST_CODE_GROUP_DETAIL);
}
}
@Override
public void onAvatarClick(String username) {
//handling when user click avatar
Intent intent = new Intent(getActivity(), UserProfileActivity.class);
intent.putExtra("username", username);
startActivity(intent);
}
@Override
public void onAvatarLongClick(String username) {
inputAtUsername(username);
}
@Override
public boolean onMessageBubbleClick(EMMessage message) {
//消息框点击事件,demo这里不做覆盖,如需覆盖,return true
//red packet code : 拆红包页面
if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_MESSAGE, false)){
if (RedPacketUtil.isRandomRedPacket(message)){
RedPacketUtil.openRandomPacket(getActivity(),message);
} else {
RedPacketUtil.openRedPacket(getActivity(), chatType, message, toChatUsername, messageList);
}
return true;
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) {
RedPacketUtil.openTransferPacket(getActivity(), message);
return true;
}
//end of red packet code
return false;
}
@Override
public void onCmdMessageReceived(List<EMMessage> messages) {
//red packet code : 处理红包回执透传消息
for (EMMessage message : messages) {
EMCmdMessageBody cmdMsgBody = (EMCmdMessageBody) message.getBody();
String action = cmdMsgBody.action();//获取自定义action
if (action.equals(RPConstant.REFRESH_GROUP_RED_PACKET_ACTION)){
RedPacketUtil.receiveRedPacketAckMessage(message);
messageList.refresh();
}
}
//end of red packet code
super.onCmdMessageReceived(messages);
}
@Override
public void onMessageBubbleLongClick(EMMessage message) {
// no message forward when in chat room
startActivityForResult((new Intent(getActivity(), ContextMenuActivity.class)).putExtra("message",message)
.putExtra("ischatroom", chatType == EaseConstant.CHATTYPE_CHATROOM),
REQUEST_CODE_CONTEXT_MENU);
}
@Override
public boolean onExtendMenuItemClick(int itemId, View view) {
switch (itemId) {
case ITEM_VIDEO:
Intent intent = new Intent(getActivity(), ImageGridActivity.class);
startActivityForResult(intent, REQUEST_CODE_SELECT_VIDEO);
break;
case ITEM_FILE: //file
selectFileFromLocal();
break;
case ITEM_VOICE_CALL:
startVoiceCall();
break;
case ITEM_VIDEO_CALL:
startVideoCall();
break;
//red packet code : 进入发红包页面
case ITEM_RED_PACKET:
if (chatType == Constant.CHATTYPE_SINGLE) {
//单聊红包修改进入红包的方法,可以在小额随机红包和普通单聊红包之间切换
RedPacketUtil.startRandomPacket(new RPRedPacketUtil.RPRandomCallback() {
@Override
public void onSendPacketSuccess(Intent data) {
sendMessage(RedPacketUtil.createRPMessage(getActivity(), data, toChatUsername));
}
@Override
public void switchToNormalPacket() {
RedPacketUtil.startRedPacketActivityForResult(ChatFragment.this, chatType, toChatUsername, REQUEST_CODE_SEND_RED_PACKET);
}
},getActivity(),toChatUsername);
} else {
RedPacketUtil.startRedPacketActivityForResult(this, chatType, toChatUsername, REQUEST_CODE_SEND_RED_PACKET);
}
break;
case ITEM_TRANSFER_PACKET://进入转账页面
RedPacketUtil.startTransferActivityForResult(this, toChatUsername, REQUEST_CODE_SEND_TRANSFER_PACKET);
break;
//end of red packet code
default:
break;
}
//keep exist extend menu
return false;
}
/**
* select file
*/
protected void selectFileFromLocal() {
Intent intent = null;
if (Build.VERSION.SDK_INT < 19) { //api 19 and later, we can't use this way, demo just select from images
intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
intent.addCategory(Intent.CATEGORY_OPENABLE);
} else {
intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
}
startActivityForResult(intent, REQUEST_CODE_SELECT_FILE);
}
/**
* make a voice call
*/
protected void startVoiceCall() {
if (!EMClient.getInstance().isConnected()) {
Toast.makeText(getActivity(), R.string.not_connect_to_server, Toast.LENGTH_SHORT).show();
} else {
startActivity(new Intent(getActivity(), VoiceCallActivity.class).putExtra("username", toChatUsername)
.putExtra("isComingCall", false));
// voiceCallBtn.setEnabled(false);
inputMenu.hideExtendMenuContainer();
}
}
/**
* make a video call
*/
protected void startVideoCall() {
if (!EMClient.getInstance().isConnected())
Toast.makeText(getActivity(), R.string.not_connect_to_server, Toast.LENGTH_SHORT).show();
else {
startActivity(new Intent(getActivity(), VideoCallActivity.class).putExtra("username", toChatUsername)
.putExtra("isComingCall", false));
// videoCallBtn.setEnabled(false);
inputMenu.hideExtendMenuContainer();
}
}
/**
* chat row provider
*
*/
private final class CustomChatRowProvider implements EaseCustomChatRowProvider {
@Override
public int getCustomChatRowTypeCount() {
//here the number is the message type in EMMessage::Type
//which is used to count the number of different chat row
return 12;
}
@Override
public int getCustomChatRowType(EMMessage message) {
if(message.getType() == EMMessage.Type.TXT){
//voice call
if (message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VOICE_CALL, false)){
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_VOICE_CALL : MESSAGE_TYPE_SENT_VOICE_CALL;
}else if (message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VIDEO_CALL, false)){
//video call
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_VIDEO_CALL : MESSAGE_TYPE_SENT_VIDEO_CALL;
}
//red packet code : 红包消息、红包回执消息以及转账消息的chatrow type
else if (RedPacketUtil.isRandomRedPacket(message)) {
//小额随机红包
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_RANDOM : MESSAGE_TYPE_SEND_RANDOM;
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_MESSAGE, false)) {
//发送红包消息
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_RED_PACKET : MESSAGE_TYPE_SEND_RED_PACKET;
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_ACK_MESSAGE, false)) {
//领取红包消息
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_RED_PACKET_ACK : MESSAGE_TYPE_SEND_RED_PACKET_ACK;
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) {
//转账消息
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_TRANSFER_PACKET : MESSAGE_TYPE_SEND_TRANSFER_PACKET;
}
//end of red packet code
}
return 0;
}
@Override
public EaseChatRow getCustomChatRow(EMMessage message, int position, BaseAdapter adapter) {
if(message.getType() == EMMessage.Type.TXT){
// voice call or video call
if (message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VOICE_CALL, false) ||
message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VIDEO_CALL, false)){
return new ChatRowVoiceCall(getActivity(), message, position, adapter);
}
//red packet code : 红包消息、红包回执消息以及转账消息的chat row
else if (RedPacketUtil.isRandomRedPacket(message)) {//小额随机红包
return new ChatRowRandomPacket(getActivity(), message, position, adapter);
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_MESSAGE, false)) {//红包消息
return new ChatRowRedPacket(getActivity(), message, position, adapter);
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_ACK_MESSAGE, false)) {//红包回执消息
return new ChatRowRedPacketAck(getActivity(), message, position, adapter);
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) {//转账消息
return new ChatRowTransfer(getActivity(), message, position, adapter);
}
//end of red packet code
}
return null;
}
}
}
点击标题返回及群聊@别人的功能
setChatFragmentListener(this);
if (chatType == Constant.CHATTYPE_SINGLE) {
Map<String,RobotUser> robotMap = DemoHelper.getInstance().getRobotList();
if(robotMap!=null && robotMap.containsKey(toChatUsername)){
isRobot = true;
}
}
// set click listener菜单的操作
titleBar.setLeftLayoutClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (EasyUtils.isSingleActivity(getActivity())) {
Intent intent = new Intent(getActivity(), MainActivity.class);
startActivity(intent);
}
onBackPressed();
}
});
((EaseEmojiconMenu)inputMenu.getEmojiconMenu()).addEmojiconGroup(EmojiconExampleGroupData.getData());
if(chatType == EaseConstant.CHATTYPE_GROUP){
inputMenu.getPrimaryMenu().getEditText().addTextChangedListener(new TextWatcher() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
if(count == 1 && "@".equals(String.valueOf(s.charAt(start)))){
startActivityForResult(new Intent(getActivity(), PickAtUserActivity.class).
putExtra("groupId", toChatUsername), REQUEST_CODE_SELECT_AT_USER);
}
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void afterTextChanged(Editable s) {
}
});
}
一些功能操作
super.registerExtendMenuItem();
//extend menu items
inputMenu.registerExtendMenuItem(R.string.attach_video, R.drawable.em_chat_video_selector, ITEM_VIDEO, extendMenuItemClickListener);
inputMenu.registerExtendMenuItem(R.string.attach_file, R.drawable.em_chat_file_selector, ITEM_FILE, extendMenuItemClickListener);
if(chatType == Constant.CHATTYPE_SINGLE){
inputMenu.registerExtendMenuItem(R.string.attach_voice_call, R.drawable.em_chat_voice_call_selector, ITEM_VOICE_CALL, extendMenuItemClickListener);
inputMenu.registerExtendMenuItem(R.string.attach_video_call, R.drawable.em_chat_video_call_selector, ITEM_VIDEO_CALL, extendMenuItemClickListener);
}
//聊天室暂时不支持红包功能
//red packet code : 注册红包菜单选项
if (chatType != Constant.CHATTYPE_CHATROOM) {
inputMenu.registerExtendMenuItem(R.string.attach_red_packet, R.drawable.em_chat_red_packet_selector, ITEM_RED_PACKET, extendMenuItemClickListener);
}
//red packet code : 注册转账菜单选项
if (chatType == Constant.CHATTYPE_SINGLE) {
inputMenu.registerExtendMenuItem(R.string.attach_transfer_money, R.drawable.em_chat_transfer_selector, ITEM_TRANSFER_PACKET, extendMenuItemClickListener);
}
//end of red packet code
if (requestCode == REQUEST_CODE_CONTEXT_MENU) {进入聊天详情
switch (resultCode) {
case ContextMenuActivity.RESULT_CODE_COPY: // copy
clipboard.setPrimaryClip(ClipData.newPlainText(null,
((EMTextMessageBody) contextMenuMessage.getBody()).getMessage()));
break;
case ContextMenuActivity.RESULT_CODE_DELETE: // delete
conversation.removeMessage(contextMenuMessage.getMsgId());
messageList.refresh();
break;
case ContextMenuActivity.RESULT_CODE_FORWARD: // forward
Intent intent = new Intent(getActivity(), ForwardMessageActivity.class);
intent.putExtra("forward_msg_id", contextMenuMessage.getMsgId());
startActivity(intent);
break;
default:
break;
}
}
if(resultCode == Activity.RESULT_OK){
switch (requestCode) {
case REQUEST_CODE_SELECT_VIDEO: //send the video
if (data != null) {
int duration = data.getIntExtra("dur", 0);
String videoPath = data.getStringExtra("path");
File file = new File(PathUtil.getInstance().getImagePath(), "thvideo" + System.currentTimeMillis());
try {
FileOutputStream fos = new FileOutputStream(file);
Bitmap ThumbBitmap = ThumbnailUtils.createVideoThumbnail(videoPath, 3);
ThumbBitmap.compress(CompressFormat.JPEG, 100, fos);
fos.close();
sendVideoMessage(videoPath, file.getAbsolutePath(), duration);
} catch (Exception e) {
e.printStackTrace();
}
}
break;
case REQUEST_CODE_SELECT_FILE: //send the file
if (data != null) {
Uri uri = data.getData();
if (uri != null) {
sendFileByUri(uri);
}
}
break;
case REQUEST_CODE_SELECT_AT_USER:
if(data != null){
String username = data.getStringExtra("username");
inputAtUsername(username, false);
}
break;
//red packet code : 发送红包消息到聊天界面
case REQUEST_CODE_SEND_RED_PACKET:
if (data != null){
sendMessage(RedPacketUtil.createRPMessage(getActivity(), data, toChatUsername));
}
break;
case REQUEST_CODE_SEND_TRANSFER_PACKET://发送转账消息
if (data != null) {
sendMessage(RedPacketUtil.createTRMessage(getActivity(), data, toChatUsername));
}
break;
//end of red packet code
default:
break;
}
}
点击头像
@Override
public void onEnterToChatDetails() {
if (chatType == Constant.CHATTYPE_GROUP) {
EMGroup group = EMClient.getInstance().groupManager().getGroup(toChatUsername);
if (group == null) {
Toast.makeText(getActivity(), R.string.gorup_not_found, Toast.LENGTH_SHORT).show();
return;
}
startActivityForResult(
(new Intent(getActivity(), GroupDetailsActivity.class).putExtra("groupId", toChatUsername)),
REQUEST_CODE_GROUP_DETAIL);
}else if(chatType == Constant.CHATTYPE_CHATROOM){
startActivityForResult(new Intent(getActivity(), ChatRoomDetailsActivity.class).putExtra("roomId", toChatUsername), REQUEST_CODE_GROUP_DETAIL);
}
}
@Override消息框点击事件、拆红包
public void onAvatarClick(String username) {
//handling when user click avatar
Intent intent = new Intent(getActivity(), UserProfileActivity.class);
intent.putExtra("username", username);
startActivity(intent);
}
@Override红包回执及消息框长按
public boolean onMessageBubbleClick(EMMessage message) {
//消息框点击事件,demo这里不做覆盖,如需覆盖,return true
//red packet code : 拆红包页面
if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_MESSAGE, false)){
if (RedPacketUtil.isRandomRedPacket(message)){
RedPacketUtil.openRandomPacket(getActivity(),message);
} else {
RedPacketUtil.openRedPacket(getActivity(), chatType, message, toChatUsername, messageList);
}
return true;
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) {
RedPacketUtil.openTransferPacket(getActivity(), message);
return true;
}
//end of red packet code
return false;
}
@Override扩展按钮
public void onCmdMessageReceived(List<EMMessage> messages) {
//red packet code : 处理红包回执透传消息
for (EMMessage message : messages) {
EMCmdMessageBody cmdMsgBody = (EMCmdMessageBody) message.getBody();
String action = cmdMsgBody.action();//获取自定义action
if (action.equals(RPConstant.REFRESH_GROUP_RED_PACKET_ACTION)){
RedPacketUtil.receiveRedPacketAckMessage(message);
messageList.refresh();
}
}
//end of red packet code
super.onCmdMessageReceived(messages);
}
@Override
public void onMessageBubbleLongClick(EMMessage message) {
// no message forward when in chat room
startActivityForResult((new Intent(getActivity(), ContextMenuActivity.class)).putExtra("message",message)
.putExtra("ischatroom", chatType == EaseConstant.CHATTYPE_CHATROOM),
REQUEST_CODE_CONTEXT_MENU);
}
@Override本地文件选择、语音通话、视频通话、及自定义chatrow类型
public boolean onExtendMenuItemClick(int itemId, View view) {
switch (itemId) {
case ITEM_VIDEO:
Intent intent = new Intent(getActivity(), ImageGridActivity.class);
startActivityForResult(intent, REQUEST_CODE_SELECT_VIDEO);
break;
case ITEM_FILE: //file
selectFileFromLocal();
break;
case ITEM_VOICE_CALL:
startVoiceCall();
break;
case ITEM_VIDEO_CALL:
startVideoCall();
break;
//red packet code : 进入发红包页面
case ITEM_RED_PACKET:
if (chatType == Constant.CHATTYPE_SINGLE) {
//单聊红包修改进入红包的方法,可以在小额随机红包和普通单聊红包之间切换
RedPacketUtil.startRandomPacket(new RPRedPacketUtil.RPRandomCallback() {
@Override
public void onSendPacketSuccess(Intent data) {
sendMessage(RedPacketUtil.createRPMessage(getActivity(), data, toChatUsername));
}
@Override
public void switchToNormalPacket() {
RedPacketUtil.startRedPacketActivityForResult(ChatFragment.this, chatType, toChatUsername, REQUEST_CODE_SEND_RED_PACKET);
}
},getActivity(),toChatUsername);
} else {
RedPacketUtil.startRedPacketActivityForResult(this, chatType, toChatUsername, REQUEST_CODE_SEND_RED_PACKET);
}
break;
case ITEM_TRANSFER_PACKET://进入转账页面
RedPacketUtil.startTransferActivityForResult(this, toChatUsername, REQUEST_CODE_SEND_TRANSFER_PACKET);
break;
//end of red packet code
default:
break;
}
//keep exist extend menu
return false;
}
Redpacketlibrary
/**
* select file
*/
protected void selectFileFromLocal() {
Intent intent = null;
if (Build.VERSION.SDK_INT < 19) { //api 19 and later, we can't use this way, demo just select from images
intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
intent.addCategory(Intent.CATEGORY_OPENABLE);
} else {
intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
}
startActivityForResult(intent, REQUEST_CODE_SELECT_FILE);
}
/**
* make a voice call
*/
protected void startVoiceCall() {
if (!EMClient.getInstance().isConnected()) {
Toast.makeText(getActivity(), R.string.not_connect_to_server, Toast.LENGTH_SHORT).show();
} else {
startActivity(new Intent(getActivity(), VoiceCallActivity.class).putExtra("username", toChatUsername)
.putExtra("isComingCall", false));
// voiceCallBtn.setEnabled(false);
inputMenu.hideExtendMenuContainer();
}
}
/**
* make a video call
*/
protected void startVideoCall() {
if (!EMClient.getInstance().isConnected())
Toast.makeText(getActivity(), R.string.not_connect_to_server, Toast.LENGTH_SHORT).show();
else {
startActivity(new Intent(getActivity(), VideoCallActivity.class).putExtra("username", toChatUsername)
.putExtra("isComingCall", false));
// videoCallBtn.setEnabled(false);
inputMenu.hideExtendMenuContainer();
}
}
/**
* chat row provider
*
*/
private final class CustomChatRowProvider implements EaseCustomChatRowProvider {
@Override
public int getCustomChatRowTypeCount() {
//here the number is the message type in EMMessage::Type
//which is used to count the number of different chat row
return 12;
}
@Override
public int getCustomChatRowType(EMMessage message) {
if(message.getType() == EMMessage.Type.TXT){
//voice call
if (message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VOICE_CALL, false)){
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_VOICE_CALL : MESSAGE_TYPE_SENT_VOICE_CALL;
}else if (message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VIDEO_CALL, false)){
//video call
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_VIDEO_CALL : MESSAGE_TYPE_SENT_VIDEO_CALL;
}
//red packet code : 红包消息、红包回执消息以及转账消息的chatrow type
else if (RedPacketUtil.isRandomRedPacket(message)) {
//小额随机红包
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_RANDOM : MESSAGE_TYPE_SEND_RANDOM;
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_MESSAGE, false)) {
//发送红包消息
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_RED_PACKET : MESSAGE_TYPE_SEND_RED_PACKET;
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_ACK_MESSAGE, false)) {
//领取红包消息
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_RED_PACKET_ACK : MESSAGE_TYPE_SEND_RED_PACKET_ACK;
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) {
//转账消息
return message.direct() == EMMessage.Direct.RECEIVE ? MESSAGE_TYPE_RECV_TRANSFER_PACKET : MESSAGE_TYPE_SEND_TRANSFER_PACKET;
}
//end of red packet code
}
return 0;
}
@Override
public EaseChatRow getCustomChatRow(EMMessage message, int position, BaseAdapter adapter) {
if(message.getType() == EMMessage.Type.TXT){
// voice call or video call
if (message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VOICE_CALL, false) ||
message.getBooleanAttribute(Constant.MESSAGE_ATTR_IS_VIDEO_CALL, false)){
return new ChatRowVoiceCall(getActivity(), message, position, adapter);
}
//red packet code : 红包消息、红包回执消息以及转账消息的chat row
else if (RedPacketUtil.isRandomRedPacket(message)) {//小额随机红包
return new ChatRowRandomPacket(getActivity(), message, position, adapter);
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_MESSAGE, false)) {//红包消息
return new ChatRowRedPacket(getActivity(), message, position, adapter);
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_ACK_MESSAGE, false)) {//红包回执消息
return new ChatRowRedPacketAck(getActivity(), message, position, adapter);
} else if (message.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) {//转账消息
return new ChatRowTransfer(getActivity(), message, position, adapter);
}
//end of red packet code
}
return null;
}
}
由于业务未涉及,暂不作分析。
总结及其他
其实正常集成,按照于海同学所说也就半天时间,这是因为的确环信的SDK使用起来比较方便。
通过大致的阅读代码,环信的Demo代码写的还是很不错的,功能齐全,注释完整。值得学习和研究。
写在最后
多学习,多积累,多输出。!
附:最近两天实际工作采用环信SDK的开发详案
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现 收起阅读 »
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面
环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0
环信官方Demo源码分析及SDK简单应用-LoginActivity
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面
环信官方Demo源码分析及SDK简单应用-EaseUI
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现
设置界面
我们来贴代码
跟我们平常写的什么我的界面是大同小异的。主要有这些,其大多设置与demoModel有关
零钱
RedPacketUtil.startChangeActivity(getActivity());接受新消息通知
settingsModel.setSettingMsgNotification(false);
声音
PreferenceManager.getInstance().setSettingMsgNotification(paramBoolean);
valueCache.put(Key.VibrateAndPlayToneOn, paramBoolean);
settingsModel.setSettingMsgSound(false);震动
settingsModel.setSettingMsgVibrate(false);消息推送设置
使用扬声器播放语音
settingsModel.setSettingMsgSpeaker(false);自定义AppKey
settingsModel.enableCustomAppkey(false);自定义server
settingsModel.enableCustomServer(false); settingsModel.enableCustomServer(false);个人资料
startActivity(new Intent(getActivity(), UserProfileActivity.class).putExtra("setting", true)通讯录黑名单
.putExtra("username", EMClient.getInstance().getCurrentUser()));
startActivity(new Intent(getActivity(), BlacklistActivity.class));诊断
startActivity(new Intent(getActivity(), DiagnoseActivity.class));IOS离线推送昵称
startActivity(new Intent(getActivity(), OfflinePushNickActivity.class));通话设置
startActivity(new Intent(getActivity(), CallOptionActivity.class));允许聊天室群主离开
settingsModel.allowChatroomOwnerLeave(false);退出群组时删除聊天数据
chatOptions.allowChatroomOwnerLeave(false);
settingsModel.setDeleteMessagesAsExitGroup(false);自动同意群组加群邀请
chatOptions.setDeleteMessagesAsExitGroup(false);
settingsModel.setAutoAcceptGroupInvitation(false);视频自适应编码
chatOptions.setAutoAcceptGroupInvitation(false);
settingsModel.setAdaptiveVideoEncode(false);退出登录
EMClient.getInstance().callManager().getCallOptions().enableFixedVideoResolution(true);
DemoHelper.getInstance().logout(false,new EMCallBack() {到这里主界面的三个fragment就都讲完了,我们来看重头戏。
@Override
public void onSuccess() {
getActivity().runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
// show login screen
((MainActivity) getActivity()).finish();
startActivity(new Intent(getActivity(), LoginActivity.class));
}
});
}
@Override
public void onProgress(int progress, String status) {
}
@Override
public void onError(int code, String message) {
getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
pd.dismiss();
Toast.makeText(getActivity(), "unbind devicetokens failed", Toast.LENGTH_SHORT).show();
}
});
}
});
环信官方Demo源码分析及SDK简单应用-EaseUI 收起阅读 »
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面
环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0
环信官方Demo源码分析及SDK简单应用-LoginActivity
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面
环信官方Demo源码分析及SDK简单应用-EaseUI
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现
刚才我们看了主界面的三个fragment中的第一个界面-会话界面,再来看通讯录界面。
通讯录界面
ContactListFragment
照例,我们来贴代码:
/**界面及初始化
* Copyright (C) 2016 Hyphenate Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hyphenate.chatuidemo.ui;
import java.util.Hashtable;
import java.util.Map;
import com.hyphenate.chat.EMClient;
import com.hyphenate.chatuidemo.DemoHelper;
import com.hyphenate.chatuidemo.DemoHelper.DataSyncListener;
import com.hyphenate.chatuidemo.R;
import com.hyphenate.chatuidemo.db.InviteMessgeDao;
import com.hyphenate.chatuidemo.db.UserDao;
import com.hyphenate.chatuidemo.widget.ContactItemView;
import com.hyphenate.easeui.domain.EaseUser;
import com.hyphenate.easeui.ui.EaseContactListFragment;
import com.hyphenate.util.EMLog;
import com.hyphenate.util.NetUtils;
import android.annotation.SuppressLint;
import android.app.ProgressDialog;
import android.content.Intent;
import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.AdapterView;
import android.widget.AdapterView.AdapterContextMenuInfo;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.Toast;
/**
* contact list
*
*/
public class ContactListFragment extends EaseContactListFragment {
private static final String TAG = ContactListFragment.class.getSimpleName();
private ContactSyncListener contactSyncListener;
private BlackListSyncListener blackListSyncListener;
private ContactInfoSyncListener contactInfoSyncListener;
private View loadingView;
private ContactItemView applicationItem;
private InviteMessgeDao inviteMessgeDao;
@SuppressLint("InflateParams")
@Override
protected void initView() {
super.initView();
@SuppressLint("InflateParams") View headerView = LayoutInflater.from(getActivity()).inflate(R.layout.em_contacts_header, null);
HeaderItemClickListener clickListener = new HeaderItemClickListener();
applicationItem = (ContactItemView) headerView.findViewById(R.id.application_item);
applicationItem.setOnClickListener(clickListener);
headerView.findViewById(R.id.group_item).setOnClickListener(clickListener);
headerView.findViewById(R.id.chat_room_item).setOnClickListener(clickListener);
headerView.findViewById(R.id.robot_item).setOnClickListener(clickListener);
listView.addHeaderView(headerView);
//add loading view
loadingView = LayoutInflater.from(getActivity()).inflate(R.layout.em_layout_loading_data, null);
contentContainer.addView(loadingView);
registerForContextMenu(listView);
}
@Override
public void refresh() {
Map<String, EaseUser> m = DemoHelper.getInstance().getContactList();
if (m instanceof Hashtable<?, ?>) {
//noinspection unchecked
m = (Map<String, EaseUser>) ((Hashtable<String, EaseUser>)m).clone();
}
setContactsMap(m);
super.refresh();
if(inviteMessgeDao == null){
inviteMessgeDao = new InviteMessgeDao(getActivity());
}
if(inviteMessgeDao.getUnreadMessagesCount() > 0){
applicationItem.showUnreadMsgView();
}else{
applicationItem.hideUnreadMsgView();
}
}
@SuppressWarnings("unchecked")
@Override
protected void setUpView() {
titleBar.setRightImageResource(R.drawable.em_add);
titleBar.setRightLayoutClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// startActivity(new Intent(getActivity(), AddContactActivity.class));
NetUtils.hasDataConnection(getActivity());
}
});
//设置联系人数据
Map<String, EaseUser> m = DemoHelper.getInstance().getContactList();
if (m instanceof Hashtable<?, ?>) {
m = (Map<String, EaseUser>) ((Hashtable<String, EaseUser>)m).clone();
}
setContactsMap(m);
super.setUpView();
listView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
EaseUser user = (EaseUser)listView.getItemAtPosition(position);
if (user != null) {
String username = user.getUsername();
// demo中直接进入聊天页面,实际一般是进入用户详情页
startActivity(new Intent(getActivity(), ChatActivity.class).putExtra("userId", username));
}
}
});
// 进入添加好友页
titleBar.getRightLayout().setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
startActivity(new Intent(getActivity(), AddContactActivity.class));
}
});
contactSyncListener = new ContactSyncListener();
DemoHelper.getInstance().addSyncContactListener(contactSyncListener);
blackListSyncListener = new BlackListSyncListener();
DemoHelper.getInstance().addSyncBlackListListener(blackListSyncListener);
contactInfoSyncListener = new ContactInfoSyncListener();
DemoHelper.getInstance().getUserProfileManager().addSyncContactInfoListener(contactInfoSyncListener);
if (DemoHelper.getInstance().isContactsSyncedWithServer()) {
loadingView.setVisibility(View.GONE);
} else if (DemoHelper.getInstance().isSyncingContactsWithServer()) {
loadingView.setVisibility(View.VISIBLE);
}
}
@Override
public void onDestroy() {
super.onDestroy();
if (contactSyncListener != null) {
DemoHelper.getInstance().removeSyncContactListener(contactSyncListener);
contactSyncListener = null;
}
if(blackListSyncListener != null){
DemoHelper.getInstance().removeSyncBlackListListener(blackListSyncListener);
}
if(contactInfoSyncListener != null){
DemoHelper.getInstance().getUserProfileManager().removeSyncContactInfoListener(contactInfoSyncListener);
}
}
protected class HeaderItemClickListener implements OnClickListener{
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.application_item:
// 进入申请与通知页面
startActivity(new Intent(getActivity(), NewFriendsMsgActivity.class));
break;
case R.id.group_item:
// 进入群聊列表页面
startActivity(new Intent(getActivity(), GroupsActivity.class));
break;
case R.id.chat_room_item:
//进入聊天室列表页面
startActivity(new Intent(getActivity(), PublicChatRoomsActivity.class));
break;
case R.id.robot_item:
//进入Robot列表页面
startActivity(new Intent(getActivity(), RobotsActivity.class));
break;
default:
break;
}
}
}
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
super.onCreateContextMenu(menu, v, menuInfo);
toBeProcessUser = (EaseUser) listView.getItemAtPosition(((AdapterContextMenuInfo) menuInfo).position);
toBeProcessUsername = toBeProcessUser.getUsername();
getActivity().getMenuInflater().inflate(R.menu.em_context_contact_list, menu);
}
@Override
public boolean onContextItemSelected(MenuItem item) {
if (item.getItemId() == R.id.delete_contact) {
try {
// delete contact
deleteContact(toBeProcessUser);
// remove invitation message
InviteMessgeDao dao = new InviteMessgeDao(getActivity());
dao.deleteMessage(toBeProcessUser.getUsername());
} catch (Exception e) {
e.printStackTrace();
}
return true;
}else if(item.getItemId() == R.id.add_to_blacklist){
moveToBlacklist(toBeProcessUsername);
return true;
}
return super.onContextItemSelected(item);
}
/**
* delete contact
*
* @param toDeleteUser
*/
public void deleteContact(final EaseUser tobeDeleteUser) {
String st1 = getResources().getString(R.string.deleting);
final String st2 = getResources().getString(R.string.Delete_failed);
final ProgressDialog pd = new ProgressDialog(getActivity());
pd.setMessage(st1);
pd.setCanceledOnTouchOutside(false);
pd.show();
new Thread(new Runnable() {
public void run() {
try {
EMClient.getInstance().contactManager().deleteContact(tobeDeleteUser.getUsername());
// remove user from memory and database
UserDao dao = new UserDao(getActivity());
dao.deleteContact(tobeDeleteUser.getUsername());
DemoHelper.getInstance().getContactList().remove(tobeDeleteUser.getUsername());
getActivity().runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
contactList.remove(tobeDeleteUser);
contactListLayout.refresh();
}
});
} catch (final Exception e) {
getActivity().runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
Toast.makeText(getActivity(), st2 + e.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
}
}).start();
}
class ContactSyncListener implements DataSyncListener{
@Override
public void onSyncComplete(final boolean success) {
EMLog.d(TAG, "on contact list sync success:" + success);
getActivity().runOnUiThread(new Runnable() {
public void run() {
getActivity().runOnUiThread(new Runnable(){
@Override
public void run() {
if(success){
loadingView.setVisibility(View.GONE);
refresh();
}else{
String s1 = getResources().getString(R.string.get_failed_please_check);
Toast.makeText(getActivity(), s1, Toast.LENGTH_LONG).show();
loadingView.setVisibility(View.GONE);
}
}
});
}
});
}
}
class BlackListSyncListener implements DataSyncListener{
@Override
public void onSyncComplete(boolean success) {
getActivity().runOnUiThread(new Runnable(){
@Override
public void run() {
refresh();
}
});
}
}
class ContactInfoSyncListener implements DataSyncListener{
@Override
public void onSyncComplete(final boolean success) {
EMLog.d(TAG, "on contactinfo list sync success:" + success);
getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
loadingView.setVisibility(View.GONE);
if(success){
refresh();
}
}
});
}
}
}
我们先来一个直观的界面感受。
首先是干嘛,是填充了该ListView的头部。
接着我们来看其他方法。
@SuppressLint("InflateParams")
@Override
protected void initView() {
super.initView();
@SuppressLint("InflateParams") View headerView = LayoutInflater.from(getActivity()).inflate(R.layout.em_contacts_header, null);
HeaderItemClickListener clickListener = new HeaderItemClickListener();
applicationItem = (ContactItemView) headerView.findViewById(R.id.application_item);
applicationItem.setOnClickListener(clickListener);
headerView.findViewById(R.id.group_item).setOnClickListener(clickListener);
headerView.findViewById(R.id.chat_room_item).setOnClickListener(clickListener);
headerView.findViewById(R.id.robot_item).setOnClickListener(clickListener);
listView.addHeaderView(headerView);
//add loading view
loadingView = LayoutInflater.from(getActivity()).inflate(R.layout.em_layout_loading_data, null);
contentContainer.addView(loadingView);
registerForContextMenu(listView);
}
刷新联系人
刷新联系人及邀请信息。
@SuppressWarnings("unchecked")常规的一些界面操作。
@Override
protected void setUpView() {
titleBar.setRightImageResource(R.drawable.em_add);
titleBar.setRightLayoutClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// startActivity(new Intent(getActivity(), AddContactActivity.class));
NetUtils.hasDataConnection(getActivity());
}
});
//设置联系人数据
Map<String, EaseUser> m = DemoHelper.getInstance().getContactList();
if (m instanceof Hashtable<?, ?>) {
m = (Map<String, EaseUser>) ((Hashtable<String, EaseUser>)m).clone();
}
setContactsMap(m);
super.setUpView();
listView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
EaseUser user = (EaseUser)listView.getItemAtPosition(position);
if (user != null) {
String username = user.getUsername();
// demo中直接进入聊天页面,实际一般是进入用户详情页
startActivity(new Intent(getActivity(), ChatActivity.class).putExtra("userId", username));
}
}
});
// 进入添加好友页
titleBar.getRightLayout().setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
startActivity(new Intent(getActivity(), AddContactActivity.class));
}
});
contactSyncListener = new ContactSyncListener();
DemoHelper.getInstance().addSyncContactListener(contactSyncListener);
blackListSyncListener = new BlackListSyncListener();
DemoHelper.getInstance().addSyncBlackListListener(blackListSyncListener);
contactInfoSyncListener = new ContactInfoSyncListener();
DemoHelper.getInstance().getUserProfileManager().addSyncContactInfoListener(contactInfoSyncListener);
if (DemoHelper.getInstance().isContactsSyncedWithServer()) {
loadingView.setVisibility(View.GONE);
} else if (DemoHelper.getInstance().isSyncingContactsWithServer()) {
loadingView.setVisibility(View.VISIBLE);
}
}
注册同步监听
比较有趣的是如下的操作。
contactSyncListener = new ContactSyncListener();添加了三个同步监听。
DemoHelper.getInstance().addSyncContactListener(contactSyncListener);
blackListSyncListener = new BlackListSyncListener();
DemoHelper.getInstance().addSyncBlackListListener(blackListSyncListener);
contactInfoSyncListener = new ContactInfoSyncListener();
DemoHelper.getInstance().getUserProfileManager().addSyncContactInfoListener(contactInfoSyncListener);
if (DemoHelper.getInstance().isContactsSyncedWithServer()) {
loadingView.setVisibility(View.GONE);
} else if (DemoHelper.getInstance().isSyncingContactsWithServer()) {
loadingView.setVisibility(View.VISIBLE);
}
- addSyncContactListener 同步联系人监听
- addSyncBlackListListener 同步黑名单监听
- addSyncContactInfoListener 同步联系人信息监听
- isContactsSyncedWithServer
- isSyncingContactsWithServer
两个方法。
我们再来看其他的方法。
注册同步监听
注销四个监听。
@Override
public void onDestroy() {
super.onDestroy();
if (contactSyncListener != null) {
DemoHelper.getInstance().removeSyncContactListener(contactSyncListener);
contactSyncListener = null;
}
if(blackListSyncListener != null){
DemoHelper.getInstance().removeSyncBlackListListener(blackListSyncListener);
}
if(contactInfoSyncListener != null){
DemoHelper.getInstance().getUserProfileManager().removeSyncContactInfoListener(contactInfoSyncListener);
}
headview四条目监听。
protected class HeaderItemClickListener implements OnClickListener{
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.application_item:
// 进入申请与通知页面
startActivity(new Intent(getActivity(), NewFriendsMsgActivity.class));
break;
case R.id.group_item:
// 进入群聊列表页面
startActivity(new Intent(getActivity(), GroupsActivity.class));
break;
case R.id.chat_room_item:
//进入聊天室列表页面
startActivity(new Intent(getActivity(), PublicChatRoomsActivity.class));
break;
case R.id.robot_item:
//进入Robot列表页面
startActivity(new Intent(getActivity(), RobotsActivity.class));
break;
default:
break;
}
}
删除联系人及其他监听实现
/**删除联系人,主要两句话。
* delete contact
*
* @param toDeleteUser
*/
public void deleteContact(final EaseUser tobeDeleteUser) {
String st1 = getResources().getString(R.string.deleting);
final String st2 = getResources().getString(R.string.Delete_failed);
final ProgressDialog pd = new ProgressDialog(getActivity());
pd.setMessage(st1);
pd.setCanceledOnTouchOutside(false);
pd.show();
new Thread(new Runnable() {
public void run() {
try {
EMClient.getInstance().contactManager().deleteContact(tobeDeleteUser.getUsername());
// remove user from memory and database
UserDao dao = new UserDao(getActivity());
dao.deleteContact(tobeDeleteUser.getUsername());
DemoHelper.getInstance().getContactList().remove(tobeDeleteUser.getUsername());
getActivity().runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
contactList.remove(tobeDeleteUser);
contactListLayout.refresh();
}
});
} catch (final Exception e) {
getActivity().runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
Toast.makeText(getActivity(), st2 + e.getMessage(), Toast.LENGTH_LONG).show();
}
});
}
}
}).start();
}
class ContactSyncListener implements DataSyncListener{
@Override
public void onSyncComplete(final boolean success) {
EMLog.d(TAG, "on contact list sync success:" + success);
getActivity().runOnUiThread(new Runnable() {
public void run() {
getActivity().runOnUiThread(new Runnable(){
@Override
public void run() {
if(success){
loadingView.setVisibility(View.GONE);
refresh();
}else{
String s1 = getResources().getString(R.string.get_failed_please_check);
Toast.makeText(getActivity(), s1, Toast.LENGTH_LONG).show();
loadingView.setVisibility(View.GONE);
}
}
});
}
});
}
}
class BlackListSyncListener implements DataSyncListener{
@Override
public void onSyncComplete(boolean success) {
getActivity().runOnUiThread(new Runnable(){
@Override
public void run() {
refresh();
}
});
}
}
class ContactInfoSyncListener implements DataSyncListener{
@Override
public void onSyncComplete(final boolean success) {
EMLog.d(TAG, "on contactinfo list sync success:" + success);
getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
loadingView.setVisibility(View.GONE);
if(success){
refresh();
}
}
});
}
}
EMClient.getInstance().contactManager().deleteContact以及
DemoHelper.getInstance().getContactList().remove(tobeDeleteUser.getUsername());其他监听均实现了DataSyncListener接口。
下面,我们来看他爹。
EaseContactListFragment
/**填充布局
* Copyright (C) 2016 Hyphenate Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hyphenate.easeui.ui;
import android.app.ProgressDialog;
import android.os.Bundle;
import android.os.Handler;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnTouchListener;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ListView;
import android.widget.Toast;
import com.hyphenate.EMConnectionListener;
import com.hyphenate.EMError;
import com.hyphenate.chat.EMClient;
import com.hyphenate.easeui.R;
import com.hyphenate.easeui.domain.EaseUser;
import com.hyphenate.easeui.utils.EaseCommonUtils;
import com.hyphenate.easeui.widget.EaseContactList;
import com.hyphenate.exceptions.HyphenateException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
/**
* contact list
*
*/
public class EaseContactListFragment extends EaseBaseFragment {
private static final String TAG = "EaseContactListFragment";
protected List<EaseUser> contactList;
protected ListView listView;
protected boolean hidden;
protected ImageButton clearSearch;
protected EditText query;
protected Handler handler = new Handler();
protected EaseUser toBeProcessUser;
protected String toBeProcessUsername;
protected EaseContactList contactListLayout;
protected boolean isConflict;
protected FrameLayout contentContainer;
private Map<String, EaseUser> contactsMap;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.ease_fragment_contact_list, container, false);
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
//to avoid crash when open app after long time stay in background after user logged into another device
if(savedInstanceState != null && savedInstanceState.getBoolean("isConflict", false))
return;
super.onActivityCreated(savedInstanceState);
}
@Override
protected void initView() {
contentContainer = (FrameLayout) getView().findViewById(R.id.content_container);
contactListLayout = (EaseContactList) getView().findViewById(R.id.contact_list);
listView = contactListLayout.getListView();
//search
query = (EditText) getView().findViewById(R.id.query);
clearSearch = (ImageButton) getView().findViewById(R.id.search_clear);
}
@Override
protected void setUpView() {
EMClient.getInstance().addConnectionListener(connectionListener);
contactList = new ArrayList<EaseUser>();
getContactList();
//init list
contactListLayout.init(contactList);
if(listItemClickListener != null){
listView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
EaseUser user = (EaseUser)listView.getItemAtPosition(position);
listItemClickListener.onListItemClicked(user);
}
});
}
query.addTextChangedListener(new TextWatcher() {
public void onTextChanged(CharSequence s, int start, int before, int count) {
contactListLayout.filter(s);
if (s.length() > 0) {
clearSearch.setVisibility(View.VISIBLE);
} else {
clearSearch.setVisibility(View.INVISIBLE);
}
}
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
public void afterTextChanged(Editable s) {
}
});
clearSearch.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
query.getText().clear();
hideSoftKeyboard();
}
});
listView.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
hideSoftKeyboard();
return false;
}
});
}
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
this.hidden = hidden;
if (!hidden) {
refresh();
}
}
@Override
public void onResume() {
super.onResume();
if (!hidden) {
refresh();
}
}
/**
* move user to blacklist
*/
protected void moveToBlacklist(final String username){
final ProgressDialog pd = new ProgressDialog(getActivity());
String st1 = getResources().getString(R.string.Is_moved_into_blacklist);
final String st2 = getResources().getString(R.string.Move_into_blacklist_success);
final String st3 = getResources().getString(R.string.Move_into_blacklist_failure);
pd.setMessage(st1);
pd.setCanceledOnTouchOutside(false);
pd.show();
new Thread(new Runnable() {
public void run() {
try {
//move to blacklist
EMClient.getInstance().contactManager().addUserToBlackList(username,false);
getActivity().runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
Toast.makeText(getActivity(), st2, Toast.LENGTH_SHORT).show();
refresh();
}
});
} catch (HyphenateException e) {
e.printStackTrace();
getActivity().runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
Toast.makeText(getActivity(), st3, Toast.LENGTH_SHORT).show();
}
});
}
}
}).start();
}
// refresh ui
public void refresh() {
getContactList();
contactListLayout.refresh();
}
@Override
public void onDestroy() {
EMClient.getInstance().removeConnectionListener(connectionListener);
super.onDestroy();
}
/**
* get contact list and sort, will filter out users in blacklist
*/
protected void getContactList() {
contactList.clear();
if(contactsMap == null){
return;
}
synchronized (this.contactsMap) {
Iterator<Entry<String, EaseUser>> iterator = contactsMap.entrySet().iterator();
List<String> blackList = EMClient.getInstance().contactManager().getBlackListUsernames();
while (iterator.hasNext()) {
Entry<String, EaseUser> entry = iterator.next();
// to make it compatible with data in previous version, you can remove this check if this is new app
if (!entry.getKey().equals("item_new_friends")
&& !entry.getKey().equals("item_groups")
&& !entry.getKey().equals("item_chatroom")
&& !entry.getKey().equals("item_robots")){
if(!blackList.contains(entry.getKey())){
//filter out users in blacklist
EaseUser user = entry.getValue();
EaseCommonUtils.setUserInitialLetter(user);
contactList.add(user);
}
}
}
}
// sorting
Collections.sort(contactList, new Comparator<EaseUser>() {
@Override
public int compare(EaseUser lhs, EaseUser rhs) {
if(lhs.getInitialLetter().equals(rhs.getInitialLetter())){
return lhs.getNick().compareTo(rhs.getNick());
}else{
if("#".equals(lhs.getInitialLetter())){
return 1;
}else if("#".equals(rhs.getInitialLetter())){
return -1;
}
return lhs.getInitialLetter().compareTo(rhs.getInitialLetter());
}
}
});
}
protected EMConnectionListener connectionListener = new EMConnectionListener() {
@Override
public void onDisconnected(int error) {
if (error == EMError.USER_REMOVED || error == EMError.USER_LOGIN_ANOTHER_DEVICE || error == EMError.SERVER_SERVICE_RESTRICTED) {
isConflict = true;
} else {
getActivity().runOnUiThread(new Runnable() {
public void run() {
onConnectionDisconnected();
}
});
}
}
@Override
public void onConnected() {
getActivity().runOnUiThread(new Runnable() {
public void run() {
onConnectionConnected();
}
});
}
};
private EaseContactListItemClickListener listItemClickListener;
protected void onConnectionDisconnected() {
}
protected void onConnectionConnected() {
}
/**
* set contacts map, key is the hyphenate id
* @param contactsMap
*/
public void setContactsMap(Map<String, EaseUser> contactsMap){
this.contactsMap = contactsMap;
}
public interface EaseContactListItemClickListener {
/**
* on click event for item in contact list
* @param user --the user of item
*/
void onListItemClicked(EaseUser user);
}
/**
* set contact list item click listener
* @param listItemClickListener
*/
public void setContactListItemClickListener(EaseContactListItemClickListener listItemClickListener){
this.listItemClickListener = listItemClickListener;
}
}
照例的填充布局
我们看到就一个search。
继续看其他的方法
@Override照例的填充,冲突标志位,初始化view。
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.ease_fragment_contact_list, container, false);
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
//to avoid crash when open app after long time stay in background after user logged into another device
if(savedInstanceState != null && savedInstanceState.getBoolean("isConflict", false))
return;
super.onActivityCreated(savedInstanceState);
}
@Override
protected void initView() {
contentContainer = (FrameLayout) getView().findViewById(R.id.content_container);
contactListLayout = (EaseContactList) getView().findViewById(R.id.contact_list);
listView = contactListLayout.getListView();
//search
query = (EditText) getView().findViewById(R.id.query);
clearSearch = (ImageButton) getView().findViewById(R.id.search_clear);
}
初始化view
@Override寻常的初始化。
protected void setUpView() {
EMClient.getInstance().addConnectionListener(connectionListener);
contactList = new ArrayList<EaseUser>();
getContactList();
//init list
contactListLayout.init(contactList);
if(listItemClickListener != null){
listView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
EaseUser user = (EaseUser)listView.getItemAtPosition(position);
listItemClickListener.onListItemClicked(user);
}
});
}
query.addTextChangedListener(new TextWatcher() {
public void onTextChanged(CharSequence s, int start, int before, int count) {
contactListLayout.filter(s);
if (s.length() > 0) {
clearSearch.setVisibility(View.VISIBLE);
} else {
clearSearch.setVisibility(View.INVISIBLE);
}
}
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
public void afterTextChanged(Editable s) {
}
});
clearSearch.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
query.getText().clear();
hideSoftKeyboard();
}
});
listView.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
hideSoftKeyboard();
return false;
}
});
}
拉黑及刷新
@Override拉黑,以及刷新。
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
this.hidden = hidden;
if (!hidden) {
refresh();
}
}
@Override
public void onResume() {
super.onResume();
if (!hidden) {
refresh();
}
}
/**
* move user to blacklist
*/
protected void moveToBlacklist(final String username){
final ProgressDialog pd = new ProgressDialog(getActivity());
String st1 = getResources().getString(R.string.Is_moved_into_blacklist);
final String st2 = getResources().getString(R.string.Move_into_blacklist_success);
final String st3 = getResources().getString(R.string.Move_into_blacklist_failure);
pd.setMessage(st1);
pd.setCanceledOnTouchOutside(false);
pd.show();
new Thread(new Runnable() {
public void run() {
try {
//move to blacklist
EMClient.getInstance().contactManager().addUserToBlackList(username,false);
getActivity().runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
Toast.makeText(getActivity(), st2, Toast.LENGTH_SHORT).show();
refresh();
}
});
} catch (HyphenateException e) {
e.printStackTrace();
getActivity().runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
Toast.makeText(getActivity(), st3, Toast.LENGTH_SHORT).show();
}
});
}
}
}).start();
}
// refresh ui
public void refresh() {
getContactList();
contactListLayout.refresh();
}
@Override
public void onDestroy() {
EMClient.getInstance().removeConnectionListener(connectionListener);
super.onDestroy();
}
联系人排序
/**联系人的排序,将会过滤掉黑名单人员。
* get contact list and sort, will filter out users in blacklist
*/
protected void getContactList() {
contactList.clear();
if(contactsMap == null){
return;
}
synchronized (this.contactsMap) {
Iterator<Entry<String, EaseUser>> iterator = contactsMap.entrySet().iterator();
List<String> blackList = EMClient.getInstance().contactManager().getBlackListUsernames();
while (iterator.hasNext()) {
Entry<String, EaseUser> entry = iterator.next();
// to make it compatible with data in previous version, you can remove this check if this is new app
if (!entry.getKey().equals("item_new_friends")
&& !entry.getKey().equals("item_groups")
&& !entry.getKey().equals("item_chatroom")
&& !entry.getKey().equals("item_robots")){
if(!blackList.contains(entry.getKey())){
//filter out users in blacklist
EaseUser user = entry.getValue();
EaseCommonUtils.setUserInitialLetter(user);
contactList.add(user);
}
}
}
}
// sorting
Collections.sort(contactList, new Comparator<EaseUser>() {
@Override
public int compare(EaseUser lhs, EaseUser rhs) {
if(lhs.getInitialLetter().equals(rhs.getInitialLetter())){
return lhs.getNick().compareTo(rhs.getNick());
}else{
if("#".equals(lhs.getInitialLetter())){
return 1;
}else if("#".equals(rhs.getInitialLetter())){
return -1;
}
return lhs.getInitialLetter().compareTo(rhs.getInitialLetter());
}
}
});
}
各种点击监听
protected EMConnectionListener connectionListener = new EMConnectionListener() {正常的点击事件。
@Override
public void onDisconnected(int error) {
if (error == EMError.USER_REMOVED || error == EMError.USER_LOGIN_ANOTHER_DEVICE || error == EMError.SERVER_SERVICE_RESTRICTED) {
isConflict = true;
} else {
getActivity().runOnUiThread(new Runnable() {
public void run() {
onConnectionDisconnected();
}
});
}
}
@Override
public void onConnected() {
getActivity().runOnUiThread(new Runnable() {
public void run() {
onConnectionConnected();
}
});
}
};
private EaseContactListItemClickListener listItemClickListener;
protected void onConnectionDisconnected() {
}
protected void onConnectionConnected() {
}
/**
* set contacts map, key is the hyphenate id
* @param contactsMap
*/
public void setContactsMap(Map<String, EaseUser> contactsMap){
this.contactsMap = contactsMap;
}
public interface EaseContactListItemClickListener {
/**
* on click event for item in contact list
* @param user --the user of item
*/
void onListItemClicked(EaseUser user);
}
/**
* set contact list item click listener
* @param listItemClickListener
*/
public void setContactListItemClickListener(EaseContactListItemClickListener listItemClickListener){
this.listItemClickListener = listItemClickListener;
}
他爷爷是EaseBaseFragment之前分析过就不分析了。 收起阅读 »
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面
环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0
环信官方Demo源码分析及SDK简单应用-LoginActivity
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面
环信官方Demo源码分析及SDK简单应用-EaseUI
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现
现在来看具体的主界面的三个Fragment
主界面的三个fragment
会话界面
我们来看会话界面的代码
我们还是挨个来读代码
package com.hyphenate.chatuidemo.ui;
import android.content.Intent;
import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.AdapterView.AdapterContextMenuInfo;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import com.easemob.redpacketsdk.constant.RPConstant;
import com.hyphenate.chat.EMClient;
import com.hyphenate.chat.EMConversation;
import com.hyphenate.chat.EMConversation.EMConversationType;
import com.hyphenate.chat.EMMessage;
import com.hyphenate.chatuidemo.Constant;
import com.hyphenate.chatuidemo.R;
import com.hyphenate.chatuidemo.db.InviteMessgeDao;
import com.hyphenate.easeui.model.EaseAtMessageHelper;
import com.hyphenate.easeui.ui.EaseConversationListFragment;
import com.hyphenate.easeui.widget.EaseConversationList.EaseConversationListHelper;
import com.hyphenate.util.NetUtils;
public class ConversationListFragment extends EaseConversationListFragment{
private TextView errorText;
@Override
protected void initView() {
super.initView();
View errorView = (LinearLayout) View.inflate(getActivity(),R.layout.em_chat_neterror_item, null);
errorItemContainer.addView(errorView);
errorText = (TextView) errorView.findViewById(R.id.tv_connect_errormsg);
}
@Override
protected void setUpView() {
super.setUpView();
// register context menu
registerForContextMenu(conversationListView);
conversationListView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
EMConversation conversation = conversationListView.getItem(position);
String username = conversation.conversationId();
if (username.equals(EMClient.getInstance().getCurrentUser()))
Toast.makeText(getActivity(), R.string.Cant_chat_with_yourself, Toast.LENGTH_SHORT).show();
else {
// start chat acitivity
Intent intent = new Intent(getActivity(), ChatActivity.class);
if(conversation.isGroup()){
if(conversation.getType() == EMConversationType.ChatRoom){
// it's group chat
intent.putExtra(Constant.EXTRA_CHAT_TYPE, Constant.CHATTYPE_CHATROOM);
}else{
intent.putExtra(Constant.EXTRA_CHAT_TYPE, Constant.CHATTYPE_GROUP);
}
}
// it's single chat
intent.putExtra(Constant.EXTRA_USER_ID, username);
startActivity(intent);
}
}
});
//red packet code : 红包回执消息在会话列表最后一条消息的展示
conversationListView.setConversationListHelper(new EaseConversationListHelper() {
@Override
public String onSetItemSecondaryText(EMMessage lastMessage) {
if (lastMessage.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_ACK_MESSAGE, false)) {
String sendNick = lastMessage.getStringAttribute(RPConstant.EXTRA_RED_PACKET_SENDER_NAME, "");
String receiveNick = lastMessage.getStringAttribute(RPConstant.EXTRA_RED_PACKET_RECEIVER_NAME, "");
String msg;
if (lastMessage.direct() == EMMessage.Direct.RECEIVE) {
msg = String.format(getResources().getString(R.string.msg_someone_take_red_packet), receiveNick);
} else {
if (sendNick.equals(receiveNick)) {
msg = getResources().getString(R.string.msg_take_red_packet);
} else {
msg = String.format(getResources().getString(R.string.msg_take_someone_red_packet), sendNick);
}
}
return msg;
} else if (lastMessage.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) {
String transferAmount = lastMessage.getStringAttribute(RPConstant.EXTRA_TRANSFER_AMOUNT, "");
String msg;
if (lastMessage.direct() == EMMessage.Direct.RECEIVE) {
msg = String.format(getResources().getString(R.string.msg_transfer_to_you), transferAmount);
} else {
msg = String.format(getResources().getString(R.string.msg_transfer_from_you),transferAmount);
}
return msg;
}
return null;
}
});
super.setUpView();
//end of red packet code
}
@Override
protected void onConnectionDisconnected() {
super.onConnectionDisconnected();
if (NetUtils.hasNetwork(getActivity())){
errorText.setText(R.string.can_not_connect_chat_server_connection);
} else {
errorText.setText(R.string.the_current_network);
}
}
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
getActivity().getMenuInflater().inflate(R.menu.em_delete_message, menu);
}
@Override
public boolean onContextItemSelected(MenuItem item) {
boolean deleteMessage = false;
if (item.getItemId() == R.id.delete_message) {
deleteMessage = true;
} else if (item.getItemId() == R.id.delete_conversation) {
deleteMessage = false;
}
EMConversation tobeDeleteCons = conversationListView.getItem(((AdapterContextMenuInfo) item.getMenuInfo()).position);
if (tobeDeleteCons == null) {
return true;
}
if(tobeDeleteCons.getType() == EMConversationType.GroupChat){
EaseAtMessageHelper.get().removeAtMeGroup(tobeDeleteCons.conversationId());
}
try {
// delete conversation
EMClient.getInstance().chatManager().deleteConversation(tobeDeleteCons.conversationId(), deleteMessage);
InviteMessgeDao inviteMessgeDao = new InviteMessgeDao(getActivity());
inviteMessgeDao.deleteMessage(tobeDeleteCons.conversationId());
} catch (Exception e) {
e.printStackTrace();
}
refresh();
// update unread count
((MainActivity) getActivity()).updateUnreadLabel();
return true;
}
}
public class ConversationListFragment extends EaseConversationListFragment来,我们还是得先去找他爹算账。
public class EaseConversationListFragment extends EaseBaseFragment哎呀,我们再去找他爷爷。
public abstract class EaseBaseFragment extends Fragment爷爷终于正常点是从Android系统类继承下来的了,我们看具体的代码
EaseBaseFragment
package com.hyphenate.easeui.ui;我们还是挨个来看代码,研究他的功能。
import android.content.Context;
import android.os.Bundle;
import android.support.v4.app.Fragment;
import android.view.View;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodManager;
import com.hyphenate.easeui.R;
import com.hyphenate.easeui.widget.EaseTitleBar;
public abstract class EaseBaseFragment extends Fragment{
protected EaseTitleBar titleBar;
protected InputMethodManager inputMethodManager;
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
inputMethodManager = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
//noinspection ConstantConditions
titleBar = (EaseTitleBar) getView().findViewById(R.id.title_bar);
initView();
setUpView();
}
public void showTitleBar(){
if(titleBar != null){
titleBar.setVisibility(View.VISIBLE);
}
}
public void hideTitleBar(){
if(titleBar != null){
titleBar.setVisibility(View.GONE);
}
}
protected void hideSoftKeyboard() {
if (getActivity().getWindow().getAttributes().softInputMode != WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) {
if (getActivity().getCurrentFocus() != null)
inputMethodManager.hideSoftInputFromWindow(getActivity().getCurrentFocus().getWindowToken(),
InputMethodManager.HIDE_NOT_ALWAYS);
}
}
protected abstract void initView();
protected abstract void setUpView();
}
@Override隐藏输入法
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
inputMethodManager = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
//noinspection ConstantConditions
titleBar = (EaseTitleBar) getView().findViewById(R.id.title_bar);
initView();
setUpView();
}
看到inputmethdManager要干嘛啊,隐藏键盘。果不其然。
protected void hideSoftKeyboard() {然后呢?
if (getActivity().getWindow().getAttributes().softInputMode != WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) {
if (getActivity().getCurrentFocus() != null)
inputMethodManager.hideSoftInputFromWindow(getActivity().getCurrentFocus().getWindowToken(),
InputMethodManager.HIDE_NOT_ALWAYS);
}
}
初始化标题头
//noinspection ConstantConditions最后初始化标题头,并且让子孙们去实现抽象方法initView和setUpView().
titleBar = (EaseTitleBar) getView().findViewById(R.id.title_bar);
隐藏和显示标题头
其中还提供了两个方法,隐藏和显示标题头
public void showTitleBar(){好了,爷爷的帐算完了,我们来找他儿子。
if(titleBar != null){
titleBar.setVisibility(View.VISIBLE);
}
}
public void hideTitleBar(){
if(titleBar != null){
titleBar.setVisibility(View.GONE);
}
}
EaseConversationListFragment
我们来看代码
package com.hyphenate.easeui.ui;填充布局
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnTouchListener;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodManager;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import com.hyphenate.EMConnectionListener;
import com.hyphenate.EMConversationListener;
import com.hyphenate.EMError;
import com.hyphenate.chat.EMClient;
import com.hyphenate.chat.EMConversation;
import com.hyphenate.easeui.R;
import com.hyphenate.easeui.widget.EaseConversationList;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
/**
* conversation list fragment
*
*/
public class EaseConversationListFragment extends EaseBaseFragment{
private final static int MSG_REFRESH = 2;
protected EditText query;
protected ImageButton clearSearch;
protected boolean hidden;
protected List<EMConversation> conversationList = new ArrayList<EMConversation>();
protected EaseConversationList conversationListView;
protected FrameLayout errorItemContainer;
protected boolean isConflict;
protected EMConversationListener convListener = new EMConversationListener(){
@Override
public void onCoversationUpdate() {
refresh();
}
};
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.ease_fragment_conversation_list, container, false);
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
if(savedInstanceState != null && savedInstanceState.getBoolean("isConflict", false))
return;
super.onActivityCreated(savedInstanceState);
}
@Override
protected void initView() {
inputMethodManager = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
conversationListView = (EaseConversationList) getView().findViewById(R.id.list);
query = (EditText) getView().findViewById(R.id.query);
// button to clear content in search bar
clearSearch = (ImageButton) getView().findViewById(R.id.search_clear);
errorItemContainer = (FrameLayout) getView().findViewById(R.id.fl_error_item);
}
@Override
protected void setUpView() {
conversationList.addAll(loadConversationList());
conversationListView.init(conversationList);
if(listItemClickListener != null){
conversationListView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
EMConversation conversation = conversationListView.getItem(position);
listItemClickListener.onListItemClicked(conversation);
}
});
}
EMClient.getInstance().addConnectionListener(connectionListener);
query.addTextChangedListener(new TextWatcher() {
public void onTextChanged(CharSequence s, int start, int before, int count) {
conversationListView.filter(s);
if (s.length() > 0) {
clearSearch.setVisibility(View.VISIBLE);
} else {
clearSearch.setVisibility(View.INVISIBLE);
}
}
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
public void afterTextChanged(Editable s) {
}
});
clearSearch.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
query.getText().clear();
hideSoftKeyboard();
}
});
conversationListView.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
hideSoftKeyboard();
return false;
}
});
}
protected EMConnectionListener connectionListener = new EMConnectionListener() {
@Override
public void onDisconnected(int error) {
if (error == EMError.USER_REMOVED || error == EMError.USER_LOGIN_ANOTHER_DEVICE || error == EMError.SERVER_SERVICE_RESTRICTED) {
isConflict = true;
} else {
handler.sendEmptyMessage(0);
}
}
@Override
public void onConnected() {
handler.sendEmptyMessage(1);
}
};
private EaseConversationListItemClickListener listItemClickListener;
protected Handler handler = new Handler(){
public void handleMessage(android.os.Message msg) {
switch (msg.what) {
case 0:
onConnectionDisconnected();
break;
case 1:
onConnectionConnected();
break;
case MSG_REFRESH:
{
conversationList.clear();
conversationList.addAll(loadConversationList());
conversationListView.refresh();
break;
}
default:
break;
}
}
};
/**
* connected to server
*/
protected void onConnectionConnected(){
errorItemContainer.setVisibility(View.GONE);
}
/**
* disconnected with server
*/
protected void onConnectionDisconnected(){
errorItemContainer.setVisibility(View.VISIBLE);
}
/**
* refresh ui
*/
public void refresh() {
if(!handler.hasMessages(MSG_REFRESH)){
handler.sendEmptyMessage(MSG_REFRESH);
}
}
/**
* load conversation list
*
* @return
+ */
protected List<EMConversation> loadConversationList(){
// get all conversations
Map<String, EMConversation> conversations = EMClient.getInstance().chatManager().getAllConversations();
List<Pair<Long, EMConversation>> sortList = new ArrayList<Pair<Long, EMConversation>>();
/**
* lastMsgTime will change if there is new message during sorting
* so use synchronized to make sure timestamp of last message won't change.
*/
synchronized (conversations) {
for (EMConversation conversation : conversations.values()) {
if (conversation.getAllMessages().size() != 0) {
sortList.add(new Pair<Long, EMConversation>(conversation.getLastMessage().getMsgTime(), conversation));
}
}
}
try {
// Internal is TimSort algorithm, has bug
sortConversationByLastChatTime(sortList);
} catch (Exception e) {
e.printStackTrace();
}
List<EMConversation> list = new ArrayList<EMConversation>();
for (Pair<Long, EMConversation> sortItem : sortList) {
list.add(sortItem.second);
}
return list;
}
/**
* sort conversations according time stamp of last message
*
* @param conversationList
*/
private void sortConversationByLastChatTime(List<Pair<Long, EMConversation>> conversationList) {
Collections.sort(conversationList, new Comparator<Pair<Long, EMConversation>>() {
@Override
public int compare(final Pair<Long, EMConversation> con1, final Pair<Long, EMConversation> con2) {
if (con1.first.equals(con2.first)) {
return 0;
} else if (con2.first.longValue() > con1.first.longValue()) {
return 1;
} else {
return -1;
}
}
});
}
protected void hideSoftKeyboard() {
if (getActivity().getWindow().getAttributes().softInputMode != WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) {
if (getActivity().getCurrentFocus() != null)
inputMethodManager.hideSoftInputFromWindow(getActivity().getCurrentFocus().getWindowToken(),
InputMethodManager.HIDE_NOT_ALWAYS);
}
}
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
this.hidden = hidden;
if (!hidden && !isConflict) {
refresh();
}
}
@Override
public void onResume() {
super.onResume();
if (!hidden) {
refresh();
}
}
@Override
public void onDestroy() {
super.onDestroy();
EMClient.getInstance().removeConnectionListener(connectionListener);
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
if(isConflict){
outState.putBoolean("isConflict", true);
}
}
public interface EaseConversationListItemClickListener {
/**
* click event for conversation list
* @param conversation -- clicked item
*/
void onListItemClicked(EMConversation conversation);
}
/**
* set conversation list item click listener
* @param listItemClickListener
*/
public void setConversationListItemClickListener(EaseConversationListItemClickListener listItemClickListener){
this.listItemClickListener = listItemClickListener;
}
}
首先onCreateView(),正常的填充了布局
return inflater.inflate(R.layout.ease_fragment_conversation_list, container, false);继续看代码
判断冲突标志位
@Override
public void onActivityCreated(Bundle savedInstanceState) {
if(savedInstanceState != null && savedInstanceState.getBoolean("isConflict", false))
return;
super.onActivityCreated(savedInstanceState);
}
@OverrideinitView()
protected void initView() {
inputMethodManager = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
conversationListView = (EaseConversationList) getView().findViewById(R.id.list);
query = (EditText) getView().findViewById(R.id.query);
// button to clear content in search bar
clearSearch = (ImageButton) getView().findViewById(R.id.search_clear);
errorItemContainer = (FrameLayout) getView().findViewById(R.id.fl_error_item);
}
覆写爷爷的家规,初始化View输入法管理器
- 会话列表List
- 查找联系人的输入框
- 清除搜索的按钮
- errorItemContainer 错误标签容器
conversationList.addAll(loadConversationList());conversationListView.init(conversationList);if(listItemClickListener != null){ conversationListView.setOnItemClickListener(new OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { EMConversation conversation = conversationListView.getItem(position); listItemClickListener.onListItemClicked(conversation); } });}EMClient.getInstance().addConnectionListener(connectionListener);query.addTextChangedListener(new TextWatcher() { public void onTextChanged(CharSequence s, int start, int before, int count) { conversationListView.filter(s); if (s.length() > 0) { clearSearch.setVisibility(View.VISIBLE); } else { clearSearch.setVisibility(View.INVISIBLE); } } public void beforeTextChanged(CharSequence s, int start, int count, int after) { } public void afterTextChanged(Editable s) { }});clearSearch.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { query.getText().clear(); hideSoftKeyboard(); }});conversationListView.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { hideSoftKeyboard(); return false; }});我们一句句的看
conversationList.addAll(loadConversationList()); conversationListView.init(conversationList);会话列表添加全部以及数据填充初始化。我们来看具体的方法
/** * load conversation list * * @return */protected List<EMConversation> loadConversationList(){ // get all conversations Map<String, EMConversation> conversations = EMClient.getInstance().chatManager().getAllConversations(); List<Pair<Long, EMConversation>> sortList = new ArrayList<Pair<Long, EMConversation>>(); /** * lastMsgTime will change if there is new message during sorting * so use synchronized to make sure timestamp of last message won't change. */ synchronized (conversations) { for (EMConversation conversation : conversations.values()) { if (conversation.getAllMessages().size() != 0) { sortList.add(new Pair<Long, EMConversation>(conversation.getLastMessage().getMsgTime(), conversation)); } } } try { // Internal is TimSort algorithm, has bug sortConversationByLastChatTime(sortList); } catch (Exception e) { e.printStackTrace(); } List<EMConversation> list = new ArrayList<EMConversation>(); for (Pair<Long, EMConversation> sortItem : sortList) { list.add(sortItem.second); } return list;}loadConversationList()返回一个EMConversation对象List。
// get all conversationsMap<String, EMConversation> conversations = EMClient.getInstance().chatManager().getAllConversations();List<Pair<Long, EMConversation>> sortList = new ArrayList<Pair<Long, EMConversation>>();通过封装的chatManager拿到所有的会话列表
/** * lastMsgTime will change if there is new message during sorting * so use synchronized to make sure timestamp of last message won't change. */synchronized (conversations) { for (EMConversation conversation : conversations.values()) { if (conversation.getAllMessages().size() != 0) { sortList.add(new Pair<Long, EMConversation>(conversation.getLastMessage().getMsgTime(), conversation)); } }}lastMsgTime会随着新消息的到来排序发生改变,所以我们用同步方法确保最新消息的时间戳不发生改变。英文不好,大致是这么个意思。
try { // Internal is TimSort algorithm, has bug sortConversationByLastChatTime(sortList); } catch (Exception e) { e.printStackTrace(); } List<EMConversation> list = new ArrayList<EMConversation>(); for (Pair<Long, EMConversation> sortItem : sortList) { list.add(sortItem.second); } return list;其中还特地注释了一把,算法有点bug。
/** * sort conversations according time stamp of last message * * @param conversationList */ private void sortConversationByLastChatTime(List<Pair<Long, EMConversation>> conversationList) { Collections.sort(conversationList, new Comparator<Pair<Long, EMConversation>>() { @Override public int compare(final Pair<Long, EMConversation> con1, final Pair<Long, EMConversation> con2) { if (con1.first.equals(con2.first)) { return 0; } else if (con2.first.longValue() > con1.first.longValue()) { return 1; } else { return -1; } } });根据最新的会话时间戳来排序。我们接着看
List<EMConversation> list = new ArrayList<EMConversation>();for (Pair<Long, EMConversation> sortItem : sortList) { list.add(sortItem.second);}return list;添加完了返回list。
conversationListView.init(conversationList);接着就初始化了。
if(listItemClickListener != null){ conversationListView.setOnItemClickListener(new OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { EMConversation conversation = conversationListView.getItem(position); listItemClickListener.onListItemClicked(conversation); } });}然后便是连接接听
EMClient.getInstance().addConnectionListener(connectionListener);添加了一个连接的监听。
protected EMConnectionListener connectionListener = new EMConnectionListener() { @Override public void onDisconnected(int error) { if (error == EMError.USER_REMOVED || error == EMError.USER_LOGIN_ANOTHER_DEVICE || error == EMError.SERVER_SERVICE_RESTRICTED) { isConflict = true; } else { handler.sendEmptyMessage(0); } } @Override public void onConnected() { handler.sendEmptyMessage(1); }};在断开连接时判断用户是否移除,是否在其他设备登陆,或者服务端的服务受到限制,是的话则标记冲突。不是则发送handler空消息。
protected Handler handler = new Handler(){ public void handleMessage(android.os.Message msg) { switch (msg.what) { case 0: onConnectionDisconnected(); break; case 1: onConnectionConnected(); break; case MSG_REFRESH: { conversationList.clear(); conversationList.addAll(loadConversationList()); conversationListView.refresh(); break; } default: break; } }};干嘛啊?调用 onConnectionDisconnected 即连接断开的处理方法
/** * disconnected with server */protected void onConnectionDisconnected(){ errorItemContainer.setVisibility(View.VISIBLE);}即显示错误条。我们再接着看代码
query.addTextChangedListener(new TextWatcher() { public void onTextChanged(CharSequence s, int start, int before, int count) { conversationListView.filter(s); if (s.length() > 0) { clearSearch.setVisibility(View.VISIBLE); } else { clearSearch.setVisibility(View.INVISIBLE); } } public void beforeTextChanged(CharSequence s, int start, int count, int after) { } public void afterTextChanged(Editable s) { }});clearSearch.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { query.getText().clear(); hideSoftKeyboard(); }});conversationListView.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { hideSoftKeyboard(); return false; }});干了些什么啊?查询、清除搜索、会话列表点击监听。其他方法
/** * connected to server */protected void onConnectionConnected(){ errorItemContainer.setVisibility(View.GONE);}连接后将错误条隐藏
case MSG_REFRESH: { conversationList.clear(); conversationList.addAll(loadConversationList()); conversationListView.refresh(); break; }服务器告诉要刷新了,那么我们就去清楚列表,然后去服务器拿并排序,然后刷新listview。其中该listview为自定义的EaseConversationList。那么儿子齐活了,我们再看孙子ConversationListFragment
package com.hyphenate.chatuidemo.ui;import android.content.Intent;import android.view.ContextMenu;import android.view.ContextMenu.ContextMenuInfo;import android.view.MenuItem;import android.view.View;import android.widget.AdapterView;import android.widget.AdapterView.AdapterContextMenuInfo;import android.widget.AdapterView.OnItemClickListener;import android.widget.LinearLayout;import android.widget.TextView;import android.widget.Toast;import com.easemob.redpacketsdk.constant.RPConstant;import com.hyphenate.chat.EMClient;import com.hyphenate.chat.EMConversation;import com.hyphenate.chat.EMConversation.EMConversationType;import com.hyphenate.chat.EMMessage;import com.hyphenate.chatuidemo.Constant;import com.hyphenate.chatuidemo.R;import com.hyphenate.chatuidemo.db.InviteMessgeDao;import com.hyphenate.easeui.model.EaseAtMessageHelper;import com.hyphenate.easeui.ui.EaseConversationListFragment;import com.hyphenate.easeui.widget.EaseConversationList.EaseConversationListHelper;import com.hyphenate.util.NetUtils;public class ConversationListFragment extends EaseConversationListFragment{ private TextView errorText; @Override protected void initView() { super.initView(); View errorView = (LinearLayout) View.inflate(getActivity(),R.layout.em_chat_neterror_item, null); errorItemContainer.addView(errorView); errorText = (TextView) errorView.findViewById(R.id.tv_connect_errormsg); } @Override protected void setUpView() { super.setUpView(); // register context menu registerForContextMenu(conversationListView); conversationListView.setOnItemClickListener(new OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { EMConversation conversation = conversationListView.getItem(position); String username = conversation.conversationId(); if (username.equals(EMClient.getInstance().getCurrentUser())) Toast.makeText(getActivity(), R.string.Cant_chat_with_yourself, Toast.LENGTH_SHORT).show(); else { // start chat acitivity Intent intent = new Intent(getActivity(), ChatActivity.class); if(conversation.isGroup()){ if(conversation.getType() == EMConversationType.ChatRoom){ // it's group chat intent.putExtra(Constant.EXTRA_CHAT_TYPE, Constant.CHATTYPE_CHATROOM); }else{ intent.putExtra(Constant.EXTRA_CHAT_TYPE, Constant.CHATTYPE_GROUP); } } // it's single chat intent.putExtra(Constant.EXTRA_USER_ID, username); startActivity(intent); } } }); //red packet code : 红包回执消息在会话列表最后一条消息的展示 conversationListView.setConversationListHelper(new EaseConversationListHelper() { @Override public String onSetItemSecondaryText(EMMessage lastMessage) { if (lastMessage.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_ACK_MESSAGE, false)) { String sendNick = lastMessage.getStringAttribute(RPConstant.EXTRA_RED_PACKET_SENDER_NAME, ""); String receiveNick = lastMessage.getStringAttribute(RPConstant.EXTRA_RED_PACKET_RECEIVER_NAME, ""); String msg; if (lastMessage.direct() == EMMessage.Direct.RECEIVE) { msg = String.format(getResources().getString(R.string.msg_someone_take_red_packet), receiveNick); } else { if (sendNick.equals(receiveNick)) { msg = getResources().getString(R.string.msg_take_red_packet); } else { msg = String.format(getResources().getString(R.string.msg_take_someone_red_packet), sendNick); } } return msg; } else if (lastMessage.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) { String transferAmount = lastMessage.getStringAttribute(RPConstant.EXTRA_TRANSFER_AMOUNT, ""); String msg; if (lastMessage.direct() == EMMessage.Direct.RECEIVE) { msg = String.format(getResources().getString(R.string.msg_transfer_to_you), transferAmount); } else { msg = String.format(getResources().getString(R.string.msg_transfer_from_you),transferAmount); } return msg; } return null; } }); super.setUpView(); //end of red packet code } @Override protected void onConnectionDisconnected() { super.onConnectionDisconnected(); if (NetUtils.hasNetwork(getActivity())){ errorText.setText(R.string.can_not_connect_chat_server_connection); } else { errorText.setText(R.string.the_current_network); } } @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { getActivity().getMenuInflater().inflate(R.menu.em_delete_message, menu); } @Override public boolean onContextItemSelected(MenuItem item) { boolean deleteMessage = false; if (item.getItemId() == R.id.delete_message) { deleteMessage = true; } else if (item.getItemId() == R.id.delete_conversation) { deleteMessage = false; } EMConversation tobeDeleteCons = conversationListView.getItem(((AdapterContextMenuInfo) item.getMenuInfo()).position); if (tobeDeleteCons == null) { return true; } if(tobeDeleteCons.getType() == EMConversationType.GroupChat){ EaseAtMessageHelper.get().removeAtMeGroup(tobeDeleteCons.conversationId()); } try { // delete conversation EMClient.getInstance().chatManager().deleteConversation(tobeDeleteCons.conversationId(), deleteMessage); InviteMessgeDao inviteMessgeDao = new InviteMessgeDao(getActivity()); inviteMessgeDao.deleteMessage(tobeDeleteCons.conversationId()); } catch (Exception e) { e.printStackTrace(); } refresh(); // update unread count ((MainActivity) getActivity()).updateUnreadLabel(); return true; }}initView()
@Overrideprotected void initView() { super.initView(); View errorView = (LinearLayout) View.inflate(getActivity(),R.layout.em_chat_neterror_item, null); errorItemContainer.addView(errorView); errorText = (TextView) errorView.findViewById(R.id.tv_connect_errormsg);}添加了错误的容器、初始化错误消息控件。
registerForContextMenu(conversationListView);注册上下文菜单
conversationListView.setOnItemClickListener(new OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { EMConversation conversation = conversationListView.getItem(position); String username = conversation.conversationId(); if (username.equals(EMClient.getInstance().getCurrentUser())) Toast.makeText(getActivity(), R.string.Cant_chat_with_yourself, Toast.LENGTH_SHORT).show(); else { // start chat acitivity Intent intent = new Intent(getActivity(), ChatActivity.class); if(conversation.isGroup()){ if(conversation.getType() == EMConversationType.ChatRoom){ // it's group chat intent.putExtra(Constant.EXTRA_CHAT_TYPE, Constant.CHATTYPE_CHATROOM); }else{ intent.putExtra(Constant.EXTRA_CHAT_TYPE, Constant.CHATTYPE_GROUP); } } // it's single chat intent.putExtra(Constant.EXTRA_USER_ID, username); startActivity(intent); } }});条目的点击监听其中做了这么些事情:
- 判断用户名是否等于当前登陆用户,是则提示不能跟自己聊天
- 如果是群聊的话,则继续判断是聊天室还是群组,并带值给ChatActivity即聊天界面
- 最后将用户名带上,跳转ChatActivity。
//red packet code : 红包回执消息在会话列表最后一条消息的展示最后是红包回执信息。
conversationListView.setConversationListHelper(new EaseConversationListHelper() {
@Override
public String onSetItemSecondaryText(EMMessage lastMessage) {
if (lastMessage.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_RED_PACKET_ACK_MESSAGE, false)) {
String sendNick = lastMessage.getStringAttribute(RPConstant.EXTRA_RED_PACKET_SENDER_NAME, "");
String receiveNick = lastMessage.getStringAttribute(RPConstant.EXTRA_RED_PACKET_RECEIVER_NAME, "");
String msg;
if (lastMessage.direct() == EMMessage.Direct.RECEIVE) {
msg = String.format(getResources().getString(R.string.msg_someone_take_red_packet), receiveNick);
} else {
if (sendNick.equals(receiveNick)) {
msg = getResources().getString(R.string.msg_take_red_packet);
} else {
msg = String.format(getResources().getString(R.string.msg_take_someone_red_packet), sendNick);
}
}
return msg;
} else if (lastMessage.getBooleanAttribute(RPConstant.MESSAGE_ATTR_IS_TRANSFER_PACKET_MESSAGE, false)) {
String transferAmount = lastMessage.getStringAttribute(RPConstant.EXTRA_TRANSFER_AMOUNT, "");
String msg;
if (lastMessage.direct() == EMMessage.Direct.RECEIVE) {
msg = String.format(getResources().getString(R.string.msg_transfer_to_you), transferAmount);
} else {
msg = String.format(getResources().getString(R.string.msg_transfer_from_you),transferAmount);
}
return msg;
}
return null;
}
});
super.setUpView();
我们接着看其他的方法
端口网络则提示没网标签。
@Override
protected void onConnectionDisconnected() {
super.onConnectionDisconnected();
if (NetUtils.hasNetwork(getActivity())){
errorText.setText(R.string.can_not_connect_chat_server_connection);
} else {
errorText.setText(R.string.the_current_network);
}
}
创建上下文菜单
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
getActivity().getMenuInflater().inflate(R.menu.em_delete_message, menu);
}
@Override上下文菜单选择的处理方法
public boolean onContextItemSelected(MenuItem item) {
boolean deleteMessage = false;
if (item.getItemId() == R.id.delete_message) {
deleteMessage = true;
} else if (item.getItemId() == R.id.delete_conversation) {
deleteMessage = false;
}
EMConversation tobeDeleteCons = conversationListView.getItem(((AdapterContextMenuInfo) item.getMenuInfo()).position);
if (tobeDeleteCons == null) {
return true;
}
if(tobeDeleteCons.getType() == EMConversationType.GroupChat){
EaseAtMessageHelper.get().removeAtMeGroup(tobeDeleteCons.conversationId());
}
try {
// delete conversation
EMClient.getInstance().chatManager().deleteConversation(tobeDeleteCons.conversationId(), deleteMessage);
InviteMessgeDao inviteMessgeDao = new InviteMessgeDao(getActivity());
inviteMessgeDao.deleteMessage(tobeDeleteCons.conversationId());
} catch (Exception e) {
e.printStackTrace();
}
refresh();
// update unread count[url=http://www.imgeek.org/article/825308690]环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面[/url]
((MainActivity) getActivity()).updateUnreadLabel();
return true;
}
删除消息并更新未读消息。
好,至此,第一个界面,会话界面到此结束。
我们再来看通讯录界面。
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面 收起阅读 »
环信官方Demo源码分析及SDK简单应用-LoginActivity
环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0
环信官方Demo源码分析及SDK简单应用-LoginActivity
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面
环信官方Demo源码分析及SDK简单应用-EaseUI
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现
上文我们在已经登录的情况下来到的MainActivity,那么我们在没有登录情况下呢,当然是来登陆页面。下面我们来看登录页。
LoginActivity
/**我们挨个来阅读
* Copyright (C) 2016 Hyphenate Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hyphenate.chatuidemo.ui;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnCancelListener;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.Log;
import android.view.View;
import android.widget.EditText;
import android.widget.Toast;
import com.hyphenate.EMCallBack;
import com.hyphenate.chat.EMClient;
import com.hyphenate.chatuidemo.DemoApplication;
import com.hyphenate.chatuidemo.DemoHelper;
import com.hyphenate.chatuidemo.R;
import com.hyphenate.chatuidemo.db.DemoDBManager;
import com.hyphenate.easeui.utils.EaseCommonUtils;
/**
* Login screen
*
*/
public class LoginActivity extends BaseActivity {
private static final String TAG = "LoginActivity";
public static final int REQUEST_CODE_SETNICK = 1;
private EditText usernameEditText;
private EditText passwordEditText;
private boolean progressShow;
private boolean autoLogin = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// enter the main activity if already logged in
if (DemoHelper.getInstance().isLoggedIn()) {
autoLogin = true;
startActivity(new Intent(LoginActivity.this, MainActivity.class));
return;
}
setContentView(R.layout.em_activity_login);
usernameEditText = (EditText) findViewById(R.id.username);
passwordEditText = (EditText) findViewById(R.id.password);
// if user changed, clear the password
usernameEditText.addTextChangedListener(new TextWatcher() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
passwordEditText.setText(null);
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void afterTextChanged(Editable s) {
}
});
if (DemoHelper.getInstance().getCurrentUsernName() != null) {
usernameEditText.setText(DemoHelper.getInstance().getCurrentUsernName());
}
}
/**
* login
*
* @param view
*/
public void login(View view) {
if (!EaseCommonUtils.isNetWorkConnected(this)) {
Toast.makeText(this, R.string.network_isnot_available, Toast.LENGTH_SHORT).show();
return;
}
String currentUsername = usernameEditText.getText().toString().trim();
String currentPassword = passwordEditText.getText().toString().trim();
if (TextUtils.isEmpty(currentUsername)) {
Toast.makeText(this, R.string.User_name_cannot_be_empty, Toast.LENGTH_SHORT).show();
return;
}
if (TextUtils.isEmpty(currentPassword)) {
Toast.makeText(this, R.string.Password_cannot_be_empty, Toast.LENGTH_SHORT).show();
return;
}
progressShow = true;
final ProgressDialog pd = new ProgressDialog(LoginActivity.this);
pd.setCanceledOnTouchOutside(false);
pd.setOnCancelListener(new OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
Log.d(TAG, "EMClient.getInstance().onCancel");
progressShow = false;
}
});
pd.setMessage(getString(R.string.Is_landing));
pd.show();
// After logout,the DemoDB may still be accessed due to async callback, so the DemoDB will be re-opened again.
// close it before login to make sure DemoDB not overlap
DemoDBManager.getInstance().closeDB();
// reset current user name before login
DemoHelper.getInstance().setCurrentUserName(currentUsername);
final long start = System.currentTimeMillis();
// call login method
Log.d(TAG, "EMClient.getInstance().login");
EMClient.getInstance().login(currentUsername, currentPassword, new EMCallBack() {
@Override
public void onSuccess() {
Log.d(TAG, "login: onSuccess");
// ** manually load all local groups and conversation
EMClient.getInstance().groupManager().loadAllGroups();
EMClient.getInstance().chatManager().loadAllConversations();
// update current user's display name for APNs
boolean updatenick = EMClient.getInstance().pushManager().updatePushNickname(
DemoApplication.currentUserNick.trim());
if (!updatenick) {
Log.e("LoginActivity", "update current user nick fail");
}
if (!LoginActivity.this.isFinishing() && pd.isShowing()) {
pd.dismiss();
}
// get user's info (this should be get from App's server or 3rd party service)
DemoHelper.getInstance().getUserProfileManager().asyncGetCurrentUserInfo();
Intent intent = new Intent(LoginActivity.this,
MainActivity.class);
startActivity(intent);
finish();
}
@Override
public void onProgress(int progress, String status) {
Log.d(TAG, "login: onProgress");
}
@Override
public void onError(final int code, final String message) {
Log.d(TAG, "login: onError: " + code);
if (!progressShow) {
return;
}
runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
Toast.makeText(getApplicationContext(), getString(R.string.Login_failed) + message,
Toast.LENGTH_SHORT).show();
}
});
}
});
}
/**
* register
*
* @param view
*/
public void register(View view) {
startActivityForResult(new Intent(this, RegisterActivity.class), 0);
}
@Override
protected void onResume() {
super.onResume();
if (autoLogin) {
return;
}
}
}
自动登录
if (DemoHelper.getInstance().isLoggedIn()) {如果已经登录那么设置自动标志位为true,跳到主界面去。
autoLogin = true;
startActivity(new Intent(LoginActivity.this, MainActivity.class));
return;
}
用户名文本变动监听
// if user changed, clear the password简单的文本变化监听,用户名变化了就把密码给清空一下。
usernameEditText.addTextChangedListener(new TextWatcher() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
passwordEditText.setText(null);
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void afterTextChanged(Editable s) {
}
});
if (DemoHelper.getInstance().getCurrentUsernName() != null) {
usernameEditText.setText(DemoHelper.getInstance().getCurrentUsernName());
}
下面我们来看登录逻辑
登录逻辑
首先判断当前是否有网络连接
if (!EaseCommonUtils.isNetWorkConnected(this)) {我们来看看这个工具类是怎么写的
Toast.makeText(this, R.string.network_isnot_available, Toast.LENGTH_SHORT).show();
return;
}
/**大家常用的通用判断网络连接方法。
* check if network avalable
*
* @param context
* @return
*/
public static boolean isNetWorkConnected(Context context) {
if (context != null) {
ConnectivityManager mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo mNetworkInfo = mConnectivityManager.getActiveNetworkInfo();
if (mNetworkInfo != null) {
return mNetworkInfo.isAvailable() && mNetworkInfo.isConnected();
}
}
return false;
}
接着往下看
String currentUsername = usernameEditText.getText().toString().trim();正常的取值,弹个进度框。
String currentPassword = passwordEditText.getText().toString().trim();
if (TextUtils.isEmpty(currentUsername)) {
Toast.makeText(this, R.string.User_name_cannot_be_empty, Toast.LENGTH_SHORT).show();
return;
}
if (TextUtils.isEmpty(currentPassword)) {
Toast.makeText(this, R.string.Password_cannot_be_empty, Toast.LENGTH_SHORT).show();
return;
}
progressShow = true;
final ProgressDialog pd = new ProgressDialog(LoginActivity.this);
pd.setCanceledOnTouchOutside(false);
pd.setOnCancelListener(new OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
Log.d(TAG, "EMClient.getInstance().onCancel");
progressShow = false;
}
});
pd.setMessage(getString(R.string.Is_landing));
pd.show();
来看比较有意思的
// After logout,the DemoDB may still be accessed due to async callback, so the DemoDB will be re-opened again.英文不好,大致的意思就是注销以后,DemoDB可能会依然在执行一些异步回调,所以DemoDB会再次重新打开,所以我们要在登陆之前确保DemoDB不会被Overlap。所以我们关闭一下数据库。
// close it before login to make sure DemoDB not overlap
DemoDBManager.getInstance().closeDB();
// reset current user name before login
DemoHelper.getInstance().setCurrentUserName(currentUsername);
然后就是在登陆之前重新设置下当前登陆的用户名
下面就是具体的登陆实现了
EMClient.getInstance().login(currentUsername, currentPassword, new EMCallBack() {我们看到环信封装了自己实现的登陆方法,并做了回调。
@Override
public void onSuccess() {
Log.d(TAG, "login: onSuccess");
// ** manually load all local groups and conversation
EMClient.getInstance().groupManager().loadAllGroups();
EMClient.getInstance().chatManager().loadAllConversations();
// update current user's display name for APNs
boolean updatenick = EMClient.getInstance().pushManager().updatePushNickname(
DemoApplication.currentUserNick.trim());
if (!updatenick) {
Log.e("LoginActivity", "update current user nick fail");
}
if (!LoginActivity.this.isFinishing() && pd.isShowing()) {
pd.dismiss();
}
// get user's info (this should be get from App's server or 3rd party service)
DemoHelper.getInstance().getUserProfileManager().asyncGetCurrentUserInfo();
Intent intent = new Intent(LoginActivity.this,
MainActivity.class);
startActivity(intent);
finish();
}
@Override
public void onProgress(int progress, String status) {
Log.d(TAG, "login: onProgress");
}
@Override
public void onError(final int code, final String message) {
Log.d(TAG, "login: onError: " + code);
if (!progressShow) {
return;
}
runOnUiThread(new Runnable() {
public void run() {
pd.dismiss();
Toast.makeText(getApplicationContext(), getString(R.string.Login_failed) + message,
Toast.LENGTH_SHORT).show();
}
});
}
});
三个接口:
- onSuccess() 成功了
- onError() 嗝屁了
- onProgress 处理中
我们看onSuccess中的代码
我们看到跳转到MainActivity之前通用做了相同的群组加载
// ** manually load all local groups and conversation
EMClient.getInstance().groupManager().loadAllGroups();
EMClient.getInstance().chatManager().loadAllConversations();
// update current user's display name for APNs
boolean updatenick = EMClient.getInstance().pushManager().updatePushNickname(
DemoApplication.currentUserNick.trim());
if (!updatenick) {
Log.e("LoginActivity", "update current user nick fail");
}
if (!LoginActivity.this.isFinishing() && pd.isShowing()) {
pd.dismiss();
}
// get user's info (this should be get from App's server or 3rd party service)
DemoHelper.getInstance().getUserProfileManager().asyncGetCurrentUserInfo();
Intent intent = new Intent(LoginActivity.this,
MainActivity.class);
startActivity(intent);
finish();
// ** manually load all local groups and conversation
EMClient.getInstance().groupManager().loadAllGroups();
EMClient.getInstance().chatManager().loadAllConversations();
// update current user's display name for APNs更新当前的推送昵称。
boolean updatenick = EMClient.getInstance().pushManager().updatePushNickname(
DemoApplication.currentUserNick.trim());
if (!updatenick) {
Log.e("LoginActivity", "update current user nick fail");
}
if (!LoginActivity.this.isFinishing() && pd.isShowing()) {
pd.dismiss();
}
异步的从App后台或者三方库中获取用户信息,想想我们之前看他的分包的时候,是不是见到过parse这个包。就是这玩意。
// get user's info (this should be get from App's server or 3rd party service)
DemoHelper.getInstance().getUserProfileManager().asyncGetCurrentUserInfo();
Intent intent = new Intent(LoginActivity.this,
MainActivity.class);
startActivity(intent);
finish();
然后跳转到主界面
然后便是注册了,是直接跳到注册界面去。onResume中如果已经登录直接return掉。
/**
* register
*
* @param view
*/
public void register(View view) {
startActivityForResult(new Intent(this, RegisterActivity.class), 0);
}
@Override
protected void onResume() {
super.onResume();
if (autoLogin) {
return;
}
}
那么我们看完了这些Activity了,接着看啥呢?啰嗦了这么久,我们终于可以看具体的主界面的三个Fragment了。
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面 收起阅读 »
环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0
环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0
环信官方Demo源码分析及SDK简单应用-LoginActivity
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面
环信官方Demo源码分析及SDK简单应用-EaseUI
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现
ChatDemoUI3.0
代码结构及逻辑分析
既然上面提到首先分析ChatDemoUI 3.0,那么我们来看看其目录结构
mainfests 清单文件我们稍后来看具体内容
java 具体的代码部分,其包名为com.hyphenate.chatuidemo.
有如下子包:
- adapter 适配器
- db 数据库相关
- domain 实体相关
- parse 第三方库 parse(用于存储 Demo 中用户的信息)管理包
- receiver 广播接收者
- runtimepermissions 运行时权限相关
- ui 界面部分
- utils 工具类
- video.util 视频录制工具包
- widget 自定义view
- Constant 常量类
- DemoApplication application
- DemoHelper Demo的帮助类
- DemoModel 逻辑相关类
- DemoApplication:继承于系统的 Application 类,其 onCreate() 为整个程序的入口,相关的初始化操作都在这里面;
- DemoHelper: Demo 全局帮助类,主要功能为初始化 EaseUI、环信 SDK 及 Demo 相关的实例,以及封装一些全局使用的方法;
- MainActivity: 主页面,包含会话列表页面(ConversationListFragment)、联系人列表页(ContactListFragment)、设置页面(SettingsFragment),前两个继承自己 EaseUI 中的 fragment;
- ChatActivity: 会话页面,这个类代码很少,主要原因是大部分逻辑写在 ChatFragment 中。ChatFragment 继承自 EaseChatFragment,做成 fragment 的好处在于用起来更灵活,可以单独作为一个页面使用,也可以和其他 fragment 一起放到一个 Activity 中;
- GroupDetailsActivity: 群组详情页面
- 分包挺清晰
- 抓住了DemoHelper和DemoModel也就抓住了整个的纲领
- 其他的你就自己扯吧。
- AndroidMainfest.xml
- DemoApplication
- SplashActivity
- 各流程类
解决sdk定义版本声明的问题,我们在后面如果使用到了红包的ui,出现了一些sdk的错误可以加上。
SDK常见的一大坨权限。其中Google Cloud Messaging还是别用吧,身在何处,能稳定么?然后就是各种各样的界面声明总共这么些个界面(Tips:由于本文是现阅读现写,所有未中文指出部分,后面代码阅读会去补上):[list=1]
<!-- 设置环信应用的appkey --> <meta-data android:name="EASEMOB_APPKEY" android:value="你自己的环信Key" />这样,我们基本AndroidMainfest就阅读完了。因为Androidmainfest.xml指出主Activity为ui包下的SplashActivity。按理说我们应该接着来看SplashActivity。但是别忘了App启动后DemoApplication是在主界面之前的。我们将在阅读完Application后再来看SplashActivity。DemoApplication上代码:
/** * Copyright (C) 2016 Hyphenate Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */package com.hyphenate.chatuidemo;import android.app.Application;import android.content.Context;import android.support.multidex.MultiDex;import com.easemob.redpacketsdk.RedPacket;public class DemoApplication extends Application { public static Context applicationContext; private static DemoApplication instance; // login user name public final String PREF_USERNAME = "username"; /** * nickname for current user, the nickname instead of ID be shown when user receive notification from APNs */ public static String currentUserNick = ""; @Override public void onCreate() { MultiDex.install(this); super.onCreate(); applicationContext = this; instance = this; //init demo helper DemoHelper.getInstance().init(applicationContext); //red packet code : 初始化红包上下文,开启日志输出开关 RedPacket.getInstance().initContext(applicationContext); RedPacket.getInstance().setDebugMode(true); //end of red packet code } public static DemoApplication getInstance() { return instance; } @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); MultiDex.install(this); }}第一句是分包,我们知道分包有以下两种方式:[list=1]
package com.hyphenate.chatuidemo.ui;import android.content.Intent;import android.os.Bundle;import android.view.animation.AlphaAnimation;import android.widget.RelativeLayout;import android.widget.TextView;import com.hyphenate.chat.EMClient;import com.hyphenate.chatuidemo.DemoHelper;import com.hyphenate.chatuidemo.R;import com.hyphenate.util.EasyUtils;/** * 开屏页 * */public class SplashActivity extends BaseActivity { private static final int sleepTime = 2000; @Override protected void onCreate(Bundle arg0) { setContentView(R.layout.em_activity_splash); super.onCreate(arg0); RelativeLayout rootLayout = (RelativeLayout) findViewById(R.id.splash_root); TextView versionText = (TextView) findViewById(R.id.tv_version); versionText.setText(getVersion()); AlphaAnimation animation = new AlphaAnimation(0.3f, 1.0f); animation.setDuration(1500); rootLayout.startAnimation(animation); } @Override protected void onStart() { super.onStart(); new Thread(new Runnable() { public void run() { if (DemoHelper.getInstance().isLoggedIn()) { // auto login mode, make sure all group and conversation is loaed before enter the main screen long start = System.currentTimeMillis(); EMClient.getInstance().chatManager().loadAllConversations(); EMClient.getInstance().groupManager().loadAllGroups(); long costTime = System.currentTimeMillis() - start; //wait if (sleepTime - costTime > 0) { try { Thread.sleep(sleepTime - costTime); } catch (InterruptedException e) { e.printStackTrace(); } } String topActivityName = EasyUtils.getTopActivityName(EMClient.getInstance().getContext()); if (topActivityName != null && (topActivityName.equals(VideoCallActivity.class.getName()) || topActivityName.equals(VoiceCallActivity.class.getName()))) { // nop // avoid main screen overlap Calling Activity } else { //enter main screen startActivity(new Intent(SplashActivity.this, MainActivity.class)); } finish(); }else { try { Thread.sleep(sleepTime); } catch (InterruptedException e) { } startActivity(new Intent(SplashActivity.this, LoginActivity.class)); finish(); } } }).start(); } /** * get sdk version */ private String getVersion() { return EMClient.getInstance().VERSION; }}UI部分我们不关心,我们来看下代码逻辑部分
new Thread(new Runnable() { public void run() { if (DemoHelper.getInstance().isLoggedIn()) { // auto login mode, make sure all group and conversation is loaed before enter the main screen long start = System.currentTimeMillis(); EMClient.getInstance().chatManager().loadAllConversations(); EMClient.getInstance().groupManager().loadAllGroups(); long costTime = System.currentTimeMillis() - start; //wait if (sleepTime - costTime > 0) { try { Thread.sleep(sleepTime - costTime); } catch (InterruptedException e) { e.printStackTrace(); } } String topActivityName = EasyUtils.getTopActivityName(EMClient.getInstance().getContext()); if (topActivityName != null && (topActivityName.equals(VideoCallActivity.class.getName()) || topActivityName.equals(VoiceCallActivity.class.getName()))) { // nop // avoid main screen overlap Calling Activity } else { //enter main screen startActivity(new Intent(SplashActivity.this, MainActivity.class)); } finish(); }else { try { Thread.sleep(sleepTime); } catch (InterruptedException e) { } startActivity(new Intent(SplashActivity.this, LoginActivity.class)); finish(); } } }).start();在这里,我们看到了这个DemoHelper帮助类,起了个线程,判断是否已经登录。我们来看看他是如何判断的。
我们来看官方文档中关于此isLoggedInBefore()的解释。
我们再回头来看刚才的代码,代码中有句注释,是这么写到。
// auto login mode, make sure all group and conversation is loaed before enter the main screen自动登录模式,请确保进入主页面后本地回话和群组都load完毕。那么代码中有两句话就是干这个事情的
EMClient.getInstance().chatManager().loadAllConversations();EMClient.getInstance().groupManager().loadAllGroups();这里部分代码最好是放在SplashActivity因为如果登录过,APP 长期在后台再进的时候也可能会导致加载到内存的群组和会话为空。
这里做了等待和判断如果栈顶的ActivityName不为空而且顶栈的名字为语音通话的Activity或者栈顶的名字等于语音通话的Activity。毛线都不做。这个地方猜测应该是指语音通话挂起,重新调起界面的过程。否则,跳到主界面。那么我们接着看主界面。MainActivity那么这个时候,我们应该怎样去看主界面的代码呢?首先看Demo的界面,然后看代码的方法,再一一对应。来,我们来看界面,界面是这个样子的。
三个界面会话、通讯录、设置有了直观的认识以后,我们再来看代码。 我们来一段一段看代码BaseActivityMainActivity继承自BaseActivity。
/** * Copyright (C) 2016 Hyphenate Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */package com.hyphenate.chatuidemo.ui;import android.annotation.SuppressLint;import android.os.Bundle;import com.hyphenate.easeui.ui.EaseBaseActivity;import com.umeng.analytics.MobclickAgent;@SuppressLint("Registered")public class BaseActivity extends EaseBaseActivity { @Override protected void onCreate(Bundle arg0) { super.onCreate(arg0); } @Override protected void onResume() { super.onResume(); // umeng MobclickAgent.onResume(this); } @Override protected void onStart() { super.onStart(); // umeng MobclickAgent.onPause(this); }}只有友盟的一些数据埋点,我们继续往上挖看他爹。EaseBaseActivity
/** * Copyright (C) 2016 Hyphenate Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */package com.hyphenate.easeui.ui;import android.annotation.SuppressLint;import android.content.Context;import android.content.Intent;import android.os.Bundle;import android.support.v4.app.FragmentActivity;import android.view.View;import android.view.WindowManager;import android.view.inputmethod.InputMethodManager;import com.hyphenate.easeui.controller.EaseUI;@SuppressLint({"NewApi", "Registered"})public class EaseBaseActivity extends FragmentActivity { protected InputMethodManager inputMethodManager; @Override protected void onCreate(Bundle arg0) { super.onCreate(arg0); //http://stackoverflow.com/questions/4341600/how-to-prevent-multiple-instances-of-an-activity-when-it-is-launched-with-differ/ // should be in launcher activity, but all app use this can avoid the problem if(!isTaskRoot()){ Intent intent = getIntent(); String action = intent.getAction(); if(intent.hasCategory(Intent.CATEGORY_LAUNCHER) && action.equals(Intent.ACTION_MAIN)){ finish(); return; } } inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); } @Override protected void onResume() { super.onResume(); // cancel the notification EaseUI.getInstance().getNotifier().reset(); } protected void hideSoftKeyboard() { if (getWindow().getAttributes().softInputMode != WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN) { if (getCurrentFocus() != null) inputMethodManager.hideSoftInputFromWindow(getCurrentFocus().getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); } } /** * back * * @param view */ public void back(View view) { finish(); }}
这段代码其实是用来解决重复实例化Launch Activity的问题。喜欢打破砂锅问到底的,可以自己去google。至于hideSoftKeyboard则是常见的隐藏软键盘其中有一句
EaseUI.getInstance().getNotifier().reset();其中Notifier()为新消息提醒类,reset()方法调用了resetNotificationCount()和cancelNotificaton()。重置消息提醒数和取消提醒。
public void reset(){ resetNotificationCount(); cancelNotificaton(); } void resetNotificationCount() { notificationNum = 0; fromUsers.clear(); } void cancelNotificaton() { if (notificationManager != null) notificationManager.cancel(notifyID); }耗电优化首先判断系统版本是否大于6.0,如果是,则判断是否忽略电池耗电的优化。
说实话自己英文水平不是太好,没搞懂为毛国人写的代码要用英文注释,难道是外国人开发的?注释本身不就是让人简单易懂代码逻辑的。可能跟这个公司大了,这个心理上有些关系吧。
确保当你在其他设备登陆或者登出的时候,界面不在后台。大概我只能翻译成这样了。但是看代码的意思应该是当你再其他设备登陆的时候啊,你的app又在后台,那么这个时候呢,咱啊就你在当前设备点击进来的时候,我就判断你这个saveInstanceState是不是为空。如果不为空而且得到账号已经remove 标识位为true的话,咱就把你当前的界面结束掉。跳到登陆页面去。否则的话,如果savedInstanceState不为空,而且得到isConflict标识位为true的话,也退出去跳到登陆页面。权限请求我们继续看下面的,封装了请求权限的代码。
继续,之后就是常规的界面初始化及其他设置了。
初始化界面方法initView()友盟的更新没用过友盟的东西
MobclickAgent.updateOnlineConfig(this);UmengUpdateAgent.setUpdateOnlyWifi(false);UmengUpdateAgent.update(this);看字面意思第一句应该是点击数据埋起点,后面应该是设置仅wifi更新为false以及设置更新。异常提示从Intent中获取的异常标志位进行一个弹窗提示
从字面上意思来看来应该是当账号冲突,移除,禁止的时候去显示异常。其中用到了showExceptionDialog()方法来显示我们来看看一下代码
当用户遇到一些异常的时候显示对话框,例如在其他设备登陆,账号被移除或者禁止。数据库相关操作
inviteMessgeDao = new InviteMessgeDao(this);UserDao userDao = new UserDao(this);初始化Fragment
conversationListFragment = new ConversationListFragment(); contactListFragment = new ContactListFragment(); SettingsFragment settingFragment = new SettingsFragment(); fragments = new Fragment { conversationListFragment, contactListFragment, settingFragment}; getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, conversationListFragment) .add(R.id.fragment_container, contactListFragment).hide(contactListFragment).show(conversationListFragment) .commit();注册广播接收者
//register broadcast receiver to receive the change of group from DemoHelperregisterBroadcastReceiver();从英文注释来看,字面意思来看是用DemoHelper来注册广播接收者来接受群变化通知。我们来看具体的代码
private void registerBroadcastReceiver() { broadcastManager = LocalBroadcastManager.getInstance(this); IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(Constant.ACTION_CONTACT_CHANAGED); intentFilter.addAction(Constant.ACTION_GROUP_CHANAGED); intentFilter.addAction(RPConstant.REFRESH_GROUP_RED_PACKET_ACTION); broadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { updateUnreadLabel(); updateUnreadAddressLable(); if (currentTabIndex == 0) { // refresh conversation list if (conversationListFragment != null) { conversationListFragment.refresh(); } } else if (currentTabIndex == 1) { if(contactListFragment != null) { contactListFragment.refresh(); } } String action = intent.getAction(); if(action.equals(Constant.ACTION_GROUP_CHANAGED)){ if (EaseCommonUtils.getTopActivity(MainActivity.this).equals(GroupsActivity.class.getName())) { GroupsActivity.instance.onResume(); } } //red packet code : 处理红包回执透传消息 if (action.equals(RPConstant.REFRESH_GROUP_RED_PACKET_ACTION)){ if (conversationListFragment != null){ conversationListFragment.refresh(); } } //end of red packet code } }; broadcastManager.registerReceiver(broadcastReceiver, intentFilter); }LocalBroadcastManager是Android Support包提供了一个工具,是用来在同一个应用内的不同组件间发送Broadcast的。使用LocalBroadcastManager有如下好处:发送的广播只会在自己App内传播,不会泄露给其他App,确保隐私数据不会泄露 其他App也无法向你的App发送该广播,不用担心其他App会来搞破坏 比系统全局广播更加高效 拦截了这么几种广播,按字面意思,应该是这么几类
- Constant.ACTION_CONTACT_CHANAGED 联系人变化广播
- Constant.ACTION_GROUP_CHANAGED 群组变化广播
- RPConstant.REFRESH_GROUP_RED_PACKET_ACTION 刷新群红包广播
接受了消息了以后调用了updateUnreadLabel();和updateUnreadAddressLable();方法
未读消息数更新
更新总计未读数量
/**
* update unread message count
*/
public void updateUnreadLabel() {
int count = getUnreadMsgCountTotal();
if (count > 0) {
unreadLabel.setText(String.valueOf(count));
unreadLabel.setVisibility(View.VISIBLE);
} else {
unreadLabel.setVisibility(View.INVISIBLE);
}
}
/**然后判断广播类型,如果当前的栈顶为主界面,则调用GroupsActivity的onResume方法。
* update the total unread count
*/
public void updateUnreadAddressLable() {
runOnUiThread(new Runnable() {
public void run() {
int count = getUnreadAddressCountTotal();
if (count > 0) {
unreadAddressLable.setVisibility(View.VISIBLE);
} else {
unreadAddressLable.setVisibility(View.INVISIBLE);
}
}
});
}
如果为群红包更新意图则调用的converstationListFragment的refersh()方法
添加联系人监听
EMClient.getInstance().contactManager().setContactListener(new MyContactListener());我们来看下这个MyContactListener()监听方法。
我们发现是MyContactListener是继承自EMContactListener的,我们再来看看EMContactListener和其官方文档的解释。
我们发现其定义了5个接口,这5个接口根据文档释义分别是如下含义:
void onContactAdded (String username)//增加联系人时回调此方法从而我们得知,我们demo中的自定义监听接口在被删除回调时,做了如下操作:
void onContactDeleted (String username)//被删除时回调此方法
void onContactInvited (String username, String reason)/**收到好友邀请 参数 username 发起加为好友用户的名称 reason 对方发起好友邀请时发出的文字性描述*/
void onFriendRequestAccepted (String username)//对方同意好友请求
void onFriendRequestDeclined (String username)//对方拒绝好友请求
如果你正在和这个删除你的人聊天就提示你这个人已把你从他好友列表里移除并且结束掉聊天界面。
测试用广播监听
//debug purpose only
registerInternalDebugReceiver();
/**至此MainActivity的OnCreate方法中所有涉及到的代码我们均已看完。
* debug purpose only, you can ignore this
*/
private void registerInternalDebugReceiver() {
internalDebugReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
DemoHelper.getInstance().logout(false,new EMCallBack() {
@Override
public void onSuccess() {
runOnUiThread(new Runnable() {
public void run() {
finish();
startActivity(new Intent(MainActivity.this, LoginActivity.class));
}
});
}
@Override
public void onProgress(int progress, String status) {}
@Override
public void onError(int code, String message) {}
});
}
};
IntentFilter filter = new IntentFilter(getPackageName() + ".em_internal_debug");
registerReceiver(internalDebugReceiver, filter);
}
其他方法
接下来我们来捡漏,看看还有剩余哪些方法没有去看。
判断当前账号是否移除
/**oncreate()
* check if current user account was remove
*/
public boolean getCurrentAccountRemoved() {
return isCurrentAccountRemoved;
}
requestPermission()
initView()
界面切换方法
/**消息刷新
* on tab clicked
*
* @param view
*/
public void onTabClicked(View view) {
switch (view.getId()) {
case R.id.btn_conversation:
index = 0;
break;
case R.id.btn_address_list:
index = 1;
break;
case R.id.btn_setting:
index = 2;
break;
}
if (currentTabIndex != index) {
FragmentTransaction trx = getSupportFragmentManager().beginTransaction();
trx.hide(fragments[currentTabIndex]);
if (!fragments[index].isAdded()) {
trx.add(R.id.fragment_container, fragments[index]);
}
trx.show(fragments[index]).commit();
}
mTabs[currentTabIndex].setSelected(false);
// set current tab selected
mTabs[index].setSelected(true);
currentTabIndex = index;
}
private void refreshUIWithMessage() {registerBroadcastReceiver()
runOnUiThread(new Runnable() {
public void run() {
// refresh unread count
updateUnreadLabel();
if (currentTabIndex == 0) {
// refresh conversation list
if (conversationListFragment != null) {
conversationListFragment.refresh();
}
}
}
});
}
unregisterBroadcastReceiver();反注册广播接收者。
onDestory()
private void unregisterBroadcastReceiver(){
broadcastManager.unregisterReceiver(broadcastReceiver);
}
@Override异常的弹窗disimiss及置空,反注册广播接收者。
protected void onDestroy() {
super.onDestroy();
if (exceptionBuilder != null) {
exceptionBuilder.create().dismiss();
exceptionBuilder = null;
isExceptionDialogShow = false;
}
unregisterBroadcastReceiver();
try {
unregisterReceiver(internalDebugReceiver);
} catch (Exception e) {
}
}
updateUnreadAddressLable()
getUnreadAddressCountTotal()
getUnreadMsgCountTotal()
getExceptionMessageId() 判断异常的种类
showExceptionDialog()
private int getExceptionMessageId(String exceptionType) {
if(exceptionType.equals(Constant.ACCOUNT_CONFLICT)) {
return R.string.connect_conflict;
} else if (exceptionType.equals(Constant.ACCOUNT_REMOVED)) {
return R.string.em_user_remove;
} else if (exceptionType.equals(Constant.ACCOUNT_FORBIDDEN)) {
return R.string.user_forbidden;
}
return R.string.Network_error;
}
getUnreadAddressCountTotal()
getUnreadMsgCountTotal()
onResume() 中做了一些例如更新未读应用事件消息,并且push当前Activity到easui的ActivityList中
onStop();
@Override
protected void onResume() {
super.onResume();
if (!isConflict && !isCurrentAccountRemoved) {
updateUnreadLabel();
updateUnreadAddressLable();
}
// unregister this event listener when this activity enters the
// background
DemoHelper sdkHelper = DemoHelper.getInstance();
sdkHelper.pushActivity(this);
EMClient.getInstance().chatManager().addMessageListener(messageListener);
}
@Override做了一些销毁的活。
protected void onStop() {
EMClient.getInstance().chatManager().removeMessageListener(messageListener);
DemoHelper sdkHelper = DemoHelper.getInstance();
sdkHelper.popActivity(this);
super.onStop();
}
onSaveInstanceState
@Override存一下冲突和账户移除的标志位
protected void onSaveInstanceState(Bundle outState) {
outState.putBoolean("isConflict", isConflict);
outState.putBoolean(Constant.ACCOUNT_REMOVED, isCurrentAccountRemoved);
super.onSaveInstanceState(outState);
}
onKeyDown();判断按了回退的时候。 moveTaskToBack(false);仅当前Activity为task根时,将activity退到后台而非finish();
@OverridegetExceptionMessageId()
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
moveTaskToBack(false);
return true;
}
return super.onKeyDown(keyCode, event);
}
showExceptionDialog()
showExceptionDialogFromIntent()
onNewIntent() Activity在singleTask模式下重用该实例,onNewIntent()->onResart()->onStart()->onResume()这么个顺序原地复活。
至此,我们的MainActivity就全部阅读完毕了。
我们是在已经登录的情况下来到的MainActivity,那么我们在没有登录情况下呢,当然是来登陆页面。下面我们来看登录页。
环信官方Demo源码分析及SDK简单应用-LoginActivity 收起阅读 »
ios V2.3.1 已发布,增加获取日志压缩文件路径接口
新功能/改进:
- 修改HttpsOnly参数默认值,默认设置为NO(由于苹果强制ATS政策延缓, 所以SDK默认关闭httpsOnly)
[[EaseMob sharedInstance].chatManager setIsUseHttpsOnly:YES];//设置httpsonly,YES开启,NO关闭
- 增加获取日志压缩文件路径接口(具体上传日志方式可由开发者决定, Demo是通过邮件的形式上报日志)
- 优化群组过多时重连卡顿问题
- 修复离线已读回执有时丢失问题
- 修复SDK收到特殊消息闪退问题
版本历史:ios 2.x更新日志
下载地址:SDK下载 收起阅读 »
Web IM V1.4.10已发布,新增语音呼叫
新功能:
[sdk] webrtc新增语音呼叫
Bug修复:
[sdk] webrtc:Firefox在结束通话后的问题
[sdk] webrtc:多次接通挂断之后,逻辑功能混乱
[sdk] webrtc:正常挂断不应该提醒offline
[sdk] webrtc:重连后无法处理音视频IQ消息
webim体验:https://webim.easemob.com
版本历史:更新日志
SDK下载:下载地址 收起阅读 »
Android 依赖EaseUI联系人列表显示昵称 修改之前的发起的那篇文章
注意 我的页面以及类都是从Demo中复制过来的
我们必须要知道好友数据是在什么位置进行数据适配的
在UserDao中有一个方法是saveContactList这个就是进行好友数据保存的操作了
之前我自己创建了一个数据库 进行操作发现出现很多问题 修改的地方也比较多 走了很多弯路
这次经过观察 Demo已经为我们创建了数据库和表 我们只需要在正确的位置把我们获取的数据保存起来就可以了
那么我们的任务就是定位这个方法是在哪调用的,经过代码的跟踪,最终定位到这个位置在
DemoHelper中asyncFetchContactsFromServer()方法
这个方法在没有修改的情况下是从环信服务器获取的好友数据
为了方便我把代码贴出来
public void asyncFetchContactsFromServer(final EMValueCallBack<List<String>> callback) {
if (isSyncingContactsWithServer) {
return;
}
isSyncingContactsWithServer = true;
new Thread() {
@Override public void run() {
List<String> usernames = null;
try {
usernames = EMClient.getInstance().contactManager().getAllContactsFromServer(); // in case that logout already before server returns, we should return immediately
if (!isLoggedIn()) {
isContactsSyncedWithServer = false;
isSyncingContactsWithServer = false;
notifyContactsSyncListener(false); return;
}
//这里就是开始从自己app的服务器获取好友数据了
Map<String, EaseUser> userlist = new HashMap<String, EaseUser>();
String url = AppConfig.BASE_URL+AppConfig.GETFRIENDS;
HashMap<String, String> map = new HashMap<>(); map.put("userName",PreforenceUtils.getStringData("userInfo","hxid"));
Log.e(TAG,url);
MyHttpUtils myHttpUtils = new MyHttpUtils();
String s = myHttpUtils.httpPost(url, "", "&user", map.toString());
Log.e(TAG,s);
JSONArray jarr = new JSONArray(s);
if(jarr.length()!=0||jarr != null){
for (int i = 0; i < jarr.length(); i++) {
JSONObject jobj = (JSONObject) jarr.get(i);
EaseUser easeUser = new EaseUser(jobj.getString("FRIENDID")); easeUser.setNick(jobj.getString("FRIENDNICKNAME"));
easeUser.setAvatar("");
Log.e(TAG,easeUser.toString());
EaseCommonUtils.setUserInitialLetter(easeUser);
//这是关键的地方
userlist.put(jobj.getString("FRIENDID"), easeUser);
}
//这是就是将数据转换成Easeuser对象 的原有方式 已经注释掉了 其他代码没有做修改
/*for (String username : usernames) {
EaseUser user = new EaseUser(username);
EaseCommonUtils.setUserInitialLetter(user);
userlist.put(username, user); }*/
// save the contact list to cache getContactList().clear(); getContactList().putAll(userlist); // save the contact list to database
UserDao dao = new UserDao(appContext);
List<EaseUser> users = new ArrayList<EaseUser>(userlist.values());
Log.e(TAG,"获取联系人");
//报讯联系人的数据就是在这了
dao.saveContactList(users);
demoModel.setContactSynced(true);
EMLog.d(TAG, "set contact syn status to true");
isContactsSyncedWithServer = true; isSyncingContactsWithServer = false;
//notify sync success notifyContactsSyncListener(true); getUserProfileManager().asyncFetchContactInfosFromServer(usernames, new EMValueCallBack<List<EaseUser>>() {
@Override public void onSuccess(List<EaseUser> uList) {
updateContactList(uList);
getUserProfileManager().notifyContactInfosSyncListener(true);
}
@Override public void onError(int error, String errorMsg) { } });
if (callback != null) { callback.onSuccess(usernames); } } }
catch (HyphenateException e) { d
emoModel.setContactSynced(false);
isContactsSyncedWithServer = false;
isSyncingContactsWithServer = false;
notifyContactsSyncListener(false);
e.printStackTrace();
if (callback != null) {
callback.onError(e.getErrorCode(), e.toString()); } }
catch (JSONException e) { e.printStackTrace(); } } }.start(); }
以上就是我的代码了 希望有用 我已经解决昵称的问题了 至于头像也是一样的道理了
之前的文章有很多问题 这里给小伙们说声对不起了 收起阅读 »
让IVR具备“判断”的能力
任何一个呼叫中心都离不开IVR(Interactive Voice Response),即互动式语音应答系统。它作为呼叫中心的门户,最初被赋予的作用有两点:一是初步甄别客户的需求,通过不同的按键设置导流客户需求;二是通过提供自动语音服务来降低日益增加的人工成本。
从IVR进入呼叫中心领域到2012年约二十多年的时间里,所有的呼叫中心管理者都是按照以上两点考虑去规划和设计自己的IVR架构,这期间呼叫中心的管理水平以及客户体验也因此而产生飞速的发展,一方面对于客户而言,很多业务都可以通过自助方式办理,真正体验到了呼叫中心全天候的业务优势;另一方面对于呼叫中心的管理者而言,大量相对简单、适合人机对话的业务被自动方式取代,所需要的人工服务成本大大降低,节省下来的人力资源可以用于更有价值的业务,同时诸如“自助服务占比”等KPI指标也应运而生,被列入呼叫中心管理和考核体系。这样的情况一方面促进了客户体验和呼叫中心管理水平的提高;另一方面随着时间的推进,它的负面作用也逐渐显现,那就是IVR的结构越来越复杂、层级越来越深。现在你拨打任何一个客服中心,无论他是银行业、保险业、电信业或其他行业,大概都会听到一个庞杂的IRV语音提示系统,注意这里我用了“系统”这个词。之所以用这个词,一方面是说明它的复杂性,如果你不是一个经常拨打的老手,你真的很不容易找到自己想要找的东西;另一方面如果抛开客户体验单纯从呼叫中心业务管理层面去看,这样的IVR真的非常完美,它承载了很多自助功能,具备完美的逻辑体系和严谨的语音提示。这时IVR的应用似乎走入了一个误区,即自身不断增加的复杂性和客户体验之间产生了不可调和的矛盾,这样的IVR对客户、对客户体验而言成了一个深不可测的黑匣子,这个黑匣子的完美逻辑让客户产生了莫大的迷惘甚至是恐惧:客户开始不知道如何找到自己需要的东西了,大量的客户因为不愿意在IVR中漫游而重新选择人工服务。
2012—2103年是个更大的分水岭,这一时期移动互联网方兴未艾,给人们的生活、工作产生巨大的、革命性的影响的同时也对社会的普遍风气产生了深远影响。时间进一步碎片化,越来越崇尚随时随地,生活工作节奏进一步加快,人们的容忍度似乎也在降低:去营业厅办理业务,对排队等候时间没法容忍;拨打客服热线,对人工服务等候时间没法容忍。这种情况下对于呼叫中心的IVR而言就会走入一个死胡同:无论怎么优化、怎么搞扁平化,客户都会认为它是如此复杂。道理很简单,业务种类的多样性以及呼叫中心管理者对IVR作用的定位都决定了IVR不可能太简单,而客户早就失去了耐性。
在这期间,IVR的整体架构其实也在不断演进,从最初适用于所有客户的通用架构细分到可以分别对不同客户群体设置的灵活的架构体系。可这样的细分方式大多数情况下是基于一个相对静态的状况,例如客户的品牌、客户使用产品的类型等,和互联网渠道“千人千面”的接触规划相比,细分和个性化的程度显然是大大的落后了。
面对客户需求的这种变化,呼叫中心的运营管理者应该如何应对?
笔者认为要对IVR的能力有清醒的认识。虽然互联网渠道异常活跃,可对于诸如银行、保险、电信等行业而言呼叫中心仍然是主要渠道,既然是这样,IVR的作用就不能被替代,关键在于怎样改变从而让用户自己在庞杂的IVR体系中进行选择的现状,也就是说让呼叫中心的IVR具备一定的“判断”能力,把客户需要的主动推给客户,降低客户满足需求的成本。
试想一想,客户拨打呼叫中心,绝大多数情况下他都会有一个或几个明确的目的,比如说查手机话费、查银行卡交易情况等,如果在客户接通呼叫中心时系统就能够知道客户的拨打目的从而直接把他需要的结果或者查询路径告诉他,是不是很神奇?客户体验是不是会爆棚呢?
这是一个寻找和确定客户需求并匹配产品或者功能的过程,也是通常所说的场景化思考和设计过程。举一个电信行业的具体的、最简单的应用场景来说明整个过程:
第一步,发现客户需求。
通过分析呼叫中心人工话务量发现每月23—24日都有大量客户来电咨询自己手机的欠费情况,而IVR中虽然有当月欠费查询的功能,但和进入人工台的咨询量相比自助使用量非常少,而且大多数客户就是查询欠费,需求非常简单。
第二步,分析客户需求的合理性。
该公司每月从20日开始对欠费客户进行催缴,25日开始陆续进行停机,因此客户在23—24日这个期间准备缴费,所以了解自己的欠费情况属于刚需,而IVR中虽然有查询欠费的自助功能,但因为涉及架构原因层级较深,对于不熟悉的客户而言难以准确找到。
第三步,设计业务场景。
针对以上客户需求进行业务场景设计:客户拨打热线——客服系统获取主叫号码——客服通过支撑系统判断客户是否欠 费——如果欠费就获取客户欠费数据——IVR直接向用户语音播报:您的当月应缴费用为**元,缴费请按1,其他服务请按2。
这是一个非常简单的业务流程,它把过去要由客户在IVR中寻找有关功能的成本或等待转入人工座席、和座席人员沟通的成本转嫁给系统之间通过的数据交互,有效提高了获取答案的效率,给客户体验带来极大提升,同时又有效控制了该类问题的人工话务量,这样的思路可以应用于很多更加复杂的场景。
通过分析客户业务状态的变化,判断客户此次拨打呼叫中心的具体需求,并且有针对性地提供有关功能,这样的IVR已经初步具备了“判断”的能力,在客户体验的层面已经在智能化改造的路上跨出了关键一步!
本文刊载于《客户世界》2016年1-2月刊文章;原文作者陈直,本文作者为山东联通客服中心。 收起阅读 »
环信移动客服走向国际化,提供多语言版本-环信移动客服v5.10发布
为适应快速增长的国际市场需要,同时为国内客户提供更好的国际化服务,环信推出移动客服的国际化版本,提供多语言版本支持。系统目前支持中/英无缝切换,功能及代码设计能够处理不同语言,与国际行业标准保持统一,方便您更好地进行国际交流。
该多语言版本也为将来支持更多国家的语言打下坚实的基础。
界面介绍
环信移动客服系统中,客服模式和管理员模式下的各个页面均制作了英文版,英文界面与中文界面结构一致,不影响现有使用习惯。登录时和登录后均可以进行语言切换,消息中心的新通知与登录后的语言保持一致。
语言设置
语言设置支持两种方式:
1. 在登录页面,可以在右上角选择使用中文或English登录环信移动客服。
2. 在客服模式下,进入“客服信息”页面,选择语言为English,并保存,即可切换至英文界面。
同样,在英文界面下,进入Personal页面,选择Language为中文,并保存,即可切换回中文界面。
环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.10
环信移动客服登陆地址http://kefu.easemob.com/ 收起阅读 »
环信IM 开发遇到两个小问题以及解决办法
环信IM 开发遇到两个小问题以及解决办法
1.消息已读提示 默认是英文“Read” 改为中文“已读”
2.消息长按操作 默认NO 改为YES 系统默认只能进行删除,复制操作 提示方式为英文 改为中文“复制”“删除”
环信Insight.io: 这才是环信开源代码正确的打开方式!
Github虽然是优秀的开源项目协作工具,但是对于源代码本身一直停留在将源代码作为纯文本处理的阶段,搜索和浏览都没有代码智能(Code Intelligence)信息,对于开发者深入了解源代码本身形成了很大的障碍。为了解决这个问题,来自硅谷的Lambda Lab团队为我们带来了Codatlas,一款能让开发者在网页端也能像用IDE一样浏览和搜索源代码的工具,下面让我们一起来看看Codatlas如何让你获得Web端的终极代码浏览体验吧。
1. 跳转到定义
想快速看到代码中的类,变量或者方法是如何定义的?没问题,点击类,变量或者方法被使用的地方就会跳转到相应定义的地方。不仅可以在项目内部跳转,跨项目也同样可以实现跳转。
2. 查找应用
同样的,如果想知道代码中的类,变量或者方法在代码库中哪些地方被使用了,可以点击类,变量或者方法的定义处来显示所有被引用的地方。引用会被进一步分成Referenced At Inherit Override等子类型方便开发者进一步缩小查找范围。
3. 类结构
通过类结构可以把一个类中的所有成员变量和方法列出来,并且点击跳到相应行
4. 语意搜索
基于对源代码的语意分析,在搜索时开发者可以直接按照类名,方法名,变量名等搜索,并且Codatlas提供自动补全功能。
除了上述基于代码智能(Code Intelligence)的功能之外,Codatlas也提供切换版本,显示Commit历史,逐行标注最近Commit信息,树状目录结构等功能。相信有了Codatlas的帮助,开发者们能够更快的上手环信SDK,也欢迎有经验的环信SDK开发者将自己开源的项目提交到Codatlas,让更多的开发者找到。
5. 在问答社区提供代码链接
在IMGeek社区的提问或者回答需要涉及到源代码?用Insight.io可以更迅速的让其他人理解你提问回答中代码的上下文:
除了上述基于代码智能(Code Intelligence)的功能之外,Insight.io也提供切换版本,显示Commit历史,逐行标注最近Commit信息,树状目录结构等功能。
如果你也有优秀的基于环信SDK开发的开源项目希望分享给大家,欢迎联系insight@insight.io让我们收录你的项目,让更多的开发者学习使用环信SDK的最佳实践。
下面是示例中涉及到的开源项目在Insight.io上的访问地址:
- http://www.insight.io/v3/github.com/easemob/sdkdemoapp3.0_android
- http://www.insight.io/v3/github.com/easemob/easeui
- http://www.insight.io/v3/github.com/easemob/emchat-server-examples
如果使用Codatlas有任何的问题或者反馈,欢迎邮件至lambdalab@lambdalab.io 收起阅读 »
环信官方Demo源码分析及SDK简单应用
环信官方Android版本的Demo,还算是功能齐全的.日常工作中我们如果只是为App加个im模块基本的界面和逻辑也出不了Demo多少。
所以如果你的公司有这方面的需求,为了能顺利拿到银子,少些波澜,我们还是一起来研究下其官方Demo吧。
感谢有环信这样强力的三方IM解决方案,并提供了简单易用而又强大的SDK,方便了我们广大中小开发者集成IM相关功能。
有缘的话,我们后面再来分析IOS版本的环信官方Demo源代码。
由于时间仓促,错误及不足之处,欢迎指正。
准备工作
我们说拿到一份代码,要想分析下内容。先看目录再看AndroidMainfest,抽丝剥茧一步步的去理解和分析。
当然这只是个人的习惯,其他有更好的方法或建议,可以留言一起讨论。
废话不多说,我们来看目录。
有三个Moudle
- ChatDemoUI3.0 //主Demo模块
- EaseUI //UI库
- redpacketlibrary //红包库
那么我们首先分析哪个库呢?自然是主Demo库,单单的去分析EaseUI库,或者红包库并没有任何意义和连贯性。下面就来进入我们的环信官方Demo源码分析,在文章的最后会教大家一些SDK的简单应用,同时分享一个我做的基于环信开发项目。
环信官方Demo源码分析及SDK简单应用
环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0
环信官方Demo源码分析及SDK简单应用-LoginActivity
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面
环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面
环信官方Demo源码分析及SDK简单应用-EaseUI
环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现 收起阅读 »
2017年SaaStr大会:AI正吃掉软件?
文章摘要:硅谷著名的投资人马克·安德森说过一句话“软件正在吃掉世界”。在2017SaaStr上,红点的合伙人Tomasz Tunguz做了一场演讲,讲述了AI是如何正在吃掉软件。作者:环信创始人 刘俊彦
硅谷著名的投资人马克·安德森说过一句话“软件正在吃掉世界”。在2017SaaStr上,红点的合伙人Tomasz Tunguz做了一场关于AI正在变成SaaS的基础平台的演讲。我们深刻的感到,下一场革命已经到来了,AI正在吃掉软件。
Tomasz首先简单介绍了他看到的在工作流,保险,建筑,医疗,农业,交通这6个行业的AI投资案例和AI所产生的价值。当然,以Tomasz一向的干货风格,他很快进入在座的SaaS企业最关心的问题:SaaS企业的AI怎么做,投资人会投什么样的AI企业:
1.企业一定要有自己的私有数据来源:
所谓私有数据,就不是你随便可以从百度下载到的数据或者从微博上爬下来的数据,而一定是只有你才能获取的独特数据。
关于怎样获得私有数据来源,这对很多初创企业都是一个难事。Tomasz有2个建议:
a. 创建自己的工作流工具。比如你可以先去做一个CRM软件,当大家都在用你的软件的时候,你就有了数据。当然这个过程可能会很慢,你要有耐心。
B.找到那些全球500强企业。告诉他们,这是你们现在面对的问题,你们自己搞不定,我们可以用AI帮你们搞定。得到500强企业的授权后,你就能接触到大量的数据。
2.这个企业一定是做end-to-end的应用的,而不是做AI平台的。
一定不要告诉我,你要做一个通用的AI平台。很明显,和巨头公司是无法在这个层面竞争的。
对创业企业来说,你要回答的问题是,你在巨头的平台上做工作,你能在这个平台上增加什么额外的价值,可以带来什么额外的竞争优势?
3.要有基于AI的很强的go-to-market的策略。
光有AI不行,还要想好怎么推向市场。
4.一定要有特定领域的行业专家
光有AI科学家和AI工程师是不行的,还要有领域知识和领域经验,要把这些领域知识翻译成AI。
5.如果有AI算法优势那就更好
这也是为什么有自己的NLP引擎的公司比完全用公用的NLP引擎的公司可能要更值钱的原因
最后,AI创业公司向VC介绍自己时,最好的AI公司是那些可以不用提机器学习,不用提深度学习,不用提任何AI的公司。你听明白了吗?
AI正在吃掉软件,也正在深刻的影响着SaaS客服行业,在客服领域AI正逐渐发挥着重要的作用,有望成为一股颠覆性的力量从而被整个行业寄予厚望。随着全媒体客服的普及和广泛应用导致企业和消费者多点接触,同时用户体验得到了企业的重视,导致客服咨询量暴增,企业有限的客服人力资源与日益增加的客服请求之间的矛盾日益尖锐,如何用有限的客服资源服务不断增长的海量客服请求需要一个颠覆型的技术来解决。相比人工客服,AI客服机器人将提供极大的效率优势。Gartner报告指出智能客服机器人(VCA-virtual customer assistance)的使用正处于临界点。大幅改进的自然语言处理技术,以聊天为中心的移动渠道与客户互动的应用,以及客户对机器人技术的接受程度,这些因素使得人们对VCA的兴趣越來越大。从被动的被人类编程出来的可以在结构化和非结构化内容库中找到问题答案的虚拟助手,到主动的有时候是机器学习得到的VCA的转变,其考察个人的特征并代表他们行动。虚拟助手正在经历从被动的被人类编程出来在结构化和非结构化内容库中找到问题答案到主动的通过机器学习能够理解用户个性化的需求并且随之采取灵活应对行为的转变。
环信作为智能客服企业的践行者,基于自然语言处理和机器学习技术推出了环信智能客服机器人,辅助或代替人工客服精准回答常见或高频问题,降低企业客服人力成本。截止2016年上半年,环信已经在客服领域服务了29000多家标杆客户,积累了人工智能在客户服务行业落地的大量最佳实践。
收起阅读 »
电商直播模式爆发,未来如何赢胜?
如日中天的直播业务正在与不同互联网行业快速结合起来,形成“直播+经济”。“直播+电商”模式爆发,成为网络零售的下一个风口
直播+娱乐已很成熟,如今还有一个正迅速崛起的商业模式—— “直播+电商”。
2016年被称作“直播+电商”的元年,今年“双十一”各大电商直播很火爆,直播平台数量呈井喷式爆发,“直播+电商”作为连接用户和商品销售的一种愈来愈重要的新模式,让业界直呼“直播+电商”已成为网络零售的下一个风口,而随着诸如AR/VR等直播的技术升级,“直播+电商”更是让业界产生无限的的想象空间。
传统电商流量红利期已过,电商布局直播的目的都是为了获取新的大量的流量入口以营造新利基,而随着资本进一步加持,今年以来国内直播平台数量持续增加,市场规模飙长。2016年春节时,国内直播平台大概有八十多家,5月份骤增至四五百家,年底更是飙到快接近一千家。艾瑞机构统计数据显示去年国内移动直播行业的市场份额为120亿元,到2020年预计将会突破1000亿元,而“直播+电商”将成为其中一支重要的生力军。
今年5月淘宝正式推出淘宝直播,至今已经有超过千万的用户观看过直播内容,超过1000人在淘宝上做过主播。在成功运营了半年之后,阿里巴巴也将电商直播栏目化植入到今年的“双十一”大促。蘑菇街9.0版本上线了全球街拍和美妆视频两项PGC(专业生产内容),用户可以在蘑菇街APP里看到每日更新的街拍图和专业的美妆视频,边看美妆边购物,效果很好。
同时,消费升级的趋势让跨境电商也加入直播阶段。去年7月第一家确立PGC直播的跨境电商菠萝蜜上线,仅两个月,波罗蜜创收1000多万;今年3月亚马逊也开始尝试网络直播服务,推送其海外商品,交易规模飙涨5倍;8月,网易考拉海购则与虎牙直播、斗鱼直播和花椒直播签订战略合作框架……
“电商+直播”,机遇与挑战并存
我们知道传统电商平台存在的痛点有二:一是商品展现形式单一,图文信息对消费者的购物决策不再充分;二是缺乏社交行为,尽管用户足不出户就能购物但还是不能互动、互视交流。而基于视频直播的电商融入一定的社交属性并承载传播商品信息方式,视频的信息维度更为丰富,可以在很大程度上打破消费者对货物看不见、摸不着、感受不到的现状,为消费者提供更全面的产品或服务信息,可以较大地提升购物体验,降低试错成本,促进了用户的有效决策,降低售前咨询的负担,同时通过网红、明星等方式聚集人气营造团购氛围,进而提高成交转化效率。尤其是那些难于现场体验、大件复杂、技术性较强的商品往往有很多问题,而通过与主播的直面互动基本可以立刻得到解答甚至能实现和明星、网红一起逛街的梦想,享受边看边买、边聊边买的体验。波罗蜜全球购的创始人张振栋说过,直播能对销售转化大幅度提升是因为在观看直播的群体内产生了从众效应。在一个强交互的场景下,屏幕两端都在向着购买的方向拉动,人群决策的效果影响了每个个体。
当前直播与电商结合的大趋势正在向三种模式发展。一是电商平台增加直播功能;二是新型“直播+电商”模式平台的出现;三是直播平台通过商品链接倒流至第三方电商平台。三种模式各有特色,但最终脱颖而出的很可能是第二种模式,并且在这种模式下会形成多强格局。
第一种模式,以天猫直播、淘宝直播为代表的大电商平台增加直播功能。从天猫直播最引以为傲的案例来看,2016年4月14日AngelaBaby在天猫直播两小时,美宝莲新品卖出10000支;4月26日杜蕾斯3小时直播,几十万用户付费观看,20%的用户引导进店。以上营销案例代表了以网红、明星、品牌直播内容为流量入口迅速打造爆款的营销方式。
第二种模式,以小红唇和波罗蜜为代表的“直播+电商”新模式的创业公司。波罗蜜是2015年初成立的主打“视频互动直播”的自营跨境电商平台,用户打开APP可以真切感受到当地购物的场景,看到各种商品在世界各地的商场店铺热卖,并能通过聊天室与现场团队实时互动。小红唇是国内针对15-25岁年轻女孩的“美妆网红”视频电商平台,网红在平台分享如何化妆护肤、如何选择化妆品等视频和直播,该公司正在通过快速融资进一步打造网红及增强变现渠道,强化直播内容+流量及品牌双向导流,粉丝有数百万。
第三种模式,直播平台通过商品链接的方式倒流到第三方电商平台。目前这种模式尚未有代表公司,原因在于转型电商的风险大、成本高,这不是目前直播平台想要看到的结果。
然而,“直播+电商”模式井喷同时也遭遇不少挑战与问题。
“直播+电商”的形式不同于传统直播平台中靠收取虚拟礼物折现,除了网店给的基本工资外主播们的收入主要靠“卖货”拿提成盈利。然而许多网红主播在推荐产品时并不专业,效果大打折扣,购买转化率低。据悉艾瑞媒体在某电商直播平台观察统计,一个平均18万粉丝的主播、2500人左右观看的直播通常一场下来只有寥寥几十单的转化,转化率为零的情况也不罕见,流量难以变现成为传统电商的切肤之痛。专家认为商家花高价请来明星和网红只能是“赚吆喝不赚钱”的尴尬局面。
可以说直播说到底拼的还是内容和玩法,虽然明星、网红或小鲜肉在直播期间短期能带来巨大的流量,但鉴于电商直播的经济属性、消费性,多数普通粉丝很难沉淀在电商平台,关键是要有对口的受众体。电商直播的营销面向的是广义人群,但也要根据消费类型、产品定位对普通观众、核心受众做精准细分、渗透,不然只有人气没有买气。观众和受众(潜在消费者)还是不一样的概念,只有针对重点、关键的受众体做出高性价比的产品平台及相应的精确宣传动作,才会有推广效果,不是有了明星、网红或小鲜肉就能带来大量购买行为。诚如京东直播负责人所说,直播实质上是一个新的内容形式,和传统媒体类似,重点还是在内容、精确对口的商品,还是靠比拼实力,未来随着直播内容数量的指数级增长,只有真正有价值、大众化、对口的内容平台才能被用户关注。
不过令人忧虑的是,当前电商直播平台公布的直播资质门槛表明店铺需拥有4万以上粉丝才有资格开通电商直播,也才能转化成一定的购买量,但庞大的粉丝基数对于白手起家的绝大多数中小卖家而言无疑是望而兴叹。
还有,有业界人士认为 “直播+电商”本质就是电视+电商,即所谓的T2O模式(TV to Online)模式,连电视这么强势的媒体都玩不转,更别说手机或PC直播。直播只是宣传方式,跟文字、图片等没有本质区别,而电商的商业本质并没有变化,过去并不存在着“文字+电商”、“图片+电商”的说法,“直播+电商”只是一个拼造的新概念,因此认为“直播还是为数很少的大玩家大平台才能玩得起”。
另外对电商直播来说,以出售为主、直播为辅,直播只是作为一种展示商品的工具,这并不能撕掉网络零售长期以来存在的某些负面标签,如数据造假、平台刷单、价格欺诈、涉黄等现象也不时隐藏在 “直播+电商”中,若不“悔改”,加了直播也未必能在多大程度上改善营销局面。
最为关键的是随着最严监管潮的来临,国内直播平台正遭遇一轮大洗牌,电商直播能否避免“殃及池鱼”并撑得住?未来电商直播格局又会发生怎样的变化?
短期内多个新政密集出台,电商直播业洗牌加快
2016年9月起,直播领域的监管骤然收紧。9月9日,新闻出版广电总局下发《关于加强网络视听节目直播服务管理有关问题的通知》,重申互联网视听节目服务机构开展直播服务必须符合《互联网视听节目服务管理规定》和《互联网视听节目服务业务分类目录》的有关规定。11月4日,国家网信办发布了《互联网直播服务管理规定》,该规定主要实行“主播实名制登记”、“黑名单制度”等强力措施,且明确提出 “双资质”的要求。12月12日,文化部又印发《互联网直播管理办法》,对网络表演单位、表演者和表演内容进行了进一步的细致规定。
在大量新规三令五申背后反映出的是直播行业加速整合、自我净化提升的现状,一系列新规的出台对大直播平台来说是利好,而对小直播平台来说则是一道迈不过去的门槛,准入门槛和从业门槛的提高将使直播行业产生重大的洗牌效应。
同样,短期内多个新政密集出台也给才露出苗头的电商直播业泼了冷水。
目前中小电商直播平台用户积累较为单薄,缺乏足够内容及内容生产能力,资源置换能力较弱,与此同时受单一商业模式影响,营收收入逐渐难以覆盖成本,未来生存压力较大。未来电商直播业强者恒强弱者恒弱的格局将愈来愈明显,中小平台数量的减少将加快。而当相关政策全部落实到位后,电商直播行业才能将逐渐建立起良性竞争的健康市场氛围。
电商直播未来之路何在?如何赢胜?
没有规矩不成方圆。可以说未来电商直播业只有合乎产业政策,守法经营才能生存,才有前途。同时,电商直播想要长久发展、弯道超车,还需解决以下几个重大问题:
1、如何持续保持高流量
未来一个阶段电商平台方需要着力解决的仍是流量问题,高流量的平台如何持续保持高流量,低流量的平台如何提升流量,都是各家需要着力解决的问题。和更加成熟的平台合作、与更具知名度的网红合作都或将成为更加主流的方式,同时直播的内容也需要加以斟酌和推敲,如何巧打“政策边球”,如何雅俗共赏,如何以更高性价比打动用户,从而刺激更多的用户参与其中,保持提升高流量,是重要的生存战略。
2、如何实现高效转化并带来高销量
直播是在做娱乐,但是“电商+直播”最重要的还是要解决买卖的生意问题,不能娱乐化,也不能商业味过浓。无论是何种营销方式,电商直播的目的有二:一是增加曝光度提升品牌美誉度;二是带来更多的销量,促使人气转化为买气。因此在直播过程中,电商直播平台更需要促成用户对商品的了解、兴趣,最后达到购买下单,这主要要着力解决高转化、高销量的问题,主要措施包括深入定制到内容层面、增加更多的互动成分、看直播有奖、积分返利等等都是可以尝试采用的方式。
3、如何解决高成本的问题
虽然电商获取新用户的成本近200元,但直播+电商模式本身的费用并不比传统方式低,或许更高。
一般情况下电商直播大抵是与国内的直播平台合作,而要更有名气更有流量,这意味着需要采用直播平台+网红这种模式来提升人气,甚至+明星,而这均需要支付很高的费用,而直播+明星对大多数平台来说更是遥不可及,所以如果要想有高流量就必然需要支付高开销,如何办? 这就需要电商直播业脑洞大开,殚精竭力了。有一个最简单办法就是美女+直播,因为美女是网红一个基本前提,而且找一个美女容易也不贵,同时可采取各种办法炒红所聘请的美女。
4、最大难题是技术问题,就是如何让用户直播时有更好的购物体验,这需要有更好的购物技术,将直播与电商结合得更顺畅,增加消费转化效率。
(1)语音技术:在主播讲解说到某个商品时,就能出现商品链接,用户可方便地加入购物车,眼下还没有直播平台做到这一点。聚划算的做法提供了新思路:通过语音口令帮助用户快速购买,在主播公布语音口令之后用户可通过聚划算App“喊出”口令进而获得优惠、购买商品,这让用户在直播中有消费欲时购物更便捷,提升了转化效率,丰富了互动方式。
(2)图像技术:在主播展示某个商品或到达某个地方时可通过图像识别技术探测对应商品,进而给用户推荐,便于用户下单,实现真正的边看边买。已有创业团队尝试在视频上实现类似技术,比如观众看到《欢乐颂》里面刘涛的衣服不错,如何方便将其加入购物车下单、如何将图像识别技术与直播结合起来是接下来的难点,要做到实时识别并不容易。
(3)VR技术:直播+VR结合将是大势所趋,VR能够让观众、消费者更全面、多维、生动地了解世界各地的商品。之于直播电商,有了VR(虚拟现实)或AR(增强现实)技术,消费者就能更好地了解商品信息或者跟明星或视频内的商品互动。比如戴上头盔让你到达一个虚拟的商场,里面有导购员(主播)正在讲解,还有一群人在围观(社交),还有琳琅满目的商品如真实般扑面而来,甚至还有声响、气味,让你有真实美妙的购物感觉,这是一种前所未有的购物体验。淘宝愚人节发布了BUY+计划就是类似理念,阿里巴巴还宣布要做VR内容平台,打造VR交互技术,直播+VR+电商打通为时不远,那时直播电商或真的爆发了,因此未来谁掌握最新最先进的直播技术,谁就能引领电商直播业的未来。
瑕瑜并现,瑕并不掩瑜,任何事物不是只有光鲜的一面。在电商与直播碰撞的第一个“双十一”,电商直播到底是网络零售的下一个风口还是无意义的流量争夺泡沫?面对直播的火爆与直播的一些乱象,电商直播是风口还是烫手的山芋?电商直播业如何应对越来越严厉的直播监管?如何快速提升直播平台人气、人脉?如何有效提升直播技术水平,让自己脱颖而出弯道超车?让我们拭目以待以察!
本文刊载于《客户世界》2016年1-2月刊文章;原文作者吴勇毅,本文作者为厦门智者恒通管理顾问机构总监。 收起阅读 »
环信头像和昵称显示的详细、详细、详细教程!
附上大神的集成链接: http://www.imgeek.org/article/825307886
通过官方的文档我们知道有两种显示头像和昵称的方式(http://docs.easemob.com/im/490integrationcases/10nickname 官方文档)
这里主要讲方式二!(通过扩展消息传递显示)
这里主要有三个类需要改,分别是:
EaseMessageViewController
EaseBaseMessageCell
chatUIhelper
首先我们需要在发送消息的时候添加扩展字段,在EaseMessageViewController.m里。可以看到有以下方法:
#pragma mark - send message
- (void)_refreshAfterSentMessage:(EMMessage*)aMessage
{
if ([self.messsagesSource count] && [EMClient sharedClient].options.sortMessageByServerTime) {
NSString *msgId = aMessage.messageId;
EMMessage *last = self.messsagesSource.lastObject;
if ([last isKindOfClass:[EMMessage class]]) {
__block NSUInteger index = NSNotFound;
index = NSNotFound;
[self.messsagesSource enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(EMMessage *obj, NSUInteger idx, BOOL *stop) {
if ([obj isKindOfClass:[EMMessage class]] && [obj.messageId isEqualToString:msgId]) {
index = idx;
*stop = YES;
}
}];
if (index != NSNotFound) {
[self.messsagesSource removeObjectAtIndex:index];
[self.messsagesSource addObject:aMessage];
//格式化消息
self.messageTimeIntervalTag = -1;
NSArray *formattedMessages = [self formatMessages:self.messsagesSource];
[self.dataArray removeAllObjects];
[self.dataArray addObjectsFromArray:formattedMessages];
[self.tableView reloadData];
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:[self.dataArray count] - 1 inSection:0] atScrollPosition:UITableViewScrollPositionBottom animated:YES];
return;
}
}
}
[self.tableView reloadData];
}
- (void)_sendMessage:(EMMessage *)message
{
if (self.conversation.type == EMConversationTypeGroupChat){
message.chatType = EMChatTypeGroupChat;
}
else if (self.conversation.type == EMConversationTypeChatRoom){
message.chatType = EMChatTypeChatRoom;
}
[self addMessageToDataSource:message
progress:nil];
__weak typeof(self) weakself = self;
[[EMClient sharedClient].chatManager sendMessage:message progress:nil completion:^(EMMessage *aMessage, EMError *aError) {
if (!aError) {
[weakself _refreshAfterSentMessage:aMessage];
}
else {
[weakself.tableView reloadData];
}
}];
}
- (void)sendTextMessage:(NSString *)text
{
NSDictionary *ext = nil;
if (self.conversation.type == EMConversationTypeGroupChat) {
NSArray *targets = [self _searchAtTargets:text];
if ([targets count]) {
__block BOOL atAll = NO;
[targets enumerateObjectsUsingBlock:^(NSString *target, NSUInteger idx, BOOL *stop) {
if ([target compare:kGroupMessageAtAll options:NSCaseInsensitiveSearch] == NSOrderedSame) {
atAll = YES;
*stop = YES;
}
}];
if (atAll) {
ext = @{kGroupMessageAtList: kGroupMessageAtAll};
}
else {
ext = @{kGroupMessageAtList: targets};
}
}
}
[self sendTextMessage:text withExt:ext];
}
- (void)sendTextMessage:(NSString *)text withExt:(NSDictionary*)ext
{
EMMessage *message = [EaseSDKHelper sendTextMessage:text
to:self.conversation.conversationId
messageType:[self _messageTypeFromConversationType]
messageExt:ext];
[self _sendMessage:message];
}
- (void)sendLocationMessageLatitude:(double)latitude
longitude:(double)longitude
andAddress:(NSString *)address
{
EMMessage *message = [EaseSDKHelper sendLocationMessageWithLatitude:latitude
longitude:longitude
address:address
to:self.conversation.conversationId
messageType:[self _messageTypeFromConversationType]
messageExt:nil];
[self _sendMessage:message];
}
- (void)sendImageMessageWithData:(NSData *)imageData
{
id progress = nil;
if (_dataSource && [_dataSource respondsToSelector:@selector(messageViewController:progressDelegateForMessageBodyType:)]) {
progress = [_dataSource messageViewController:self progressDelegateForMessageBodyType:EMMessageBodyTypeImage];
}
else{
progress = self;
}
EMMessage *message = [EaseSDKHelper sendImageMessageWithImageData:imageData
to:self.conversation.conversationId
messageType:[self _messageTypeFromConversationType]
messageExt:nil];
[self _sendMessage:message];
}
- (void)sendImageMessage:(UIImage *)image
{
id progress = nil;
if (_dataSource && [_dataSource respondsToSelector:@selector(messageViewController:progressDelegateForMessageBodyType:)]) {
progress = [_dataSource messageViewController:self progressDelegateForMessageBodyType:EMMessageBodyTypeImage];
}
else{
progress = self;
}
EMMessage *message = [EaseSDKHelper sendImageMessageWithImage:image
to:self.conversation.conversationId
messageType:[self _messageTypeFromConversationType]
messageExt:nil];
[self _sendMessage:message];
}
- (void)sendVoiceMessageWithLocalPath:(NSString *)localPath
duration:(NSInteger)duration
{
id progress = nil;
if (_dataSource && [_dataSource respondsToSelector:@selector(messageViewController:progressDelegateForMessageBodyType:)]) {
progress = [_dataSource messageViewController:self progressDelegateForMessageBodyType:EMMessageBodyTypeVoice];
}
else{
progress = self;
}
EMMessage *message = [EaseSDKHelper sendVoiceMessageWithLocalPath:localPath
duration:duration
to:self.conversation.conversationId
messageType:[self _messageTypeFromConversationType]
messageExt:nil];
[self _sendMessage:message];
}
- (void)sendVideoMessageWithURL:(NSURL *)url
{
id progress = nil;
if (_dataSource && [_dataSource respondsToSelector:@selector(messageViewController:progressDelegateForMessageBodyType:)]) {
progress = [_dataSource messageViewController:self progressDelegateForMessageBodyType:EMMessageBodyTypeVideo];
}
else{
progress = self;
}
EMMessage *message = [EaseSDKHelper sendVideoMessageWithURL:url
to:self.conversation.conversationId
messageType:[self _messageTypeFromConversationType]
messageExt:nil];
[self _sendMessage:message];
}
有发送各种消息的,我们要每个里边都加扩展字段么?那恐怕要累死咯! 仔细看会发现发送消息的方法最后都会走一个方法:
- (void)_sendMessage:(EMMessage *)message
{
if (self.conversation.type == EMConversationTypeGroupChat){
message.chatType = EMChatTypeGroupChat;
}
else if (self.conversation.type == EMConversationTypeChatRoom){
message.chatType = EMChatTypeChatRoom;
}
[self addMessageToDataSource:message
progress:nil];
__weak typeof(self) weakself = self;
[[EMClient sharedClient].chatManager sendMessage:message progress:nil completion:^(EMMessage *aMessage, EMError *aError) {
if (!aError) {
[weakself _refreshAfterSentMessage:aMessage];
}
else {
[weakself.tableView reloadData];
}
}];
}
好的,就是这里了,添加扩展字段,包含用户的头像地址,昵称和环信ID。 找到保存用户信息的类UserCacheInfo,找到相应的字段,在这个方法里添加如下代码:
NSMutableDictionary *Muext = [NSMutableDictionary dictionaryWithDictionary:message.ext];
UserCacheInfo *info = [UserCacheManager currUser];
[Muext setObject:kCurrEaseUserId forKey:kChatUserId];
[Muext setObject:info.NickName forKey:kChatUserNick];
[Muext setObject:info.AvatarUrl forKey:kChatUserPic];
message.ext = Muext;
这样第一步就完成了!
接下来我们要在接收消息的方法里保存传过来的扩展消息里的头像、昵称和环信ID,这就用到chatUIhelper.m这个类,这个方法里:
- (void)didReceiveMessages:(NSArray *)aMessages
{
BOOL isRefreshCons = YES;
for(EMMessage *message in aMessages){
[UserCacheManager saveInfo:message.ext];// 通过消息的扩展属性传递昵称和头像时,需要调用这句代码缓存
BOOL needShowNotification = (message.chatType != EMChatTypeChat) ? [self _needShowNotification:message.conversationId] : YES;
#ifdef REDPACKET_AVALABLE
/**
* 屏蔽红包被抢消息的提示
*/
NSDictionary *dict = message.ext;
needShowNotification = (dict && [dict valueForKey:RedpacketKeyRedpacketTakenMessageSign]) ? NO : needShowNotification;
#endif
UIApplicationState state = [[UIApplication sharedApplication] applicationState];
if (needShowNotification) {
#if !TARGET_IPHONE_SIMULATOR
switch (state) {
case UIApplicationStateActive:
[self playSoundAndVibration];
break;
case UIApplicationStateInactive:
[self playSoundAndVibration];
break;
case UIApplicationStateBackground:
[self showNotificationWithMessage:message];
break;
default:
break;
}
#endif
}
if (_chatVC == nil) {
_chatVC = [self _getCurrentChatView];
}
BOOL isChatting = NO;
if (_chatVC) {
isChatting = [message.conversationId isEqualToString:_chatVC.conversation.conversationId];
}
if (_chatVC == nil || !isChatting || state == UIApplicationStateBackground) {
[self _handleReceivedAtMessage:message];
if (self.conversationListVC) {
[_conversationListVC refresh];
}
if (self.mainVC) {
NOTIFY_POST(kSetupUnreadMessageCount);
}
return;
}
if (isChatting) {
isRefreshCons = NO;
}
}
if (isRefreshCons) {
if (self.conversationListVC) {
[_conversationListVC refresh];
}
if (self.mainVC) {
NOTIFY_POST(kSetupUnreadMessageCount);
}
}
}
关键就是这句话:
[UserCacheManager saveInfo:message.ext];// 通过消息的扩展属性传递昵称和头像时,需要调用这句代码缓存!!!
到这里头像和昵称的问题就基本解决了!
- 重要的总是留在最后!!! 不看后悔哦!!!
上两步完成后你会惊奇的发现头像和昵称正常显示了,然而当你换个头像测试的时候,你会发现很不美妙,头像没有更换,这是什么问题呢? 这就要用到开始讲到的第一个类EaseBaseMessageCell.m,我们仔细看代码会发现它是怎么赋值的,如下:
#pragma mark - setter
- (void)setModel:(id<IMessageModel>)model
{
[super setModel:model];
if (model.avatarURLPath) {
[self.avatarView sd_setImageWithURL:[NSURL URLWithString:model.avatarURLPath] placeholderImage:model.avatarImage];
} else {
self.avatarView.image = model.avatarImage;
}
_nameLabel.text = model.nickname;
if (self.model.isSender) {
_hasRead.hidden = YES;
switch (self.model.messageStatus) {
case EMMessageStatusDelivering:
{
_statusButton.hidden = YES;
[_activity setHidden:NO];
[_activity startAnimating];
}
break;
case EMMessageStatusSuccessed:
{
_statusButton.hidden = YES;
[_activity stopAnimating];
if (self.model.isMessageRead) {
_hasRead.hidden = NO;
}
}
break;
case EMMessageStatusPending:
case EMMessageStatusFailed:
{
[_activity stopAnimating];
[_activity setHidden:YES];
_statusButton.hidden = NO;
}
break;
default:
break;
}
}
}
看到这里就明白了是头像缓存了,直接用的是缓存里的头像,我们需要更新的话直接设置一下缓存策略就可以了,代码修改如下:
把 [self.avatarView sd_setImageWithURL:[NSURL URLWithString:model.avatarURLPath] placeholderImage:model.avatarImage];
改成 [self.avatarView sd_setImageWithURL:[NSURL URLWithString:model.avatarURLPath] placeholderImage:model.avatarImage options:EMSDWebImageRefreshCached];
然后运行一下你会发现世界如此美好,大功告成!
对各位小伙伴you有没有帮助呢?
如有任何问题,请咨询【环信IM互帮互助群】,群号:340452063 (进群记得改名片哦!江南大神也在群里!)
本人群里的名片:上海-iOS-小码农 。 收起阅读 »
技术选型最怕的是什么?
昨天聊聊架构发布了一篇关于技术选型的文章,文章作者介绍了目前流行的技术选型方式,比如有微博驱动、技术会议驱动、嗓门驱动、领导驱动.....不少读者都表示深有体会,并在评论区贴出了自己的经历。今天再推荐一篇由环信首席架构师一乐所撰写的关于技术选型的文章(旧文),希望能帮到各位。另推荐一乐的个人微信一乐来了,id是yilecoming,欢迎关注。这也许是我上半年最大的欠账,在去普吉的飞机上突发无聊,想想还了这债吧。
去年的时候,我们使用Cassandra出了一次问题,定位加修复用了一晚上。当我把经历发出来的时候,收到了下面一段话:
“一个开源产品,连官方文档都没看完大半,然后匆匆忙忙上生产环境,出了问题团团转。若是不能掌控就先不要玩,说回这Cassandra的例子,在对它不了解的情况下,仅通过Google就能解决问题,不正说明它不难掌握有大量资料可查吗,实在不行还能翻代码。”
我现在都不知道这位神仙从哪里看到的匆匆忙忙上和团团转,当时我还是忍了,因为实在太忙,口水又没那么多。当然我思考了很多,这就是你现在看到的文章。我相信它会有一些价值,毕竟有些事情有的人你不告诉他,他永远不可能知道。比如个体认知的局限,比如口无遮拦的损失,比如做事之人才会有的思考角度。
本文讲的是技术选型。
大多数技术都存在选型问题,因为技术的发展已经让一件事情可以有多种解决方案,选型问题就自然出现。前段时间也有人说过语言选型,这里举的例子是在组件、框架、服务的范畴。其中有相通之处,各位可以自行领会。
选型最怕什么
怕失败么?那肯定的。你的服务崩溃,用户愤而投诉,客户电话打到老板那里,明天你要洗干净到办公室去一趟(笑...)。而所有对失败的无法容忍,最终都会变成一句话,为什么你要选这个型?
你总要回答这个问题,所以选型一怕随意,公鸡头母鸡头,选上哪头是哪头;二怕凭感觉,某某已经在用听起来还不错。你需要真正的思考,而且尽可能的全面。我下文会详细讲解,但这还不是最怕的。
最怕的是什么?看看本文开头引用的那句话,你体会一下。
嗯,最怕的是喷子。怕任意总结,如果再加上一些诋毁,一次选型失败足以让人心碎一万次。
失败不可怕,可怕的是没有总结,因为没有总结就没有提高。而比没有总结更可怕的是乱总结。
为了方便理解,我再帮你换个角度。你天天在河边走,一次不小心湿了鞋。如果是本文说的这种人,那肯定要说:
一条公共的河,你连旱季旺季都没搞清楚,就匆匆忙忙跑过来散步,湿了鞋还到处讲。若是脚不行就别在这玩,说回这条河,湿了鞋就能爬上来,不正说明他水不深么。
这种人实在不算少见。他说的每一句话都有一点道理,但都跟事情的本质毫无关系,每一句话又都掺加了嘲讽,来体现那无处安放的莫名优越感。而所有的这些,对于解决问题和后续提高通常毫无帮助。
想想也真遗憾,人生本是如此美好,有的人却硬生生地活成了奇葩。
选型需要什么
言归正传,我认为有三点不可或缺:分析、实验和胆量。
- 分析
分析主要有定性分析和定量分析。实际操作中,前者主要针对的是模型维度的估计,用来考虑一个组件是否有可能达到它宣称的目的,后者主要用来验证,用来确认它是否在真的做到了。
比如在语言选型时,你要考虑它的范型、内存模型和并发设计;数据库选型时你要考虑存储模型、支撑量级、成本开销;开源项目要考虑它的社区发展、文档完善程度;如果是库或者中间件,还要考虑他的易用性、灵活性以及可替代性,等等。
需要说明的一点是,我个人并不觉得阅读全部源码或者文档这种事情是必须的,这不局限在OS、VM层面。不仅因为这样的事情会耗费过多精力,而且受制于代码以及文档质量,就算真正阅读完毕也未必意味着完全领会。
这些都是定性的,而定性的东西就有可能存在理解偏差。一个库可以完成工作,并不代表它在高并发压力下依然表现正常;一个语言做到了自动管理内存,并不代表他能做得很好没有副作用;一件事情设计者觉得达到了目标并不代表能够满足使用者期望。因此我们还需要量化分析,也就是一直口口相传的,用数据说话。
量化分析需要你构建或使用现成的工具和数据集,对服务进行特定场景下的分析。通过提高压力、增加容量或者针对性的测试,来验证之前的定性分析是否达到预期,并分析不同技术之间的差异和表现。
- 实验
量化分析可以为真正的实验做一些准备和帮助,但是实验要走的明显更远。到了这一步,意味着要在真正的业务场景下进行验证,这跟量化分析中通用性场景有所不同。
在真正的业务中采用需要很多细致和琐碎的工作,除此之外,还要构建自己的测试工具集,这需要非常扎实的业务理解能力和勤奋的工作。而所有这些,你需要在开发环境做一次,在沙箱环境做一次,然后在仿真环境再做一次。
这几步经常被简化,但经验告诉我们,如果你想做一个高可用的系统,你就不应该少走任何一步。
步子大了,容易扯到蛋。
- 胆量
实验做完,剩下的就是上线,但这一步有很多人跨不过去。因为就算做了再多准备,你依然不敢说百分百保证没问题。现实情况是,80%的线上问题都是升级或者上线引起的。
你需要胆量。
这不是说要硬着头皮做,人家都是艺高才胆大。所以为了让胆子大一点,你首先需要考虑降级和开关。从最悲观的角度来重新审视整个方案,如果升级出现问题怎么办,如何才能让出现的问题影响最小化。
而只要弄完了这些,也就只要再记住一句话就行:
你行你上啊!
对技术服务的提醒
- 得到认可
刚才在胆量里没说的一点。我们经常会看到,一项新技术在公司内久久难以推行,因为业务主管百般阻挠。即使排除利益纠葛,仍然会发现一种发自心底的不信任存在。而这种不信任,又往往来源于对同事工作的不认可。
这个问题原因很多,也许没有通用的解决方案,但我说一个例子。
我们最近开始使用Codis,就是@goroutine 和几个家伙之前搞过的玩意儿。虽然他们最近已经独立开搞像Google Spanner但拥有更高级特性的TiDB(就是太牛了的意思)。由于我对他们比较熟悉和认可,所以在Codis尝试方面也多出很多底气。这种信任并非完全来自于出问题之后的直接电话支持,而是真心觉得活儿好。
反过来,这对很多服务也是一个提醒,特别是云服务。也许只要你得到合作伙伴的认可,或者至少让他们觉得,自己动手不会比你做得更好,你基本也就成功了。
对于大多数理性创业公司来讲,他们还是更愿意把精力放在自己的主要业务上,不会希望所有的服务都自己做,因为这个年代,唯快不破,创业等不起一辈子。
- 产品意识
回到开始那句话,“在对它不了解的情况下,仅通过google就能解决问题,不正说明它不难掌握有大量资料可查吗,实在不行还能翻代码。” 这话有些道理,然而却存在一个问题,这个问题就是:
作为一个使用者,是否有能力解决遇到的问题,与是否有意愿去遇到并解决问题,是两回事。
你有本CPU设计手册,你可以说处理器很简单,但我只想看个电影啊?给你Linux内核的源码,你可以说内核设计不难掌握,但我只想跑个游戏啊?何况他们是否因此就变得不难了,也是值得怀疑的。
这其实反映了技术人的产品意识。
很多技术人员喜欢玩酷的东西,他们愿意去探索新的领域,把不可能的变为可能。但是很多时候,他们做出来的东西却很难使用。
有的库可以增加很多参数,参数之间却有耦合,导致你在采用的时候需要写很多设置代码,而有点库却只需要一行代码;有的服务功能众多,却需要用户学习繁杂的步骤,而有的服务却可以开箱即用;有的服务功能可以实现,却会有很多不稳定甚至崩溃的情况出现,等等。
对于实现的工程师来讲,可能最大的区别在于,你是否考虑从用户的角度审视过自己的东西。即使这个服务也许只是为其他技术人员使用的。
技术人员可以,也应该,让技术人员更幸福。
最后,聊聊架构为大家送上一个关于技术选型的小漫画。其实技术并没有错,错的也许是我们.....
收起阅读 »
用户的满意是检验服务流程的唯一标准
某日上午,致电当地10000号开通ADSL上网服务,CSR认真地讲解该项业务的办理流程以及每个环节的服务承诺,包括致电后24小时内查线,48小时内上门安装。我欣然接受。
当日下午,很快接到CSR的安装预约电话,告知次日便可以为我上门安装,同时询问我是上午方便还是下午方便。如此快速的响应令我很高兴,并约定次日上午,同时将自己的活动安排在下午。
次日午后,仍然不见有人前来安装,也再没有人与我联系。于是我再次致电客服,讲明情况后,客服代表建议我自己与安装人员联系,并给我了一个电话号码。由于这本应该是电信公司内部协调处理的事情,所以我希望这位客服代表能够直接为我处理,CSR却“严格”地根据他们的工作流程拒绝了我。客服代表给我的解释是:如果由她下工单,要在3个工作日后能才上门给我安装,如果想要尽快安装的话,就需要我自己与安装人员联系。
可能是感觉到我有些不满意,这名CSR又问我是否需要投诉?我说:“不必了,我只是希望能够兑现今天为我上门安装的承诺。”
结束通话,我不禁深思:单从流程上讲,这是一个标准的操作流程,由CSR下工单,工单流转到安装人员处,安装人员上门安装。同时该流程也有明确的KPI指标(3天内完成)。从这一点来看,似乎这流程没有什么问题,CSR的服务也完全符合标准。但试问,这个流程的执行能够令用户满意吗?不会!因为:用户的感受是检验流程的唯一标准。
流程有四个关键因素:“目标”、“输入”、“输出”以及“活动”。我们先看“目标”这个重要因素:对于客服中心来讲,目标无疑应该是让用户满意。所以,客服中心的各个业务受理流程,每一个流程都应该是基于为用户服务并获得用户满意的这个原则。一旦流程的执行不能够实现这一目标,则无论该流程的执行多么好,都已经失去了流程本身的意义。
所以,客服中心在制定以及执行流程的过程中,需要定期的对现有流程进行目标校正,针对在流程的执行过程中用户不同的需求,以实现目标为宗旨梳理并调整现有流程。结合上面的这个case,从用户对该流程的不满可以看出,对现有流程进行修改已经具备了必要性。
第二个因素是“输入”。在上面这个案例中,当我再次致电时,实际上如果仍然按照原来的流程执行,那么已经无法再令我感到满意了。最初,我的需求是希望尽快开通上网服务,并且也接受电信的服务标准。当第二次联系客服中心时,需求已经变为要求能够兑现之前的承诺------当日完成上门安装。很明显,此时用户需求已经变更,但CSR仍然执行新业务开通的标准流程,当然无法得到用户的满意。原因很简单,我的服务需求已经发生了变化,对于该流程来讲,其“输入”已经由“新用户安装需求”转变为“兑现安装服务承诺”。“输入”已经改变,而CSR仍按固有流程受理,自然无法令用户满意。此时,用户的感受已经检验出该流程已经需要“优化”了。
再来看一下“输出”这个因素。根据该客服中心的流程,输出结果有两种:一种是由CSR下工单,然后三天内安排安装;一种是用户自行联系安装人员。但无论是哪种输出结果,都不能够获得用户的满意。第一种超出了用户能够接受的服务时效,第二种将本不该用户完成的“工作”转移给了用户。
最后关注一下“活动”这个因素。CSR对原有流程的执行可谓一丝不苟,而且也不存在“活动”不畅的情况。但是不是这样的执行就是合理的呢?我们知道,要使一个服务流程达到预期的效果,要求在流程的执行过程中,每一个“活动”都应该有其既定的“执行”步骤以及标准(流程KPI),但这不代表因此就禁锢了CSR为用户提供服务的主动性。客服中心在设计流程以及规范流程执行标准时,同时需要考虑到流程应有的“弹性”,即在用户需求发生改变时,应有紧急受理流程供CSR执行。这样,才能真正实现流程为用户服务的最终目的。
从上面流程图的对比中很容易看到,这本不是一个很复杂的流程,而且优化后的流程也更加的简单,更重要的是能够减少用户的不满,最终获得用户的满意。
结合这个Case,必须强调一个重要的问题:谁是这个流程中的关键角色?
对于客服中心来讲,当受理了用户的需求时,CSR与用户便成了服务流程中的关键角色,安装人员只是CSR的一个协作角色(内部流程下游角色)。如果在为用户提供服务过程中,将安装人员“引入”流程中,无疑将影响流程的执行效果。另外,让用户代替CSR去联系“安装人员”这更加影响用户的感受。
由于“安装人员”只是CSR的协作角色,并非是这个流程中的关键角色,所以应该采用“安装人员”相对CSR的悬挂流程模式。相关的流程模型可参照下面的流程模型对比图:
通过对比,很明显后者减少了流程中的关键角色,提高了流程的执行效率。CSR直接为用户提供了“一站式服务”,用户只需要通过一个电话便能解决问题,而至于CSR与安装人员的工作协调,完全不需要用户去介入。
上面的案例中,我更多的相信这并不全是CSR的责任,作为客服中心则应该在设计服务流程的同时考虑到特殊情况的处理流程,作好各种“预备方案”,以此指引CSR更有效、更灵活地为用户提供服务。而设计与优化流程的原则只有一个:用户的满意是检验流程的唯一标准。 收起阅读 »
天津电视台采访环信天津研发中心
环信天津研发中心赵贵斌接受采访
环信天津滨海新区研发中心负责人赵贵斌表示环信代表了中国企业级服务的新生力量,将把环信在企业级服务的优势经验带到新区,服务好新区的众多金融和外贸客户。现阶段天津研发中心是北京中心的有益补充,未来将立足天津辐射世界,更希望有志于用技术改变世界的工程师们加入环信落户天津,一起把环信天津研发中心做大做强,用卓越的技术连接人与人,连接人与商业来改变每个人的生活和工作!
环信天津滨海新区研发中心热招职位
收起阅读 »
基于环信的仿QQ即时通讯的简单实现
因为没有刻意去做聊天软件,花的时间也不多,然后界面就很简单,都是一些基本知识,如果觉得功能简单,可以自行添加,我这就不多介绍了。
照例先来一波动态演示:
功能很简单,注册用户 --> 用户登录 --> 选择聊天对象 --> 开始聊天
使用到的知识点:
RecyclerView依赖的库
CardView
环信的API的简单使用
compile 'com.android.support:appcompat-v7:24.2.1'1、聊天页面
compile 'com.android.support:cardview-v7:24.1.1'
compile 'com.android.support:recyclerview-v7:24.0.0'
首先是看了郭神的《第二行代码》做了聊天界面,用的是RecyclerView
a. 消息类的封装
public class MSG {b. RecyclerView子项的布局
public static final int TYPE_RECEIVED = 0;//消息的类型:接收
public static final int TYPE_SEND = 1; //消息的类型:发送
private String content;//消息的内容
private int type; //消息的类型
public MSG(String content, int type) {
this.content = content;
this.type = type;
}
public String getContent() {
return content;
}
public int getType() {
return type;
}
}
<LinearLayout这是左边的部分,至于右边应该也就简单了。我用CardView把ImageView包裹起来,这样比较好看。效果如下:
android:id="@+id/ll_msg_left"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
<!-- 设置点击效果为水波纹(5.0以上) -->
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:orientation="horizontal"
android:padding="2dp">
<android.support.v7.widget.CardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:cardCornerRadius="20dp"
app:cardPreventCornerOverlap="false"
app:cardUseCompatPadding="true">
<ImageView
android:layout_width="50dp"
android:layout_height="50dp"
android:scaleType="centerCrop"
android:src="@mipmap/man" />
</android.support.v7.widget.CardView>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/message_left"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_msg_left"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="10dp"
android:textColor="#fff" />
</LinearLayout>
</LinearLayout>
c. RecyclerView适配器
public class MsgAdapter extends RecyclerView.Adapter<MsgAdapter.MyViewHolder> {这部分应该也没什么问题,就是适配器的创建,我之前的文章也讲过 传送门:简单粗暴----RecyclerViewd.
private List<MSG> mMsgList;
public MsgAdapter(List<MSG> mMsgList) {
this.mMsgList = mMsgList;
}
@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = View.inflate(parent.getContext(), R.layout.item_msg, null);
MyViewHolder holder = new MyViewHolder(view);
return holder;
}
@Override
public void onBindViewHolder(MyViewHolder holder, int position) {
MSG msg = mMsgList.get(position);
if (msg.getType() == MSG.TYPE_RECEIVED){
//如果是收到的消息,显示左边布局,隐藏右边布局
holder.llLeft.setVisibility(View.VISIBLE);
holder.llRight.setVisibility(View.GONE);
holder.tv_Left.setText(msg.getContent());
} else if (msg.getType() == MSG.TYPE_SEND){
//如果是发送的消息,显示右边布局,隐藏左边布局
holder.llLeft.setVisibility(View.GONE);
holder.llRight.setVisibility(View.VISIBLE);
holder.tv_Right.setText(msg.getContent());
}
}
@Override
public int getItemCount() {
return mMsgList.size();
}
static class MyViewHolder extends RecyclerView.ViewHolder{
LinearLayout llLeft;
LinearLayout llRight;
TextView tv_Left;
TextView tv_Right;
public MyViewHolder(View itemView) {
super(itemView);
llLeft = (LinearLayout) itemView.findViewById(R.id.ll_msg_left);
llRight = (LinearLayout) itemView.findViewById(R.id.ll_msg_right);
tv_Left = (TextView) itemView.findViewById(R.id.tv_msg_left);
tv_Right = (TextView) itemView.findViewById(R.id.tv_msg_right);
}
}
}
RecyclerView初始化
就是一些基本的初始化,我就不赘述了,讲一下添加数据的细节处理
btSend.setOnClickListener(new View.OnClickListener() {至此界面已经结束了,接下来就是数据的读取
@Override
public void onClick(View v) {
String content = etInput.getText().toString().trim();
if (!TextUtils.isEmpty(content)){
...//环信部分的发送消息
MSG msg = new MSG(content, MSG.TYPE_SEND);
mList.add(msg);
//当有新消息时,刷新RecyclerView中的显示
mAdapter.notifyItemInserted(mList.size() - 1);
//将RecyclerView定位到最后一行
mRecyclerView.scrollToPosition(mList.size() - 1);
etInput.setText("");
}
}
});
2. 环信API的简单应用
官网有详细的API介绍 环信即时通讯云V3.0,我这里就简单介绍如何简单集成
a. 环信开发账号的注册
环信官网
b. SDK导入
你可以直接下载然后拷贝工程的libs目录下
Android Studio可以直接添加依赖
将以下代码放到项目根目录的build.gradle文件里
repositories {在你的module的build.gradle里加入以下代码
maven { url "https://raw.githubusercontent.com/HyphenateInc/Hyphenate-SDK-Android/master/repository" }
}
android {如果想使用不包含音视频通话的sdk,用
//use legacy for android 6.0
useLibrary 'org.apache.http.legacy'
}
dependencies {
compile 'com.android.support:appcompat-v7:23.4.0'
//Optional compile for GCM (Google Cloud Messaging).
compile 'com.google.android.gms:play-services-gcm:9.4.0'
compile 'com.hyphenate:hyphenate-sdk:3.2.3'
}
compile 'com.hyphenate:hyphenate-sdk-lite:3.2.3'c. 清单文件配置
<?xml version="1.0" encoding="utf-8"?>APP打包混淆
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="Your Package"
android:versionCode="100"
android:versionName="1.0.0">
<!-- Required -->
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_MOCK_LOCATION" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.GET_TASKS" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:name="Your Application">
<!-- 设置环信应用的AppKey -->
<meta-data android:name="EASEMOB_APPKEY" android:value="Your AppKey" />
<!-- 声明SDK所需的service SDK核心功能-->
<service android:name="com.hyphenate.chat.EMChatService" android:exported="true"/>
<service android:name="com.hyphenate.chat.EMJobService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="true"
/>
<!-- 声明SDK所需的receiver -->
<receiver android:name="com.hyphenate.chat.EMMonitorReceiver">
<intent-filter>
<action android:name="android.intent.action.PACKAGE_REMOVED"/>
<data android:scheme="package"/>
</intent-filter>
<!-- 可选filter -->
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.USER_PRESENT" />
</intent-filter>
</receiver>
</application>
</manifest>
-keep class com.hyphenate.** {*;}d. 初始化SDK
-dontwarn com.hyphenate.**
在自定义Application的onCreate中初始化
public class MyApplication extends Application {e. 注册和登陆
private Context appContext;
@Override
public void onCreate() {
super.onCreate();
EMOptions options = new EMOptions();
options.setAcceptInvitationAlways(false);
appContext = this;
int pid = android.os.Process.myPid();
String processAppName = getAppName(pid);
// 如果APP启用了远程的service,此application:onCreate会被调用2次
// 为了防止环信SDK被初始化2次,加此判断会保证SDK被初始化1次
// 默认的APP会在以包名为默认的process name下运行,如果查到的process name不是APP的process name就立即返回
if (processAppName == null || !processAppName.equalsIgnoreCase(appContext.getPackageName())) {
Log.e("--->", "enter the service process!");
// 则此application::onCreate 是被service 调用的,直接返回
return;
}
//初始化
EMClient.getInstance().init(getApplicationContext(), options);
//在做打包混淆时,关闭debug模式,避免消耗不必要的资源
EMClient.getInstance().setDebugMode(true);
}
private String getAppName(int pID) {
String processName = null;
ActivityManager am = (ActivityManager) this.getSystemService(ACTIVITY_SERVICE);
List l = am.getRunningAppProcesses();
Iterator i = l.iterator();
PackageManager pm = this.getPackageManager();
while (i.hasNext()) {
ActivityManager.RunningAppProcessInfo info = (ActivityManager.RunningAppProcessInfo) (i.next());
try {
if (info.pid == pID) {
processName = info.processName;
return processName;
}
} catch (Exception e) {
// Log.d("Process", "Error>> :"+ e.toString());
}
}
return processName;
}
}
注册要在子线程中执行
//注册失败会抛出HyphenateExceptionf. 发送消息
EMClient.getInstance().createAccount(username, pwd);//同步方法
EMClient.getInstance().login(userName,password,new EMCallBack() {//回调
@Override
public void onSuccess() {
EMClient.getInstance().groupManager().loadAllGroups();
EMClient.getInstance().chatManager().loadAllConversations();
Log.d("main", "登录聊天服务器成功!");
}
@Override
public void onProgress(int progress, String status) {
}
@Override
public void onError(int code, String message) {
Log.d("main", "登录聊天服务器失败!");
}
});
//创建一条文本消息,content为消息文字内容,toChatUsername为对方用户或者群聊的id,后文皆是如此g. 接收消息
EMMessage message = EMMessage.createTxtSendMessage(content, toChatUsername);
//发送消息
EMClient.getInstance().chatManager().sendMessage(message);
msgListener = new EMMessageListener() {接收消息的监听器分别需要在OnResume()和OnDestory()方法中注册和取消注册
@Override
public void onMessageReceived(List<EMMessage> messages) {
//收到消息
String result = messages.get(0).getBody().toString();
String msgReceived = result.substring(5, result.length() - 1);
Log.i(TAG, "onMessageReceived: " + msgReceived);
final MSG msg = new MSG(msgReceived, MSG.TYPE_RECEIVED);
runOnUiThread(new Runnable() {
@Override
public void run() {
mList.add(msg);
mAdapter.notifyDataSetChanged();
mRecyclerView.scrollToPosition(mList.size() - 1);
}
});
}
@Override
public void onCmdMessageReceived(List<EMMessage> messages) {
//收到透传消息
}
@Override
public void onMessageRead(List<EMMessage> list) {
}
@Override
public void onMessageDelivered(List<EMMessage> list) {
}
@Override
public void onMessageChanged(EMMessage message, Object change) {
//消息状态变动
}
};
EMClient.getInstance().chatManager().addMessageListener(msgListener);//注册需要注意的是,当接收到消息,需要在主线程中更新适配器,否则会不能及时刷新出来
EMClient.getInstance().chatManager().removeMessageListener(msgListener);//取消注册
到此,一个简单的即时聊天Demo已经完成,功能很简单,如果需要添加额外功能的话,可以自行参考官网,官网给出的教程还是很不错的!
最后希望大家能多多支持我,需要你们的支持喜欢!!
作者:环信开发者下位子 收起阅读 »
是对单位的认可、对领导的眷恋、对事业的追求,环信工程师们春节前3天发布了环信移动客服v5.9!!!
2017年1月24日,离中国农历新年还有3天,环信工程师们本着对单位的认可、对领导的眷恋、对事业的追求、对党的忠诚以及对所有环信客户成功的承诺,发布了环信移动客服v5.9版本,谱写了一曲忠诚的赞歌!本次版本新增了黑名单、支持查看位置消息、添加会话标签时支持对会话标签进行搜索等新的特性,以及若干优化。虽然只是一次普通的更新,但是凝结的是所有环信工程师们的勤恳和汗水,环信移动客服在整个2016年度始终保持了最少两周发布一个大版本的速度,力求产品和服务一直能够满足大中型企业用户的业务增长需求,特别是在2016年8月份环信移动客服5.0旗舰版发布以后,整个中国的SaaS客服市场欣欣向荣,全媒体智能云客服的大时代从而正式拉开大幕!
在整个2016年环信全媒体智能云客服平台的经营数据也取得了长足的进步和发展,不管是新增服务企业数、日服务用户数、还是营收都取得了爆发式的增长,环信也拿下了诸如国美在线、中信证券、新浪微博、泰康在线、神州专车等众多标杆企业用户,在2017年,环信也将一如既往秉承以客户成功为己任,以产品和技术驱动,用卓越的技术服务好包括金融、电商、教育、医疗、旅游、O2O、制造业等领域的每一位小伙伴们!
客服模式
支持查看位置消息
在会话、历史会话、客户中心等页面,可以查看客户发送的位置消息。点击位置图标,可以查看图片形式的位置消息。
新接入的会话增加颜色提醒
在会话页面,为新接入的会话增加背景颜色,使客服能够优先关注到新会话,及时回复访客,提升首次响应速度。客服手动回复消息后,背景颜色消失,客户再次发送消息时,背景颜色不再变化。
添加会话标签时,支持对会话标签进行搜索
在会话、历史会话等页面添加会话标签时,支持对会话标签的叶结点进行搜索。搜索完成后,直接选择叶结点,即可添加会话标签。
黑名单功能
新增黑名单功能,在会话进行中,如果客户恶意骚扰,客服可以手动将其加入黑名单,加入黑名单后,会话立即自动结束。客服还可以在“历史会话”和“客户中心”,将客户手动加入黑名单,或移除黑名单。黑名单中的客户可以再次发送消息,但系统不会为其创建会话;客户被移除黑名单后,再次发送消息时可以成功创建会话。
1. 加入黑名单:
在会话过程中,客服可以点击“资料”页签下方的“加入黑名单”按钮,填写加入黑名单的理由,将客户加入黑名单。
或者,客服可以在“历史会话”或“客户中心”页面找到需要加入黑名单的客户,点击该客户,在详情页点击“加入黑名单”按钮,将客户加入黑名单。
注意:管理员需要在“管理员模式 > 设置 > 系统开关”页面打开“客服可以使用访客中心功能”开关,客服才能使用“客户中心”页面。
2. 移除黑名单:
客服可以在“历史会话”或“客户中心”页面找到已经加入黑名单的客户,点击该客户,在详情页点击“移除黑名单”按钮,将客户移除黑名单。
支持从消息中心跳转至客户中心
在消息中心页面,收到与客户有关的系统消息时(如不活跃会话自动结束),可以在消息详情页点击“查看详情”按钮,进入客户中心页面查看客户的详情。
【优化】支持查看客服同事的头像
与客服同事聊天时,以及将会话转接给客服同事时,可以查看客服同事的头像。
【优化】在历史会话中显示微信/微博网友发送的表情
在历史会话页面,支持显示微信/微博网友发送的表情,和会话页面的显示保持一致。
【优化】接到呼叫中心来电时,客服状态自动置为忙碌
接到呼叫中心来电时,如果客服在线状态为空闲,则自动置为忙碌,此时不会自动分配会话,进行中会话可以继续处理。挂断电话后,客服状态自动恢复空闲。如果接到呼叫中心来电时,客服状态为忙碌、离开、隐身,则不改变原来的状态。接听电话过程中,依然可以手动修改在线状态。
呼叫中心为增值服务,如需开通,请提供租户ID并联系环信商务经理。
管理员模式
管理黑名单
管理员可以在“客户中心”对黑名单进行管理,包括将客户加入黑名单,查看黑名单中的客户,将客户移除黑名单等。黑名单中的客户可以再次发送消息,但系统不会为其创建会话;客户被移除黑名单后,再次发送消息时可以成功创建会话。
1. 加入黑名单:
在“历史会话”或“客户中心”页面,可以将客户加入黑名单。以“客户中心”为例,点击任意客户,进入详情页。通过“互动记录”鉴定需要将该客户加入黑名单后,进入“基本资料”tab页签,点击“加入黑名单”按钮,在对话框中填写加入黑名单的理由,并保存。
2. 查看黑名单:
在“客户中心”页面,点击右上角的“黑名单”按钮,切换至“黑名单”页面,可以查看黑名单中的客户及加入黑名单的原因。
3. 移除黑名单:
在“黑名单”页面,点击删除按钮,可以快速将客户移除黑名单。
或者,在“历史会话”或“客户中心”页面,通过“访客ID”找到黑名单中的客户,进入详情页,点击“移除黑名单”按钮,将客户移除黑名单。
访客新消息排在会话列表顶端
新增“访客新消息排在会话列表顶端”开关,开关打开时,访客发送新消息,该会话主动排在客服的“进行中”会话列表顶端。
进入“设置 > 系统开关”页面,可以打开这个开关。
微信渠道向Iframe传递微信公众号的原始ID
可以使用移动客服提供的Iframe集成CRM系统,环信默认向Iframe传递两个参数:easemobId和visitorImId(访客ID),用于向CRM系统查询详细的客户资料。对于微信渠道,额外传递参数to,该参数的值为微信公众号的原始ID。
进入移动客服的“管理员模式 > 设置 > 自定义信息接口”页面可以配置CRM系统的服务器地址,详情请查看环信文档:CRM系统对接。
进入微信公众平台,在“公众号设置”页面,可以查看微信公众号的“原始ID”。
【优化】当前会话页面固定显示“刷新”按钮
当前会话页面的“刷新”按钮改为固定显示,管理员可以随时点击“刷新”,查看最新的进行中会话列表。但是,进行中会话的最新消息内容不会一起更新。
Android客服工作台
当前版本:V2.6
优化推送栏提醒频率
Android客服工作台对推送栏提醒频率进行了优化,减少推送次数,使提醒更合理。
关于更多Android客服工作台的更新日志,请查看Android客服工作台更新日志。
iOS客服工作台
当前版本:V2.1
支持设置最大接待人数
iOS客服工作台支持设置最大接待人数。最大接待人数的上限为100,登录客服工作台后可以查看自己的最大接待人数,并根据需要做出调整。
【优化】减少不必要的推送
iOS客服工作台对离线推送进行分类处理,减少不必要的推送。
关于更多iOS客服工作台的更新日志,请查看iOS客服工作台更新日志。
商城Demo
通过扫描二维码获取关联配置
“商城Demo”支持通过扫描移动客服“管理员模式 > 渠道管理 > 手机APP”页面任意关联的二维码获取关联配置,包括该关联的AppKey和IM服务号以及该租户的租户ID和留言ID。
获取关联配置后,可以使用“商城Demo”直接发起会话或留言,操作更便捷。
环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.9
环信移动客服登陆地址http://kefu.easemob.com/ 收起阅读 »
环信移动客服v5.8已发布-实时监控页面新增“平均响应时长”以及技能组数据
实时监控页面新增技能组数据和平均响应时长
实时监控页面,将原来的客服排名变更为全部客服的排序,增加“平均响应时长”,并且为“接起会话数”、“平均首次响应时长”、“满意度”、“平均响应时长”模块增加技能组数据。
以“平均响应时长”模块为例,该模块显示所有客服或技能组今天参与会话期间的平均响应时长,并进行排名。当客服数量较多时,可以下拉查看更多客服的名次和数据。点击“技能组”,可以切换为技能组的“平均响应时长”排名。
【优化】工作量报表“会话数分布”扩展维度
工作量报表中,扩展“会话数分布”图形的横坐标维度,覆盖更多指标区间。
- 会话数分布(按会话消息数维度):增加消息数量“30-60”与“60以上”。
会话数分布(按会话时长维度):增加会话时长“5分钟-10分钟”和“10分钟”以上。
【优化】工作质量导出报表增加“有效人工会话占比”个数
工作质量导出报表中,“有效人工会话占比”增加有效会话和无效会话的个数。
环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.8
环信移动客服登陆地址http://kefu.easemob.com/ 收起阅读 »
保险业客服进化论,看泰康在线的SaaS客服进阶之路!
- 互联网+时代,保险行业面临5大客服痛点亟需解决
- 全渠道客服是趋势,泰康在线先从微信客服单点突破!
图1. 环信全渠道客服工作台界面 基于庞大的粉丝积累和逐渐提升的客服咨询量,平台稳定性也是必须要考虑的因素。以千万级并发IM业务起家的环信移动客服平台运行稳定,通过了多次线上活动的峰值考验。
- 从服务中挖掘销售机会,泰康在线三招玩转精准营销!
图2.不同渠道对应不同技能组示意图 2.丰富客户画像,让转化更精准 大数据如今对于更深入理解客户、挖掘客户需求的意义无需多说,泰康结合环信提供的一套灵活的标签体系,针对从客户和会话两个维度进行标签分类,能够更有效地帮助积累话术经验,进行精准客服。同时,结合轻营销的能力,对客户进行个性化的消息推送、回呼等行为,营销效率得到提升。
图3.客户标签示意图
图4.会话标签示意图 3.轨迹跟踪,了解访客来源及历史行为。 如果能知道客户在网页或APP上看了啥,从哪个入口进来的,那是再好不过了,这些信息可以帮助企业更好地判断是否有销售机会、在哪些方面有销售机会以及从哪入手去和客户接触。在泰康的APP渠道中就集成了轨迹跟踪的功能,不论你在哪一款保险产品页面进入客服,都会自动带上对应产品的访问轨迹,方便客服人员判断,你可能感兴趣的是哪款产品,从而做到有的放矢。
图5.APP端访问轨迹示意图
- 智能客服机器人的诀窍,在机器也在人!
随着人工智能的兴起,保险行业使用客服机器人也并非新鲜事。然而智能机器人的选型才是真正的难中之难。首先不能选机器人和客服平台分属两家公司的,一旦出现问题容易互相踢皮球;其次,要评估机器人厂家在单轮会话,多轮会话,人机协助这3个核心功能上的表现和指标。最重要的是要选拥有自主知识产权,真正能够基于语义分析和深度学习混合计算进行服务的机器人。泰康选择与环信合作,机器人对这些条件的达成,自然不言而喻,目前已在人工客服下班时间为泰康在线保驾护航,目前业务问答正确率达到行业领先水平。
智能机器人除了本身对自动回复、单轮/多轮会话、自主学习等功能的支持,现阶段一般都需要人工的参与才能够达到最佳客服效果。泰康在这方面的策略也堪称实践典范:在知识库中配置了以30多个核心业务咨询场景为核心的智能文字IVR菜单,并在欢迎语、默认回复等引导语中,尽量将用户问题范围收敛到已有知识规则上,大幅提升了机器人工作效率。
图6.客服机器人体验示意图
自此,泰康在线联手环信率先在互联网保险行业实现了包括电话坐席、网页端、APP端、微信端等全渠道打通的全媒体客服接入,引领了保险业智能客服的潮流,实现了服务渠道的一体化,通过精准营销实现了业务增长,实实在在给用户打造了一套用户体验为王的一站式智能互联网保险服务。
泰康在线客服主管童娜表示:“环信智能客服平台适用多业务场景,一站式后台提高客服效率的同时,也给客户带来更好的咨询体验。”
下一步泰康将在呼叫中心融合、智能营销和大数据分析方面做更多的创新和尝试。环信也将一如既往的秉承以客户成功为己任,通过自身优秀的产品技术和服务实力在推动包括保险、金融、教育、医疗、旅游等各大行业全媒体智能SaaS客服解决方案上发挥更大的价值。
关于泰康在线
2015年11月18日,泰康在线财产保险股份有限公司作为行业内首家由国内大型保险企业发起成立的互联网保险公司,正式在武汉挂牌成立,注册资本金人民币10亿元。其前身是2000年8月成立的泰康人寿的官方网站.经过十多年的发展,目前已经已形成互联网产寿险结合的保险产品体系,产品线涵盖互联网财产险、旅行险、健康险、意外险、养老金和理财险等。收起阅读 »
如何通过呼叫中心发现客户变化?
客户分析能够通过为语音分析、文本分析和客户反馈调查提供软件应用来解决这些问题。这些应用是人力资源优化战略的组成部分,这个战略是由分析驱动的,能提供关键的、全新的客户中心视角。因为它们基于公用的人力资源优化平台,可以分享数据,你的组织能够从数据的协同效益中受益。
语音分析
语音分析是当下的热门话题,这种兴奋很容易理解。呼叫中心记录了不计其数的客户通话,但是只有一小部分能被听完。此外,他们更注意坐席人员的表现,而非客户。就算组织有意识把关注的重点转移到客户身上,但是只听如此少的谈话记录也不足以充分了解客户的话语和感受,很容易产生误解。
如果你愿意,语音分析解决方案可以“听”到所有通话记录。这个方案使用了多种技术和算法来索引和记录谈话,让所有谈话都能被搜到。使用索引技术,语音分析就能把记录下的对话按照兴趣的类别进行分类——投诉电话、转移呼叫和情感呼叫。接着,语音分析用算法把与兴趣相类似的单词或短语分门别类,提示呼叫的根本原因。
除了区分原因,语音分析还自动跟踪趋势和变化。按照日、周甚至更长的时间间隔,语音分析可以提醒你它所监测到的相关类别、措辞和短语的变化。
有个关于德国外包商为他的客户提供语音分析的例子。一天,外包商的语音分析解决方案显示电话数量急剧增加——从零飙升至几百——且含有以下词语“blah, blah, blah”。更为奇怪的是,这些通话的平均时长是一般通话的3倍。分析结果十分骇人。起初,他们认为是一些坐席人员在使用奇怪的词语。但在检查了一些新类别中的通话记录之后,他们很快发现,客户收到了一封来自于市场部的信,解释了服务合同中的一些新特点。这封信十分晦涩难懂,促使很多客户打来电话说:“我收到了一封信,说我的服务发生了变化blah, blah, blah,我不明白这封信到底是什么意思。”外包商立刻通知市场部,这些问题很快就得到了澄清。
文本分析
如今,客户的“声音”并非仅限于呼叫中心。越来越多的客户通过智能手机发送文本信息(SMS)。在反复尝试解决问题而未果后,他们就会沮丧,进而转向社交网站,如Facebook和专业投诉网站
重点是,客户不仅仅是在谈论你的公司;他们写信给你的同时还在写关于你的事。了解顾客写出来的“声音”就需要使用专注于内部和外部的文本通讯分析工具。这正是文本分析的职责。这些解决方案使用自然的语言处理算法和方法以采集和分析电子邮件、网络聊天和社交媒体网站上的帖子。
虽然监视公共网站和需要密码的网站很有趣并且很具话题性,但是大部分内容都无关具体的某个企业。网络是一个浩瀚的海洋,深邃无比,耗费大量时间进行分析也不一定可以获得有益的见解。
相反,组织自身的文本档案仓库就像是一个大湖泊,是有限的。它存有许多来自客户的邮件、调查、网络反馈对话等等文本。所有这些,尽管数量也十分庞大,但都集中关注于你自己的企业,是了解客户的很丰富的资源。文本分析——特别是与语音分析共同使用时——能让组织把从客户邮件和语音记录中获得的有用信息结合起来,得到许多可操作性的信息。
客户反馈调查
在高度竞争化的时代,征求客户反馈意见非常重要,不能被忽视,也不能执行不力。客户分析解决方案使用简短的、对文本十分敏感的动态的调查来捕捉客户对产品、流程和员工表现的意见。它能提供十分有价值的“由外之内”的观点评估客户的意见,让呼叫中心和市场部门通过电话和网络获得客户的看法。如果能被正确执行,这些调查就能够取得很好的效果,提供关于员工、流程和情绪的宝贵信息。在把衡量坐席人员业绩表现的调查结果与内部质量监控进行比对时,客户反馈调查会特别有帮助。
客户分析的附加价值主张
虽然客户分析提供了以上价值,但是还有其他两个特殊的益处。越来越多的公司希望能更好的处理与他们相关的评论。如果能把文本和语音分析结合起来使用,就可以提供各种各样早期的预警系统。公司可以标记出新出现的问题,及时解决,避免负面信息的爆发。
另一种特殊的价值主张通过语音分析和质量监控的集成来实现。当语音分析处理并把录音分类保存在数据库中时,质量监控和指导就可以启动——甚至能够超额进行。为什么呢?因为通过高密度的对通话的分类,主管和坐席人员能够准确找到典型的或者涉及工作所需技巧和行为的记录。无论哪种方式,拥有实在的例子能让呼叫中心采取高针对性的指导和培训,进而带来很好的效果。
最终,客户分析可以提供深刻的洞察力以推动更好的决策。通过把这种洞察力和其他措施相结合,就能获得对客户服务、体验和满意度的360度全方位视角——在不断变化的客户人口统计数据和情绪变动中更有效地发展。
本文转自:天华东航运营中心 收起阅读 »