注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

教您5分钟集成环信移动客服SDK

欢迎各位小伙伴们注册环信移动客服! 给您的客服账户集成相关渠道后才能使用客服功能呦,试用期短暂,机不可失,快来看看我们的集成攻略!   您可以―― 5 分钟集成环信SDK,轻松处理来自您APP的用户咨询 请先打开客服后台按照以下步骤添加app渠道关联 ...
继续阅读 »
欢迎各位小伙伴们注册环信移动客服!

给您的客服账户集成相关渠道后才能使用客服功能呦,试用期短暂,机不可失,快来看看我们的集成攻略!
 
您可以―― 5 分钟集成环信SDK,轻松处理来自您APP的用户咨询

请先打开客服后台按照以下步骤添加app渠道关联


001.gif


添加关联后即可开始进行集成
 
Android SDK:请参考移动客服 Android SDK 集成

iOS SDK:请参考移动客服 iOS SDK 集成

也可以直接查看APP集成指南
 
您还可以――模拟客服场景体验

扫码下方二维码,下载并安装【环信移动客服】app


001.png


按照图一添加APP渠道关联
打开【环信移动客服】APP,点击右下角的【设置】按钮后点击右上角【扫一扫】扫描关联app页面下方二维码,将该客服体验DEMO与您的客服账号关联起来,即可体验与客服聊天或与客户聊天

您也可以――为您的其他渠道接入移动客服

微博快速集成指南 

微信快速集成指南   

网页快速集成指南
 
在您使用中遇到任何问题,可从以下 4 个途径得到解答!
 
开发文档 - 常见问题的解决方案在这里都能找到!
开发文档收录了所有常见问题,并按照“新手上路、客服模式、管理员模式、多渠道集成、第三方系统对接”对内容进行分类,同时,提供模糊搜索。

在线技术咨询 - 超快的问题响应机制,专业技术团队在线解答!
点击【客服后台】-【管理员模式】-【技术支持】-【联系客服】,输入您的问题即可。

环信社区- 使用者交流专区,召唤老司机搞定技术难题!
山不在高,有仙则灵,社区不在大,有大神就行!

电话咨询 - 最直接的方式,专职客服一对一解答!
咨询热线:400-612-1986

感谢读到这里的您,下方附上最新鲜的集成说明文档,据说看完走桃花呦
 
  收起阅读 »

李理:Theano tutorial和卷积神经网络的Theano实现 Part1

本系列文章面向深度学习研发者,希望通过Image Caption Generation,一个有意思的具体任务,深入浅出地介绍深度学习的知识。本系列文章涉及到很多深度学习流行的模型,如CNN,RNN/LSTM,Attention等。本文为第8篇。 作者:李理 ...
继续阅读 »
本系列文章面向深度学习研发者,希望通过Image Caption Generation,一个有意思的具体任务,深入浅出地介绍深度学习的知识。本系列文章涉及到很多深度学习流行的模型,如CNN,RNN/LSTM,Attention等。本文为第8篇。

作者:李理
目前就职于环信,即时通讯云平台和全媒体智能客服平台,在环信从事智能客服和智能机器人相关工作,致力于用深度学习来提高智能机器人的性能。

相关文章: 
李理:从Image Caption Generation理解深度学习(part I)
李理:从Image Caption Generation理解深度学习(part II)
李理:从Image Caption Generation理解深度学习(part III)
李理:自动梯度求解 反向传播算法的另外一种视角
李理:自动梯度求解——cs231n的notes
李理:自动梯度求解——使用自动求导实现多层神经网络
李理:详解卷积神经网络
 
1. Theano的发音

第一次碰到时很自然的发音是 /θi.ˈæ.noʊ/,不过如果看一些视频可能也有发/te.ˈaː.no/的。这两种都有,比较官方的说法可能是这个
I think I say roughly /θi.ˈæ.noʊ/ (using the international phonetic alphabet), or /te.ˈaː.no/ when speaking Dutch, which is my native language. I guess the latter is actually closer to the original Greek pronunciation :)
另外从这里也有说明:
Theano was written at the LISA lab to support rapid development of efficient machine learning algorithms. Theano is named after the Greek mathematician, who may have been Pythagoras’ wife.
维基百科对此作出的解释是:
Theano (/θɪˈænoʊ/; Greek: Θεανώ; fl. 6th-century BC), or Theano of Crotone,[1] is the name given to perhaps two Pythagorean philosophers.
因此用英语的发音是 /θɪˈænoʊ/。
 
2. Theano简介

Theano是一个Python库,它可以让你定义,优化以及对数学表达式求值,尤其是多维数组(numpy的ndarray)的表达式的求值。对于解决大量数据的问题,使用Theano可能获得与手工用C实现差不多的性能。另外通过利用GPU,它能获得比CPU上的C实现快很多数量级。

Theano把计算机代数系统(CAS)和优化的编译器结合在一起。 它也可以对许多数学操作生成自定义的c代码。这种CAS和优化编译的组合对于有复杂数学表达式重复的被求值并且求值速度很关键的问题是非常有用的。对于许多不同的表达式只求值一次的场景,Theano也能最小化编译/分析的次数,但是仍然可以提供诸如自动差分这样的符号计算的特性。

Theano的编译器支持这些符号表达式的不同复杂程度的许多优化方法:
  • 用GPU来计算
  • 常量折叠(constant folding)【编译时的常量表达式计算,参考这里】
  • 合并相似的子图,避免重复计算
  • 算术简化,比如把x*y/y简化成y,–x【两次求负】简化成x
  • 在不同的上下文中插入高效的BLAS函数(比如GEMM)
  • 使用Memory Aliasing【详细参考这里】来避免重复计算
  • 对于不涉及aliasing的操作尽量使用就地的运算【类似与x*=2 vs y=x*2】
  • Elementwise的子表达式的循环的合并(loop fusion)【这是一项编译器优化技巧,简单的说就是把相同下标的循环合并起来,例子可以参考这里】
提高数值运算的稳定性,比如: 
log(1+exp(x))andlog(∑iexp(x[i]))[/i]
 
  • ​【关于这个我们罗嗦一点,读者如果读过的文章,肯定还记得计算softmax时先把向量减去最大的元素,避免exp运算的溢出】
    • 更多内容请参考优化部分
