Skip to content

mui 初级入门教程(五)— 聊聊即时通讯(IM),基于环信 web im SDK

写在前面

感觉自从qq、微信这种APP用多了,现在都没啥人发短信了,现在什么APP都想加入IM的功能,曾经有段时间在折腾自己撸一个聊天的东西,也尝试过很多平台,今天这里给大家介绍一下从零开始自己做一个聊天的app功能。因为之前帮朋友做过一个基于环信的聊天功能,这里就以环信的平台为例举个例子说明。这篇文章注意想讲解一下集成这种第三方的一般实现方法,不会一下子就把所有的功能都集成,因为之前做环信主要是在微信上用,所以用的是环信的Web IM,遇到了蛮多坑,这次打算用dcloud这边的mui重新集成,所以在没有完全做完之前,所以也不知道有些坑具体能够在有限的时间内解决,本文仅供参考,欢迎大家去实践检验。在写这篇文章之前先贴一个Dcloud论坛中的资源帖,【即时通信、im 问题汇总】

准备工作

1.注册账号

我们要先去环信官网注册一个账号,然后在后台创建一个应用,因为我们后面在做功能的时候可以用后面发送消息及图片来测试收消息,用户管理在后台也可以看得一清二楚。

环信官网

创建成功后找到应用标识(AppKey),这个在后期配置中会用到。

2.下载 SDK

http://www.easemob.com/download/im 这里我们使用的是Web IM,所以下载的SDKWeb IM版本,下载之后我们会看到一个演示demo,由于这个是pc版本,和我们需求不一致,所以我们只需要关心sdk目录下的文件和 sdk 集成需要修改的配置文件easemob.im.config.js

js
|---README.MD
|---index.html:demo首页,包含sdk基础功能和浏览器兼容性的解决方案

|---static/
	js/
		easemob.im.config.js:sdk集成需要修改的配置文件
	css/
	img/
	sdk//*sdk相关文件*/
		release.txt:各版本更新细节
		quickstart.md:环信WebIM快速入门文档
		easemob.im-1.1.js:js sdk
		easemob.im-1.1.shim.js:支持老版本sdk api
		strophe.js:sdk依赖脚本

3.开发文档

Web IM 介绍 http://docs.easemob.com/im/400webimintegration/10webimintro

项目实战

由于这篇重在在于如何使用第三方开发IM,感觉说再多也诶有意义,直接上代码说明。不讲解过多的原理、细节,只讲究开发流程。

1.用户注册功能

首先我们在 hbuilder 中先新建一个项目easemobIM,然后把环信sdk文件夹和配置文件拷贝到我们的工程中。为了节约时间,下面的功能演示我是根据官方登录模板改的。 html/reg.html

html
<!DOCTYPE HTML>
<html>
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
		<title></title>
		<link href="../css/mui.min.css" rel="stylesheet" />
		<link href="../css/style.css" rel="stylesheet" />
		<style>
			.mui-input-group:first-child {
				margin-top: 20px;
			}
			.mui-input-group label {
				width: 22%;
			}
			.mui-input-row label~input,
			.mui-input-row label~select,
			.mui-input-row label~textarea {
				width: 78%;
			}
			.mui-checkbox input[type=checkbox],
			.mui-radio input[type=radio] {
				top: 6px;
			}
			.mui-content-padded {
				margin-top: 25px;
			}
			.mui-btn {
				padding: 10px;
			}
		</style>
	</head>
	<body>
		<header class="mui-bar mui-bar-nav">
			<a class="mui-action-back mui-icon mui-icon-left-nav mui-pull-left"></a>
			<h1 class="mui-title">注册</h1>
		</header>
		<div class="mui-content">
			<form class="mui-input-group">
				<div class="mui-input-row">
					<label>手机</label>
					<input id='username' type="text" class="mui-input-clear mui-input" placeholder="请输入手机号码">
				</div>
				<div class="mui-input-row">
					<label>昵称</label>
					<input id='nickname' type="text" class="mui-input-clear mui-input" placeholder="请输入昵称">
				</div>
				<div class="mui-input-row">
					<label>密码</label>
					<input id='password' type="password" class="mui-input-clear mui-input" placeholder="请输入密码">
				</div>
				<div class="mui-input-row">
					<label>确认</label>
					<input id='password_confirm' type="password" class="mui-input-clear mui-input" placeholder="请确认密码">
				</div>
			</form>
			<div class="mui-content-padded">
				<button id='reg' class="mui-btn mui-btn-block mui-btn-primary">注册</button>
			</div>
		</div>

		<script src="../js/mui.min.js"></script>
		<!--sdk-->
		<script src="../sdk/strophe.js"></script>
		<script src="../sdk/easemob.im-1.1.js"></script>
		<script src="../sdk/easemob.im-1.1.shim.js"></script><!--兼容老版本sdk需引入此文件-->
		<!--config-->
		<script src="../js/easemob.im.config.js"></script>
		<script>
			mui.init();

			// 输入参数
			var regConfig = {
				username: mui("#username")[0],
				nickname: mui("#nickname")[0],
				password: mui("#password")[0],
				passwordConfirm: mui("#password_confirm")[0]
			};

			// 注册事件监听
			mui("#reg")[0].addEventListener('tap',function(){
				var username = regConfig.username.value;
				var nickname = regConfig.nickname.value;
				var password = regConfig.password.value;
				var passwordConfirm = regConfig.passwordConfirm.value;

				// 电话号码校验
				if (!isMobile(username)){
	                mui.toast("电话号码格式不正确");
	                return;
	            }
				// 昵称非空校验
				if (!isEmpty(nickname)){
					mui.toast('昵称不能为空');
					return;
				}
				// 密码非空校验
				if (!isEmpty(password)){
					mui.toast('密码不能为空');
					return;
				}
				// 密码重复校验
				if (passwordConfirm != password) {
					mui.toast('密码两次输入不一致');
					return;
				}
               // 环信SDK注册
				var options = {
					username : username,
					password : password,
					nickname : nickname,
					appKey : Easemob.im.config.appkey,
					success : function(result) {
						//注册成功;
						console.log(JSON.stringify(result))
						mui.toast('注册成功');
					},
					error : function(e) {
						//注册失败;
						console.log(JSON.stringify(e));
						mui.toast('注册失败:'+e.error);
					}
				};
				Easemob.im.Helper.registerUser(options);

			});

			// 是否为电话号码
			function isMobile(value) {
                var validateReg = /0?(13|14|15|18)[0-9]{9}/;
				return validateReg.test(value);
            }

			// 是否为空
			function isEmpty(value){
				var validateReg = /^\S+$/;
				return validateReg.test(value);
			}
		</script>
	</body>
</html>

这是注册页面的代码,我们首先要引入环信的sdkeasemob.im.config.js,并且将easemob.im.config.js中的appkey换成自己的,然后根据用户名/密码/昵称注册环信 Web IM,提交注册的代码为:

js
var options = {
  username: username,
  password: password,
  nickname: nickname,
  appKey: Easemob.im.config.appkey,
  success: function(result) {
    //注册成功;
    console.log(JSON.stringify(result));
    mui.toast("注册成功");
  },
  error: function(e) {
    //注册失败;
    console.log(JSON.stringify(e));
    mui.toast("注册失败:" + e.error);
  }
};
Easemob.im.Helper.registerUser(options);

我们注册完了后可以在环信后台【IM 用户】查看用户注册信息,我们我们用其他平台,只需要把这块的内容改成相应的内容就 OK。

2.用户登录功能

有了注册页面的经验,我们写登录页面也很简单,页面布局脚本和其他与登录逻辑无关的代码我这里不贴了,大家在我最后给的地址上下载完整代码,这里只讲解基本基本思路。环信登录优两种方法,一种是通过实例化new Easemob.im.Connection()建立连接,一种是使用工具类Easemob.im.Helper.login2UserGrid(options),我们刚刚注册就是使用了工具类,为了便于大家后面的学习,我们在这里把两种方法都说一下:

实例化new Easemob.im.Connection()建立连接

1.创建连接

js
var conn = new Easemob.im.Connection();

2.初始化连接

js
conn.init({
  onOpened: function() {
    alert("成功登录");
    conn.setPresence();
  }
});

3.初始化连接

js
// 打开连接
conn.open({
  user: username,
  pwd: password,
  appKey: Easemob.im.config.appkey
});

这里我们需要注意的是open()方法中需要配置的属性是userpwd,这和我们注册时的有区别,要注意哦!

这里需要说明的是init()是环信提供的一个通用的方法,比如后面我们要用到的接收文本消息、图片消息等一系列的回调方法都写在这个里面,onOpened()方法主要是用于当执行conn.open()方法时需要执行的方法,我们一般会把页面需要初始化的逻辑写在onOpened()中,比如查询好友。

完整代码:

js
// 输入参数
var loginConfig = {
  username: mui("#username")[0],
  password: mui("#password")[0]
};
// 创建一个新的连接
var conn = new Easemob.im.Connection();
// 初始化连接
conn.init({
  onOpened: function() {
    mui.toast("成功登录");
    conn.setPresence();
    mui.openWindow({
      url: "html/tab-webview-main.html",
      extras: {
        username: loginConfig.username.value,
        password: loginConfig.password.value
      }
    });
  }
});
// 登录事件监听
mui("#login")[0].addEventListener("tap", function() {
  var username = loginConfig.username.value;
  var password = loginConfig.password.value;
  // 电话号码校验
  if (!isMobile(username)) {
    mui.toast("电话号码格式不正确");
    return;
  }
  // 密码非空校验
  if (!isEmpty(password)) {
    mui.toast("密码不能为空");
    return;
  }
  // 打开连接
  conn.open({
    user: username,
    pwd: password,
    appKey: Easemob.im.config.appkey
  });
});

工具类Easemob.im.Helper.login2UserGrid(options)建立连接

js
// 登录
var options = {
  user: username,
  pwd: password,
  appKey: Easemob.im.config.appkey,
  success: function(data) {
    console.log(JSON.stringify(data));
    mui.toast("成功登录");
    mui.openWindow({
      url: "html/tab-webview-main.html",
      extras: {
        username: loginConfig.username.value,
        password: loginConfig.password.value
      }
    });
  },
  error: function(e) {
    console.log(JSON.stringify(e));
    mui.toast("成功失败:" + e);
  }
};
Easemob.im.Helper.login2UserGrid(options);

上面我们用了两种方法讲解了登录的方法,各有优劣,第二种只做登录的工作,代码也比较简洁,但是当我们的页面是多个页面时我们的登录状态是不能检测到的,这个时候我们还是需要在每个页面通过创建连接初始化,所以我们在页面跳转过程加入了拓展参数extras传递参数,然后在登陆后的页面接收就可以。

3.页面传参深入探究

为了尽可能简单的演示我们的功能,我这里不使用个性化的设计,就用官方模板组中的【mui 底部选项卡(webview 模式)】进行展示。新建模板文件如下:

我们去掉第一个选项卡,只保留消息tab-webview-subpage-chat.html、通讯录tab-webview-subpage-contact.html、设置tab-webview-subpage-setting.html三个选项卡。

拓展参数extras传值