3. Theano安装请参考这里,这里就不赘述了。4. 官方Tutorial4.1 Baby Steps - Algebra内容来自这里。4.1.1 Adding two Scalars
>>> import numpy>>> import theano.tensor as T>>> from theano import function>>> x = T.dscalar('x')>>> y = T.dscalar('y')>>> z = x + y>>> f = function([x, y], z)>>> f(2, 3)array(5.0)
我们这段代码首先定义了符号变量x和y,它们的类型是double。使用theano.tensor.dscalar(‘x’)定义了一个名字叫x的类型为double的标量(scalar)。注意符号变量的名字是theano看到的,而我们把theano创建的dscalar赋给x是在python里的。在使用theano是我们需要区分普通的python变量和theano的符号变量。theano用符号变量创建出一个computing graph,然后在这个graph上执行各种运算。定义了x和y之后,我们通过操作(op)+定义了符号变量z。接下来我们定义了一个函数(function) f,这个函数的输入是符号变量x和y,输出是符号变量z接下来我们可以”执行“这个函数 f(2,3)运行 f = function([x, y], z)会花费比较长的时间,theano会将函数构建成计算图,并且做一些优化。
>>> type(x)<class 'theano.tensor.var.TensorVariable'>>>> x.typeTensorType(float64, scalar)>>> T.dscalarTensorType(float64, scalar)>>> x.type is T.dscalarTrue
dscalar(‘x’) 返回的对象的类型是theano.tensor.var.TensorVariable,也就是一种符号变量。这种对象有一个type属性,x.type是TensorType。对于dscalar,它的TensorType是64位的浮点数的一个标量。除了变量,我们也可以定义向量(vector)和矩阵matrix。 然后用在前面增加’b’,’w’,’i’,’l’,’f’,’d’,’c’分别表示8位,16位,32位,64位的整数,float,double以及负数。比如imatrix就是32位整数类型的矩阵,dvector就是单精度浮点数的向量。4.2 More Examples 参考这里。这部分会介绍更多的theano的概念,最后包含一个Logistic Regression的例子,包括怎么用theano自动求梯度。4.2.1 Logistic Function函数定义为:
s(x)=11+e−x
函数图像为:这个函数的特点是它的值域是(0,1),当x趋近 −∞ 时值趋近于0,当x趋近 ∞ 时值趋近于1。我们经常需要对一个向量或者矩阵的每一个元素都应用一个函数,我们把这种操作叫做elementwise的操作(numpy里就叫universal function, ufunc)比如下面的代码对一个矩阵计算logistic函数:
>>> import theano>>> import theano.tensor as T>>> x = T.dmatrix('x')>>> s = 1 / (1 + T.exp(-x))>>> logistic = theano.function([x], s)>>> logistic([[0, 1], [-1, -2]])array([[ 0.5       ,  0.73105858],       [ 0.26894142,  0.11920292]])
logistic是elementwise的原因是:定义这个符号变量的所有操作——除法,加法,指数取反都是elementwise的操作。另外logistic函数和tanh函数有如下关系:
s(x)=11+e−x=1+tanh(x/2)2
我们可以使用下面的代码来验证这个式子:
>>> s2 = (1 + T.tanh(x / 2)) / 2>>> logistic2 = theano.function([x], s2)>>> logistic2([[0, 1], [-1, -2]])array([[ 0.5       ,  0.73105858],       [ 0.26894142,  0.11920292]])
4.2.2 使用共享变量(shared variable)一个函数可以有内部的状态。比如我们可以实现一个累加器,在开始的时候,它的值被初始化成零。然后每一次调用,这个状态会加上函数的参数。首先我们定义这个累加器函数,它把参数加到这个内部状态变量,同时返回这个状态变量老的值【调用前的值】
>>> from theano import shared>>> state = shared(0)>>> inc = T.iscalar('inc')>>> accumulator = function([inc], state, updates=[(state, state+inc)])
这里有不少新的概念。shared函数会返回共享变量。这种变量的值在多个函数直接可以共享。可以用符号变量的地方都可以用共享变量。但不同的是,共享变量有一个内部状态的值,这个值可以被多个函数共享。我们可以使用get_value和set_value方法来读取或者修改共享变量的值。另外一个新的概念是函数的updates参数。updates参数是一个list,其中每个元素是一个tuple,这个tuple的第一个元素是一个共享变量,第二个元素是一个新的表达式。updates也可以是一个dict,key是共享变量,值是一个新的表达式。不管用哪种方法,它的意思是:当函数运行完成后,把新的表达式的值赋给这个共享变量。上面的accumulator函数的updates是把state+inc赋给state,也就是每次调用accumulator函数后state增加inc。让我们来试一试!
>>> print(state.get_value())0>>> accumulator(1)array(0)>>> print(state.get_value())1>>> accumulator(300)array(1)>>> print(state.get_value())301
开始时state的值是0。然后调用一次accumulator(1),这个函数返回state原来的值,也就是0。然后把state更新为1。然后再调用accumulator(300),这一次返回1,同时把state更新为301。我们有可以重新设置state的值。只需要调用set_value方法就行:
>>> state.set_value(-1)>>> accumulator(3)array(-1)>>> print(state.get_value())2
我们首先把state设置成-1,然后调用accumulator(3),返回-1,同时吧state更新成了2。我们前面提到过,多个函数可以“共享”一个共享变量,因此我们可以定义如下的函数:
>>> decrementor = function([inc], state, updates=[(state, state-inc)])>>> decrementor(2)array(2)>>> print(state.get_value())0
我们定义了decrementor函数,它每次返回之前的state的值,同时把state减去输入参数inc后赋给state。调用decrementor(2),返回state的之前的值2,同时把state更新成0。你可能会奇怪为什么需要updates机制。你也可以让这个函数返回这个新的表达式【当然原来的返回值仍然返回,多返回一个就行】,然后用在numpy更新state。首先updates机制是一种语法糖,写起来更简便。但更重要的是为了效率。共享变量的共享又是可以使用就地(in-place)的算法【符号变量包括共享变量的内存是由Theano来管理的,把它从Theano复制到numpy,然后修改,然后在复制到Theano很多时候是没有必要的,更多Theano的内存管理请参考这里】。另外,共享变量的内存是由Theano来分配和管理,因此Theano可以根据需要来把它放到GPU的显存里,这样用GPU计算时可以避免CPU到GPU的数据拷贝,从而获得更好的性能。有些时候,你可以通过共享变量来定义了一个公式(函数),但是你不想用它的值。这种情况下,你可以用givens这个参数。
>>> fn_of_state = state * 2 + inc>>> # The type of foo must match the shared variable we are replacing>>> # with the ``givens``>>> foo = T.scalar(dtype=state.dtype)>>> skip_shared = function([inc, foo], fn_of_state, givens=[(state, foo)])>>> skip_shared(1, 3)  # we're using 3 for the state, not state.valuearray(7)>>> print(state.get_value())  # old state still there, but we didn't use it0
首先我们定义了一个符号变量fn_of_state,它用到了共享变量state。然后我们定义skip_shared,他的输入参数是inc和foo,输出是fn_of_state。注意:fn_of_state依赖state和inc两个符号变量,如果参数inc直接给定了。另外一个参数foo取代(而不是赋值给)了inc,因此实际 fn_of_state = foo * 2 + inc。我们调用skip_shared(1,3)会得到7,而state依然是0(而不是3)。如果把这个计算图画出来的话,实际是用foo替代了state。givens参数可以取代任何符号变量,而不只是共享变量【从计算图的角度就非常容易理解了,后面我们会讲到Theano的计算图】。你也可以用这个参数来替代常量和表达式。不过需要小心的是替代的时候不要引入循环的依赖。【比如a=b+c,你显然不能把c又givens成a,这样循环展开就不是有向无环图了】有了上面的基础,我们可以用Theano来实现Logistic Regression算法了。不过这里没有介绍grad,我们先简单的介绍一下,内容来自这里。使用Theano的好处就是auto diff,在前面也介绍过来,几乎所有的深度学习框架/工具都是提供类似的auto diff的功能,只不过定义graph的“语言/语法”和“粒度”不一样。另外除了求梯度,大部分工具还把训练算法都封装好了。而Theano就比较“原始”,它除了自动求梯度,并不会帮你实现sgd或者Adam算法,也不会帮你做dropout,不会帮你做weight decay和normalization,所有这些都得你自己完成。这可能会让那些希望把深度学习当成一个“黑盒”的用户有些失望,对于这样的用户最好用Keras,caffe这样的工具。但是对于想理解更多细节和自己“创造”一种新的网络结构的用户,Theano是个非常好的工具,它提供常见的op,也可以自定义op(python或者c),对于rnn也有非常好的支持。我们下面用Theano来实现对函数
f(x)=x2
的导数。
>>> import numpy>>> import theano>>> import theano.tensor as T>>> from theano import pp>>> x = T.dscalar('x')>>> y = x ** 2>>> gy = T.grad(y, x)>>> pp(gy)  # print out the gradient prior to optimization'((fill((x ** TensorConstant{2}), TensorConstant{1.0}) * TensorConstant{2}) * (x ** (TensorConstant{2} - TensorConstant{1})))'>>> f = theano.function([x], gy)>>> f(4)array(8.0)>>> numpy.allclose(f(94.2), 188.4)True
首先我们定义符号变量x,然后用x定义y,然后使用grad函数求y对x的(偏)导数gy【grad函数返回的仍然只是一个符号变量,可以认为用y和x定义了一个新的符号变量gy】,然后定义函数f,它的输入是x,输出是gy。注意:y是x的函数,gy是x和y的函数,所以最终gy只是x的函数,所以f的输入只有x。 f编译好了之后,给定x,我们就可以求
∂y∂x
在这个点上的值了。4.2.3 一个实际的例子:Logistic RegressionLogistic Regression(LR)简介LR模型用来进行二分类,它对输入进行仿射变换,然后用logistic函数把它压缩到0和1之间,训练模型就是调整参数,对于类别0,让模型输出接近0的数,对于类别1,让模型输出接近1的数。预测的时候如果大于0.5就输出1,反之输出0。因此我们可以把模型的输出当成概率:
P(y=1|x)=hw(x)=11+exp(−wTx)
P(y=0|x)=1−P(y=1|x)=1−hw(x)
对于两个概念分布,cross-entroy是最常见的一种度量方式。【详细介绍参考这里】
loss=−ylogP(y=1|x)−(1−y)logP(y=0|x)=−yloghw(x)−(1−y)log(1−hw(x))
如果真实值y=1,那么第二项就是0,
loss=−loghw(x)
,如果
hw(x)
趋近1,那么loss就趋近0;反之如果
hw(x)
趋近0,那么loss就趋近无穷大。如果真实值y=0,那么第一项就是0,
loss=−log(1−hw(x))
,如果
hw(x)
趋近0,
1−hw(x)
趋近1,loss趋近0;反之loss趋近无穷大。因此从上面的分析我们发现,这个loss函数是符合直觉的,模型输出
hw(x)
越接近真实值,loss越小。有了loss,我们就可以用梯度下降求(局部)最优参数了。【这个loss函数是一个凸函数,所以局部最优就是全局最优,有兴趣的读者可以参考这里,不过对于工程师来说没有必要了解这些细节。我们常见的神经网络是非常复杂的非线性函数,因此loss通常也是非凸的,因此(随机)梯度下降只能得到局部最优解,但是深度神经网络通常能找到比较好的局部最优解,有也一些学者在做研究,有兴趣的读者请参考这里以及这里】接下来是求梯度?有了Theano,我们只需要写出loss就可以啦,剩下的梯度交给Theano就行了。代码分析接下来我们来分析用Theano实现LR算法的代码。每行代码前面都会加上相应的注释,请读者阅读仔细阅读每行代码和注释。
import numpyimport theanoimport theano.tensor as Trng = numpy.randomN = 400                                   # 训练数据的数量 400feats = 784                               # 特征数 784# 生成训练数据: D = ((N, feates), N个随机数值) ,随机数是0或者1D = (rng.randn(N, feats), rng.randint(size=N, low=0, high=2))training_steps = 10000# 定义两个符号变量,x和y,其中x是一个double的matrix,y是一个double的vectorx = T.dmatrix("x")y = T.dvector("y")# 随机初始化参数w,它的大小是feats## 我们把w定义为共享变量,这样可以在多次迭代中共享。w = theano.shared(rng.randn(feats), name="w")# b也是共享变量,我们不需要随机初始化,一般bias出初始化为0就行了。b = theano.shared(0., name="b")print("Initial model:")print(w.get_value())print(b.get_value())# 构造Theano表达式图p_1 = 1 / (1 + T.exp(-T.dot(x, w) - b))   # 模型输出1的概率,一次输出的是N个样本prediction = p_1 > 0.5                    # 基于p_1预测分类xent = -y * T.log(p_1) - (1-y) * T.log(1-p_1) # Cross-entropy loss functioncost = xent.mean() + 0.01 * (w ** 2).sum()# loss函数,前面xent是一个向量,所以求mean,然后使用L2 正则化,w越大就惩罚越大gw, gb = T.grad(cost, [w, b])             # 计算cost对w和b的梯度# train是一个函数,它的输入是x和y,输出是分类预测prediction和xent,注意updates参数,每次调用train函数之后都会更新w<-w-0.1*gw, b<-b-0.1*gbtrain = theano.function(          inputs=[x,y],          outputs=[prediction, xent],          updates=((w, w - 0.1 * gw), (b, b - 0.1 * gb)))# pridict是一个函数,输入x,输出predictionpredict = theano.function(inputs=[x], outputs=prediction)# 训练,就是用训练数据x=D[0], y=D[1]进行训练。# 也就算调用train函数,train函数会使用当前的w和b“前向”计算出prediction和xent,同时也计算出cost对w和b的梯度。然后再根据updates参数更新w和bfor i in range(training_steps):    pred, err = train(D[0], D[1])print("Final model:")print(w.get_value())print(b.get_value())print("target values for D:")print(D[1])print("prediction on D:")print(predict(D[0]))
注意:我们为了提高效率,一次计算N个训练数据,p_1 = 1 / (1 + T.exp(-T.dot(x, w) - b)),这里x是N feats,w是feats 1,-T.dot(x,w)是N 1,而-b是一个1 1的数,所以会broadcasting,N个数都加上-b。然后exp,然后得到p_1,因此p_1是N*1的向量,代表了N个训练数据的输出1的概率。我们可以看到,在Theano里,我们实现一个模型非常简单,我们之需要如下步骤:[list=1]
  • 只需要把输入和输出定义成符号变量【有时为了加速我们可能也会把它定义成共享变量从而让它放到gpu的显存里,后面的例子会介绍到】
  • 把模型的参数定义成共享变量
  • 然后写出loss函数的公式,同时定义loss对参数的梯度
  • 定义一个训练函数train,输入是模型的输入变量,输出是loss【或者prediction,这样我们可以在训练的时候打印出预测的准确率】,updates用来更新模型参数
  • 写有一个for循环不停的调用train

  • 当然这是全量的梯度下降,如果是batch的随机梯度下降,只需要每次循环传入一个batch的输入和输出就行。
     
    5. 计算图
    5.1 图的结构

    内容来自这里

    如果不了解原理而想在Theano里调试和profiling代码不是件简单的事情。这部分介绍给你关于Theano你必须要了解的一些实现细节。

    写Theano代码的第一步是使用符号变量写出所有的数学变量。然后用+,-,*,sum(), tanh()等操作写出各种表达式。所有这些在theano内部都表示成op。一个op表示一种特定的运算,它有一些输入,然后计算出一些输出。你可以把op类比成编程语言中的函数。

    Theano用图来表示符号数学运算。这些图的点包括:Apply(实在想不出怎么翻译),变量和op,同时图也包括这些点的连接(有向的边)。Apply代表了op对某些变量的计算【op类比成函数的定义,apply类比成函数的实际调用,变量就是函数的参数】。区分通过op定义的计算和把这个计算apply到某个实际的值是非常重要的。【我们在编程时里定义 x和y,然后定义z=x+y,我们就得到了z的值,但是我们在Theano里定义符号变量x和y,然后定义z=x+y,因为x和y只是一个符号,所以z也只是一个符号,我们需要再定义一个函数,它的输入是x和y输出z。然后”调用“这个函数,传入x和y的实际值,才能得到z的值】。符号变量的类型是通过Type这个类来表示的。下面是一段Theano的代码以及对应的图。

    代码:
    import theano.tensor as T

    x = T.dmatrix('x')
    y = T.dmatrix('y')
    z = x + y
    图:
    图中的箭头代表了Python对象的引用。蓝色的框是Apply节点,红色的是变量,绿色的是Op,紫色的是Type。

    当我们常见符号变量并且用Apply Op来产生更多变量的时候,我们创建了一个二分的有向无环图。如果变量的owner有指向Apply的边,那么说明这个变量是由Apply对应的Op产生的。此外Apply节点的input field和output field分别指向这个Op的输入和输出变量。

    x和y的owner是None,因为它不是由其它Op产生的,而是直接定义的。z的owner是非None的,这个Apply节点的输入是x和y,输出是z,Op是+,Apply的output指向了z,z.owner指向Apply,因此它们 是互相引用的。
     
    5.2 自动求导

    有了这个图的结构,自动计算导数就很容易了。tensor.grad()唯一需要做的就是从outputs逆向遍历到输入节点【如果您阅读过之前的自动求导部分,就会明白每个Op就是当时我们说的一个Gate,它是可以根据forward阶段的输入值计算出对应的local gradient,然后把所有的路径加起来就得到梯度了】。对于每个Op,它都定义了怎么根据输入计算出偏导数。使用链式法则就可以计算出梯度了。

    5.3 优化

    当编译一个Theano函数的时候,你给theano.function的其实是一个图(从输出变量遍历到输入遍历)。你通过这个图结构来告诉theano怎么从input算出output,同时这也让theano有机会来优化这个计算图【你可以把theano想像成一个编译器,你通过它定义的符号计算语法来定义函数,然后调用函数。而theano会想方设法优化你的函数(当然前提是保证结果是正确的)】。Theano的优化包括发现图里的一些模式(pattern)然后把他替换新的模式,这些新的模式计算的结果和原来是一样的,但是心模式可能更快更稳定。它也会检测图里的重复子图避免重复计算,还有就是把某些子图的计算生成等价的GPU版本放到GPU里执行。

    比如,一个简单的优化可能是把
    xyy
    优化成x。
    例子
    >>> import theano
    >>> a = theano.tensor.vector("a") # declare symbolic variable
    >>> b = a + a ** 10 # build symbolic expression
    >>> f = theano.function([a], b) # compile function
    >>> print(f([0, 1, 2])) # prints `array([0,2,1026])`
    [ 0. 2. 1026.]
    >>> theano.printing.pydotprint(b, outfile="./pics/symbolic_graph_unopt.png", var_with_name_simple=True)
    The output file is available at ./pics/symbolic_graph_unopt.png
    >>> theano.printing.pydotprint(f, outfile="./pics/symbolic_graph_opt.png", var_with_name_simple=True)
    The output file is available at ./pics/symbolic_graph_opt.png
    我们定义
    b=a+a10
    ,f是函数,输入a,输出b。下面是没有优化的图:
    没有优化的图有两个Op,power和add【还有一个DimShuffle,这个是Theano自己增加的一个Op,对于常量10,theano会创建一个TensorConstant。它是0维的tensor,也就是一个scalar。但是a我们定义的是一个vector,power是一个elementwise的操作,底数是一个vector,那么指数也要是同样大小的vector。dimshuffle(‘x’)就是给0维tensor增加一个维度变成1维的tensor(也就是vector),这样维数就对上了,但是x的shape可能是(100,)的,而常量是(1,),大小不一样怎么办呢?这就是broadcasting作的事情了,它会把dimshuffle(‘x’, 10)扩展成(100,)的向量,每一个值都是10【实际numpy不会那么笨的复制100个10,不过我们可以这么理解就好了】。之前我们也学过numpy的broadcasting,theano和numpy的broadcasting使用一些区别的,有兴趣的读者可以参考这里。这里就不过多介绍了,如果后面有用到我们再说。

    下面是优化过的图:
    优化后变成了一个ElementWise的操作,其实就是把
    b=a+a10
    优化成了
    b=a+((a2)2)2+a2
    关于Theano的简单介绍就先到这里,后面讲到RNN/LSTM会更多的介绍theano的scan函数以及怎么用Theano实现RNN/LSTM。下面我们讲两个实际的例子:用Theano来实现LR和MLP。
     
    6. Classifying MNIST digits using Logistic Regression
    参考链接
    注意这里说的LR和前面的LR是不同的,很多文献说的Logistic Regression是两类的分类器,这里的LR推广到了多类,有些领域把它叫做最大熵(Max Entropy)模型,有的叫多类LR(multi-class logistic regression)。这里的LR是多类(10)的分类器,前面我们说的是标准的LR,是一个两类的分类器。

    6.1 模型定义

    Logistic Regression可以认为是一个1层的神经网络,首先是一个仿射变换(没有激活函数),然后接一个softmax。

    logistic regression的公式如下:

    输出Y是有限的分类。比如对于MNIST数据,Y的取值是0,1,…,9。我们训练的时候如果图片是数字3,那么Y就是one-hot的表示的十维的向量[0,0,0,1,0,0,0,0,0,0] 
    预测的时候给定一个x,我们会计算出一个十维的向量,比如[0.1, 0.8 , 0.0125, 0.0125,…0.0125]。那么我们会认为这是数字1,因为模型认为输出1的概率是0.8。

    模型定义的代码如下所示:
    # initialize with 0 the weights W as a matrix of shape (n_in, n_out)
    self.W = theano.shared(
    value=numpy.zeros(
    (n_in, n_out),
    dtype=theano.config.floatX
    ),
    name='W',
    borrow=True
    )
    # initialize the biases b as a vector of n_out 0s
    self.b = theano.shared(
    value=numpy.zeros(
    (n_out,),
    dtype=theano.config.floatX
    ),
    name='b',
    borrow=True
    )

    # symbolic expression for computing the matrix of class-membership
    # probabilities
    # Where:
    # W is a matrix where column-k represent the separation hyperplane for
    # class-k
    # x is a matrix where row-j represents input training sample-j
    # b is a vector where element-k represent the free parameter of
    # hyperplane-k
    self.p_y_given_x = T.nnet.softmax(T.dot(input, self.W) + self.b)

    # symbolic description of how to compute prediction as class whose
    # probability is maximal
    self.y_pred = T.argmax(self.p_y_given_x, axis=1)
    theano里最重要的就是shared变量,我们一般把模型的参数定义为shared变量,我们可以用numpy的ndarray来定义它的shape并且给这些变量赋初始化的值。

    self.W = theano.shared(
    value=numpy.zeros(
    (n_in, n_out),
    dtype=theano.config.floatX
    ),
    name='W',
    borrow=True
    )
    (1) shared函数的value参数

    上面我们定义了shared变量self.W,用numpy.zeros((n_in, n_out), dtype=theano.config.floatX)来定义了它是二维的数组(也就是矩阵),并且shape是(n_in, n_out),数据类型是theano.config.floatX,这是theano的一个配置项,我们可以在环境变量THEANO_FLAGS或者在$HOME/.theanorc文件里配置。所有的配置选项请参考这里。

    config.floatX用来配置使用多少位的浮点数。我们定义shared变量时引用theano.config.floatX,这样就不用在代码里写死到底是用32位还是64位的浮点数,而是可以在环境变量或者配置文件里制定了。

    比如我们在允许python是加上 THEANO_FLAGS=’floatX=float32’ python xxx.py,那么W就是32位的浮点数。

    (2) shared函数的name参数

    shared变量另外一个参数就是name,给变量命名便于调试。

    (3) shared函数的borrow参数

    使用theano时要区分两部分内存,一部分是我们的代码(包括numpy)的内存,另外就是theano自己管理的内存,这包括shared变量和apply函数时的一些临时内存。所有的theano的函数只能处理它自己管理的内存。那么函数的input呢?默认情况下我们传给theano函数的是python的对象或者numpy的对象,会复制到theano管理的临时变量里。因此为了优化速度,我们有时会把训练数据定义成shared变量,避免重复的内存拷贝。

    borrow=True(默认是False)让theano shallow copy numpy的ndarray,从而不节省空间。borrow是True的缺点是复用ndarray的内存空间,如果用同一个ndarray给多个shared变量使用,那么它们是共享这个内存,任何一个人改了,别人都能看得到。我们一般不会用一个ndarray构造多个shared 变量,所以一般设置成True。

    更多theano的内存管理请参考这里。

    【self.b的定义类似】

    接下来我们定义p_y_given_x,首先是仿射变换 T.dot(input, selft.W) + selft.b。然后加一个softmax。

    接下来是y_pred:
    self.y_pred = T.argmax(self.p_y_given_x, axis=1)
    我们使用argmax函数来选择概率最大的那个下标。注意axis=1,如果读者follow之前的代码,应该能明白代码的含义,这和numpy里的argmax的axis完全是一样的,原因是因为我们一次求了batch个输入的y。如果不太理解,请读者参考之前的文章。

    6.2 定义loss function

    前面的文章已经讲过很多次cross entropy的损失函数了。也就是真实分类作为下标去取p_y_given_x 对应的值,然后-log就是这一个训练样本的loss,但是我们需要去一个batch的loss,所以要用两个下标,一个是[0,1, …, batchSize-1],另一个就是样本的真实分类y(每个y都是0-9)。

    具体的代码如下:
    return -T.mean(T.log(self.p_y_given_x)[T.arange(y.shape[0]), y])
    这里先对所有的p_y_given_x求log,然后在切片出想要的值,其实也可以先切片在求log:
    return -T.mean(T.log(self.p_y_given_x[T.arange(y.shape[0]), y]))
    我自己测试了一下,后者确实快(30s vs 20s),这么一个小小的修改速度就快了很多。

    6.3 定义类LogisticRegression

    我们可以把上面的所有代码封装成一个LogisticRegression类,以便重复使用。请读者仔细阅读每行代码和注释。
    class LogisticRegression(object):
    """多类 Logistic Regression 分类器

    lr模型由weight矩阵W和biase向量b确定。通过把数据投影到一系列(分类数量个)超平面上,到朝平面的距离就被认为是预测为这个分类的概率
    """

    def __init__(self, input, n_in, n_out):
    """ 初始化参数

    :参数类型 input: theano.tensor.TensorType
    :参数说明 input: 符号变量代表输入的一个mini-batch

    :参数类型 n_in: int
    :参数说明 n_in: 输入神经元的个数,mnist是28*28=784

    :参数类型 n_out: int
    :参数说明 n_out: 输出的个数,mnist是10

    """
    # start-snippet-1
    # 把weight W初始化成0,shape是(n_in, n_out)
    self.W = theano.shared(
    value=numpy.zeros(
    (n_in, n_out),
    dtype=theano.config.floatX
    ),
    name='W',
    borrow=True
    )
    # 把biase初始化成0,shape是(n_out,)
    self.b = theano.shared(
    value=numpy.zeros(
    (n_out,),
    dtype=theano.config.floatX
    ),
    name='b',
    borrow=True
    )

    # 给定x,y输出0-9的概率,前面解释过了
    self.p_y_given_x = T.nnet.softmax(T.dot(input, self.W) + self.b)

    # 预测
    self.y_pred = T.argmax(self.p_y_given_x, axis=1)
    # end-snippet-1

    # 把模型的参数都保存起来,后面updates会用到
    self.params = [self.W, self.b]

    # 记下input 为什么要保存到self里?因为我们在预测的时候一般会重新load这个LogisticRegression类,因为模型的参数是LogisticRegression的成员变量(self.W, self.b),使用pickle.load的时候会恢复这些参数,同时也会重新调用__init__方法,所以整个计算图就恢复了。我们预测的时候需要定义predict的函数(还有一张方法就是在LogisticRegression里定义predict函数),这个时候就还需要输入input,所以保存input,具体预测的代码:
    #### load the saved model
    #### classifier = pickle.load(open('best_model.pkl'))

    #### compile a predictor function
    #### predict_model = theano.function(
    #### inputs=[classifier.input],
    #### outputs=classifier.y_pred)
    self.input = input

    def negative_log_likelihood(self, y):
    """返回预测值在给定真实分布下的负对数似然(也就是cross entropy loss)

    参数类型 type y: theano.tensor.TensorType
    参数说明 param y: 每个训练数据对应的正确的标签(分类)组成的vecotr(因为我们一次计算一个minibatch)
    注意:我们这里使用了平均值而不是求和因为这样的话learning rate就和batch大小无关了【我们调batch的时候可以不影响learning rate】
    """
    #前面已经说过了,这里不再解释
    return -T.mean(T.log(self.p_y_given_x)[T.arange(y.shape[0]), y])


    def errors(self, y):
    """返回一个float代表这个minibatch的错误率

    :参数类型 type y: theano.tensor.TensorType
    :参数说明 param y: 同上面negative_log_likelihood的参数y
    """

    # 检查维度是否匹配
    if y.ndim != self.y_pred.ndim:
    raise TypeError(
    'y should have the same shape as self.y_pred',
    ('y', y.type, 'y_pred', self.y_pred.type)
    )
    # y必须是int类型的数据
    if y.dtype.startswith('int'):
    # the T.neq op 返回0和1,如果预测值y_pred和y不同就返回1
    # T.neq是一个elementwise的操作,所以用T.mean求评价的错误率
    return T.mean(T.neq(self.y_pred, y))
    else:
    raise NotImplementedError()
    我们使用这个类的方法:
      # 生成输入的符号变量 (x and y 代表了一个minibatch的数据)  
    x = T.matrix('x') # 数据
    y = T.ivector('y') # labels

    # 构造LogisticRegression对象
    # MNIST的图片是28*28的,我们把它展开成784的向量
    classifier = LogisticRegression(input=x, n_in=28 * 28, n_out=10)
    有了这个类的对象,接下来就可以定义lost function:
    cost = classifier.negative_log_likelihood(y)
    6.4 模型训练

    在大部分编程语言里,我们都需要手工求loss对参数的梯度:
    ∂ℓ/∂W
    ∂ℓ/∂b
    。对于复杂的模型,这非常容易弄错。另外还有很多细节比如数值计算的稳定性(stability)。如果使用Theano,问题就很简单了,因为它会自动求导并且会做一些数学变换来提供数值计算的稳定性。

    To get the gradients \partial{\ell}/\partial{W} and \partial{\ell}/\partial{b} in Theano, simply do the following:

    在Theano中求
    ∂ℓ/∂W
    ∂ℓ/∂b
    ,只需要如下两行代码:
     g_W = T.grad(cost=cost, wrt=classifier.W)
    g_b = T.grad(cost=cost, wrt=classifier.b)
    g_W and g_b are symbolic variables, which can be used as part of a computation graph. The function train_model, which performs one step of gradient descent, can then be defined as follows:

    g_W和g_b是符号变量,也是计算图的一部分。函数train_model,没调用一次进行一个minibatch的梯度下降,可以如下定义:
     # 参数W和b的更新
    updates = [(classifier.W, classifier.W - learning_rate * g_W),
    (classifier.b, classifier.b - learning_rate * g_b)]

    train_model = theano.function(
    inputs=[index],
    outputs=cost,
    updates=updates,
    givens={
    x: train_set_x[index * batch_size: (index + 1) * batch_size],
    y: train_set_y[index * batch_size: (index + 1) * batch_size]
    }
    )
    注意:这个train_model函数的参数是minibatch的下标。为了提高训练速度,我们使用Theano时通常会把所有的训练数据也定义为共享变量,以便把它们放到GPU的显存里,从而避免在cpu和gpu直接来回的复制数据【如果训练数据太大不能放到显存里呢?比较容易想到的就是把训练数据(随机)的切分成能放到内存的一个个window,然后把这个window的数据加载到显存训练,然后再训练下一个window】。而我们每次训练时通过index来从train_set_x里选取这个minibatch的数据:
        givens={
    x: train_set_x[index * batch_size: (index + 1) * batch_size],
    y: train_set_y[index * batch_size: (index + 1) * batch_size]
    }
    givens之前我们解释过了,就是通过参数index来确定当前的训练数据。为什么要用givens来制定x和y?因为我们没有办法直接把x和y作为参数传给train_model【否则就需要在cpu和gpu复制数据了】我们通过把train_set_x和train_set_y定义为共享变量,然后通过givens和index来制定当前这个minibatch的x和y的值。

    每次调用train_model,Theano会根据当前的W和b计算loss和梯度g_W和g_b,然后执行updates更新W和b。

    6.5 测试模型

    要测试模型,首先需要定义错误率:
    def errors(self, y):
    if y.ndim != self.y_pred.ndim:
    raise TypeError(
    'y should have the same shape as self.y_pred',
    ('y', y.type, 'y_pred', self.y_pred.type)
    )
    # check if y is of the correct datatype
    if y.dtype.startswith('int'):
    return T.mean(T.neq(self.y_pred, y))
    else:
    raise NotImplementedError()
    前面是检查y和y_pred的shape是否匹配,因为Theano的Tensor在编译时是没有shape信息的。另外y是运行是传入的,我们也要检查一下它的Type是否int。

    关键的一行代码是:
    return T.mean(T.neq(self.y_pred, y))
    T.neq是个elementwise的函数,如果两个值相等就返回0,不相等返回1,然后调用mean函数就得到错误率。

    接下来我们需要定义一个函数来计算错误率,这个函数和训练非常类似,不过用的数据是测试数据和validation数据而已。validation可以帮助我们进行early-stop。我们保留的最佳模型是在validation上表现最好的模型。
      test_model = theano.function(
    inputs=[index],
    outputs=classifier.errors(y),
    givens={
    x: test_set_x[index * batch_size: (index + 1) * batch_size],
    y: test_set_y[index * batch_size: (index + 1) * batch_size]
    }
    )

    validate_model = theano.function(
    inputs=[index],
    outputs=classifier.errors(y),
    givens={
    x: valid_set_x[index * batch_size: (index + 1) * batch_size],
    y: valid_set_y[index * batch_size: (index + 1) * batch_size]
    }
    )

    6.6 完整的代码
    from __future__ import print_function

    __docformat__ = 'restructedtext en'

    import six.moves.cPickle as pickle
    import gzip
    import os
    import sys
    import timeit

    import numpy

    import theano
    import theano.tensor as T


    class LogisticRegression(object):

    def __init__(self, input, n_in, n_out):
    # start-snippet-1
    # initialize with 0 the weights W as a matrix of shape (n_in, n_out)
    self.W = theano.shared(
    value=numpy.zeros(
    (n_in, n_out),
    dtype=theano.config.floatX
    ),
    name='W',
    borrow=True
    )
    # initialize the biases b as a vector of n_out 0s
    self.b = theano.shared(
    value=numpy.zeros(
    (n_out,),
    dtype=theano.config.floatX
    ),
    name='b',
    borrow=True
    )

    self.p_y_given_x = T.nnet.softmax(T.dot(input, self.W) + self.b)

    self.y_pred = T.argmax(self.p_y_given_x, axis=1)
    # end-snippet-1

    # parameters of the model
    self.params = [self.W, self.b]

    # keep track of model input
    self.input = input

    def negative_log_likelihood(self, y):

    # start-snippet-2
    return -T.mean(T.log(self.p_y_given_x)[T.arange(y.shape[0]), y])
    # end-snippet-2

    def errors(self, y):
    # check if y has same dimension of y_pred
    if y.ndim != self.y_pred.ndim:
    raise TypeError(
    'y should have the same shape as self.y_pred',
    ('y', y.type, 'y_pred', self.y_pred.type)
    )
    # check if y is of the correct datatype
    if y.dtype.startswith('int'):
    # the T.neq operator returns a vector of 0s and 1s, where 1
    # represents a mistake in prediction
    return T.mean(T.neq(self.y_pred, y))
    else:
    raise NotImplementedError()


    def load_data(dataset):
    ''' Loads the dataset

    :type dataset: string
    :param dataset: the path to the dataset (here MNIST)
    '''

    #############
    # LOAD DATA #
    #############

    # Download the MNIST dataset if it is not present
    data_dir, data_file = os.path.split(dataset)
    if data_dir == "" and not os.path.isfile(dataset):
    # Check if dataset is in the data directory.
    new_path = os.path.join(
    os.path.split(__file__)[0],
    "..",
    "data",
    dataset
    )
    if os.path.isfile(new_path) or data_file == 'mnist.pkl.gz':
    dataset = new_path

    if (not os.path.isfile(dataset)) and data_file == 'mnist.pkl.gz':
    from six.moves import urllib
    origin = (
    'http://www.iro.umontreal.ca/~lisa/deep/data/mnist/mnist.pkl.gz'
    )
    print('Downloading data from %s' % origin)
    urllib.request.urlretrieve(origin, dataset)

    print('... loading data')

    # Load the dataset
    with gzip.open(dataset, 'rb') as f:
    try:
    train_set, valid_set, test_set = pickle.load(f, encoding='latin1')
    except:
    train_set, valid_set, test_set = pickle.load(f)
    # train_set, valid_set, test_set format: tuple(input, target)
    # input is a numpy.ndarray of 2 dimensions (a matrix)
    # where each row corresponds to an example. target is a
    # numpy.ndarray of 1 dimension (vector) that has the same length as
    # the number of rows in the input. It should give the target
    # to the example with the same index in the input.

    def shared_dataset(data_xy, borrow=True):

    data_x, data_y = data_xy
    shared_x = theano.shared(numpy.asarray(data_x,
    dtype=theano.config.floatX),
    borrow=borrow)
    shared_y = theano.shared(numpy.asarray(data_y,
    dtype=theano.config.floatX),
    borrow=borrow)

    return shared_x, T.cast(shared_y, 'int32')

    test_set_x, test_set_y = shared_dataset(test_set)
    valid_set_x, valid_set_y = shared_dataset(valid_set)
    train_set_x, train_set_y = shared_dataset(train_set)

    rval = [(train_set_x, train_set_y), (valid_set_x, valid_set_y),
    (test_set_x, test_set_y)]
    return rval


    def sgd_optimization_mnist(learning_rate=0.13, n_epochs=1000,
    dataset='mnist.pkl.gz',
    batch_size=600):
    datasets = load_data(dataset)

    train_set_x, train_set_y = datasets[0]
    valid_set_x, valid_set_y = datasets[1]
    test_set_x, test_set_y = datasets[2]

    # compute number of minibatches for training, validation and testing
    n_train_batches = train_set_x.get_value(borrow=True).shape[0] // batch_size
    n_valid_batches = valid_set_x.get_value(borrow=True).shape[0] // batch_size
    n_test_batches = test_set_x.get_value(borrow=True).shape[0] // batch_size

    ######################
    # BUILD ACTUAL MODEL #
    ######################
    print('... building the model')

    # allocate symbolic variables for the data
    index = T.lscalar() # index to a [mini]batch

    # generate symbolic variables for input (x and y represent a
    # minibatch)
    x = T.matrix('x') # data, presented as rasterized images
    y = T.ivector('y') # labels, presented as 1D vector of [int] labels

    # construct the logistic regression class
    # Each MNIST image has size 28*28
    classifier = LogisticRegression(input=x, n_in=28 * 28, n_out=10)

    # the cost we minimize during training is the negative log likelihood of
    # the model in symbolic format
    cost = classifier.negative_log_likelihood(y)

    # compiling a Theano function that computes the mistakes that are made by
    # the model on a minibatch
    test_model = theano.function(
    inputs=[index],
    outputs=classifier.errors(y),
    givens={
    x: test_set_x[index * batch_size: (index + 1) * batch_size],
    y: test_set_y[index * batch_size: (index + 1) * batch_size]
    }
    )

    validate_model = theano.function(
    inputs=[index],
    outputs=classifier.errors(y),
    givens={
    x: valid_set_x[index * batch_size: (index + 1) * batch_size],
    y: valid_set_y[index * batch_size: (index + 1) * batch_size]
    }
    )

    # compute the gradient of cost with respect to theta = (W,b)
    g_W = T.grad(cost=cost, wrt=classifier.W)
    g_b = T.grad(cost=cost, wrt=classifier.b)

    # start-snippet-3
    # specify how to update the parameters of the model as a list of
    # (variable, update expression) pairs.
    updates = [(classifier.W, classifier.W - learning_rate * g_W),
    (classifier.b, classifier.b - learning_rate * g_b)]

    # compiling a Theano function `train_model` that returns the cost, but in
    # the same time updates the parameter of the model based on the rules
    # defined in `updates`
    train_model = theano.function(
    inputs=[index],
    outputs=cost,
    updates=updates,
    givens={
    x: train_set_x[index * batch_size: (index + 1) * batch_size],
    y: train_set_y[index * batch_size: (index + 1) * batch_size]
    }
    )
    # end-snippet-3

    ###############
    # TRAIN MODEL #
    ###############
    print('... training the model')
    # early-stopping parameters
    patience = 5000 # look as this many examples regardless
    patience_increase = 2 # wait this much longer when a new best is
    # found
    improvement_threshold = 0.995 # a relative improvement of this much is
    # considered significant
    validation_frequency = min(n_train_batches, patience // 2)
    # go through this many
    # minibatche before checking the network
    # on the validation set; in this case we
    # check every epoch

    best_validation_loss = numpy.inf
    test_score = 0.
    start_time = timeit.default_timer()

    done_looping = False
    epoch = 0
    while (epoch < n_epochs) and (not done_looping):
    epoch = epoch + 1
    for minibatch_index in range(n_train_batches):

    minibatch_avg_cost = train_model(minibatch_index)
    # iteration number
    iter = (epoch - 1) * n_train_batches + minibatch_index

    if (iter + 1) % validation_frequency == 0:
    # compute zero-one loss on validation set
    validation_losses = [validate_model(i)
    for i in range(n_valid_batches)]
    this_validation_loss = numpy.mean(validation_losses)

    print(
    'epoch %i, minibatch %i/%i, validation error %f %%' %
    (
    epoch,
    minibatch_index + 1,
    n_train_batches,
    this_validation_loss * 100.
    )
    )

    # if we got the best validation score until now
    if this_validation_loss < best_validation_loss:
    #improve patience if loss improvement is good enough
    if this_validation_loss < best_validation_loss * \
    improvement_threshold:
    patience = max(patience, iter * patience_increase)

    best_validation_loss = this_validation_loss
    # test it on the test set

    test_losses = [test_model(i)
    for i in range(n_test_batches)]
    test_score = numpy.mean(test_losses)

    print(
    (
    ' epoch %i, minibatch %i/%i, test error of'
    ' best model %f %%'
    ) %
    (
    epoch,
    minibatch_index + 1,
    n_train_batches,
    test_score * 100.
    )
    )

    # save the best model
    with open('best_model.pkl', 'wb') as f:
    pickle.dump(classifier, f)

    if patience <= iter:
    done_looping = True
    break

    end_time = timeit.default_timer()
    print(
    (
    'Optimization complete with best validation score of %f %%,'
    'with test performance %f %%'
    )
    % (best_validation_loss * 100., test_score * 100.)
    )
    print('The code run for %d epochs, with %f epochs/sec' % (
    epoch, 1. * epoch / (end_time - start_time)))
    print(('The code for file ' +
    os.path.split(__file__)[1] +
    ' ran for %.1fs' % ((end_time - start_time))), file=sys.stderr)


    def predict():
    """
    An example of how to load a trained model and use it
    to predict labels.
    """

    # load the saved model
    classifier = pickle.load(open('best_model.pkl'))

    # compile a predictor function
    predict_model = theano.function(
    inputs=[classifier.input],
    outputs=classifier.y_pred)

    # We can test it on some examples from test test
    dataset='mnist.pkl.gz'
    datasets = load_data(dataset)
    test_set_x, test_set_y = datasets[2]
    test_set_x = test_set_x.get_value()

    predicted_values = predict_model(test_set_x[:10])
    print("Predicted values for the first 10 examples in test set:")
    print(predicted_values)


    if __name__ == '__main__':
    sgd_optimization_mnist()
    大部分代码都已经解释过来,不过还有两个函数shared_dataset和sgd_optimization_mnist需要再稍微解释一下。

    前面说过,为了提高训练速度,我们需要把训练数据定义成共享变量。不过GPU里只能存储浮点数【这不是GPU的限制,而是Theano的限制,具体参考这里】,但是我们需要把y当成下标用,所以需要转成int32:
    return shared_x, T.cast(shared_y, 'int32')
    不过即使这样,cast操作(op)还是会把y复制到cpu上进行运算的。所有涉及到y的计算是会放到cpu上的,也就是计算图的loss会在cpu上运行。这是Theano的一个缺陷,不知道为什么会是这样的设计。不过那个stackoverflow的帖子回复里Daniel Renshaw说如果只是把int用作下标,不知会不会能在GPU上。但是计算error肯定是在CPU上了,不过error函数不是在训练阶段,调用的次数也不会太多。

    sgd_optimization_mnist实现sgd训练。

    其实就是不停的调用train_model函数,每经过一次epoch,就在validation数据上进行一次validation,如果错误率比当前的最佳模型好,就把它保存为最佳模型【用的是pickle】。不过这里使用了一个early-stop的技巧【参考这里】。

    除了一个最大的epoch的限制,如果迭代次数iter大于patience,那么就early-stop。patience的初始值是5000,也就是说至少要进行5000次迭代。如果这一次的错误率 < 上一次的错误率乘以improvement_threshold(0.995),那么就认为是比较大的一个提高,patience = max(patience, iter * patience_increase)。patience_increase=2。 大概的idea就是,如果有比较大的提高,那么就多一些”耐心“,多迭代几次。反之如果没有太多提高,咱就没”耐心“了,就early-stop了。

    6.7 使用训练好的模型来预测
    def predict():
    """
    An example of how to load a trained model and use it
    to predict labels.
    """

    # load the saved model
    classifier = pickle.load(open('best_model.pkl'))

    # compile a predictor function
    predict_model = theano.function(
    inputs=[classifier.input],
    outputs=classifier.y_pred)

    # We can test it on some examples from test test
    dataset='mnist.pkl.gz'
    datasets = load_data(dataset)
    test_set_x, test_set_y = datasets[2]
    test_set_x = test_set_x.get_value()

    predicted_values = predict_model(test_set_x[:10])
    print("Predicted values for the first 10 examples in test set:")
    print(predicted_values)
    前面都解释过了,首先pickle恢复模型的参数和计算图,然后定义predict_model函数,然后进行预测就行了。
     
    7. 使用Theano实现CNN
     
    更新中... 收起阅读 »

    视频客服来了!环信移动客服v5.14已发布,支持实时视频、消息回撤功能!

    客服模式 新增实时视频功能 支持客服与客户进行实时视频聊天。当APP或网页渠道的客户发起视频聊天时,客服可以在网页端客服工作台接受邀请,开始与客户进行实时视频聊天。聊天视频支持在会话、历史会话、客户中心等页面进行回放。 注:实时视频功能仅Chrome浏览...
    继续阅读 »
    客服模式

    新增实时视频功能


    支持客服与客户进行实时视频聊天。当APP或网页渠道的客户发起视频聊天时,客服可以在网页端客服工作台接受邀请,开始与客户进行实时视频聊天。聊天视频支持在会话、历史会话、客户中心等页面进行回放。

    注:实时视频功能仅Chrome浏览器在https模式下支持。

    实时视频功能为增值服务,如需开通,请提供租户ID并联系环信商务经理。

    客服端示例: 

    001.png


    APP访客端示例: 

    002.png


    新增消息撤回功能

    新增消息撤回功能。客服使用网页端客服工作台与APP、网页渠道的客户聊天时,可以撤回2分钟内的聊天消息。聊天消息被撤回后,将在APP、网页访客端消失。

    注:移动端客服工作台暂时不支持该功能。

    消息撤回功能为增值服务,如需开通,请提供租户ID并联系环信商务经理。

     
    支持显示APP访客端的设备信息

    当客服与APP渠道的客户聊天时,会话页面显示客户使用的设备信息,包含IP地址、运营商、操作系统等,帮助客服深入了解服务的客户。

    注:暂时仅最新版移动客服Android SDK支持获取用户的设备信息。

    获取APP访客端设备信息功能为增值服务,如需开通,请提供租户ID并联系环信商务经理。

    【优化】支持显示待接入会话的访客标签

    支持在待接入页面显示等待接入的会话的访客标签,帮助客服优先接待重要客户。

    访客标签可能来自:
    • 重复咨询的客户:该客户之前咨询时,客服为其添加了访客标签;
    • 客户被转接技能组:客服将客户的会话转接技能组前为客户添加了访客标签,由于技能组全忙,会话再次进入待接入,此时,管理员可以根据访客标签优先处理该客户的会话。
     【优化】支持保存客服同事之间的聊天消息支持保存客服同事之间的聊天消息。客服在“会话”页面与客服同事聊天后,聊天消息将保存在聊天窗口。注:移动端客服工作台暂时不支持该功能。【优化】支持显示留言的渠道信息支持在留言页面显示留言的渠道信息,即,提交留言的渠道(APP、web等)。管理员模式【优化】支持多次发送访客超时提示语支持多次发送访客超时提示语,优化提醒频率和间隔,提升客户体验。设置方式:进入“管理员模式 > 设置 > 系统开关”页面,打开“访客超时未回复自动结束会话”开关,并设置访客未回复超时时间(第一次发送超时提示语的时间)、提醒次数、提醒间隔、超时提示语,以及自动结束会话的时间(最后一次超时提示语发出x分钟后)。【优化】质量检查增加指标“单次响应时长超过x秒”质量检查增加指标“单次响应时长超过x秒”,考察客服的“最大响应时长”是否在合理区间。在会话详情页的“质检”tab页签显示该会话的最大响应时长,以及单次响应时长超过质检标准的次数。Web插件(访客端)当前版本:V43.15支持实时视频访客端Web插件支持实时视频功能,允许客户使用网页端聊天窗口向客服发起视频聊天。发起视频聊天的方法:在聊天窗口点击视频按钮,发起视频邀请,等待客服接受视频邀请,然后在聊天窗口加入视频聊天。注:实时视频功能需要在https模式下工作,仅电脑和Android手机的Chrome浏览器支持。实时视频功能为增值服务,需要在客服端开通才能使用。支持消息撤回访客端Web插件支持消息撤回。当客服在网页端客服工作台撤回消息时,该消息在访客端聊天窗口上消失(桌面聊天窗口和H5网页均支持)。该功能无需额外集成。消息撤回功能为增值服务,需要在客服端开通才能使用。移动客服Android SDK当前版本:V1.0.5支持实时语音、实时视频移动客服Android SDK支持实时语音、实时视频(实时音视频)。当客户使用Android APP联系客服时,可以向客服发起视频聊天。实时音视频功能需要调用Android SDK的接口进行集成,集成方式可参考“商城”demo。支持消息撤回移动客服Android SDK支持消息撤回。当客服在网页端客服工作台撤回消息时,该消息在Android APP上消失。该功能由SDK直接提供,无需额外集成。消息撤回功能为增值服务,需要在客服端开通才能使用。支持获取设备信息移动客服Android SDK支持获取用户的设备信息,如IP地址、运营商、操作系统等。当用户使用Android APP联系客服时,可以在网页端客服工作台看到该用户的设备信息。该功能由SDK直接提供,无需额外集成。获取APP访客端设备信息功能为增值服务,需要在客服端开通才能使用。关于移动客服Android SDK的集成说明,请查看移动客服 Android SDK 集成。“商城”demo (Android)移动客服“商城”demo(Android版)已支持实时视频、消息撤回、获取设备信息这三个增值功能,在网页端客服工作台开通这些功能后,可以先在“商城”demo上体验。体验方法:联系环信商务经理,开通上述增值服务;[list=1]
  • 下载最新版“商城”demo。登录网页端客服工作台,进入“管理员模式 > 渠道管理 > 手机APP”,选择任一关联,用手机扫描页面上的二维码;
  • 下载完成后,打开“商城”demo的设置页面,点击“扫描”,再次扫描“手机APP”页面的二维码。
  • 使用“商城”demo向客服发起聊天,并发送文字消息或发起视频邀请,然后在网页端客服工作台体验上述功能。

  •  
    环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.14
     
    环信移动客服登陆地址http://kefu.easemob.com/
    收起阅读 »

    一天轻松集成,环信Unity版SDK帮助游戏APP实现从0到1社交突破!

      你辛辛苦苦花大价钱推广的游戏是否还是粘性不够?随着不断的新游戏的冲击用户活跃度每况愈下?如何提升游戏内玩家社交体检已是每个产品经理的必修课。近日,环信宣布Unity版SDK正式发布,只需一天即可轻松集成IM功能,帮助游戏APP实现从0到1的游戏内社交...
    继续阅读 »
     
       你辛辛苦苦花大价钱推广的游戏是否还是粘性不够?随着不断的新游戏的冲击用户活跃度每况愈下?如何提升游戏内玩家社交体检已是每个产品经理的必修课。近日,环信宣布Unity版SDK正式发布,只需一天即可轻松集成IM功能,帮助游戏APP实现从0到1的游戏内社交突破。

    timg_(1).jpg



    Unity

       Unity SDK是为使用Unity开发的游戏等软件中集成IM功能提供的SDK。依赖Unity的跨平台特性,SDK可以轻松的运行于Android、IOS、MAC、Linux和Window等多个平台产品之上,用户可以用SDK实现IM功能。Unity SDK使用C#进行开发,目前支持登陆、注册、单聊、群聊、文本消息、文件消息,还可以实现群组管理等功能。尚未实现语音通话和视频通话功能。emclient-u3d 为 Open Source, Link to :https://github.com/easemob/emclient-u3d/
     
    Unity SDK 更新日志

    版本:V3.0.0 试用版 2017-03-17
    1. 登陆、登出、注册。
    2. 聊天:单聊、群聊消息收发。
    3. 消息类型:

    • 可发送:文本、文件。
    • 可接受:文本、文件。
    [list=1]
  • 群组管理:创建群、加入群、退出群、添加用户、获取当前用户加入群、获取群详情。
  • 会话管理:获取用户所有会话、获取会话最新消息、获取会话指定消息。
  • 设置管理:设置用户是否自动同意加入群组。

  •  
    环信Unity SDK下载http://www.easemob.com/download/im 收起阅读 »

    环信生曦:全媒体客服如何做好信息共享设计

       经过数十年的沉淀,国内客户中心正从话音呼叫中心、网页端客服向全媒体架构的统一客服平台升级,有调研机构预计2017年全媒体客户中心将进入高速发展期。作为传统呼叫中心的继任者,全媒体客户中心将在技术上如何演进?未来形态又将如何?以环信为首的中国创新者们正在慢...
    继续阅读 »
       经过数十年的沉淀,国内客户中心正从话音呼叫中心、网页端客服向全媒体架构的统一客服平台升级,有调研机构预计2017年全媒体客户中心将进入高速发展期。作为传统呼叫中心的继任者,全媒体客户中心将在技术上如何演进?未来形态又将如何?以环信为首的中国创新者们正在慢慢给出答案。近日,环信设计组负责人生曦撰文浅谈全媒体客服如何做好信息共享设计,和大家一起摸索全媒体客服设计中的痛点和难点。



    0a7df6f.jpg


    是的,您没看错,上图当年那位最帅的青涩骚年就是现在的老司机生曦


    什么是服务设计?与产品设计有哪些不同?

       服务设计是基于某个行业的服务需要,以服务流程中的参与者为核心,所涉及的服务场景、交互逻辑以及操作方式等进行的设计。因此,服务设计的衡量标准也是以参与者的服务效率和使用体验进行设定。与产品设计的不同之处在于,服务设计所面对的参与者不仅是为用户,也包含服务人员本身;服务设计不仅是通过产品交互界面(设备的操作界面),也可能会有除产品以外的服务(话术访谈等)。因此对设计师有更高的要求,需要对完成的服务场景有行业化的认识,对客户服务有深度的行业理解。

    什么是全媒体服务?

       某种行业服务过程中,需要通过不同方式进行服务接入、统一分配服务资源、合理进行服务转化等一系列服务操作中,包含了多种渠道服务的支持。例如,传统的医疗服务行业,通过挂号,缴费,分诊,就医等全线下完成服务。逐渐转化为通过电话、网站、APP等多渠道进行预约、挂号等,选择进行线下就医,或者在线完成医疗咨询、诊治等,后续进行在线回访以及调查等。典型的传统转向数字化的全渠道全媒体服务模式。

    全媒体服务如何实现信息共享?

       全媒体服务根据每个行业的需要,有不同的服务场景和流程定制,信息同步高效是全媒体服务的基础,设计通常会选择业务信息聚合节点来完成信息聚合。以电商为例,通常以消费者(用户)为维度进行信息聚合,客户服务人员可以通过用户聚合查看到相关的互动记录信息,进行连续不间断的服务支持;以客户服务人员本身作为信息聚合节点,查看相关服务操作过程,来管理和监控服务流程;可以通过服务信息单元进行聚合节点,查看相关涉及的消费者与客户服务人员的行为,来进行服务跟踪和追溯服务状态。当然还有更多信息聚合节点,可以根据行业服务需要进行定制和管理。通过梳理具有行业目的的信息线索,进行节点聚合,是在服务设计中实现信息共享方式最有效的办法。

    如何确保和渠道服务能力畅通连贯?

       现实生活中,我们经常与遇到一些骚扰的营销电话,其中或多或少可能之前是我们之前有所涉及的服务,但是也许目前不需要的就成了骚扰电话。而有一些可能就成为我们下一次消费的开始,这是渠道营销服务的一种方式。所以营销服务的节点(信息线索)是根据客户信息聚合,如果还有其他渠道方面的节点,我们也许会收到短信或者APP等消息推送,也可能会更有效的完成此次营销服务。所以服务节点聚合的效率是保证服务与服务之间准确、连贯、有效的关键因素。例如,环信移动客服,通过对全渠道消息、语音统一接入,进行服务资源调度与分配,将信息整合为几大重要的信息聚合节点,客户资料中心、云数据计算分析、全媒体接入等等,为客户提供在线服务、管理、追踪、营销等组合业务,高效且系统化的满足行业需求定制。

    新生服务如何可以融入现有服务设计中?

       这个非常有挑战性的话题,作为设计师,不断的进行设计创新,完善产品、优化服务是产品生命的保证。而技术新模式的加入也在不断改变和推翻低效的工具和实现方式。这不经意间就会落后的技术时代,是与客户一起深入行业发展,共同创新摸索产品的必经之路。现在正在追捧的VR/AR技术,以及AI等人工智能在服务领域的渗透,产品设计也在悄然发生变革。当然,对技术能力的验证不能闭门造车,环信移动客服智能机器人系统中,悄悄然无声融入的智能业务场景应答服务已经在很多大客户身上得到了应用,并迸发出了强大的生产力。在现有产品系统框架的基础上逐渐结合AI与行业服务,以此为基础进行尝试与深入,逐渐可以成长和完善为新的利器。

    最后安利一个服务流程设计工具:http://servicedesigntools.org/repository 收起阅读 »

    方向

      我还记得,自己选择程序员的初衷。   从高考结束到现在,从开始有编程的思想到现在;从我接触游戏到现在,从我第一次开始想,这个程序到底是怎么完成的到现在。   我开始 写Hello World的时候,只知道一个public  static  void mai...
    继续阅读 »
      我还记得,自己选择程序员的初衷。
      从高考结束到现在,从开始有编程的思想到现在;从我接触游戏到现在,从我第一次开始想,这个程序到底是怎么完成的到现在。
      我开始 写Hello World的时候,只知道一个public  static  void main(String st[]){};然后慢慢的,一个类,一个包,一个 项目,最后 ,一个完整的程序, 波折几百次; 最终不再出现 ANR,那 一瞬间,有一种 释然,一种轻松, 一种成就 包含当时的 心情用最丰盛的庆功会 诠释了这一切。
       
      到我 第一次真正的去理解 一个项目,做一个 项目,一个社交 项目。
    一个我 做了一整年的 项目, 在我心里,我是带着遗憾 离开的,我始终 觉得并没有把这个项目 做好,并没有去真正的体现项目的精髓所在, 社交所在;可能我当时 不明白 产品 的思想,  但我思想 倾向于技术不成熟。我觉得一个 好的程序员是要将 产品思想准确 转变成代码的 一个转变器, 所以,当时的心情 也就是我说的 思想一半,技术 一半完成了一个让我非常遗憾的产品。
       
     所以 我会在想,我要在下一个产品做到最好, 做到让自己 百分之百的满意,让这个应用能够 被 许多人认可。 在我 希望下一个产品 能够用得上 社交,用的上sdk的 时候,我真的 等来了一个纯社交的产品;其实我 心里在想,我 要深度去集成 环信sdk,可能我不知道sdk 里面的内容是 什么,并不知道sdk的原理;我却接到了一个自己写即时通讯 项目,以及去实现屏幕同步的项目。
      当我听到了自己做这些 东西的 时候,我有点 失望,因为我不能够去好好的,认真的去看,去 实现环信sdk的 即时通讯。
      初衷,也许来源于一个 后来才 发现自己喜欢, 愿意跟人去沟通 的自己。
      这也就是我 从失望慢慢 变成期待,去体验实现 这个过程的 每一步,我喜欢这样的感觉。
      今天早上在车上我想到了一句话,也许,我依然在走环信 即时通讯的路
      收起阅读 »

    环信公开课第十期--环信3.0实时音视频解析

      环信的小伙伴们,还记得那天夕阳下的奔跑吗?“环信直播课堂”回来了!久别半年,环信直播课堂已经升级完成,更名为“环信公开课”,不止在社区,这次加入了微信、QQ群等各种互动,更多新玩法等待你来发掘! 最接地气的实时音视频技术,环信SDK3.0实时音视频解析-...
    继续阅读 »
     
    环信的小伙伴们,还记得那天夕阳下的奔跑吗?“环信直播课堂”回来了!久别半年,环信直播课堂已经升级完成,更名为“环信公开课”,不止在社区,这次加入了微信、QQ群等各种互动,更多新玩法等待你来发掘!

    最接地气的实时音视频技术,环信SDK3.0实时音视频解析--环信公开课第十期

    ea8a5d9.jpg


     
    课程简介:
    APP如何快速实现实时音视频功能?

    音视频开发过程中遇到坑怎么处理?

    视频聊天想加入更多新玩法?

    环信公开课第十期(2017.3.30 19:00)●环信SDK3.0实时音视频讲解,学习环信音视频开发,让你的APP聊起来!

     
    课程看点:
    环信SDK3.0实时音视频讲解(2017.3.30 19:00)
    ☞ 如何快速实现移动端实时音视频
    ☞ APP视频聊天横竖屏切换实现
    ☞ 视频聊天录制保存实现
    ☞ P2P模式和转发模式的处理
    ☞ 自由问答
    课程说明:
    主讲嘉宾:环信Android工程师 刘立正
    参会对象:移动开发者/产品经理/APP开发
    参会时间:2017.3.30(周四)19:00
    活动形式:线上微课堂
    注意事项:联网手机|电脑均可观看

     报名地址:点击报名


    7ea7108.jpg


      收起阅读 »

    环信移动客服v5.13发布——支持接收并播放微信小视频,支持向工单系统提交工单

    客服模式 支持接收并播放微信小视频 移动客服系统支持接收并播放微信小视频。客服与微信网友聊天时,若收到对方发来的微信小视频,可以直接点击视频进行播放。视频支持全屏切换。 视频消息可以在历史会话等页面查看,并支持导出和下载(导出文件中包含视频消息的下载地址...
    继续阅读 »
    客服模式

    支持接收并播放微信小视频

    移动客服系统支持接收并播放微信小视频。客服与微信网友聊天时,若收到对方发来的微信小视频,可以直接点击视频进行播放。视频支持全屏切换。

    视频消息可以在历史会话等页面查看,并支持导出和下载(导出文件中包含视频消息的下载地址)。

    注:目前仅网页版客服工作台支持播放微信小视频,且视频来源为微信授权模式集成的微信公众号。 

    001.png


    支持向工单系统提交工单

    环信已推出功能强大的支持多人协作的在线工单系统,用于处理邮件、网页、电话渠道提交的工单。环信移动客服系统提供“工单融合”功能,与工单系统连通。

    当客户咨询的问题需要后续跟进处理时,客服可以在“工单”页面为客户创建工单,由工单系统的专家继续为客户解答问题。创建工单后,客服可以在工单详情页查看工单进度。

    环信工单系统采用私有部署的方式,如需开通“工单融合”功能(增值服务),请联系环信商务经理。 

    002.png


    管理员模式

    新增权限管理,支持自定义角色和权限


    新增权限管理功能,允许管理员自定义角色,并设置该角色可以使用的管理员模式和客服模式的页面。

    操作方法:

    1. 添加角色。进入“管理员模式 > 设置 > 权限管理”页面,点击“添加角色”按钮,输入角色名称,并保存。 

    003.png


    2. 设置自定义角色的权限。点击新添加的角色,在权限页面勾选该角色可以使用的页面,包括管理员模式和客服模式的页面,并保存。

    注:如果允许该角色查看“客服模式 > 客户中心”页面,需要同时在“管理员模式 > 设置 > 系统开关”页面打开“客服可以使用访客中心”开关。

    004.png


    3. 设置客服的角色。进入“管理员模式 > 成员管理 > 客服”页面,设置客服的角色。 

    005.png


    移动客服系统与工单系统融合

    环信已推出功能强大的支持多人协作的在线工单系统,用于处理邮件、网页、电话渠道提交的工单。环信移动客服系统提供“工单融合”功能,与工单系统连通。

    在“管理员模式 > 工单”页面,管理员可以为客户创建工单,并查看移动客服系统中已提交的所有工单及其进度。

    环信工单系统采用私有部署的方式,如需开通“工单融合”功能(增值服务),请联系环信商务经理。 

    006.png


    机器人自定义菜单支持导入/导出

    机器人自定义菜单提供菜单模版,并支持导入、导出菜单,便于对菜单进行批量维护。

    注:在菜单中添加的多媒体文件不支持导出。 

    007.png


    Web插件(访客端)

    当前版本:V43.14

    显示当前排队人数

    网页端的客户联系客服时,支持在网页端聊天窗口显示当前排队人数。桌面聊天窗口和H5网页均支持。

    显示当前排队人数为旗舰版功能,如需开通,请提供租户ID并联系环信商务经理。 

    008.png


    环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.13

     
    环信移动客服登陆地址http://kefu.easemob.com/
    收起阅读 »

    【公告】全新的环信移动客服访客端SDK正式发布

    尊敬的客户,您好!   全新的环信移动客服访客端SDK正式发布,功能全面升级,性能大幅优化:   -          支持消息传输双通道。当主通道意外受阻时,第二通道自动启用,成倍提升了系统的可靠性,确保消息必达 -          基于MSync协议,更...
    继续阅读 »
    尊敬的客户,您好!
     
    全新的环信移动客服访客端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

    1:下载demo后要看文档, 2:要记得新建lib文件夹 3:下载libHyphenateFullSDk.a并将其拖入新建的lib文件中, 4:到这步基本ok了,你可以开始浪起~ 5:感谢江南孤鹜!  
    1:下载demo后要看文档,
    2:要记得新建lib文件夹
    3:下载libHyphenateFullSDk.a并将其拖入新建的lib文件中,
    4:到这步基本ok了,你可以开始浪起~
    5:感谢江南孤鹜!
     

    环信移动客服v5.12发布——留言页面改版,支持批量分配留言

    客服模式 留言页面改版,支持批量分配留言 优化留言页面,分类显示未处理、处理中、已解决、未分配的留言;支持选择多个留言,并对留言进行批量分配。 设置方法: 勾选多个留言后,点击页面右上角的“处理”按钮,对留言进行批量分配。点击任意一条留言,可以查看留言详...
    继续阅读 »
    客服模式

    留言页面改版,支持批量分配留言


    优化留言页面,分类显示未处理、处理中、已解决、未分配的留言;支持选择多个留言,并对留言进行批量分配。

    设置方法:
    • 勾选多个留言后,点击页面右上角的“处理”按钮,对留言进行批量分配。
    • 点击任意一条留言,可以查看留言详情,回复留言、分配留言、修改留言状态。

    001.png

    【优化】进行中会话列表提示转接的会话当客服收到机器人或其他客服转接的会话时,进行中会话列表显示转接标志,提示客服这是一条转接的会话。 

    002.png

    【优化】历史会话支持根据技能组进行筛选支持根据技能组筛选历史会话,筛选结果为路由或转接到该技能组并由该技能组的客服结束的会话。【优化】客户中心支持根据客户ID模糊查询在客户中心页面,支持按客户ID模糊查询,即输入客户ID的关键字段,即可查询到该客户的详细资料。注:若在客服模式下显示“客户中心”页面,需要管理员在“管理员模式 > 设置 > 系统开关”页面打开“客服可以使用访客中心功能”开关。【优化】消息中心区分管理员通知和系统消息消息中心增加分类:管理员通知、系统消息,方便客服快速查找管理员通知。【优化】允许客服查看自己的“平均会话时长”“统计数据”页面增加“平均会话时长”指标,允许客服查看自己的“平均会话时长”。  管理员模式留言页面改版,支持批量分配留言优化留言页面,分类显示未处理、处理中、已解决、全部留言;支持选择多个留言,并对留言进行批量分配。设置方法:
    • 勾选多个留言后,点击页面右上角的“处理”按钮,对留言进行批量分配。
    • 点击任意一条留言,可以查看留言详情,回复留言、分配留言、修改留言状态。



    003.png


    新增访客标签、访客资料、坐席信息变更等“自定义事件”

    自定义事件推送新增以下事件:添加访客标签、删除访客标签、更改访客资料、坐席信息变更。当移动客服系统中出现以上事件时,可以实时推送到客户的服务器。

    设置方法:进入“管理员模式 > 设置 > 自定义事件推送”页面,创建事件推送,设置服务器地址,勾选需要推送的事件,并保存。

    自定义事件推送为增值服务,如需开通,请提供租户ID并联系环信商务经理。 

    004.png


    【优化】历史会话支持根据技能组进行筛选

    支持根据技能组筛选历史会话,筛选结果为路由或转接到该技能组并由该技能组的客服结束的会话。

    【优化】客户中心支持根据客户ID模糊查询

    在客户中心页面,支持按客户ID模糊查询,即输入客户ID的关键字段,即可查询到该客户的详细资料。

    【优化】消息中心区分管理员通知和系统消息

    消息中心增加分类:管理员通知、系统消息,方便客服快速查找管理员通知。

    【优化】工作量报表增加指标“接起次数”

    “统计查询 > 工作量”页面的客服/技能组工作量详情里,增加“接起次数”,表示客服或技能组在接起的会话中的服务次数。

    例如:一条会话被客服A接起,转接至客服B,再次转接至客服A,并由客服A结束。那么,客服A的接起会话数为1,接起次数为2;客服B的接起会话数为1,接起次数为1。 

    005.png


    【优化】工作质量报表增加无效人工会话明细

    “统计查询 > 工作质量”页面的客服/技能组工作质量详情里,增加无效人工会话明细:客服无消息、访客无消息、均无消息的无效会话数。 

    006.png


    iOS客服工作台

    当前版本:V2.1.3

    支持编辑客户资料中的“自定义字段”。

    注:如需添加“自定义字段”,请登录网页版客服工作台,进入“管理员模式 > 设置 > 客户资料自定义”页面进行设置。

    关于更多iOS客服工作台的更新日志,请查看iOS 客服工作台 更新日志
     
    PC客服工作台

    当前版本:V2.0.2017.02284

    支持消息提示、弹窗和闪烁分开控制,保存文件时支持选择存储路径。

    关于更多PC客服工作台的更新日志,请查看PC 客服工作台 更新日志

    Web插件(访客端)

    当前版本:V43.13

    新增消息预知功能,与网页端客户聊天时,在会话面板显示客户的输入状态及正在输入的内容(下图),使客服能够更高效地解答客户的疑问。  

    007.png


    消息预知功能为增值服务,如需开通,请提供租户ID并联系环信商务经理。

    关于Web插件的集成说明,请查看网页渠道集成。 
     
    环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.12

     
    环信移动客服登陆地址http://kefu.easemob.com/
    收起阅读 »

    【环信征文】做一个IOS聊天APP如何实现发送/预览文件功能

    导语 在实际项目开发中,用户之间经常需要在聊天窗口发送文件。环信官方IOS版Demo功能很强大,本文主要介绍在IOS版APP中,如何结合iCloud Drive一步步实现【发送文件】和【预览文件】的功能。 一、认识iCloud Drive i...
    继续阅读 »

    1.png



    导语


    在实际项目开发中,用户之间经常需要在聊天窗口发送文件。环信官方IOS版Demo功能很强大,本文主要介绍在IOS版APP中,如何结合iCloud Drive一步步实现【发送文件】和【预览文件】的功能。




    2.png



    一、认识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功能。



    3.png


    2.授权APP使用iCloud服务:选中【Capabilities】标签,点击开关启用【iCloud】服务,勾选【Services】组中的【iCloud Documents】项,下面的容器【Containers】项会自动选上,如下图所示:

    4.png



    授权与容器

    容器是存放在服务器的保存所有app数据的一个概念性位置,分为公有数据库与私有数据库。


    5.png



    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.显示【发送文件】按钮:在聊天窗口的扩展面板中增加【发送文件】的按钮。

    6.png


    (1).在EaseChatBarMoreView.h增加如下代码(具体代码可下载源码参考,文章底下有下载链接):

    7.png



     (3).编译运行项目,进入单聊页面,打开页面底下的扩展面板,就可以看到【发送文件】的按钮已经可以显示出来了。

    8.png


    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文件中添加如下代码:

    10.png


    在EaseSDKHelper.h添加如下代码:

    11.png


    在EaseBubbleView+File.m文件中添加如下代码:

    12.png


    编译运行,效果如下图所示:

    14.png


     
    四、实现【预览文件】功能

    点击聊天窗口中的文件类型消息,使用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>

    点击聊天窗口后,查看文件的效果如下图所示:


    15.png


     
     
    这样,一个简单的发送、预览文件功能就完成了。

    技巧:如何参考代码实现功能


    在百度网盘中下载本项目的完整源码,然后在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)运了。    想走向人生巅峰先看这里  ...
    继续阅读 »
     大家静一静,静一静!能听本人发几句言吗,好吧,能容小弟说一大堆废话吗?这里可以施展你的才华!别走啊客官,这里还有大量金钱!这就对了,您坐好了,听我说
     
    环信稿酬计划,恭喜你,环信能够与你相遇,算得上是走了(狗shi)运了。 
     
    想走向人生巅峰先看这里 
     
    1、投稿文章内容必须是关于“环信”、“移动开发”和“人工智能”之类的。这里的“环信”可以是即时通讯云,也可以是“环信移动客服”,当然也可以是“大数据、人工智能”等等,智者见智,希望看到你脑洞大开的良心佳作。
     
    2、文章字数在1000至5000为宜。大神作品另论。显然这里“大神”指的是江南孤鹜午夜狂魔以及李理等作者,或者自认为技术水平超过他们的,也请不吝赐教。
     
    3、文章名字统一规定为《【环信征文】|XXXXXXX》,方便审稿以及打赏。“环信”两个字还是要写在题目上的,万一通过了呢!) 
     
    4、文章投稿至我要上周刊。被收录即代表你已通过。  
     
    5、投稿文章数量不限。但文章必须是2017年3月之后创作的作品。没错,我们就是这么喜新厌旧。优秀作品会被立即推至环信官网资讯首页,并且有机会被收录至环信开发者周刊、环信博客、环信微博以及环信公众号等平台。 
     
    以下内容并不重要,忙的朋友可以退出本文。
    文章审核通过,将获得50~500人民币现金奖励(注:具体金额根据文章阅读数和评委评分比重各占一半!阅读数是重要的参考信息,最终决定权依然在评委手中。)


    图片1.png


      收起阅读 »

    java使用Jersey调用rest api实例

    Jersey的各个版本使用方法都不一样,而且网上对Client的解释也不多。折腾了一天,终于搞定了。 我用的版本是1.19,需要以下几个jar包: 首先要写一个网络访问类,这里举了post和delete的例子,其他的方法基本相同:import ja...
    继续阅读 »
    Jersey的各个版本使用方法都不一样,而且网上对Client的解释也不多。折腾了一天,终于搞定了。
    我用的版本是1.19,需要以下几个jar包:


    QQ截图20170314134106.png


    首先要写一个网络访问类,这里举了post和delete的例子,其他的方法基本相同:
    import java.util.HashMap;
    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;
    }
    }
    }
    使用的例子(创建群组方法,注意这里面使用了设置参数和Header的方法):
    	/**
    * 创建群组
    * @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等均引入了聊天机器人,所以毋庸置疑,聊天机器人将成为...
    继续阅读 »
      2016年作为人工智能元年,聊天机器人(Chatbot)在各大行业的应用方兴未艾。据统计,2016年有超过3万个聊天机器人品牌和6千个相关技能涌入市场。国外包括Facebook Messenger、Slack等均引入了聊天机器人,所以毋庸置疑,聊天机器人将成为我们未来生活中不可或缺的一部分。近日,IMGEEK开源社区热心开发者&朝阳区群众“晨星桑”一言不合他就花2小时写了一个Slack的聊天机器人,猛戳“阅读原文”下载GitHub源码。


    c558601.jpg



     简介
    什么是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,如下图

    001.png


    什么是环信移动客服

       环信移动客服是一款国内领先的全媒体智能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就创建好啦

    002.png


    初始化设置你的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进行事件订阅验证的请求体
    {
    "type": "url_verification",
    "challenge": "xxxxxxxxxxxxxx",
    "token": "slack verify token"
    }
    Step 2: 处理消息的事件

       当我们想处理用户发来的消息的时候,我们需要处理消息的事件订阅。
    最外层的type为event_callback,event为事件内容,根据event中的type能区分event的类型,channel为消息投递的channel id,user为发送人的id,text为消息的内容
    要小心:如果是机器人发送的消息,event中会有bot_id,如果处理不当会导致消息循环发送(不要问我怎么知道的……)

    Step 3: 把消息发送到移动客服
    Step 3.1: 获取发消息的Token
    方法:POST
    地址:https://a1.easemob.com/{org}/{app}/token

    请求体:
    {
    "client_id": "client_id",
    "client_secret": "client_secret",
    "grant_type": "client_credentials"
    }
    响应体:
    {
    "accessToken": "ABCDEFG"
    }
    Step 3.2:发送文本消息

    from为发送消息的人,目前以slackTeamId_slackUserId_slackChannelId为格式
    方法:POST
    地址: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": "这里填写公司名称,也可以为空"
    }
    }
    }
    }
    Step 4: 让Slack App接收移动客服消息

       如果你想使用移动客服回调的方式接收消息,你需要跟你的移动客服客户经理申请开通;当然你也可以使用环信即时通讯云的SDK开发,使用长连接接收消息。
    以下以回调模式举例,简单的集成只需要关心如下几个字段

    • eventType应该为chat

    • from 消息的发送人,应该就是imServiceNumber,要跟集成移动客服的保持一致

    • payload是消息的内容,简单的可以先支持txt类型的

    • to是接收消息的人,目前以slackTeamId_slackUserId_slackChannelId为格式


    消息回调的body
    {
    "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"
    }
    Step 5: 处理OAuth回调

    OAuth回调的时候,slack会传给我们一个code,这个code相当于一个临时令牌,来换取accessToken等信息,下面的API就是如何使用code来获取这些信息
    方法:POST
    地址: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"
    }
    Step 6: 把收到的消息发给Slack 用户

       发消息需要以SlackBot的身份发送,需要根据teamId获取到SlackBot,获取bot的accessToken。什么时候能获取到SlackBot信息呢?在Slack用户安装App的时候,进行完OAuth认证,Slack Bot信息就会通过 OAuth 回调传给我们了
    方法:GET
    地址:https://slack.com/api/chat.postMessage
    请求参数:
    token botAccessToken
    channel 填写用户名中的channelId
    text 消息内容
    as_user 设置为true,就会以bot身份显示,而不是app
    至此你就可以在移动客服中和Slack中的用户聊天啦
    如果还需要获取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 收起阅读 »

    环信CEO刘俊彦荣获2016年度最具技术领导力人物奖

        作为英国伦敦大学的计算机硕士,有着17年程序员生涯的环信CEO凭借打造环信即时通讯云和环信移动客服两款现象级的SaaS产品获得由<互联网周刊>颁发的"2016年度最具技术领导力人物奖"。      同时近期环信也在资...
    继续阅读 »
        作为英国伦敦大学的计算机硕士,有着17年程序员生涯的环信CEO凭借打造环信即时通讯云和环信移动客服两款现象级的SaaS产品获得由<互联网周刊>颁发的"2016年度最具技术领导力人物奖"。
     
       同时近期环信也在资本市场获得了极大的追捧,3月8日获得由经纬领投的C轮103000000元人民币融资。环信创始人、CEO刘俊彦表示:“本轮融资资金将用于环信BI和AI层的产品打磨、完善生态圈建设以及提升垂直行业解决方案能力,继续在追求卓越的技术道路上深耕。

    ea790d9dly1fdgtk2bckzj20m80ermyq.jpg




    ea790d9dly1fdgtk0oavij20qo0zkk0j.jpg




    ea790d9dly1fdgttvdvufj20zk0qok2g.jpg


      收起阅读 »

    炸窝了,苹果禁止使用热更新

    今天一早,不少iOS开发群都炸窝了,原因是部分iOS开发者收到了苹果的警告邮件: 有开发者质疑可能是项目中使用了JSPatch、weex以及ReactNative等热更新技术。对于修复bug提交审核的开发者来说,热更新技术可以帮开发者避免长时间的...
    继续阅读 »
    今天一早,不少iOS开发群都炸窝了,原因是部分iOS开发者收到了苹果的警告邮件:

    001.png


    有开发者质疑可能是项目中使用了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的同学来说,这是个悲惨的故事

    002.png




    003.png


    从苹果的角度看,禁止应用使用热更新技术更多是为了保护用户隐私、数据安全以及其全力打造的生态圈。对于用户来说,出于安全起见,应谨慎授予应用权限;对于开发者来说,为了审核以及长远的用户体验考虑,不要轻易触碰苹果拉的那条红线。

    004.png


    以上内容来源于CocoaChinaGitHub 收起阅读 »

    Android ios V3.3.0 SDK 已发布,增加群组、聊天室管理员权限

     Android​ V3.3.0 2017-03-07   新功能: 群组和聊天室改造:增加管理员权限,新增禁言,增减管理员的功能,支持使用分批的方式获取成员,禁言,管理员列表,支持完善的聊天室功能。新增加API请查看链接3.3.0 api修改优化dns...
    继续阅读 »


    7658.jpg_wh860_.jpg


     Android​ V3.3.0 2017-03-07
     
    新功能:
    1. 群组和聊天室改造:增加管理员权限,新增禁言,增减管理员的功能,支持使用分批的方式获取成员,禁言,管理员列表,支持完善的聊天室功能。新增加API请查看链接3.3.0 api修改
    2. 优化dns劫持时的处理
    3. 增加EMConversation.latestMessageFromOthers,表示收到对方的最后一条消息
    4. 增加EMClient.compressLogs,压缩log,Demo中增加通过邮件发送log的示例
    5. libs.without.audio继续支持armeabi,解决armeabi-v5te的支持问题


    bug 修订:
    1. 修复2.x升级3.x消息未读数为0的bug
    2. Demo在视频通话时,主叫方铃声没有播放的问题
    3. Demo在视频通话时,主叫方在建立连接成功后,文字提示不正确
    4. Demo在聊天窗口界面,清空消息后,收到新的消息,返回会话列表,未读消息数显示不正确
    5. 修复在Oppo和Vivo手机上出现的JobService报错。
    6. EMGroupManager.createGroup成员列表数超过512产生的overflow错误
    7. 修复部分手机在网络切换时发消息慢的bug

     
    ios V3.3.0 2017-03-07
     
    新功能:
    1. 新增:群组改造,增加一系列新接口,具体查看iOS iOS 3.3.0 api修改
    2. 新增:获取SDK日志路径接口,将日志文件压缩成.gz文件,返回gz文件路径,[EMClient getLogFilesPath:]
    3. 更新:使用视频通话录制功能时,必须在开始通话之前调用[EMVideoRecorderPlugin initGlobalConfig]


    优化:
    1. 优化DNS劫持时的处理
    2. 切换网络时,减小消息重发的等待时间


    修复:
    1. 音视频通话丢包率(以前返回的是丢包数)
    2. IOS动态库用H264编码在iPhone6s上崩溃
    3. 实时音视频新旧版互通崩溃

     
    版本历史:Android SDK更新日志  ios SDK更新日志
    下载地址:SDK下载 收起阅读 »

    环信完成C轮1.03亿元融资,深耕BI和AI层!

    3月8日,企业级软件服务提供商环信今日对外宣布,完成C轮103000000元人民币融资,本轮融资由经纬领投,银泰嘉禾跟投。环信创始人、CEO刘俊彦表示:“本轮融资资金将用于环信BI和AI层的产品打磨、完善生态圈建设以及提升垂直行业解决方案能力。” ...
    继续阅读 »
    3月8日,企业级软件服务提供商环信今日对外宣布,完成C轮103000000元人民币融资,本轮融资由经纬领投,银泰嘉禾跟投。环信创始人、CEO刘俊彦表示:“本轮融资资金将用于环信BI和AI层的产品打磨、完善生态圈建设以及提升垂直行业解决方案能力。”


    QQ图片20170308135918.png


    环信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,一个有意思的具体任务,深入浅出地介绍深度学习的知识。本系列文章涉及到很多深度学习流行的模型,如CNN,RNN/LSTM,Attention等。本文为第7篇。 作者:李...
    继续阅读 »
    本系列文章面向深度学习研发者,希望通过Image Caption Generation,一个有意思的具体任务,深入浅出地介绍深度学习的知识。本系列文章涉及到很多深度学习流行的模型,如CNN,RNN/LSTM,Attention等。本文为第7篇。

    作者:李理 
    目前就职于环信,即时通讯云平台和全媒体智能客服平台,在环信从事智能客服和智能机器人相关工作,致力于用深度学习来提高智能机器人的性能。

    相关文章: 
    李理:从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条边(连接)。连接方式如下图所示:

    001.jpg


    具体来讲,对于输入图片的每一个像素,我们把它的灰度值作为对应神经元的输入。对于28×28的图像来说,我们的网络有784个输入神经元。然后我们训练这个网络的weights和biases来使得它可以正确的预测对应的数字。

    我们之前设计的神经网络工作的很好:在MNIST手写识别数据集上我们得到了超过98%的准确率。但是仔细想一想的话,使用全连接的网络来识别图像有一些奇怪。因为这样的网络结构没有考虑图像的空间结构。比如,它对于空间上很近或者很远的像素一样的对待。这些空间的概念【比如7字会出现某些像素在某个水平方向同时灰度值差不多,也就是上面的那一横】必须靠网络从训练数据中推测出来【但是如果训练数据不够而且图像没有做居中等归一化的话,如果训练数据的7的一横都出现在图像靠左的地方,而测试数据把7写到右下角,那么网络很可能学不到这样的特征】。那为什么我们不能设计一直网络结构考虑这些空间结构呢?这样的想法就是下面我们要讨论的CNN的思想。

    这种神经网络利用了空间结构,因此非常适合用来做图片分类。这种结构训练也非常的快,因此也可以训练更“深”的网络。目前,图像识别大都使用深层的卷积神经网络及其变种。

    卷积神经网络有3个基本的idea:局部感知域(Local Recpetive Field),权值共享和池化(Pooling)。下面我们来一个一个的介绍它们。

    局部感知域

    在前面图示的全连接的层里,输入是被描述成一列神经元。而在卷积网络里,我们把输入看成28×28方格的二维神经元,它的每一个神经元对应于图片在这个像素点的强度(灰度值),如下图所示:

    002.jpg


    和往常一样,我们把输入像素连接到隐藏层的神经元。但是我们这里不再把输入的每一个像素都连接到隐藏层的每一个神经元。与之不同,我们把很小的相临近的区域内的输入连接在一起。

    更加具体的来讲,隐藏层的每一个神经元都会与输入层一个很小的区域(比如一个5×5的区域,也就是25个像素点)相连接。隐藏对于隐藏层的某一个神经元,连接如下图所示:

    003.jpg


    输入图像的这个区域叫做那个隐藏层神经元的局部感知域。这是输入像素的一个小窗口。每个连接都有一个可以学习的权重,此外还有一个bias。你可以把那个神经元想象成用来分析这个局部感知域的。

    我们然后在整个输入图像上滑动这个局部感知域。对于每一个局部感知域,都有一个隐藏层的神经元与之对应。为了具体一点的展示,我们首先从最左上角的局部感知域开始:

    004.jpg


    然后我们向右滑动这个局部感知域:

    005.jpg


    以此类推,我们可以构建出第一个隐藏层。注意,如果我们的输入是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。

    上面描述的网络结构只能检测一种局部的特征。为了识别图片,我们需要更多的特征映射。隐藏一个完整的卷积神经网络会有很多不同的特征映射:

    006.jpg


    在上面的例子里,我们有3个特征映射。每个映射由一个5×5的weights和一个biase确定。因此这个网络能检测3种特征,不管这3个特征出现在图像的那个局部感知域里。

    为了简化,上面之展示了3个特征映射。在实际使用的卷积神经网络中我们会使用非常多的特征映射。早期的一个卷积神经网络——LeNet-5,使用了6个特征映射,每一个都是5×5的局部感知域,来识别MNIST数字。因此上面的例子和LeNet-5很接近。后面我们开发的卷积层将使用20和40个特征映射。下面我们先看看模型学习到的一些特征:

    007.jpg


    这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区域里激活值最大的值,如下图所示:

    008.jpg


    注意卷积层的输出是24×24的,而池化后是12×12的。

    就像上面提到的,卷积层通常会有多个特征映射。我们会对每一个特征映射进行max-pooling操作。因此,如果一个卷积层有3个特征映射,那么卷积加max-pooling后就如下图所示:

    009.jpg


    我们可以把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直接的一个数字:

    010.jpg


    这个网络的输入的大小是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):

    011.jpg


    在这种网络结构中,我们可以认为卷积和池化层可以学会输入图片的局部的空间特征,而全连接的层整合全局的信息,学习出更抽象的特征。这是卷积神经网络的常见结构。

    下面是代码:
    >>> net = Network([
    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)
    【注意图片的大小,开始是(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】

    这个模型得到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([
    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)
    【注意图片的大小,开始是(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)。然后是全连接的层】 
    这个模型得到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.py
    expand_mnist.py这个脚本就会扩展数据。它只是简单的把图片向上下左右各移动了一个像素。扩展后训练数据从50000个变成了250000个。
     
    接下来我们用扩展后的数据来训练模型:
    >>> expanded_training_data, _, _ = network3.load_data_shared(
    "../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)
    这个模型的准确率是99.37%。扩展数据看起来非常trival,但是却极大的提高了识别准确率。

    改进版本5

    接下来还有改进的办法吗?我们的全连接层只有100个神经元,增加神经元有帮助吗? 作者尝试了300和1000个神经元的全连接层,得到了99.46%和99.43%的准确率。相对于99.37%并没有本质的提高。

    那再加一个全连接的层有帮助吗?我们了尝试一下:
    >>> 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),
    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)
    在第一个全连接的层之后有加了一个100个神经元的全连接层。得到的准确率是99.43%,把这一层的神经元个数从100增加到300个和1000个得到的准确率是99.48 %和99.47%。有一些提高但是也不明显。

    为什么增加更多层提高不多呢,按说它的表达能力变强了,可能的原因是过拟合。那怎么解决过拟合呢?一种方法就是dropout。drop的详细解释请参考这里。简单来说,dropout就是在训练的时候随机的让一些神经元的激活“丢失”,这样网络就能学到更加鲁棒的特征,因为它要求某些神经元”失效“的情况下网络仍然能工作,因此就不会那么依赖某一些神经元,而是每个神经元都有贡献。

    下面是在两个全连接层都加入50%的dropout:
    >>> 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=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)
    使用dropout后,我们得到了99.60%的一个模型。

    这里有两点值得注意:
    1. 训练的epoch变成了40.因为dropout减少了过拟合,所以我们不需要60个epoch。
    2. 全连接层使用了1000个神经元。因为dropout会丢弃50%的神经元,所以从直觉来看1000个神经元也相当于只有500个。如果过用100个神经元感觉太少了点。作者经过验证发现有了dropout用1000个比300个的效果好。


    改进版本6

    ensemble多个神经网络。作者分别训练了5个神经网络,每一个都达到了99.6%的准确率,然后用它们来投票,得到了99.67%准确率的模型。

    这是一个非常不错的模型了,10000个测试数据只有33个是错误的,我们把错误的图片都列举了出来:

    012.jpg


    图片的右上角是正确的分类,右下角是模型的分类。可以发现有些错误可能人也会犯,因为有些数字人也很难分清楚。

    【为什么只对全连接的层使用dropout?】

    如果读者仔细的阅读代码,你会发现我们只对全连接层进行了dropout,而卷积层没有。当然我们也可以对卷积层进行dropout。但是没有必要。因为卷积层本身就有防止过拟合的能力。原因是权值共享强制网络学到的特征是能够应用到任何位置的特征。这让它不太容易学习到特别局部的特征。因此也就没有必要对它进行的dropout了。

    更进一步

    感兴趣的读者可以参考这里,列举了MNIST数据集的最好结果以及对应的论文。目前最好的结果是99.79%

    What’s Next?

    接下来的文章会介绍theano,一个非常流行的深度学习框架,然后会讲解network3.py,也就是怎么用theano实现CNN。敬请关注。
      收起阅读 »

    两会专题报道:从北京到天津,他们改变了什么?看环信天津研发中心负责人赵贵斌的花样人生

        年近40岁的北京人赵贵斌,去年10月带着组建环信天津研发中心的任务来到天津于家堡,和同事们一起,让已经在北京生根的环信延伸到天津。早在来天津之前,这名清华北大双料高材生就已经属于“外溢”的人才,曾跟随妻子在宁波工作过几年。他是跟随着工作走,也是跟随着自...
    继续阅读 »


    0.jpg


        年近40岁的北京人赵贵斌,去年10月带着组建环信天津研发中心的任务来到天津于家堡,和同事们一起,让已经在北京生根的环信延伸到天津。早在来天津之前,这名清华北大双料高材生就已经属于“外溢”的人才,曾跟随妻子在宁波工作过几年。他是跟随着工作走,也是跟随着自己的心走。 如今,环信正在快速地发展,招聘到更多的人才,是他面临的最大难题。

    0_(1).jpg


      对于家堡这个地方,赵贵斌有着很高的评价。他和妻子居住在公寓,周围有购物中心、菜市场、高铁站,干什么都很方便。平时闲下来,赵贵斌还喜欢在河边散步、跑步。这周边人少,安静,不堵车,他说在这样的环境中,他能够很快地高效率地投入到工作,更能有许多独立思考问题的时间。 

    0_(2).jpg


      如今,赵贵斌的公司正在快速地发展,招聘到更多的人才,是他们面临的最大难题。赵贵斌希望,那些盲目地想要去“北漂”的年轻人,应该好好地算一笔账,说不定算明白之后就会发现,北京之外,还有更多更明智的选择。 (来自:腾讯大燕网·天津站) 收起阅读 »

    环信移动客服v5.11发布——机器人渠道设置变更为路由规则“渠道指定”,管理员可以发通知给所有客服

    客服模式 【优化】历史会话支持查询会话转接情况 在“历史会话”页面可以查看会话是否经过转接,并且可以根据“是否转接”对会话进行筛选。 【优化】导出管理提供下载记录 在“导出管理”页面,可以查看每个导出文件的下载记录,包括客服名称、下载时间、IP地址。 ...
    继续阅读 »
    客服模式

    【优化】历史会话支持查询会话转接情况

    在“历史会话”页面可以查看会话是否经过转接,并且可以根据“是否转接”对会话进行筛选。

    【优化】导出管理提供下载记录

    在“导出管理”页面,可以查看每个导出文件的下载记录,包括客服名称、下载时间、IP地址。

    管理员模式

    机器人渠道设置变更为路由规则“渠道指定”

    机器人渠道设置从“智能机器人 > 机器人设置”页面转移到“设置 > 会话分配规则”页面的路由规则“渠道指定”,并增加为渠道指定技能组。

    数据迁移

    版本更新后,原来的“机器人开关”中渠道设置将完全同步至“渠道指定”,工作时间设置变化为:
    • “全天接会话”:对应“全天指定机器人”;
    • “上班时间客服全忙以及下班时间接会话”和“仅下班时间接会话”:均对应“上班时间不指定”和“下班时间指定机器人”。
    渠道指定通过“渠道指定”为APP、网页、微信、微博四种渠道分别指定机器人或技能组,可以进行全天统一指定,或者分上下班时间指定。增加“渠道指定”后的路由规则更灵活,可以通过调整优先级和全天/分上下班指定机器人或技能组,搭配出各种路由方式,满足更多定制化的需求。

    001.png

    各路由规则说明如下:
    • 渠道指定:为APP、网页、微信、微博这四种渠道分别指定机器人或技能组,支持全天指定和分上下班时间指定。
    • 关联指定:为各个渠道内的关联分别指定机器人或技能组,支持全天指定和分上下班时间指定。
    • 入口指定:通过网页和APP的访客端指定会话分配的技能组。
    • 默认指定:将没有指定技能组的会话统一分配至未分组。
    优先级说明如下:
    • 渠道指定、关联指定、入口指定这三种路由规则可以上下拖动,排在上方的路由规则优先级高;
    • 当优先级高的路由规则指定机器人,优先级低的路由规则指定技能组,那么,机器人转人工时,分两种情况:
    [list=1]
  • “智能机器人 > 机器人设置 > 自动回复 > 转人工设置”页面设定了“转人工指定技能组”时,机器人转人工的会话将分配给此处指定的技能组;
  • “转人工设置”页面未指定技能组时,机器人转人工的会话将分配给优先级低的路由规则指定的技能组。
    • 当优先级高的路由规则指定技能组,优先级低的路由规则指定机器人,那么,会话由指定的技能组接待,不会再转给机器人。


    管理员可以发通知给所有客服

    在管理员模式的“消息中心”页面,管理员可以向客服团队成员发布通知,内容可以是文字或附件,通知将展示在收件人的消息中心。

    在“消息中心”页面,点击“发送新通知”,从右侧选择客服同事,并填写主题、内容或添加附件,点击“发送通知”,向收件人发送一条通知。

    002.png


    新增技术支持模块

    移动客服系统新增“技术支持”页面,提供查看文档、常见问题的快捷入口,网络检测功能,以及联系环信官网客服的按钮。


    003.png


    【优化】机器人菜单增加“返回上一级”选项

    支持创建4级机器人菜单,第2-4级菜单增加“返回上一级”选项,优化机器人接待时的用户体验。

    004.png


    【优化】增加开关控制仅机器人接待会话时是否自动发送满意度评价邀请

    在“设置 > 系统开关”页面,当“会话结束自动发送满意度评价邀请”开关打开时,可以进一步选择仅机器人接待会话时,是否自动发送满意度评价邀请。

    005.png


    【优化】客户资料自定义中系统字段默认打开且不可关闭

    在客户资料自定义页面,系统字段默认打开且不可关闭,避免误操作。原本处于关闭状态的系统字段会自动打开,并显示在“资料”页签。

    说明:仍然可以手动控制自定义字段的“字段开关”。

    007.png


    【优化】客户中心导出文件包含“自定义字段”

    在“客户中心”页面,点击“导出基本资料”按钮,可以导出客户的基本资料,导出文件中包含在“设置 > 客户资料自定义”页面添加并打开的自定义字段。

    【优化】模糊搜索支持导出会话

    在“搜索”页面,对会话进行搜索后,可以点击“导出”按钮,导出搜索结果。

    生成导出文件后,请前往“导出管理”页面下载。

    【优化】历史会话支持查询会话转接情况

    在“历史会话”页面可以查看会话是否经过转接,并且可以根据“是否转接”对会话进行筛选。

    管理员模式下,可以在会话详情页查看详细的转接记录。

    【优化】导出管理提供下载记录

    在“导出管理”页面,可以查看每个导出文件的下载记录,包括客服名称、下载时间、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制胜之道本就不在工具层!

        走进环信的办公室,一层看起来人数不算太多,都是市场和销售人员,另一层的工位则几乎全满,全都是技术人员。当我诧异于环信的技术团队如此庞大的时候,刘俊彦仿佛看穿了我的心思,这位CEO笑着说到:“我们相信,技术实力能让我们走得更远。”    环信从20...
    继续阅读 »
        走进环信的办公室,一层看起来人数不算太多,都是市场和销售人员,另一层的工位则几乎全满,全都是技术人员。当我诧异于环信的技术团队如此庞大的时候,刘俊彦仿佛看穿了我的心思,这位CEO笑着说到:“我们相信,技术实力能让我们走得更远。”

       环信从2013年成立,从一开始的“即时通讯云”到移动客服,再到入选“Gartner 2016Cool Vendor”,环信的成绩着实令人惊诧。直到上周五,牛透社获取了来自Gartner和环信联合发布的《下一代客户服务软件趋势》报告,我们开始重新审视客服领域,重新审视带给我们诸多震撼的环信。

    连接:从即时通讯云到人工智能,让连接价值更深入

       “目前的环信,其实有三条产品线,分别是:即时通讯云、移动客服、人工智能。”当我问及环信当下的产品结构时,刘俊彦给出了这样的答案。

       环信在2013年成立时,开始做即时通讯云,让企业的APP拥有像微信一样的聊天沟通能力,这个场景是“连接人和人”。

       但当产品上线后,他们很快发现,IM其实天然的还适用在第二个场景——“连接人和商业”。最典型的例子就是淘宝旺旺。旺旺作为一款聊天工具,很好地连接了消费者和企业客服,这样的场景,用户黏性会更强,在商业上的价值也更高。这个场景离企业更近,也更适合商业化,环信移动客服应运而生,其核心就是用即时通讯工具连接人和商业。随后,环信又推出了全媒体客服,让用户可以通过各种通道联系到企业。

       实际上,牛透社此前对于环信的印象就止步于此了,看起来,环信似乎就是做了这两件事情,并凭此声名大噪。但刘俊彦说,环信还有第三条产品线——环信人工智能。

       当环信在做移动客服的时候,发现他们在一开始构建的“蓝图”其实还不够完美,有个最大的问题——当你真的将工具做得很出色,让任何一个消费者在任何地点都能很容易地与企业聊天沟通的时候,所有的压力和责任就会转移到商家身上。在手机时代,每个人都能24小时联系到商家,但商家的客服人员其实是有限的,用户需求的激增难以满足,所以回过头来看,这样的客服工具并没能真的提升用户体验,反而让商家倍感压力。

       “我们希望能通过人工智能解决商家日益高涨的客服成本和用户需求不断增长的天然矛盾,人工智能显然是绝佳的手段。”刘俊彦这样评价道人工智能产品线。

    1488254559811329.jpg


    环信创始人兼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正在吃掉软件……其中有些观点引起了笔者的兴趣,笔者也查找梳理了这家近两年在移动客服领域风生水起的创业企业的资料。全媒体客服、客服移动化、智能化、...
    继续阅读 »


    c8ea15ce36d3d539138996423387e950342ab0ff.jpg


       从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这样的巨头通常是不出来分享经验的。

    2934349b033b5bb56863b97f3fd3d539b700bcff.jpg


    从核心竞争力看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的商业文化、社交文化,也让环信移动客服获得了快速发展。

    a8014c086e061d956164aeb972f40ad162d9ca15.jpg


       2017年,环信的重点是加大二三四层核心竞争力的建设:将数据产品做得更好,在生态圈建设方面继续建设自身的PaaS平台,并在AI层面加大投入。

       总的来说,刘俊彦认为客服是中国企业级服务市场六大核心赛道——客服云、市场云、销售云、HR云、财务云、协同云之一,环信希望能够在这一赛道上深耕细作并筑起足够高的竞争壁垒,成为像Salesforce一样的SaaS企业巨头。 收起阅读 »

    Gartner联合环信发布《下一代客户服务软件趋势报告》

        呼叫中心(客户中心)行业在中国近二十年来发展迅猛。在中国的发展路径是遵循呼叫、接触、互动(call center -> contact center -> interaction center)的过程。而新时代的客户互动又注入了客户契合互动...
    继续阅读 »
        呼叫中心(客户中心)行业在中国近二十年来发展迅猛。在中国的发展路径是遵循呼叫、接触、互动(call center -> contact center -> interaction center)的过程。而新时代的客户互动又注入了客户契合互动(engagement)的内涵,客户中心正在发生深刻的变革。随着国内以环信为典型代表的一批新一代全媒体智能SaaS客服企业的蓬勃发展,全媒体智能客服已经逐渐成为客服行业的标配。中国移动互联网发展之迅猛已经领先世界,社交媒体爆发以及专门为移动端提供SaaS客服的厂商纷纷崛起,促进了全媒体客服领域的高速发展。自2014年以来,中国SaaS客服领域吸引了大量的投资者,投资总额近1亿美元,同时BAT等互联网巨头也已经入场。

       经过数十年的沉淀,客户中心正从话音呼叫中心、网页端客服向全媒体架构的统一客服平台升级,经过2015年的启动期,2017年全媒体客户中心将进入高速发展期,同时以环信为首的一批中国SaaS客服企业取得了井喷发展。在北美,2015年客服软件市场采购总额高达96亿美元,这个市场中孕育了多家备受业界关注和追捧的公司,如Salesforce和Zendesk。

       作为中国全媒体智能客服的倡领者,环信CEO刘俊彦认为:“未来以移动端为核心的全媒体接入、跨媒体、跨渠道、跨部门的客户服务体验,以及智能客服机器人将成为下一代客服软件的三大核心驱动力。”客户中心经过多年的发展,从单一的语音服务渠道进化成为多介质的全媒体服务渠道,并最终将发展成整合传播服务、营销、销售和产品用户体验为一体的互动中心。未来客户中心将以“体验”为核心视角,描绘一副全媒体接入、人工智能驱动、大数据升华,参与企业全要素、全流程运作的服务蓝图。


    23期_相关推荐里.jpg



       Gartner 认为技术创新有时领先于客户需求,有时滞后于客户需求。在本报告中,我们采用步调分层这一方法从以下三个层次对现有和新兴技术要求做了分析:记录系统、创新系统、差异化系统。对于客户服务,我们划分了下列层次:


    33cff40.jpg


    核心问题是:如何将步调层次中的技术转化为承载客户服务和支持的 IT 项目。需要着力解决的核心问题是组建合适的团队以开展企业级客户服务和支持。团队至少需要包括:

    IT 架构师和应用设计师——配合核心客户服务。

    一位业务主管——代表下列客户旅程: 从潜在客户显现需求至他们成为既有客 户,乃至增销和续订。

    数据和分析专家——提出可用的分析方 案,说明如何创建相关的控制面板和基 准。他们了解如何捕捉有关客户服务和 支持的成功标准,并提高相关的分析成 熟度。

    营销专业人士——分享他们在对业务的目 标新客户群体进行旅程分析和行为观察 方面所取得的经验。

       我们提供基于客户交互的共同路线图。每个公司都应该使用相关的步调层次工具包来创建自己的三至五年路线图,以便从当前过渡到未来状态。虽然工具包并不产生路线图,但它确实可以为制定 IT 计划奠定良好的基础。 


    00dc3a0.jpg


    01·最佳实践:全媒体客服核心在于移动端接入,移动端客服最佳体验是基于IM
     
       移动互联时代,客户正转移至移动端,服务需要紧跟客户步伐。Gartner报告指出:“消费者对移动设备的偏好正在快速发展,对于一些行业而言,到2019年,移动设备的使用将占到所有互联网交互的85%,如果不能改善移动客户服务,企业将遭受损失。”

       因为技术门槛高,目前仅有部分大型企业能够在移动APP上提供端到端的、完整的客户服务支持能力,但是中小型企业的部署热情高涨。同时,在社交媒体上(如Facebook、微博、微信等)入驻的企业都已经开始在平台上提供客户服务能力,相比传统的网页客服和呼叫中心,社交媒体客服更是得到年轻用户的青睐。包括移动APP内置客服、社交媒体客服、网页客服/HTML5客服、传统呼叫中心等接入的全媒体客服已是大势所趋,而全媒体接入的核心在于移动端接入。
     1、全媒体客服主流接入渠道特性

    21daa47.jpg


       当前国内的主要接入渠道包括移动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,客服也随时可以将消息以推送方式通知到手机。用户绝不会错过任何有价值的消息。

    b58330c.jpg


    示例:国美在线APP通过环信提供的APP内置客服很好的服务了上亿用户。

    2.3 移动APP内置IM(即时通讯)客服技术选型建议

    e1e5908.jpg


    附录:Gartner研究——移动端客户服务

    59d6bc5.jpg



    02·跨渠道环境下的客户服务体验是客服行业面对全媒体客服新趋势的主要挑战
     
       全媒体客服不仅只是多渠道的接入,更重要的是用户跨渠道的体验和跟踪,在海量的数据中发现问题。而要做到这一点,企业首先需要理解客户到底体验到了什么。
     
       今天,全球来看,越来越多的企业正在通过构建一个有效的客户声音系统,来透析客户对企业产品和服务的准确体验,帮助企业识别和改善客户旅程的各个阶段。对企业而言,倾听客户声音的能力决定了他们在客户体验这个领域上的竞争力。

       Gartner报告指出:“VOC是企业有关客户体验管理(CEM)战略需要考量的核心维度。CEM是个很大的话题,覆盖了企业交付給用户的客户体验的方方面面,是未来五年全球CEO所关注的排名前三的重点领域之一。”


    5de0ce9.jpg


    附录:Gartner研究——客户声音

    71b26f1.jpg


    03·智能客服机器人是下一代客服的核心驱动力
       随着全媒体客服的普及和广泛应用导致企业和消费者多点接触,同时用户体验得到了企业的重视,导致客服咨询量暴增,企业有限的客服人力资源与日益增加的客服请求之间的矛盾日益尖锐,如何用有限的客服资源服务不断增长的海量客服请求需要一个颠覆型的技术来解决。相比人工客服,智能客服机器人将提供极大的效率优势。
     
       Gartner报告指出智能客服机器人(VCA-virtual customer assistance)的使用正处于临界点。大幅改进的自然语言处理技术,以聊天为中心的移动渠道与客户互动的应用,以及客户对机器人技术的接受程度,这些因素使得人们对VCA的兴趣越來越大。

       从被动的被人类编程出来的,可以在结构化和非结构化内容库中找到问题答案的虚拟助手,到主动的有时候是机器学习得到的VCA的转变,其考察个人的特征并代表他们行动。虚拟助手正在经历从被动的被人类编程出来,在结构化和非结构化内容库中找到问题答案,到主动的通过机器学习能够理解用户个性化的需求,并且随之采取灵活应对行为的转变。
    附录:Gartner研究——虚拟客户助手

    44caab5.jpg



    QQ图片20170224171931.png

    环信移动客服——全媒体智能云客服倡领者,于2016年荣膺“Gartner 2016 Cool Vendor”。环信支持全媒体接入,包括网页在线客服、社交媒体客服(微博、微信)、APP内置客服和呼叫中心等多种渠道均可一键接入。基于环信业界领先的IM长连接技术保证消息必达,并通过智能客服机器人技术降低人工客服工作量。同时,基于人工智能和大数据挖掘的客户旅程透析产品"环信客户声音"能够帮助企业优化运营,提高跨渠道客服体验。典型用户包括国美在线、58到家、楚楚街、海尔、神州专车、新东方、链家、泰康在线、中信证券等众多标杆企业。
    如需阅读Gartner报告全文,请点击“阅读原文”查看详情。 收起阅读 »

    【公告】原有的历史消息拉取接口会按照计划在3.1日正式下线

    亲爱的环信用户:       原有的历史消息拉取接口会按照计划在3.1日正式下线(具体可参照2016年12月28日发送的相关通知),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的全选按钮、点击商品、编...
    继续阅读 »

    淘宝.gif



    ViewController: 购物车界面
    整个界面就是TableView + 底部结账栏View组成


    3899794-1ec96767b3e7afd5.png



    以店铺为section:商店下的商品为row和店铺名称组成一个 section

    定制段头的View 把section的全选按钮、点击商品、编辑的三个按钮的方法用代理的方法。

    -(UIView*)tableView:(UITableView*)tableView viewForHeaderInSection:(NSInteger)section;




    3899794-6ef9165898df88f7.png


    建议使用Masonry进行cell适配

    cell的创建就是和我们平常的一样,把要展示的样式代码编写或者xib都可以。再把数据源填充到我们所创建好的cell中和段头上。
    创建好一个View添加在TableView的下方。View上写上全选及总金额等UI。每次我们选定的物品的增减都要调用该View赋值的方法,刷新金额等字段显示。


    3899794-700992bcfec1fcb0.png


    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:

    主要由三部分组成。商品数量 + 商品种类 + 删除

    3899794-6ce64a551798a1eb.png


    商品数量用的是第三方PPNumberButton,点击时改变model中该商品的实际数量;点击商品种类进行重新选择(方法未实现,原理一样);点击删除进行cell的删除及数据源的删除。

    注释:这里的删除 及 修改 都是要对数据源进行修改在刷新的

    Model:数据源的处理及购物车内各类按钮的判断

    demo中的数据源我没有放到model中去处理,其实原理都一样,我把判断各类按钮的判断字段加到数据源中去了,如果用model模型的话,自己加上相对应的字段,并设置初始值。当进行model数据源的修改时,直接进行修改。


    3899794-272f9ea50f05377f.png


    这是一种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    取消选中




    3899794-b9d2effc299da195.png



    如果不用系统的话,则利用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! 收起阅读 »

    主流观点:呼叫中心还是客户中心?

            近年来,呼叫中心常常被称为这个时代的“白领血汗工厂”。作为一名资深的客户服务代表、主管、经理、总监,当我第一次听到这种说法时,被深深地触动了。我熟悉的很多呼叫中心都把办公场所设在比较偏远的地方,在这些地方一般人很难把工作有效地完成...
    继续阅读 »
       

    2793.jpg_wh860_.jpg


        近年来,呼叫中心常常被称为这个时代的“白领血汗工厂”。作为一名资深的客户服务代表、主管、经理、总监,当我第一次听到这种说法时,被深深地触动了。我熟悉的很多呼叫中心都把办公场所设在比较偏远的地方,在这些地方一般人很难把工作有效地完成做好,但客服工作人员却可以在电话里处理好各种任务。

       想一想,传统的呼叫中心是什么样子呢?它们通常是由一些普通格子间组成,几乎没有什么可以发挥创造力的空间。通话时长和通话处理时长都是很重要的指标,这说明每一通电话都是在处理业务而不是单纯地交流互动。另外对于呼叫中心的工作人员来说,即使公司朝着蓬勃的方向发展,客服工作人员的发展空间也是极其有限的。

       将呼叫中心转型为客户中心。有人会认为,以这样一种方式来加深企业与客户之间的关系,无异于痴人说梦。但事实真的是这样吗?其实只要做出一些调整,呼叫中心就可以变为客户中心。

       第一步也是最简单的一步是将部门名称改为客户中心。如果我们希望员工成为客户关系的建立者,那么我们应该从部门的定位开始。在这个以交易时间、任务量和呼入量来微观管理的时代,我们应该时常这样反问自己:如果我们不以最好的方式来对待我们的员工,又怎能期望他们以最好的方式来对待我们最尊贵的客人?如果做不到最好,起码得一视同仁,只可惜我们连这样也做不到,我们只是一味地强调员工是企业与客户关系的建立者以及公司产品的形象代表,仅此而已。通常情况下,呼叫中心是企业与客户仅有的互动方式。客户愿意花时间打电话给我们,是因为遇到了各种类型的问题,他们需要我们提供帮助。难道我们不想让员工在成为公司政策执行者的同时还是客户问题的解决者吗?这样不仅不会让客户的投诉不断升级,也能更好地帮旅客解决问题。

       如何管理比较合适呢?怎样能在让公司在付出最小代价的同时能够给员工最大的权限去帮客户解决问题?这就需要我们花时间培养员工提前预判的能力并在此基础上出色地完成任务,这样也能在机会来临时给客户带去惊喜。这其中的关键是需要准备,我们不能单纯地期望我们的员工认识到这些东西,也不能在一个问题上只告诉他们一次就期待他们能坚持,更不能在没有被加强训练的情况下期待他们保持正确的工作习惯。

       解决方案:创建一个世界一流的服务体系。在这样一个体系中,所有员工都能发现并及时指出不足,坚持同样的标准,在把握机会的同时不断变得更好。为了能让员工更好地处理每天发生的不同情况,我们需要花更多的时间在幕后管理他们。这允许我们监督客服代表的活动以确保团队中的每个成员不在微观管理下都能恪尽职守。这让我们的客服代表们不会压力过大、负担过重进而保持员工数量的稳定性,因为我们没有足够的人员来雇佣和培训,这对呼叫中心来说是一大难题。这听起来很棒,但我们如何把它完成好呢?我建议从以下两个步骤开始,但两者都将需要投入一定的时间和人力成本。

       第一步是让你的团队一起参与创造你的客户体验周期,这包括与团队一起研究确定客户的接触点。一旦确定了这些接触点,你就可以借此去剖析每一个人,找到可能出错的地方(服务缺陷),接下来就知道每个人和每通电话里需要做什么(操作和体验标准),同时我们能通过哪些方式来取悦客户(利用机会超越机会)。

       通过这个项目,你和你的一线团队将会大开眼界,并产生新的使命感。你的团队会重现活力并能很好地完成工作。虽然这是一个很棒的开始,你不能指望这样一个开始在没有加强巩固的基础上就能保持不断发展的势头。接下来我们该做第二步了。

       第二步是解决日常会议。在你说“这在这里永远都行不通,因为……”之前(我知道你会这样说,因为我听过所有的借口,而且我自己曾经也这样说过),可以思考下金牌服务的典范:丽思卡尔顿酒店。他们每天都会举行一个他们称之为“站起来”的会议。福来鸡也一样。这些公司已经忽略每个员工不能每天都出席会议的事实,因为他们一天有多种轮班制。他们能做的是利用好平台专注他们的服务价值,讨论并解决问题,庆祝每天成功的案例。

       结论:这个过程在成就一个伟大的团建活动的同时也会不断提升团队成员的自主性和增强他们的归属感。创建属于自己客户体验周期,并在日常活动中不断加强,这有助于给团队带去新的使命感,并让员工变成真正的客户关系的建设者。随着日常会议深入,活动并不会因时间推移而逐渐消失,而是会变成一种超越规范并深入人心的文化。

    本文刊载于《客户世界》2016年11月刊;作者Dave Murray为DiJulius集团高级客户体验顾问;译者皮晶晶为深圳航空营销委电子商务电话营销中心运营人员。 收起阅读 »

    SaaStr 2017大会启示:SaaS创业最正确的十种姿势!

    很多人感叹:“听过很多道理,却依然过不好这一生。听过很多创业鸡汤,却仍然没升职加薪...”那是因为你们还没有找到最正确的姿势。近日,环信CEO刘俊彦亲临美国SaaStr Annual 2017大会,帮你揭秘SaaS创业最正确的十种姿势!     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)。

    bb791b7.jpg


    在Jon的演讲中,他分享了作为一个SaaS的二次创业者,他认为有10个方面在重新来过时,可以做的更好。
     

    • 定义公司的愿景和核心价值观



       Jon创建Engagio公司时,第一天做的第一件事就是定义公司的愿景和价值观。以前在Marketo,这个事情在公司成立2年后才开始做。

    6c02cfa.jpg

       其实我很惊讶Jon把公司愿景和价值观放在第一。环顾四周,国内大部分创业公司是没有明确的公司愿景和价值观的。

       以环信自己为例子,环信从做IM云到客服云到AI,虽然一直明确的以连接为主线,IM是连接人与人,客服是连接人与商业,但真正正式确定公司的愿景是在去年:“连接人与人,连接人与商业,用卓越的技术改变每个人的生活和工作”。同时也定下了核心价值观。但Jon是对的,当我做了环信的愿景和价值观后,我发现我确实希望我能做的更早。
     

    • 建立有凝聚力的团队


      
       我估计Jon应该是Patrick Lencioni 的粉丝,因为这页讲的完全就是“优势(The Advantage)”这本书的内容,即建立信任->掌控冲突->兑现承诺->承担责任->关注结果。这本书确实很好,尤其建议技术出身的CEO多看看,我每次坐飞机都带着,用来帮助睡眠。

    QQ图片20170222160052.png

     

    • 用文化来驱动公司运行


     这包括一整套公司运转流程:

    招聘
    员工福利
    员工入职,学习,发展
    绩效管理
    办公环境
    企业社会责任

    QQ图片20170222160216.png

       我又一次被切中了痛点。我们的员工入职融入至今都还做的不够完美。招聘机器还有上升空间,想到我们可能每天都在错过优秀的人才,很忧虑。

       员工的绩效考核和薪酬激励制度还需要继续完善。几十个人的公司时不觉得这些制度流程的完善有多么重要,变成几百人的公司后,再补课就发现晚了。
     

    •  高效会议


     这个就不用说了。好在环信这样的工程师文化的公司还没有太多会议,程序员都讲究“code wins”
     

    •  融资策略


     
       Jon在这一次重新创业的时候,希望能够用较少轮次的融资,较少的稀释,拿到以前的Marketo更多轮次融资同样金额的钱。这当然很好,但这是创二代的特权。

       同时,Jon还说到,这一次创业,他会更重视财务管理,更重视盈利(少烧钱),他会希望B轮的钱一直用到公司盈利。

    QQ图片20170222160407.png


    • 财务管理


     
    更好的财务管理
     

    • 竞争对手分析


     
       一定要重视竞品分析。所以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简单应用 环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0 环信官方Demo源码分析及SDK简单应用-LoginActivity 环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-...
    继续阅读 »
    环信官方Demo源码分析及SDK简单应用

    环信官方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进行编码
    • 房源详情增加咨询按钮,点击进入咨询对话框,并且将房源信息带入对话框。
    • 消息中心
    [list=1]
  • 主界面TABBAR点击消息进入该界面
  • 包含系统消息入库和咨询用户列表
  • 从TABBAR点击“消息”图标进入本页面后,可以在本页面进入”系统消息“,并且将咨询过的用户会话显示在本页,长按任意一条会话,提示删除当前会话确定。
  • 列表排序:“系统通知“仍然在最上面的位置,不受排序影响
  • 咨询排序:按最后聊天时间倒序排列,咨询列表默认显示20条,多了拖动加载分页数据。
  • 无咨询用户时,只显示”系统通知“入口即可。
  • 咨询列表:长按可删除当前聊天对象,需要有确认对话框(只删除会话,不删除聊天记录)
  •  
    • 根据UE和UI改造聊天窗口(EaseUI库)
    注意以下几点[list=1]
  • 从房源详情页进入时,就返回房源详情页,从消息中心进入时,就返回消息中心。
  • 显示当前咨询人的经纪人姓名,并显示当前咨询的对象的在线状态(在线/离线)
  • 标题头中的电话按钮可以直接拨打电话
  • 对于从房源详情进入时带入的房源详情类型的聊天条目,经纪人可以点击查看该房源在STM中的详情。
  • 显示经纪人照片上传的照片,如果经纪人没有上传照片,就显示一个经纪人的占位图(要区别于用户的占位图)
  • 当前用户头像默认显示当前用户的头像,如果没有头像,就显示一个默认的占位图
  • 聊天内容上长按可复制
  • 发送的是手机号码时可以直接打电话。
  • 思路先做加法,再做减法我们来按照原有代码改造和设计环信SDK部分相关代码改造,两个部分来做工作。将具体的功能点拆分并给出实现。我们在Demo上修改,修改完成后剔除无关代码抽取成独立的我们需要的相关代码。整个工作也就结束了。通过之前的代码阅读,我们知道整个Demo是一个相对完整的App,而我们实际工作中集成个im基本出不了这个范围。就好比这次迭代也是。因为实际整个涉及的只有会话列表和聊天界面,我们主要关注ConversationListFragmentChatActivity就行了。实现SeeHouse相关改造原有代码改造房源详情增加咨询按钮,点击进入咨询对话框,并且将房源信息带入对话框。主界面TABBAR点击消息进入该界面涉及环信SDK部分相关代码改造包含系统消息入库和咨询用户列表同列表,不同type类型区分,并置顶系统消息 从TABBAR点击“消息”图标进入本页面后,可以在本页面进入”系统消息“,并且将咨询过的用户会话显示在本页,长按任意一条会话,提示删除当前会话确定。直接贴过去,Demo已经实现。 列表排序:“系统通知“仍然在最上面的位置,不受排序影响根据Type来判断类型,并排序置顶。 咨询排序:按最后聊天时间倒序排列,咨询列表默认显示20条,多了拖动加载分页数据。sort算法改一下,看下本身是否带分页。 无咨询用户时,只显示”系统通知“入口即可。无需实现。 咨询列表:长按可删除当前聊天对象,需要有确认对话框(只删除会话,不删除聊天记录)

    001.jpg

    环信的哥哥们已经帮我们实现了。但是根据要求呢,我没只需要删除会话,所以我们把第二项注释掉。

    002.jpg

    我们把对应处的判断代码和对应的menu文件em_delete_message中的标签给注释掉。看效果。

    003.jpg

    从房源详情页进入时,就返回房源详情页,从消息中心进入时,就返回消息中心。​直接finish();显示当前咨询人的经纪人姓名,并显示当前咨询的对象的在线状态(在线/离线)官方的EaseUi是这么说的

    004.png

    我们来找下EaseTitleBar

    004.jpg

    我们来看下他的布局
     <?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的。

    005.jpg

    我们来对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是在线的。注意一点

    006.jpg

    所以昵称是在咱自己的体系的。可以从现有的App里提取,如果有的话。我们知道从列表ConversationListFragment->ChatActivity->ChatFragment那么如何接受和发送自己与他人的头像和昵称呢?我们来玩这个ChatFragment

    007.jpg

    在OnSetMessageAttributes中,设置我们要发送时的消息扩展属性。那么接收怎么办呢,我们来看下DemoHelper中的getUserInfo()方法。

    008.jpg

    无聊的用鄙人蹩脚的英文写了一把注释。英文若是写的不对就不对吧。标题头中的电话按钮可以直接拨打电话修改删除按钮为打电话,并改动相关代码显示经纪人照片上传的照片,如果经纪人没有上传照片,就显示一个经纪人的占位图(要区别于用户的占位图)修改原demo当前用户头像默认显示当前用户的头像,如果没有头像,就显示一个默认的占位图修改原demo。聊天内容上长按可复制

    009.jpg

    自带了,后面我们可能需要去掉转发。发送的是手机号码时可以直接打电话。我们再长按后判断其是否为电话号码,如果是添加一项拨打电话。引用关系是这样的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;            }        }
    记住先提取字符串中的数字,再去匹配正则。

    010.jpg


    011.jpg

    STM集成在本质上是相同的。不同的是一个是用户端,一个是经纪人端标注下需要注意的几个地方
    • 头像和昵称的扩展互通,是SeeHouse和STM两边都需要做的。
    • 因为有一条对于从房源详情进入时带入的房源详情类型的聊天条目,经纪人可以点击查看该房源在STM中的详情。是在STM中单独实现的。SeeHouse负责带入,STM负责点击跳转。

    对于从房源详情进入时带入的房源详情类型的聊天条目,经纪人可以点击查看该房源在STM中的详情。

    创建图文chatrow并设置对应点击事件代码。

    集成至目标App
    不需要的代码,我们只做注释,不删除,防止后面增加了,需要了。避免一系列麻烦。
    ​剔除红包库​
    在ChatUIDemo3.0的build.gradle中注释编译红包依赖库。

    各种编译,遇到报错就删除相关代码
    剔除不需要的代码

    注意EaseUI下有个SimpleDemo

    012.jpg


    目标App集成与调试

    因为是公司的商业项目,这里就不贴出来了。接着完成需调试才能完成的功能点

    总结
    好了,至此,我们开发详案写完了,代码也写完了。因为本文写的时候UI还未出,所以后面就是根据UI改改的调整调整界面的小事情了。

    有任何问题或者其他事宜请联系我: 5108168@qq.com,欢迎指正和勘误。 收起阅读 »

    环信之Android修改圆形头像

    直接进入正题吧,在demo的EaseUi里面utils包下面有个EaseUserUtils类里面有如下代码: 然后只要在setUserAvatar这个方法里面稍作修改,可以看出用的是glide加载图片,于是我们可以写一个把图片转为圆形的类Gl...
    继续阅读 »
    直接进入正题吧,在demo的EaseUi里面utils包下面有个EaseUserUtils类里面有如下代码:


    1.PNG


    然后只要在setUserAvatar这个方法里面稍作修改,可以看出用的是glide加载图片,于是我们可以写一个把图片转为圆形的类GlideCircleTransform, 代码如下:
    public class GlideCircleTransform extends BitmapTransformation {

    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();
    }
    }
    然后稍加修改setUserAvatar方法,代码如下:
    /**
    * 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);
    }
    }
    其中default_user_head_img是我项目中的默认头像,大家改为自己的即可。
    到现在EaseConversationListFragment中的头像就变成圆形的了,但是EaseChatFragment还要修改easeui布局文件, 文件列表如下:


    2.PNG


    我们在这些资源文件中可以用于显示头像的ImageView


    3.PNG


    把android:src="@drawable/ease_default_avatar"这行 删掉即可。
    跑起来看看,是不是都是圆形头像了。
    好了,教程结束。 收起阅读 »

    环信官方Demo源码分析及SDK简单应用-EaseUI

    环信官方Demo源码分析及SDK简单应用 环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0 环信官方Demo源码分析及SDK简单应用-LoginActivity 环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-...
    继续阅读 »
    环信官方Demo源码分析及SDK简单应用

    环信官方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
     
    // 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
    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
    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;
    }
    本地文件选择、语音通话、视频通话、及自定义chatrow类型​
     
    /**
    * 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;
    }

    }
    Redpacketlibrary

    由于业务未涉及,暂不作分析。
     
    总结及其他

    其实正常集成,按照于海同学所说也就半天时间,这是因为的确环信的SDK使用起来比较方便。

    通过大致的阅读代码,环信的Demo代码写的还是很不错的,功能齐全,注释完整。值得学习和研究。

    写在最后

    多学习,多积累,多输出。!
     
    附:最近两天实际工作采用环信SDK的开发详案

    环信官方Demo源码分析及SDK简单应用-IM集成开发详案及具体代码实现
    收起阅读 »

    环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-设置界面

    环信官方Demo源码分析及SDK简单应用 环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0 环信官方Demo源码分析及SDK简单应用-LoginActivity 环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-...
    继续阅读 »
    环信官方Demo源码分析及SDK简单应用

    环信官方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() {

    @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();
    }
    });
    }
    });
    到这里主界面的三个fragment就都讲完了,我们来看重头戏。
     
    环信官方Demo源码分析及SDK简单应用-EaseUI 收起阅读 »

    环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-通讯录界面

    环信官方Demo源码分析及SDK简单应用 环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0 环信官方Demo源码分析及SDK简单应用-LoginActivity   环信官方Demo源码分析及SDK简单应用-主界面的三个fragment...
    继续阅读 »
    环信官方Demo源码分析及SDK简单应用

    环信官方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的头部。

    001.jpg

     
    @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);
    }
    注销四个监听。
     
    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;
    }
    }
    headview四条目监听。

    删除联系人及其他监听实现​
    /**
    * 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;
    }

    }
    填充布局

    照例的填充布局

    002.jpg


    我们看到就一个search。

    继续看其他的方法
    @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);
    }
    照例的填充,冲突标志位,初始化view。

    初始化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简单应用 环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0 环信官方Demo源码分析及SDK简单应用-LoginActivity   环信官方Demo源码分析及SDK简单应用-主界面的三个fragment...
    继续阅读 »
    环信官方Demo源码分析及SDK简单应用

    环信官方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
    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);
    }
    }
    好了,爷爷的帐算完了,我们来找他儿子。

    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);
    }
    判断冲突标志位
    @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);
    }
    initView()

    覆写爷爷的家规,初始化View输入法管理器
    • 会话列表List
    • 查找联系人的输入框
    • 清除搜索的按钮
    • errorItemContainer 错误标签容器
    继续看代码setUpView()方法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;    }});
    我们一句句的看
    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简单应用 环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0   环信官方Demo源码分析及SDK简单应用-LoginActivity   环信官方Demo源码分析及SDK简单应用-主界面的三个fragmen...
    继续阅读 »
    环信官方Demo源码分析及SDK简单应用

    环信官方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()) {
    autoLogin = true;
    startActivity(new Intent(LoginActivity.this, MainActivity.class));

    return;
    }
    如果已经登录那么设置自动标志位为true,跳到主界面去。

    用户名文本变动监听​
    // 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.
    // close it before login to make sure DemoDB not overlap
    DemoDBManager.getInstance().closeDB();

    // reset current user name before login
    DemoHelper.getInstance().setCurrentUserName(currentUsername);
    英文不好,大致的意思就是注销以后,DemoDB可能会依然在执行一些异步回调,所以DemoDB会再次重新打开,所以我们要在登陆之前确保DemoDB不会被Overlap。所以我们关闭一下数据库。

    然后就是在登陆之前重新设置下当前登陆的用户名

    下面就是具体的登陆实现了
    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中的代码
     
    // ** 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();
    我们看到跳转到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();
    异步的从App后台或者三方库中获取用户信息,想想我们之前看他的分包的时候,是不是见到过parse这个包。就是这玩意。

    然后跳转到主界面
     
    /**
    * register
    *
    * @param view
    */
    public void register(View view) {
    startActivityForResult(new Intent(this, RegisterActivity.class), 0);
    }

    @Override
    protected void onResume() {
    super.onResume();
    if (autoLogin) {
    return;
    }
    }
    然后便是注册了,是直接跳到注册界面去。onResume中如果已经登录直接return掉。

    那么我们看完了这些Activity了,接着看啥呢?啰嗦了这么久,我们终于可以看具体的主界面的三个Fragment了。
     
    环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-会话界面 收起阅读 »

    环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0

    环信官方Demo源码分析及SDK简单应用 环信官方Demo源码分析及SDK简单应用-ChatDemoUI3.0 环信官方Demo源码分析及SDK简单应用-LoginActivity 环信官方Demo源码分析及SDK简单应用-主界面的三个fragment-...
    继续阅读 »
    环信官方Demo源码分析及SDK简单应用

    环信官方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,那么我们来看看其目录结构

    001.jpg



    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
    • 各流程类
    AndroidMainfest.xml实际上没什么好说的,不过我们还是细细的研究一番

    002.jpg

    解决sdk定义版本声明的问题,我们在后面如果使用到了红包的ui,出现了一些sdk的错误可以加上。

    003.jpg

    SDK常见的一大坨权限。其中Google Cloud Messaging还是别用吧,身在何处,能稳定么?然后就是各种各样的界面声明总共这么些个界面(Tips:由于本文是现阅读现写,所有未中文指出部分,后面代码阅读会去补上):[list=1]
  • 开屏页
  • 登陆页
  • 注册
  • 聊天
  • 添加好友
  • 群组邀请
  • 群组列表
  • 聊天室详情
  • 新建群组
  • 退出群组提示框
  • 群组选人
  • PickAtUserActivity
  • 地图
  • 新的朋友邀请消息页面
  • 转发消息用户列表页面
  • 自定义的contextmenu
  • 显示下载大图页面
  • 下载文件
  • 黑名单
  • 公开的群聊列表
  • PublicChatRoomsActivity
  • 语音通话
  • 视频通话
  • 群聊简单信息
  • 群组黑名单用户列表
  • GroupBlacklistActivity
  • GroupSearchMessageActivity
  • PublicGroupsSeachActivity
  • EditActivity
  • EaseShowVideoActivity
  • ImageGridActivity
  • RecorderVideoActivity
  • DiagnoseActivity
  • OfflinePushNickActivity
  • robots list
  • RobotsActivity
  • UserProfileActivity
  • SetServersActivity
  • OfflinePushSettingsActivity
  • CallOptionActivity
  • 发红包
  • 红包详情
  • 红包记录
  • WebView
  • 零钱
  • 绑定银行卡
  • 群成员列表
  • 支付宝h5支付页面
  • 转账页面
  • 转账详情页面
  • 再往下就是相关的一些广播接收者,服务,以及杂七杂八的东西了。有如下部分:开机自启动[list=1]
  • GCM
  • 小米推送
  • 华为推送
  • 友盟
  • EMChat服务
  • EMJob服务
  • EMMonitor Receiver
  • 百度地图服务
  • 其中比较重要的
     <!-- 设置环信应用的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]
  • 项目中的Application类继承MultiDexApplication。
  • 在自己的Application类的attachBaseContext方法中调用MultiDex.install(this);。
  • 然后又做了几件事[list=1]
  • 初始化DemoHelper
  • 初始化红包并开启日志输出
  • Application就这样没了,我们继续看SplashActivity。SplashActivity我们来看代码:
     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帮助类,起了个线程,判断是否已经登录。我们来看看他是如何判断的。

    005.jpg

    我们来看官方文档中关于此isLoggedInBefore()的解释。

    006.jpg

    我们再回头来看刚才的代码,代码中有句注释,是这么写到。
    // 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 长期在后台再进的时候也可能会导致加载到内存的群组和会话为空。

    007.jpg

    这里做了等待和判断如果栈顶的ActivityName不为空而且顶栈的名字为语音通话的Activity或者栈顶的名字等于语音通话的Activity。毛线都不做。这个地方猜测应该是指语音通话挂起,重新调起界面的过程。否则,跳到主界面。那么我们接着看主界面。MainActivity那么这个时候,我们应该怎样去看主界面的代码呢?首先看Demo的界面,然后看代码的方法,再一一对应。来,我们来看界面,界面是这个样子的。

    008.jpg

    三个界面会话、通讯录、设置有了直观的认识以后,我们再来看代码。 我们来一段一段看代码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();    }}

    009.jpg

    这段代码其实是用来解决重复实例化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,如果是,则判断是否忽略电池耗电的优化。

    010.jpg

    说实话自己英文水平不是太好,没搞懂为毛国人写的代码要用英文注释,难道是外国人开发的?注释本身不就是让人简单易懂代码逻辑的。可能跟这个公司大了,这个心理上有些关系吧。

    011.jpg

    确保当你在其他设备登陆或者登出的时候,界面不在后台。大概我只能翻译成这样了。但是看代码的意思应该是当你再其他设备登陆的时候啊,你的app又在后台,那么这个时候呢,咱啊就你在当前设备点击进来的时候,我就判断你这个saveInstanceState是不是为空。如果不为空而且得到账号已经remove 标识位为true的话,咱就把你当前的界面结束掉。跳到登陆页面去。否则的话,如果savedInstanceState不为空,而且得到isConflict标识位为true的话,也退出去跳到登陆页面。权限请求我们继续看下面的,封装了请求权限的代码。

    012.jpg


    013.jpg

    继续,之后就是常规的界面初始化及其他设置了。

    014.jpg

    初始化界面方法initView()友盟的更新没用过友盟的东西
    MobclickAgent.updateOnlineConfig(this);​UmengUpdateAgent.setUpdateOnlyWifi(false);​UmengUpdateAgent.update(this);
    看字面意思第一句应该是点击数据埋起点,后面应该是设置仅wifi更新为false以及设置更新。异常提示从Intent中获取的异常标志位进行一个弹窗提示

    015.jpg

    从字面上意思来看来应该是当账号冲突,移除,禁止的时候去显示异常。其中用到了showExceptionDialog()方法来显示我们来看看一下代码

    016.jpg

    当用户遇到一些异常的时候显示对话框,例如在其他设备登陆,账号被移除或者禁止。数据库相关操作
    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);
    }
    }
    更新总计未读数量
     /**
    * 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);
    }
    }
    });

    }
    然后判断广播类型,如果当前的栈顶为主界面,则调用GroupsActivity的onResume方法。

    如果为群红包更新意图则调用的converstationListFragment的refersh()方法

    017.jpg


    添加联系人监听
    EMClient.getInstance().contactManager().setContactListener(new MyContactListener());
    我们来看下这个MyContactListener()监听方法。

    018.jpg


    我们发现是MyContactListener是继承自EMContactListener的,我们再来看看EMContactListener和其官方文档的解释。

    019.jpg


    我们发现其定义了5个接口,这5个接口根据文档释义分别是如下含义:
    void    onContactAdded (String username)//增加联系人时回调此方法

    void onContactDeleted (String username)//被删除时回调此方法

    void onContactInvited (String username, String reason)/**收到好友邀请 参数 username 发起加为好友用户的名称 reason 对方发起好友邀请时发出的文字性描述*/

    void onFriendRequestAccepted (String username)//对方同意好友请求

    void onFriendRequestDeclined (String username)//对方拒绝好友请求
    从而我们得知,我们demo中的自定义监听接口在被删除回调时,做了如下操作:

    020.jpg


    如果你正在和这个删除你的人聊天就提示你这个人已把你从他好友列表里移除并且结束掉聊天界面。

    测试用广播监听​
    //debug purpose only
    registerInternalDebugReceiver();
    /**
    * 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);
    }
    至此MainActivity的OnCreate方法中所有涉及到的代码我们均已看完。
    其他方法

    接下来我们来捡漏,看看还有剩余哪些方法没有去看。

    021.jpg


    判断当前账号是否移除
    /**
    * check if current user account was remove
    */
    public boolean getCurrentAccountRemoved() {
    return isCurrentAccountRemoved;
    }
    oncreate()

    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() {
    runOnUiThread(new Runnable() {
    public void run() {
    // refresh unread count
    updateUnreadLabel();
    if (currentTabIndex == 0) {
    // refresh conversation list
    if (conversationListFragment != null) {
    conversationListFragment.refresh();
    }
    }
    }
    });
    }
    registerBroadcastReceiver()

    unregisterBroadcastReceiver();反注册广播接收者。
     
    private void unregisterBroadcastReceiver(){
    broadcastManager.unregisterReceiver(broadcastReceiver);
    }
    onDestory()
    @Override
    protected void onDestroy() {
    super.onDestroy();

    if (exceptionBuilder != null) {
    exceptionBuilder.create().dismiss();
    exceptionBuilder = null;
    isExceptionDialogShow = false;
    }
    unregisterBroadcastReceiver();

    try {
    unregisterReceiver(internalDebugReceiver);
    } catch (Exception e) {
    }

    }
    异常的弹窗disimiss及置空,反注册广播接收者。

    updateUnreadAddressLable()

    getUnreadAddressCountTotal()

    getUnreadMsgCountTotal()

    getExceptionMessageId() 判断异常的种类
     
    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;
    }
    showExceptionDialog()

    getUnreadAddressCountTotal()

    getUnreadMsgCountTotal()

    onResume() 中做了一些例如更新未读应用事件消息,并且push当前Activity到easui的ActivityList中
     
    @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);
    }
    onStop();
    @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();
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (keyCode == KeyEvent.KEYCODE_BACK) {
    moveTaskToBack(false);
    return true;
    }
    return super.onKeyDown(keyCode, event);
    }
    getExceptionMessageId()

    showExceptionDialog()

    showExceptionDialogFromIntent()

    onNewIntent() Activity在singleTask模式下重用该实例,onNewIntent()->onResart()->onStart()->onResume()这么个顺序原地复活。

    至此,我们的MainActivity就全部阅读完毕了。

    我们是在已经登录的情况下来到的MainActivity,那么我们在没有登录情况下呢,当然是来登陆页面。下面我们来看登录页。
     
    环信官方Demo源码分析及SDK简单应用-LoginActivity 收起阅读 »

    ios V2.3.1 已发布,增加获取日志压缩文件路径接口

    ios版本:V2.3.1 2016-02-17 新功能/改进: 修改HttpsOnly参数默认值,默认设置为NO(由于苹果强制ATS政策延缓, 所以SDK默认关闭httpsOnly) [[EaseMob sharedInstance].chatMa...
    继续阅读 »
    ios版本:V2.3.1 2016-02-17

    2351.jpg_wh860_.jpg



    新功能/改进:
    1. 修改HttpsOnly参数默认值,默认设置为NO(由于苹果强制ATS政策延缓, 所以SDK默认关闭httpsOnly)

    [[EaseMob sharedInstance].chatManager setIsUseHttpsOnly:YES];//设置httpsonly,YES开启,NO关闭

    1. 增加获取日志压缩文件路径接口(具体上传日志方式可由开发者决定, Demo是通过邮件的形式上报日志)
    2. 优化群组过多时重连卡顿问题
    3. 修复离线已读回执有时丢失问题
    4. 修复SDK收到特殊消息闪退问题

     
     版本历史:ios 2.x更新日志
    下载地址:SDK下载
    收起阅读 »

    Web IM V1.4.10已发布,新增语音呼叫

    新功能: [sdk] webrtc新增语音呼叫 Bug修复: [sdk] webrtc:Firefox在结束通话后的问题 [sdk] webrtc:多次接通挂断之后,逻辑功能混乱 [sdk] webrtc:正常挂断不应该提醒offline [sdk] ...
    继续阅读 »

    2196.jpg_wh860_.jpg


    新功能:


    [sdk] webrtc新增语音呼叫



    Bug修复:


    [sdk] webrtc:Firefox在结束通话后的问题
    [sdk] webrtc:多次接通挂断之后,逻辑功能混乱
    [sdk] webrtc:正常挂断不应该提醒offline
    [sdk] webrtc:重连后无法处理音视频IQ消息


     
    webim体验:https://webim.easemob.com 

    版本历史:更新日志
     
    SDK下载:下载地址
    收起阅读 »

    Android 依赖EaseUI联系人列表显示昵称 修改之前的发起的那篇文章

    在设置时要说明 好友数据由app的服务器提供的  所以服务端也要集成 注意 我的页面以及类都是从Demo中复制过来的 我们必须要知道好友数据是在什么位置进行数据适配的 在UserDao中有一个方法是saveContactList这个就是进行好友数据保存的操作了...
    继续阅读 »
    在设置时要说明 好友数据由app的服务器提供的  所以服务端也要集成
    注意 我的页面以及类都是从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),即互动式语音应答系统。它作为呼叫中心的门户,最初被赋予的作用有两点:一是初步甄别客户的需求,通过不同的按键设置导流客户需求;二是通过提供自动语音服务来...
    继续阅读 »
       

    3623.jpg_wh860_.jpg


       任何一个呼叫中心都离不开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.png


     
    语言设置

    语言设置支持两种方式:
    1. 在登录页面,可以在右上角选择使用中文或English登录环信移动客服。

    图片2.png


    2. 在客服模式下,进入“客服信息”页面,选择语言为English,并保存,即可切换至英文界面。
    同样,在英文界面下,进入Personal页面,选择Language为中文,并保存,即可切换回中文界面。

    图片3.png



    环信移动客服更新日志http://docs.easemob.com/cs/releasenote/5.10 

    环信移动客服登陆地址http://kefu.easemob.com/ 收起阅读 »

    环信IM 开发遇到两个小问题以及解决办法

    环信IM 开发遇到两个小问题以及解决办法 1.消息已读提示 默认是英文“Read” 改为中文“已读” 2.消息长按操作 默认NO  改为YES  系统默认只能进行删除,复制操作   提示方式为英文  改为中文“复制”“删除”

    环信IM 开发遇到两个小问题以及解决办法
    1.消息已读提示 默认是英文“Read” 改为中文“已读”
    2.消息长按操作 默认NO  改为YES  系统默认只能进行删除,复制操作   提示方式为英文  改为中文“复制”“删除”

    环信Insight.io: 这才是环信开源代码正确的打开方式!

        环信作为国内领先的企业级服务软件提供商,截至2016年上半年,环信即时通讯云共服务了82149家App客户,SDK覆盖手机终端5.64亿,平台日发送消息5.57亿条。 为了让开发者能够更快速的使用环信API开发应用,环信在Github上开源了自己诸如e...
    继续阅读 »
        环信作为国内领先的企业级服务软件提供商,截至2016年上半年,环信即时通讯云共服务了82149家App客户,SDK覆盖手机终端5.64亿,平台日发送消息5.57亿条。 为了让开发者能够更快速的使用环信API开发应用,环信在Github上开源了自己诸如easeUIsdkdemoapp3.0_androidemchat-server-examples等项目,阅读这些官方开源项目的源代码无疑是上手环信API最快的方式。

       Github虽然是优秀的开源项目协作工具,但是对于源代码本身一直停留在将源代码作为纯文本处理的阶段,搜索和浏览都没有代码智能(Code Intelligence)信息,对于开发者深入了解源代码本身形成了很大的障碍。为了解决这个问题,来自硅谷的Lambda Lab团队为我们带来了Codatlas,一款能让开发者在网页端也能像用IDE一样浏览和搜索源代码的工具,下面让我们一起来看看Codatlas如何让你获得Web端的终极代码浏览体验吧。

    1. 跳转到定义

    想快速看到代码中的类,变量或者方法是如何定义的?没问题,点击类,变量或者方法被使用的地方就会跳转到相应定义的地方。不仅可以在项目内部跳转,跨项目也同样可以实现跳转。


    001.gif


    2. 查找应用

    同样的,如果想知道代码中的类,变量或者方法在代码库中哪些地方被使用了,可以点击类,变量或者方法的定义处来显示所有被引用的地方。引用会被进一步分成Referenced At Inherit Override等子类型方便开发者进一步缩小查找范围。

    002.gif


    3. 类结构

    通过类结构可以把一个类中的所有成员变量和方法列出来,并且点击跳到相应行

    003.gif


    4. 语意搜索

    基于对源代码的语意分析,在搜索时开发者可以直接按照类名,方法名,变量名等搜索,并且Codatlas提供自动补全功能。

    004.gif


    除了上述基于代码智能(Code Intelligence)的功能之外,Codatlas也提供切换版本,显示Commit历史,逐行标注最近Commit信息,树状目录结构等功能。相信有了Codatlas的帮助,开发者们能够更快的上手环信SDK,也欢迎有经验的环信SDK开发者将自己开源的项目提交到Codatlas,让更多的开发者找到。
     
    5. 在问答社区提供代码链接

    IMGeek社区的提问或者回答需要涉及到源代码?用Insight.io可以更迅速的让其他人理解你提问回答中代码的上下文:


    imgeek-2.gif


    除了上述基于代码智能(Code Intelligence)的功能之外,Insight.io也提供切换版本,显示Commit历史,逐行标注最近Commit信息,树状目录结构等功能。

    如果你也有优秀的基于环信SDK开发的开源项目希望分享给大家,欢迎联系insight@insight.io让我们收录你的项目,让更多的开发者学习使用环信SDK的最佳实践。

    下面是示例中涉及到的开源项目在Insight.io上的访问地址:


     如果使用Codatlas有任何的问题或者反馈,欢迎邮件至lambdalab@lambdalab.io 收起阅读 »

    环信官方Demo源码分析及SDK简单应用

    前言 环信官方Android版本的Demo,还算是功能齐全的.日常工作中我们如果只是为App加个im模块基本的界面和逻辑也出不了Demo多少。 所以如果你的公司有这方面的需求,为了能顺利拿到银子,少些波澜,我们还是一起来研究下其官方Demo吧。 感谢有环信...
    继续阅读 »
    前言
    环信官方Android版本的Demo,还算是功能齐全的.日常工作中我们如果只是为App加个im模块基本的界面和逻辑也出不了Demo多少。

    所以如果你的公司有这方面的需求,为了能顺利拿到银子,少些波澜,我们还是一起来研究下其官方Demo吧。

    感谢有环信这样强力的三方IM解决方案,并提供了简单易用而又强大的SDK,方便了我们广大中小开发者集成IM相关功能。

    有缘的话,我们后面再来分析IOS版本的环信官方Demo源代码。

    由于时间仓促,错误及不足之处,欢迎指正。
    准备工作
     
    我们说拿到一份代码,要想分析下内容。先看目录再看AndroidMainfest,抽丝剥茧一步步的去理解和分析。

    当然这只是个人的习惯,其他有更好的方法或建议,可以留言一起讨论。

    废话不多说,我们来看目录。

    001.jpg


    有三个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是如何正在吃掉软件。
    作者:环信创始人 刘俊彦  

    硅谷著名的投资人马克·安德森说过一句话“软件正在吃掉世界”。在2017SaaStr上,红点的合伙人Tomasz Tunguz做了一场关于AI正在变成SaaS的基础平台的演讲。我们深刻的感到,下一场革命已经到来了,AI正在吃掉软件。

    001.jpg


    Tomasz首先简单介绍了他看到的在工作流,保险,建筑,医疗,农业,交通这6个行业的AI投资案例和AI所产生的价值。当然,以Tomasz一向的干货风格,他很快进入在座的SaaS企业最关心的问题:SaaS企业的AI怎么做,投资人会投什么样的AI企业:

    002.jpg



    003.jpg




    004.jpg




    005.jpg





    006.jpg


    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年被称作“直播+电商”的元年,今年“双十一...
    继续阅读 »
    如日中天的直播业务正在与不同互联网行业快速结合起来,形成“直播+经济”。

    直播+娱乐已很成熟,如今还有一个正迅速崛起的商业模式—— “直播+电商”。
    “直播+电商”模式爆发,成为网络零售的下一个风口
     
       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月刊文章;原文作者吴勇毅,本文作者为厦门智者恒通管理顾问机构总监。 收起阅读 »

    环信头像和昵称显示的详细、详细、详细教程!

    写在前边:本文由江南大神的环信集成demo衍生而来! 附上大神的集成链接: http://www.imgeek.org/article/825307886    通过官方的文档我们知道有两种显示头像和昵称的方式(http://docs.easemob.com...
    继续阅读 »
    写在前边:本文由江南大神的环信集成demo衍生而来!

    附上大神的集成链接: 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-小码农  。 收起阅读 »

    技术选型最怕的是什么?

    昨天聊聊架构发布了一篇关于技术选型的文章,文章作者介绍了目前流行的技术选型方式,比如有微博驱动、技术会议驱动、嗓门驱动、领导驱动.....不少读者都表示深有体会,并在评论区贴出了自己的经历。今天再推荐一篇由环信首席架构师一乐所撰写的关于技术选型的文章(旧文),...
    继续阅读 »


    WOE262@[SP29YD2R2}GGSSQ.png

    昨天聊聊架构发布了一篇关于技术选型的文章,文章作者介绍了目前流行的技术选型方式,比如有微博驱动、技术会议驱动、嗓门驱动、领导驱动.....不少读者都表示深有体会,并在评论区贴出了自己的经历。今天再推荐一篇由环信首席架构师一乐所撰写的关于技术选型的文章(旧文),希望能帮到各位。另推荐一乐的个人微信一乐来了,id是yilecoming,欢迎关注。
    这也许是我上半年最大的欠账,在去普吉的飞机上突发无聊,想想还了这债吧。

    去年的时候,我们使用Cassandra出了一次问题,定位加修复用了一晚上。当我把经历发出来的时候,收到了下面一段话:

    “一个开源产品,连官方文档都没看完大半,然后匆匆忙忙上生产环境,出了问题团团转。若是不能掌控就先不要玩,说回这Cassandra的例子,在对它不了解的情况下,仅通过Google就能解决问题,不正说明它不难掌握有大量资料可查吗,实在不行还能翻代码。”

    我现在都不知道这位神仙从哪里看到的匆匆忙忙上和团团转,当时我还是忍了,因为实在太忙,口水又没那么多。当然我思考了很多,这就是你现在看到的文章。我相信它会有一些价值,毕竟有些事情有的人你不告诉他,他永远不可能知道。比如个体认知的局限,比如口无遮拦的损失,比如做事之人才会有的思考角度。

    本文讲的是技术选型。

    大多数技术都存在选型问题,因为技术的发展已经让一件事情可以有多种解决方案,选型问题就自然出现。前段时间也有人说过语言选型,这里举的例子是在组件、框架、服务的范畴。其中有相通之处,各位可以自行领会。
     
    选型最怕什么
     
    怕失败么?那肯定的。你的服务崩溃,用户愤而投诉,客户电话打到老板那里,明天你要洗干净到办公室去一趟(笑...)。而所有对失败的无法容忍,最终都会变成一句话,为什么你要选这个型?

    你总要回答这个问题,所以选型一怕随意,公鸡头母鸡头,选上哪头是哪头;二怕凭感觉,某某已经在用听起来还不错。你需要真正的思考,而且尽可能的全面。我下文会详细讲解,但这还不是最怕的。

    最怕的是什么?看看本文开头引用的那句话,你体会一下。

    嗯,最怕的是喷子。怕任意总结,如果再加上一些诋毁,一次选型失败足以让人心碎一万次。

    失败不可怕,可怕的是没有总结,因为没有总结就没有提高。而比没有总结更可怕的是乱总结。

    为了方便理解,我再帮你换个角度。你天天在河边走,一次不小心湿了鞋。如果是本文说的这种人,那肯定要说:

    一条公共的河,你连旱季旺季都没搞清楚,就匆匆忙忙跑过来散步,湿了鞋还到处讲。若是脚不行就别在这玩,说回这条河,湿了鞋就能爬上来,不正说明他水不深么。

    这种人实在不算少见。他说的每一句话都有一点道理,但都跟事情的本质毫无关系,每一句话又都掺加了嘲讽,来体现那无处安放的莫名优越感。而所有的这些,对于解决问题和后续提高通常毫无帮助。

    想想也真遗憾,人生本是如此美好,有的人却硬生生地活成了奇葩。
     
    选型需要什么
     
    言归正传,我认为有三点不可或缺:分析、实验和胆量。
     

    •  分析


     
    分析主要有定性分析和定量分析。实际操作中,前者主要针对的是模型维度的估计,用来考虑一个组件是否有可能达到它宣称的目的,后者主要用来验证,用来确认它是否在真的做到了。

    比如在语言选型时,你要考虑它的范型、内存模型和并发设计;数据库选型时你要考虑存储模型、支撑量级、成本开销;开源项目要考虑它的社区发展、文档完善程度;如果是库或者中间件,还要考虑他的易用性、灵活性以及可替代性,等等。

    需要说明的一点是,我个人并不觉得阅读全部源码或者文档这种事情是必须的,这不局限在OS、VM层面。不仅因为这样的事情会耗费过多精力,而且受制于代码以及文档质量,就算真正阅读完毕也未必意味着完全领会。

    这些都是定性的,而定性的东西就有可能存在理解偏差。一个库可以完成工作,并不代表它在高并发压力下依然表现正常;一个语言做到了自动管理内存,并不代表他能做得很好没有副作用;一件事情设计者觉得达到了目标并不代表能够满足使用者期望。因此我们还需要量化分析,也就是一直口口相传的,用数据说话。

    量化分析需要你构建或使用现成的工具和数据集,对服务进行特定场景下的分析。通过提高压力、增加容量或者针对性的测试,来验证之前的定性分析是否达到预期,并分析不同技术之间的差异和表现。
     

    • 实验


     
    量化分析可以为真正的实验做一些准备和帮助,但是实验要走的明显更远。到了这一步,意味着要在真正的业务场景下进行验证,这跟量化分析中通用性场景有所不同。

    在真正的业务中采用需要很多细致和琐碎的工作,除此之外,还要构建自己的测试工具集,这需要非常扎实的业务理解能力和勤奋的工作。而所有这些,你需要在开发环境做一次,在沙箱环境做一次,然后在仿真环境再做一次。

    这几步经常被简化,但经验告诉我们,如果你想做一个高可用的系统,你就不应该少走任何一步。

    步子大了,容易扯到蛋。 
     

    • 胆量


     
    实验做完,剩下的就是上线,但这一步有很多人跨不过去。因为就算做了再多准备,你依然不敢说百分百保证没问题。现实情况是,80%的线上问题都是升级或者上线引起的。

    你需要胆量。

    这不是说要硬着头皮做,人家都是艺高才胆大。所以为了让胆子大一点,你首先需要考虑降级和开关。从最悲观的角度来重新审视整个方案,如果升级出现问题怎么办,如何才能让出现的问题影响最小化。

    而只要弄完了这些,也就只要再记住一句话就行:

    你行你上啊!
     
    对技术服务的提醒
     

    • 得到认可


     
    刚才在胆量里没说的一点。我们经常会看到,一项新技术在公司内久久难以推行,因为业务主管百般阻挠。即使排除利益纠葛,仍然会发现一种发自心底的不信任存在。而这种不信任,又往往来源于对同事工作的不认可。

    这个问题原因很多,也许没有通用的解决方案,但我说一个例子。

    我们最近开始使用Codis,就是@goroutine 和几个家伙之前搞过的玩意儿。虽然他们最近已经独立开搞像Google Spanner但拥有更高级特性的TiDB(就是太牛了的意思)。由于我对他们比较熟悉和认可,所以在Codis尝试方面也多出很多底气。这种信任并非完全来自于出问题之后的直接电话支持,而是真心觉得活儿好。

    反过来,这对很多服务也是一个提醒,特别是云服务。也许只要你得到合作伙伴的认可,或者至少让他们觉得,自己动手不会比你做得更好,你基本也就成功了。

    对于大多数理性创业公司来讲,他们还是更愿意把精力放在自己的主要业务上,不会希望所有的服务都自己做,因为这个年代,唯快不破,创业等不起一辈子。 
     

    • 产品意识


     
    回到开始那句话,“在对它不了解的情况下,仅通过google就能解决问题,不正说明它不难掌握有大量资料可查吗,实在不行还能翻代码。” 这话有些道理,然而却存在一个问题,这个问题就是:

    作为一个使用者,是否有能力解决遇到的问题,与是否有意愿去遇到并解决问题,是两回事。

    你有本CPU设计手册,你可以说处理器很简单,但我只想看个电影啊?给你Linux内核的源码,你可以说内核设计不难掌握,但我只想跑个游戏啊?何况他们是否因此就变得不难了,也是值得怀疑的。

    这其实反映了技术人的产品意识。

    很多技术人员喜欢玩酷的东西,他们愿意去探索新的领域,把不可能的变为可能。但是很多时候,他们做出来的东西却很难使用。

    有的库可以增加很多参数,参数之间却有耦合,导致你在采用的时候需要写很多设置代码,而有点库却只需要一行代码;有的服务功能众多,却需要用户学习繁杂的步骤,而有的服务却可以开箱即用;有的服务功能可以实现,却会有很多不稳定甚至崩溃的情况出现,等等。

    对于实现的工程师来讲,可能最大的区别在于,你是否考虑从用户的角度审视过自己的东西。即使这个服务也许只是为其他技术人员使用的。

    技术人员可以,也应该,让技术人员更幸福。

    最后,聊聊架构为大家送上一个关于技术选型的小漫画。其实技术并没有错,错的也许是我们.....

    R[Z}P6_7OS1Y}196XXK5Y.png


      收起阅读 »

    用户的满意是检验服务流程的唯一标准

    写这个话题,源于本人的一次亲身经历,一个典型的案例。     某日上午,致电当地10000号开通ADSL上网服务,CSR认真地讲解该项业务的办理流程以及每个环节的服务承诺,包括致电后24小时内查线,48小时内上门安装。我欣然接受。  ...
    继续阅读 »
    写这个话题,源于本人的一次亲身经历,一个典型的案例。 


    3190.jpg_wh860_.jpg



       某日上午,致电当地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更有效、更灵活地为用户提供服务。而设计与优化流程的原则只有一个:用户的满意是检验流程的唯一标准。 收起阅读 »