上一小节中,我们在登陆页面通过拓展参数extras传值,在主页面接收数据的方法为:

js
mui.plusReady(function() {
  var self = plus.webview.currentWebview();
  var username = self.username;
  var password = self.password;
  mui.toast("username:" + username + "<br />" + "password:" + password);
});

在主界面mui.plusReady方法里面拿到值,然后可以在创建子webview时候用拓展参数传值,然后在子页用下面的方法用同样的方法可以拿到值。但是其实我们不需要父页面向子页面发消息,直接在子页面通过这个找到父页面对象就 OK 了,如下: 子页面代码:

js
mui.plusReady(function() {
  var self = plus.webview.currentWebview().parent();
  var username = self.username;
  var password = self.password;
  console.log("username:" + username + "password:" + password);
});

预加载时使用mui.fire()传值

这里需要特别说明一下的是我们有时候想要预加载我们的主页面,这里我们有个地方我需要特别注意的是,我们需要用mui.fire()传递参数:

mui.fire(target,event,data)

特别提醒一下:target是需要接受参数的webview对象,而不是id,在这个地方我出过错误,当时一直没有察觉,如果是id,需要使用plus.webview.getWebviewById(id)进行转换。

比如我们在登陆页面使用preload预加载,代码如下:

js
...
var mainPage = null;
mui.plusReady(function(){
	mainPage = mui.preload({
		"url": 'html/tab-webview-main.html',
		"id": 'main'
	});
})
...

登陆按钮监听事件中的 success 方法:

js
mui.fire(mainPage, "show", {
  username: loginConfig.username.value,
  password: loginConfig.password.value
});
setTimeout(function() {
  mui.openWindow({
    id: "main",
    show: {
      aniShow: "pop-in"
    },
    waiting: {
      autoShow: false
    }
  });
}, 0);

在主页面中通过自定义show事件获得参数:

js
var username = null,
  password = null;
// 页面传参数事件监听
window.addEventListener("show", function(event) {
  // 获得事件参数
  username = event.detail.username;
  password = event.detail.password;
  console.log("username:" + username + "password:" + password);
});

我们需要注意的是我们刚刚在登录页面的账号密码传递到了tab-webview-main.html主页面,但是我们的每个子页面没有拿到账号密码。这里就有个容易犯错的地方,我们可能会直接在创建子webview时候通过拓展参数extras传值。

  • 经过试验发现经过预加载的主界面tab-webview-main.htmlmui.plusReady方法比页面的自定义事件监听先执行,这是因为我们通过预加载的时候其实已经就执行了mui.plusReady方法,而自定义事件是在webview打开的时候执行。当主界面被预加载时,子页面的loaded事件也随着完成,创建子页面的时候我们根本就没有拿到数据怎么传,自然在子页得到的是undefined。我们这个时候如果想在主界面生成子页面的时候通过拓展参数extras传递给子页面根本行不通!
  • 当需要接受参数的webview已经完成loaded事件,我们就不能使用拓展参数extras传参数,这个时候我们可以使用webview.evalJS()或者mui.fire();另外我们使用webview.evalJS()或者mui.fire()时,接收参数的页面的loaded事件也必须发生才能使用。

  • mui 传参数只能相互关联的两个 webview 之间传,比如 A 页面打开 B 页面,B 页面打开 C 页面,A 页面可以传值给 B 页面,但是 A 页面不能传值给 C 页面,我们可以通过 B 页面传给 C 页面。

验证一个 webview 的 loaded 事件是否完成的方法:

js
var ws = plus.webview.getWebviewById(id);
ws.addEventListener(
  "loaded",
  function(e) {
    console.log("Loaded: " + e.target.getURL());
  },
  false
);

验证一个 webview 的 show 事件是否完成的方法:

js
var ws = plus.webview.currentWebview();
ws.addEventListener(
  "show",
  function(e) {
    console.log("Webview Showed");
  },
  false
);

说这两个监听事件有啥用处呢,我们在预加载webview的时候,预加载完成的过程,loaded事件也随之完成,但是只有页面被打开时,show事件才完成,我们可以选择合适的时机发送或者接受参数。

这里需要说明的是如果你想localstorageStorage等本地存储传值,完全可以不用extras或者mui.fire(),当然还可以用url传参数。

因为当初就是为了一个想法,预加载试试,然后试着试着各种问题,不过也因此明白了很多规则和调试方法,在这里提出来顺便总结一下页面传参需要注意的问题,免得新手在此花了很多冤枉时间,搞得现在都快忘了前面写了啥。其实这一部分可以独立出来,但是总感觉这种东西不是啥难事,脱离实际去讲总觉得不合适。

4.获取好友列表及添加好友

获取好友列表

我们在登陆页面与环信的服务器建立了联系,但是由于我们执行跳转了,我们依然还需要在需要请求数据时候在当前页面再次建立连接,前面我们讲到可以通过实例化new Easemob.im.Connection()建立连接,我们这里可以在当前页面实例化建立连接,而不是使用登录时的登陆工具类。实例化new Easemob.im.Connection()的三个步骤大家可以查看前面的内容,这里需要说明的是我们获取好友列表是在conn.init方法的onOpened : function(){}; 中添加 getRoster 回调方法,从而获取好友列表。

js
// 创建连接
var conn = new Easemob.im.Connection();
// 初始化连接
conn.init({
  onOpened: function() {
    // mui.toast("成功登录");
    conn.setPresence(); //设置在线状态
    conn.getRoster({
      success: function(roster) {
        console.log(JSON.stringify(roster));
        // 获取当前登录人的好友列表
        for (var i in roster) {
          var ros = roster[i]; //好友的对象
          //ros.name为好友名称
        }
      }
    });
  }
});

mui.plusReady(function() {
  var self = plus.webview.currentWebview().parent();
  var username = self.username;
  var password = self.password;
  console.log("username:" + username + "password:" + password);
  // 打开连接
  conn.open({
    user: username,
    pwd: password,
    appKey: Easemob.im.config.appkey
  });
});

很显然我们在执行后是空的,因为从开始到现在我们都是自己和自己玩,都没有找朋友,那下面我们就去找朋友,之所以先要把这个先写出来,因为这个我觉得是基本逻辑,你待会儿加了好友,怎么看,就通过这里查询,然后才能说后面的聊天。

添加好友

首先我们得去邀请对方吧,那么我们得知道对方的号码吧,上面我们用的是手机号码作为用户名,为的就是保证用户ID唯一性。

邀请发起方:

我们通过执行conn.subscribe可以发起邀请,添加发起方,获取要添加好友名称,参数为:

js
{
  to: user,  //对方用户名
  message:"加个好友呗"  //对方收到的消息
}

这里我们在头部右上角叫一个添加好友按钮:

html
<button id="addfriend" class="mui-btn mui-btn-blue mui-btn-link mui-pull-right">添加</button>

为了简单演示,我们直接弹出一个输入对话框:

js
// 添加好友
mui("#addfriend")[0].addEventListener("tap", function(e) {
  e.detail.gesture.preventDefault();
  var btnArray = ["确定", "取消"];
  mui.prompt(
    "请输入你要添加的好友的用户名:",
    "手机号",
    "邀请好友",
    btnArray,
    function(e) {
      if (e.index == 0) {
        var user = e.value;
        conn.subscribe({
          to: user,
          message: "加个好友呗"
        });
        mui.toast("邀请发送成功!");
      } else {
        mui.toast("你取消了发送!");
      }
    }
  );
});

需要说明的是如果添加好友是一个单独的页面,或者说所在页面没有和环信建立连接,依然还有进行前面说的三步连接。

邀请接受方: 被添加方,在 con.init 方法中调用 handlePresence 回调方法。

js
conn.init({
  //收到联系人订阅请求的回调方法
  onPresence: function(message) {
    handlePresence(message);
  }
});

//easemobwebim-sdk中收到联系人订阅请求的处理方法,具体的type值所对应的值请参考xmpp协议规范
var handlePresence = function(e) {
  mui.toast(JSON.stringify(e));
  var user = e.from;
  //(发送者希望订阅接收者的出席信息)
  if (e.type == "subscribe") {
    mui.confirm("有人要添加你为好友", "添加好友", ["确定", "取消"], function(
      e
    ) {
      if (e.index == 0) {
        //同意添加好友操作的实现方法
        conn.subscribed({
          to: user,
          message: "[resp:true]"
        });
        mui.toast("你同意添加好友请求");
      } else {
        //拒绝添加好友的方法处理
        conn.unsubscribed({
          to: user,
          message: "rejectAddFriend"
        });
        mui.toast("你拒绝了添加好友");
      }
    });
  }
};

前面登陆注册一直很顺利,没啥问题,但是做这个请求好友的时候就出问题了,我们在发送好友请求的时候,然后切换账号登陆的时候接受不到消息。调了好久才发现一些问题:

  • 我们发送好友的消息在主界面,所以我初始化了连接,接受消息的在子页面也初始化了连接,居然有时候会有提示onflict,有两种方法:第一,主界面不做任何请求的事,点击添加好友时候,父页面给子页面发消息,然后子页面执行请求添加好友;第二,所有的初始化请求放在主界面,然后收到消息给对应的子页面发消息,为了减少请求,个人采用第二种方法。
  • 当解决上面的冲突问题,为什么登录后收不到消息?这里有个略坑的是环信文档中查询好友时候把onOpened中的这句conn.setPresence();屏蔽了,然后就收不到消息。查文档 常见问题 中说:登录之后需要设置在线状态,才能收到消息。请检查登录成功后是否调用过 conn.setPresence();。加上果然没问题了。。。

剩下的功能我们主要看这个文档 初始化连接,主要是说明了初始化时候的一些回调函数的基本用法,我们这里先来看看onPresence,这个是收到联系人订阅请求的回调方法,基本数据类型如下:

js
{
  "from":"xxxxxxxxxxx",
  "to":"yyyyyyyyyyy",
  "fromJid":"jszblog#musicbox_xxxxxxxxxxx@easemob.com",
  "toJid":"jszblog#musicbox_yyyyyyyyyyy@easemob.com",
  "type":"subscribe",
  "chatroom":false,
  "destroy":false,
  "status":"加个好友呗"
}

这里的 xxxxxxxxxxx 和 yyyyyyyyyyy 是电话号码,以为我是用电话作为用户名的,出于隐私保护用字母代替。

当我们切换账号会发现查询好友的地方可以查到好友,下面我们就进行好友列表展示,然后就是和好友聊天咯。

5.数据绑定和本地缓存处理机制

当我们重新登录的时候打印roster时会得到下面的json对象:

js
[
  {
    subscription: "from",
    jid: "jszblog#musicbox_xxxxxxxxxxx@easemob.com",
    name: "xxxxxxxxxxx",
    groups: []
  }
];

为了考虑如果用户没有联网或者数据不能及时更新也能够正常看到历史记录,这里我们考虑做缓存,由于环信web im不具备缓存功能,所以我们这里采用本地存储作为缓存的方案,本地存储可以使用5+中的storage模块,也可以使用localStoragesessionStorage,由于storage模块中的数据有效域不同,可在应用内跨域操作,数据存储期是持久化的,并且没有容量限制,这里我们采用这个方案,至于如果想把本案例中的例子用于浏览器端的同志,可以采用localStorage作缓存功能。

html5+中的storage模块比较简单,文档中介绍了几个基本方法,具体看看文档就可以学会使用,文档见 【storage】

plus.storage.setItem(key, value);

plus.storage.setItem在存储时是以key-value的形式存储,我们可以在查询到好友信息时候,将对象转换成字符串存储在本地,JSON.stringify()json对象转换成json字符串。

js
plus.storage.setItem("roster", JSON.stringify(roster));

plus.storage.getItem(key);

我们在子页面通过plus.storage.getItem获取存储的字符串,然后通过JSON.parse()将字符串转化成对象获取相关信息。

js
var roster = plus.storage.getItem("roster");
var obj = JSON.parse(roster);
for (var i in obj) {
  console.log(obj[i].name);
}

我们现在要做的无非是将信息展示出来,但是这里有用的信息目前只有 name,毕竟没有上传文件,所以也不存在头像、昵称、签名这种个性化信息。如何把json信息展示出来前面的文章中我们是使用直接生成dom节点或者拼接html字符串,但是这种过于繁琐,当然也有人使用【js 模板引擎】,本来准备早点在文章中给一些新手介绍一下vue.js这种MV-*框架,但是考虑本文中实例的性能,暂且还是用之前用过的一个 js 模板引擎artTemplate,文档戳这里:https://github.com/aui/artTemplate。artTemplate有简洁语法版和原生语法版,就是使用语法不一样而已,这里我使用简洁语法版,戳这里下载—— 下载地址

为了简单,我们采用模板中通讯录的 html 结构,文档中有这样的一个例子:

编写模板: 使用一个 type="text/html"script 标签存放模板:

html
<script id="test" type="text/html">
  <h1>{{title}}</h1>
  <ul>
    {{each list as value i}}
      <li>索引 {{i + 1}} :{{value}}</li>
    {{/each}}
  </ul>
</script>

渲染模板:

js
var data = {
  title: "标签",
  list: ["文艺", "博客", "摄影", "电影", "民谣", "旅行", "吉他"]
};
var html = template("test", data);
document.getElementById("content").innerHTML = html;

具体语法参考这里:artTemplate 简洁版语法

我们可以这样写:

html
<div class="mui-content">
  <!--内容-->
  <ul id="roster-cnt" class="mui-table-view mui-table-view-striped mui-table-view-condensed"></ul>
</div>

<<!--模板-->
<script id="roster-tpl" type="text/html">
	{{each roster as value index}}
			<li class="mui-table-view-cell" data-chatname="{{value.name}}">
					<div class="mui-slider-cell">
							<div class="oa-contact-cell mui-table">
									<div class="oa-contact-avatar mui-table-cell">
											<img src="http://placehold.it/60x60" />
									</div>
									<div class="oa-contact-content mui-table-cell">
											<div class="mui-clearfix">
													<h4 class="oa-contact-name">小青年</h4>
													<span class="oa-contact-position mui-h6">湖北</span>
											</div>
											<p class="oa-contact-email mui-h6">
													{{value.name}}
											</p>
									</div>
							</div>
					</div>
			</li>
	{{/each}}
</script>
js
mui.plusReady(function() {
  var roster = plus.storage.getItem("roster");
  // console.log(roster);
  var data = {
    roster: JSON.parse(roster)
  };
  var html = template("roster-tpl", data);
  document.getElementById("roster-cnt").innerHTML = html;
});

我们其实可以直接先遍历找到name然后填充就ok,这为了后续方便添加昵称、地址、头像等个性化地址,直接使用artTemplateeach方法。

6.聊天消息封装

当我们完成了前面登陆、注册、添加好友等功能,我们就进行最重要的内容了,既然是聊天功能,当然要聊起来,不然就不叫IM,但是很多人一开始就太过于关注聊天这个功能,而忽略了前面的基础过程,导致对api不熟悉,自然些聊天过程也是漏洞百出,代码逻辑混乱,所以也就放弃了。本文为即时通讯第一篇,没有介绍过多原理,也没有介绍聊天过程的高级功能,仅作为新手入门的基础篇介绍,后面会再深入探究更多内容。废话不多说,我们继续看文档写下面的内容。

我们先新建一个single-chat.html,本文不打算基于html mui中的页面去构建聊天页面,打算从零开始写。

首先我们需要在刚刚那个通讯录页面里面点击进入聊天页面,将用户名的值传到聊天页面,我们可以直接在创建的时候用拓展参数传,或者预加载打开时用mui.fire(),不多说,自己参考第三小节。

我们先说说布局的问题,先上图

clipboard.png

对应的布局详细代码如下:

html
<style>
.chat-history-date{
  display: block;
  padding-top: 5px;
  text-align: center;
  font-size: 12px;
}
.chat-receiver,.chat-sender{
  margin: 5px;
    clear:both;  
}
.chat-avatar img{
    width: 40px;
    height: 40px;
    border-radius: 50%;
}
.chat-receiver .chat-avatar{
  float: left;
}
.chat-sender .chat-avatar{
  float: right;
}
.chat-content{
  position: relative;
  max-width: 60%;
    min-height: 20px;
  margin: 0 10px 10px 10px;
    padding: 10px;
    font-size:15px;
    border-radius:7px;
}
.chat-content img{
  width: 100%;
}
.chat-receiver .chat-content{
  float: left;
  color: #383838;
    background-color: #f5f5f5;
}
.chat-sender .chat-content{
    float:right;
    color: #ffffff;
    background-color: #15b5e9;
}
.chat-triangle{
  position: absolute;
  top:6px;
  width:0px;
  height:0px;
    border-width:8px;
    border-style:solid;
}
.chat-receiver .chat-triangle{
  left:-16px;
    border-color:transparent #f5f5f5 transparent transparent;
}
.chat-sender .chat-triangle{
  right:-16px;
    border-color:transparent transparent transparent #15b5e9;
}
</style>

<!--消息最后历史时间-->
<p class="chat-history-date">01:59</p>
<!--接收文本消息-->
<div class="chat-receiver">
  <div class="chat-avatar">
      <img src="../img/chat-1.png">
    </div>
    <div class="chat-content">
      <div class="chat-triangle"></div>
      <span>如果是接受消息,请使用.chat-receiver类,如果是发送消息,请使用.chat-sender,头像是.chat-avatar类,内容是.chat-content类。.chat-content下如果是span标签则为文本消息,若为img标签则为图片消息。</span>
    </div>
</div>
<!--发送文本消息-->
<div class="chat-sender">
    <div class="chat-avatar">
      <img src="../img/chat-2.png">
    </div>
    <div class="chat-content">
      <div class="chat-triangle"></div>
      <span>如果你要修改聊天气泡的背景颜色,请修改.chat-content的background-color和.chat-triangle的border-color</span>
    </div>
</div>
<!--发送图片消息-->
<div class="chat-sender">
    <div class="chat-avatar">
      <img src="../img/chat-2.png">
    </div>
    <div class="chat-content">
      <div class="chat-triangle"></div>
      <img src="../img/test.jpg"/>
    </div>
</div>

我们的消息分为发送和收到两种情况,上面是静态效果,我们下面需要做的事获取数据然后动态展示,现在我们先封装一下页面展示效果的代码。这里我们使用两种方法,一种是直接用js生成dom节点,这种使用于结构固定后面不需要改动的,直接用一个js function封装,每次调用一行代码就可以直接显示内容,这样想想都觉得很棒。

老司机,别说话,快看代码!

js
/**
 * @description 显示消息
 * @param {String} who 消息来源,可选参数: {params} 'sender','receiver'
 * @param {Object} type 消息类型,可选参数: {params} 'text','url','img'
 * @param {JSON} data 消息数据,可选参数: {params}
 * ('text'和'url'类型的msg是文字,img类型的msg是img地址)
 */
var appendMsg = function(who, type, data) {
  // 生成节点
  var domCreat = function(node) {
    return document.createElement(node);
  };

  // 基本节点
  var msgItem = domCreat("div"),
    avatarBox = domCreat("div"),
    contentBox = domCreat("div"),
    avatar = domCreat("img"),
    triangle = domCreat("div");

  // 头像节点
  avatarBox.className = "chat-avatar";
  avatar.src = who == "sender" ? data.senderAvatar : data.receiverAvatar;
  avatarBox.appendChild(avatar);

  // 内容节点
  contentBox.className = "chat-content";
  triangle.className = "chat-triangle";
  contentBox.appendChild(triangle);

  // 消息类型
  switch (type) {
    case "text":
      var msgTextNode = domCreat("span");
      var textnode = document.createTextNode(data.msg);
      msgTextNode.appendChild(textnode);
      contentBox.appendChild(msgTextNode);
      break;
    case "url":
      var msgUrlNode = domCreat("a");
      var textnode = document.createTextNode(data.msg);
      if (data.indexOf("http://") < 0) {
        data.msg = "http://" + data.msg;
      }
      msgUrlNode.setAttribute("href", data.msg);
      msgUrlNode.appendChild(textnode);
      contentBox.appendChild(msgUrlNode);
      break;
    case "img":
      var msgImgNode = domCreat("img");
      msgImgNode.src = data.msg;
      contentBox.appendChild(msgImgNode);
      break;
    default:
      break;
  }

  // 节点连接
  msgItem.className = "chat-" + who;
  msgItem.appendChild(avatarBox);
  msgItem.appendChild(contentBox);
  document.querySelector(data.el).appendChild(msgItem);
};

其实后面我们拓展也很容易的,只需要不断加type类型就ok,这些都是dom操作的基本方法,如果对一些方法不熟悉,建议看看相关的内容。这里遵照JSDoc+规范还加上了使用参数提示,在hbuilder使用可以查看参数含义,再也不用担心写代码时忘记了参数含义。

这里我们也可以用模板引擎的办法去封装,代码如下:

html
<script id="msg-tpl" type="text/html">
	<div class="chat-{{who}}">
		<div class="chat-avatar">
				<img src="{{avatar}}">
		</div>
		<div class="chat-content">
				<div class="chat-triangle"></div>
				{{if type=="text"}}
				<span>{{msg}}</span>
		{{else if type=="url"}}
				<a href="{{msg}}">{{msg}}</a>
		{{else if type=="img"}}
				<img src="{{msg}}"/>
		{{/if}}
		</div>
	</div>
</script>

模板渲染:

js
/**
 * @description 显示消息
 * @param {String} who 消息来源,可选参数: {params} 'sender','receiver'
 * @param {Object} type 消息类型,可选参数: {params} 'text','url','img'
 * @param {JSON} data 消息数据,可选参数: {params}
 * ('text'和'url'类型的msg是文字,img类型的msg是img地址)
 */
var appendMsg = function(who, type, data) {
  var html = template("msg-tpl", {
    who: who,
    type: type,
    avatar: who == "sender" ? data.senderAvatar : data.receiverAvatar,
    msg: data.msg
  });
  document.querySelector(data.el).innerHTML += html;
};

大家使用也很简单,调用方法如下:

js
appendMsg("sender", "text", {
  el: "#msg-list", //消息容器
  senderAvatar: "../img/chat-1.png", //发送者头像
  receiverAvatar: "../img/chat-2.png", //接收者头像
  msg: "你好" //消息内容
});

如果大家觉得每次调用还要填写容器 id,头像地址这种基本固定的内容很麻烦,大家也可以继续封装:

js
/**
 * 消息初始化
 */
var msgInit = {
  el: "#msg-list", //消息容器
  senderAvatar: "../img/chat-1.png", //发送者头像
  receiverAvatar: "../img/chat-2.png" //接收者头像
};

/**
 * @description 展示消息精简版
 * @param {String} who 消息来源,可选参数: {params} 'sender','receiver'
 * @param {Object} type 消息类型,可选参数: {params} 'text','url','img'
 * @param {Object} msg ('text'和'url'类型的msg是文字,img类型的msg是img地址)
 */
var msgShow = function(who, type, msg) {
  appendMsg(who, type, {
    el: msgInit.el,
    senderAvatar: msgInit.senderAvatar,
    receiverAvatar: msgInit.receiverAvatar,
    msg: msg
  });
};

调用方法很简单:

js
msgShow("sender", "text", "你好");

两种方法实现封装的函数一样,这里只是给大家演示一下对于这种动态结构的html的一些方法,当然只要你愿意,你可以直接用字符串拼接,或者用<template></template>标签自己做一个这样的模板引擎,或者使用使用更加方便的mvcmvvm框架。

之所以要花大篇幅内容将这些基础内容,是因为看到很多人代码写得那叫一个混乱,如果接口啥的一改,我相信这些人会疯掉,因为代码缺乏一定的通用性,没有把变与不变的内容分别拿出来。当然我们上面其实有些东西没有封装进去,比如用户名或者昵称,这在群聊中是有必要的,这里只是以最简单的例子来说明,大家可以根据自己的业务需求自由发挥。

7.单聊之文本消息

基本思路

其实写到这里本篇基本也算告一段落,但是考虑到很多新手对于收发消息很多还是有一些问题,我们这里就还是把文本消息发送接收写完了再收篇。

上面我们我们讲了怎么把消息展示出来,但是毕竟聊起来数据是动态的,那么发送接收数据是很重要的一步,先来写发送消息。我们先定义一个底部的输入框加按钮,代码如下:

html
<style type="text/css">
footer {
  position: fixed;
  width: 100%;
  height: 50px;
  min-height: 50px;
  border-top: solid 1px #bbb;
  left: 0px;
  bottom: 0px;
  overflow: hidden;
  padding: 0px 50px;
  background-color: #fafafa;
}
.footer-left {
  position: absolute;
  width: 50px;
  height: 50px;
  left: 0px;
  bottom: 0px;
  text-align: center;
  vertical-align: middle;
  line-height: 100%;
  padding: 12px 4px;
}
.footer-right {
  position: absolute;
  width: 50px;
  height: 50px;
  right: 0px;
  bottom: 0px;
  text-align: center;
  vertical-align: middle;
  line-height: 100%;
  padding: 12px 5px;
  display: inline-block;
}
.footer-center {
  height: 100%;
  padding: 5px 0px;
}
.footer-center [class*=input] {
  width: 100%;
  height: 100%;
  border-radius: 5px;
}
.footer-center .input-text {
  background: #fff;
  border: solid 1px #ddd;
  padding: 10px !important;
  font-size: 16px !important;
  line-height: 18px !important;
  font-family: verdana !important;
  overflow: hidden;
}

footer .mui-icon {
    color: #000;
}
footer .mui-icon:active {
  color: #007AFF !important;
}
.footer-right span{
  color: #0062CC;
  line-height: 30px;
}
</style>
<div class="mui-content">
  <div id="msg-list"></div>
</div>
<footer>
  <div class="footer-left">
    <i id='msg-choose-img' class="mui-icon mui-icon-camera" style="font-size: 28px;"></i>
  </div>
  <div class="footer-center">
      <textarea id='msg-text' type="text" class='input-text'></textarea>
  </div>
  <div class="footer-right">
    <span id='msg-send-text'>发送</span>
  </div>
</footer>

为了代码整洁规范,方便后期封装,参考hello muiim-chat.html的写法,我们先定义一下ui控件对象:

js
// UI控件对象
var ui = {
  content: mui(".mui-content"[0]),
  msgList: mui("#msg-list")[0],
  footer: mui("footer")[0],
  msgChooseImg: mui("#msg-choose-img")[0],
  msgText: mui("#msg-text")[0],
  msgSendText: mui("#msg-send-text")[0]
};

发送文本消息很简单:

js
// 发送文本消息
ui.msgSendText.addEventListener("tap", function() {
  sendText();
});

// 发送文本
var sendText = function() {
  var msg = ui.msgText.value.replace(new RegExp("\n", "gm"), "<br/>");
  var validateReg = /^\S+$/;
  // 获得键盘焦点
  msgTextFocus();
  if (validateReg.test(msg)) {
    // 消息展示出来
    msgShow("sender", "text", msg);
    // 发送文本消息到环信服务器
    conn.sendTextMessage({
      to: chatName, //用户登录名,SDK根据AppKey和domain组织jid,如easemob-demo#chatdemoui_**TEST**@easemob.com,中"to:TEST",下同
      msg: msg, //文本消息
      type: "chat"
      //ext :{"extmsg":"extends messages"}//用户自扩展的消息内容(群聊用法相同)
    });
    // 清空文本框
    ui.msgText.value = "";
    // 恢复输入框高度(因为我们这里是50px,你可以写一个全局变量)
    ui.footer.style.height = "50px";
    // 保持输入状态
    mui.trigger(ui.msgText, "input", null);
    // 这一句让内容滚动起来
    msgScrollTop();
  } else {
    mui.toast("文本消息不能为空");
  }
};

这里的msgTextFocus();msgScrollTop();是封装的两个方法,具体的且看下文。

再来说说收消息,我们需要在conn.init()配置设置收到消息的回调函数onTextMessage:

js
// 初始化连接
conn.init({
  onOpened: function() {
    //mui.toast("成功登录");
    conn.setPresence();
  },
  // 收到文本消息时的回调函数
  onTextMessage: function(message) {
    // console.log(JSON.stringify(message));
    var from = message.from; //消息的发送者
    var msg = message.data; //文本消息体
    //mui.toast(msg);
    // 收到文本消息在页面展示
    msgShow("receiver", "text", msg);
    msgScrollTop();
  },
  // 收到图片消息时的回调函数
  onPictureMessage: function(message) {
    handlePictureMessage(message);
  }
});

至此我们完成了基本的文本消息收发功能,但是有几个细节是需要处理的,比如我们上面说的两个函数啥意思,我们没有解释。

获得输入框焦点事件和强制弹出软键盘

我们如果不做处理,在输入框失去焦点时软键盘会自动收回软键盘,这样很影响聊天时候的用户体验。这个时候我们可以在输入完内容,准备发送时,保持输入状态mui.trigger(ui.msgText, 'input', null);

让输入框获得焦点的方法:

js
// 获得输入框键盘焦点
var msgTextFocus = function() {
  ui.msgText.focus();
  setTimeout(function() {
    ui.msgText.focus();
  }, 150);
};

强制弹出软键盘的方法:

js
// 强制弹出软键盘
var showKeyboard = function() {
  if (mui.os.ios) {
    var webView = plus.webview.currentWebview().nativeInstanceObject();
    webView.plusCallMethod({
      setKeyboardDisplayRequiresUserAction: false
    });
  } else if (mui.os.android) {
    var Context = plus.android.importClass("android.content.Context");
    var InputMethodManager = plus.android.importClass(
      "android.view.inputmethod.InputMethodManager"
    );
    var main = plus.android.runtimeMainActivity();
    var imm = main.getSystemService(Context.INPUT_METHOD_SERVICE);
    imm.toggleSoftInput(0, InputMethodManager.SHOW_FORCED);
  }
};

聊天消息高度调整

聊天消息如何发送或者收到一条自己往上滚动呢?我们看 qq 消息就是最后一条消息就会自动出现在输入框之上,调整方法是使用scrollTop方法,通过计算scrollHeight和`offsetHeight 的高度,实现调整。对这些高度不理解?看这里:

其实这个地方有很多技术细节,比如消息高度虽然可以获取,但是要实现局部滚动,那么必须禁止浏览器默认的滚动模式,具体可以看看这篇文章的实现原理浅议内滚动布局

具体 css 样式设置方法:

css
html,
body {
  height: 100%;
  margin: 0px;
  padding: 0px;
  overflow: hidden;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
}
.mui-content{
  height: 100%;
  padding: 44px 0px 50px 0px;
  overflow: auto;
  background-color: #eaeaea;
}
#msg-list {
  height: 100%;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}

调用的函数封装如下:

js
// 消息滚动
var msgScrollTop = function() {
  ui.msgList.scrollTop = ui.msgList.scrollHeight + ui.msgList.offsetHeight;
};

输入框高度如何自适应

不多说直接上代码:

js
// 输入框监听事件
ui.msgText.addEventListener("input", function(event) {
  msgTextFocus();
  ui.footer.style.height = this.scrollHeight + "px";
});

解决长按导致致键盘关闭的问题

js
// 解决长按“发送”按钮,导致键盘关闭的问题;
ui.msgSendText.addEventListener("touchstart", function(event) {
  msgTextFocus();
  event.preventDefault();
});
ui.msgSendText.addEventListener("touchmove", function(event) {
  msgTextFocus();
  event.preventDefault();
});

当做到这里我们基本要讲解的够新手去理解了,但是对于项目功能实现来说,远远不够,毕竟只是文字发送接收,那么图片、语音、地址等等高级功能呢,我们这篇文章限于篇幅不可能一一道来,只能后面再做补充。这里希望更多人参与到其中进行贡献。这里可以放出地址了,详情代码请关注这里:https://github.com/zhaomenghuan/mui-demo/tree/master/example/easemobIM。后期功能拓展和 bug 修复都贵提交到这里,欢迎大家贡献。

写在后面

由于这段时间确实有点忙,这篇文章也花了很多时间去码字,去修改,改了很多次,才有这篇文章,希望能够给新手一些启示和帮助吧!本文不是着重讲环信 sdk 怎么用,而是讲解这个过程中可能会遇到的一些问题和实现思路,所以不建议新手直接拿最后的代码改之类的,还是看懂了思路再说,所以至于这个 IM 更多的功能后期会不会继续开发,暂时是未知数,所以大家不要等待,欢迎大神多多贡献分享相关代码,这样方便更多人学习使用。

2018.05.25 更新说明:

这篇文章2016年06月15日发布,在这之后很多人因为这篇文章找我定制过 IM 的功能,当前文章中的版本和后续的版本可能存在诸多差异,由于精力有限没办法去更正文章中的差异,但是基本思路应该是相通的。如果有类似需求的朋友,我这边可以提供付费版源码,如果需要通过邮件联系我:1028317108@qq.com。

写这些代码也许就一两个小时的事,写一篇大家好接受的文章需要几天的酝酿,如果文章对您有帮助请我喝杯咖啡吧!