基于云信IM实现的文字+图片消息聊天功能

前言

记得在初学Android时,自己当时定下的目标的是实现一个QQ,虽然当时的想法比较高,但是自己当时技术不足,很多功能无从下手,最后便做了一些QQ的效果来当做学习,后来就搁置在那里了,然后在大二暑假在工作室做项目,项目里需要用到一个客服的功能,其实就是一个在线聊天,当时也是花了很多功夫,最后是借助三方平台融云的IM来实现的,不过当时时间很紧,于是没有将过程记录下来,正好最近接触到了云信,于是补上一篇实现聊天的博客,功能实现借助的是三方平台–云信,主要是使用了一下云信的IM功能,在这里也非常感谢云信能提供给开发者的服务,点个赞,同时SDK使用起来真的非常方便友好,不过云信的功能是要付费的哦,今天客服小姐姐给我打电话才知道,/略尴尬。

开发前的准备

在开发前,我们首先要去官网注册一个开发者账号,然后创建一个自己的app,创建app后,就会自动生成app对应的appkey,然后为了方便调试,我们在app的管理界面创建两个调试账号,如图,点击账号管理即可创建
这里写图片描述
这两个调试账号用于我们聊天过程中的两个账号互相聊天。
也就是一共需要三个东西,一个appkey,二个调试账号。

正文

首先,我们创建好项目后,需要对项目进行配置,配置方法,官方帮助文档已经说的非常详细了,就不赘述了,我就直接贴一下我配置的,因为我只需要用到IM功能,所以我只配置了如下几个依赖

// 添加依赖。注意,版本号必须一致。
// 基础功能 (必需)
implementation 'com.netease.nimlib:basesdk:5.4.0'
// 聊天室需要
implementation 'com.netease.nimlib:chatroom:5.4.0'
// 小米、华为、魅族、fcm 推送
implementation 'com.netease.nimlib:push:5.4.0'

然后再手动导入相应的so包
这里写图片描述
再在清单文件中注册相应的服务和广播,具体可以去文章末尾下载源码查看,需要说明的是,如果要运行的话,需要将下面的value值更换为你自己申请的key值,否则在最后登陆的时候,会提示登陆失败

<meta-data
    android:name="com.netease.nim.appKey"
    android:value="380d3252cff90baf6dc1718ff931ae70" />

配置工作准备完毕,我们现在开始编写代码,首先我们新建一个BaseApplication,在里面初始化云信的SDK,代码如下

public class BaseApplication extends Application{

    @Override
    public void onCreate() {
        super.onCreate();

        // SDK初始化(启动后台服务,若已经存在用户登录信息, SDK 将完成自动登录)
        NIMClient.init(this, loginInfo(), options());

    }

    // 如果返回值为 null,则全部使用默认参数。
    private SDKOptions options() {
        SDKOptions options = new SDKOptions();

        // 如果将新消息通知提醒托管给 SDK 完成,需要添加以下配置。否则无需设置。
        StatusBarNotificationConfig config = new StatusBarNotificationConfig();
        config.notificationEntrance = ChatActivity.class; // 点击通知栏跳转到该Activity
        config.notificationSmallIconId = R.mipmap.ic_launcher_round;
        // 呼吸灯配置
        config.ledARGB = Color.GREEN;
        config.ledOnMs = 1000;
        config.ledOffMs = 1500;
        // 通知铃声的uri字符串
        config.notificationSound = "android.resource://com.netease.nim.demo/raw/msg";
        options.statusBarNotificationConfig = config;

        // 配置保存图片,文件,log 等数据的目录
        // 如果 options 中没有设置这个值,SDK 会使用下面代码示例中的位置作为 SDK 的数据目录。
        // 该目录目前包含 log, file, image, audio, video, thumb 这6个目录。
        // 如果第三方 APP 需要缓存清理功能, 清理这个目录下面个子目录的内容即可。
        String sdkPath = Environment.getExternalStorageDirectory() + "/" + getPackageName() + "/nim";
        options.sdkStorageRootPath = sdkPath;

        // 配置是否需要预下载附件缩略图,默认为 true
        options.preloadAttach = true;

        // 配置附件缩略图的尺寸大小。表示向服务器请求缩略图文件的大小
        // 该值一般应根据屏幕尺寸来确定, 默认值为 Screen.width / 2
        options.thumbnailSize = 480/2;

        // 用户资料提供者, 目前主要用于提供用户资料,用于新消息通知栏中显示消息来源的头像和昵称
        options.userInfoProvider = new UserInfoProvider() {
            @Override
            public UserInfo getUserInfo(String account) {
                return null;
            }

            @Override
            public String getDisplayNameForMessageNotifier(String account, String sessionId,
                                                           SessionTypeEnum sessionType) {
                return null;
            }

            @Override
            public Bitmap getAvatarForMessageNotifier(SessionTypeEnum sessionType, String sessionId) {
                return null;
            }
        };
        return options;
    }

    // 如果已经存在用户登录信息,返回LoginInfo,否则返回null即可
    private LoginInfo loginInfo() {
        SharedPreferences sp=getSharedPreferences("userinfo",MODE_PRIVATE);
        String userStr=sp.getString("userLogin","");
        if(!TextUtils.isEmpty(userStr)){
            return new Gson().fromJson(userStr, new TypeToken<LoginInfo>(){}.getType());
        }
        return null;
    }

}

首先我的初始化方法是用官方推荐的方法,参数二,使用loginInfo()方法获取本地的用户信息,这个就是如果用户登录了,那么就将用户的信息保存到本地,然后下次初始化就不用获取这些信息了,参数三options() 方法主要是进行一些参数配置,代码中的注释已经很清楚了。
然后我们将AndroidManifest.xml中的Application节点改为自己的BaseApplication。
然后我们首先从登陆开始。写一个简单的布局,2个Edittext和一个Button,然后输入用户名和密码,点击按钮登陆,所以核心代码就是登陆怎么写,看登陆的核心方法:

    private void login() {
        //封装登录信息.
        LoginInfo info = new LoginInfo(et1.getText().toString(), et2.getText().toString());
        //请求服务器的回调
        RequestCallback<LoginInfo> callback =
                new RequestCallback<LoginInfo>() {
                    @Override
                    public void onSuccess(LoginInfo param) {
                        Toast.makeText(MainActivity.this, "登录成功", Toast.LENGTH_SHORT).show();

                        // 可以在此保存LoginInfo到本地,下次启动APP做自动登录用
                        SharedPreferences.Editor editor=sp.edit();
                        editor.putString("userLogin",gson.toJson(param));
                        //跳转到消息页面
                        startActivity(new Intent(MainActivity.this, ContactActivity.class));
                        //NimUIKit.startP2PSession(MainActivity.this, "1234");
                        finish();
                    }

                    @Override
                    public void onFailed(int code) {
                        Toast.makeText(MainActivity.this, "登录失败", Toast.LENGTH_SHORT).show();

                    }

                    @Override
                    public void onException(Throwable exception) {
                        Toast.makeText(MainActivity.this, exception.toString(), Toast.LENGTH_SHORT).show();
                    }

                };
        //发送请求.
        NIMClient.getService(AuthService.class).login(info)
                .setCallback(callback);

    }

可以看到,我们首先封装一个LoginInfo 对象,然后声明一个请求登陆服务的回调,在回调中再根据登陆结果做出相应的动作,其中要注意的就是在登陆成功后记得使用SharedPreferences 保存用户的数据,最后我们再调用login方法登陆,同时将之前声明的回调设置上去,不过说句题外话,这种链式的操作我是非常喜欢的,简洁明了。
登陆成功之后,第二个界面我设置为选择聊天对象的界面,界面元素也很简单,一个ReyclerView的列表,显示几个联系人的信息,然后这里可以设置为之前申请的二个调试账号,然后在点击相应的联系人的时候,将对应的用户信息传递到第三个界面–聊天界面上去,下面重点说一下聊天界面的实现,在开始之前,还是先看一下最后的效果

界面中消息的界面采用RecyclerView,根据消息的情况进行收发,所以所有的核心操作都在RecyclerView的适配器里。
首先为了消息的方便管理,我自己定义了一个消息实体,只是为了简化操作

public class MessageEntity{

    private String message;//消息的文字内容
    private boolean isMine;//是否为自己发出
    private int msgType;//消息类型
    private String imagePath;//图片消息中图片的路径

    public MessageEntity(String message,String imagePath,int msgType, boolean isMine) {
        this.message = message;
        this.imagePath=imagePath;
        this.msgType=msgType;
        this.isMine = isMine;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public int getMsgType() {
        return msgType;
    }

    public void setMsgType(int msgType) {
        this.msgType = msgType;
    }

    public String getImagePath() {
        return imagePath;
    }

    public void setImagePath(String imagePath) {
        this.imagePath = imagePath;
    }

    public boolean isMine() {
        return isMine;
    }

    public void setMine(boolean mine) {
        isMine = mine;
    }

    @Override
    public String toString() {
        return "MessageEntity{" +
                "message='" + message + '\'' +
                ", isMine=" + isMine +
                ", msgType=" + msgType +
                ", imagePath='" + imagePath + '\'' +
                '}';
    }
}

然后我们再来看看RecyclerView 的适配器怎么实现,首先根据消息的情况,我将消息分为了两大类:自己发出的消息和收到的消息,这两种消息需要根据情况作出不同的处理,所以我们列表项的布局需要定义二个,一个用于显示自己发出的消息,一个用于显示收到的消息,然后我增加了一个图片发送和接收的功能,这里的处理是:首先在消息列表项里适当的位置放置好显示文字的TextView和显示图片的ImageView,如果消息类型字段msgType为图片类型,那么将TextView置空,如果为文字类型,那么将ImaeView置为不可见,这样就可以既发文字又发图片了,至于语音和地图之类的,这个只需要将msgType字段多设置几个类型即可,然后修改对应的列表项布局再去扩展。
好了,有了上面的思路,我们再看看适配器的代码

public class MessageAdapter extends RecyclerView.Adapter<MessageAdapter.ViewHolder> {

    private ArrayList<MessageEntity> mData;
    private OnItemClickListener onItemClickListener;
    private LayoutInflater inflater;
    private Context context;

    public enum ITEM_TYPE {
        ITEM_TYPE_MINE,
        ITEM_TYPE_OTHER
    }

    public MessageAdapter(Context context,ArrayList<MessageEntity> data) {
        this.context=context;
        this.mData = data;
        inflater=LayoutInflater.from(context);
    }

    public void updateData(ArrayList<MessageEntity> data) {
        this.mData = data;
        notifyDataSetChanged();
    }


    //参数二为itemView的类型,viewType代表这个类型值
    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
        View v;
        if(viewType==ITEM_TYPE.ITEM_TYPE_MINE.ordinal()){
            v= inflater.inflate(R.layout.item_cv_mine, viewGroup, false);
        }else{
            v= inflater.inflate(R.layout.item_cv_other, viewGroup, false);
        }
        return new ViewHolder(v);
    }

    @Override
    public void onBindViewHolder(@NonNull final ViewHolder holder, int position) {
        // 绑定数据
        MessageEntity entity=mData.get(position);
        if(entity.getMsgType()==1){
            holder.mTv.setVisibility(View.VISIBLE);
            holder.mTv.setText(entity.getMessage());
            holder.mIv.setVisibility(View.GONE);
        }else{
            holder.mIv.setVisibility(View.VISIBLE);
            holder.mTv.setVisibility(View.GONE);
            RequestOptions options = new RequestOptions()
                    .transforms(new RotateTransformation(ImageUtils.parseImageDegree(entity.getImagePath())));
            Glide.with(context).load(entity.getImagePath()).apply(options).into(holder.mIv);
        }
        holder.itemView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(final View v) {
                if (onItemClickListener != null) {
                    int pos = holder.getLayoutPosition();
                    onItemClickListener.onItemClick(holder.itemView, pos);
                }
            }
        });

        holder.itemView.setOnLongClickListener(new View.OnLongClickListener() {
            @Override
            public boolean onLongClick(View v) {
                if (onItemClickListener != null) {
                    int pos = holder.getLayoutPosition();
                    onItemClickListener.onItemLongClick(holder.itemView, pos);
                }
                //表示此事件已经消费,不会触发单击事件
                return true;
            }
        });
    }

    @Override
    public int getItemViewType(int position) {
        if(mData.get(position).isMine()){
            return ITEM_TYPE.ITEM_TYPE_MINE.ordinal();
        }
        return ITEM_TYPE.ITEM_TYPE_OTHER.ordinal();
    }

    @Override
    public int getItemCount() {
        return mData == null ? 0 : mData.size();
    }

    public static class ViewHolder extends RecyclerView.ViewHolder {

        TextView mTv;
        ImageView mIv;

        public ViewHolder(View itemView) {
            super(itemView);
            mTv = itemView.findViewById(R.id.item_tv);
            mIv=itemView.findViewById(R.id.iv);
        }
    }

    public void addNewItem(MessageEntity entity) {
        if (mData == null) {
            mData = new ArrayList<>();
        }
        mData.add(getItemCount(), entity);
        notifyDataSetChanged();
    }

    public void deleteItem(int position) {
        if (mData == null || mData.isEmpty()) {
            return;
        }
        mData.remove(position);
        notifyDataSetChanged();
    }

    public void setOnItemClickListener(MessageAdapter.OnItemClickListener listener) {
        this.onItemClickListener = listener;
    }

    public interface OnItemClickListener {
        void onItemClick(View view, int position);

        void onItemLongClick(View view, int position);
    }

}

ITEM_TYPE 枚举类型就是代表当前消息的来源,一个是自己发出的,一个是接收到的,在onCreateViewHolder 方法中根据viewType参数来设置对应的布局,然后我们在onBindViewHolder 方法中根究viewType字段的值去判断是图片消息还是文字消息,再作出相应的逻辑处理,当然不要忘记重写getItemViewType 方法来设置列表项类型,不然我们在onCreateViewHolder 中根据参数viewType 是获取不到的。
有了适配器之后,我们再在Activity中作出相应的逻辑操作,首先是发送消息,代码如下

                //发送消息
                // 以单聊类型为例
                SessionTypeEnum sessionType = SessionTypeEnum.P2P;
                String text = et3.getText().toString();
                // 创建一个文本消息
                IMMessage textMessage = MessageBuilder.createTextMessage(account, sessionType, text);
                // 发送给对方
                NIMClient.getService(MsgService.class).sendMessage(textMessage, false);
                //tv2.setText(text);
                mAdapter.addNewItem(new MessageEntity(text,null,1,true));
                mRecyclerView.scrollToPosition(list.size());
                et3.setText("");

首先获取当前聊天的类型,单聊还是群聊等,这里设置为单聊,然后利用MessageBuilder构建一个IMMessage对象,其中accout代表账号,最后再调用NIMClientgetService方法获取服务,然后调用sendMessage来发送IMMessage对象, 整个过程还是很简单的,发送完成之后,我们还要更新我们的界面,调用adapter提供的addNewItem方法,构建一个MessageEntity对象,因为我这里例子是发送文本消息,所以参数一为文本内容,参数二图片路径为空,参数三消息类型为1(1代表文本消息,2代表图片消息),参数四代表是否为本人发出,这里是我们主动发出的消息,所以设置为true。然后为了用户体验效果更好,在每发送一条消息之后,将RecyclerView滚动到当前消息列表的最下方,最后再将EditText 置空,完毕。
上面的是发送文字消息,接下来看怎么实现发送图片消息。
首先发送消息,我设置的为先跳转到媒体的图片选择界面,获取到对应的图片之后,再将图片以消息的形式发送出去。
图片按钮点击事件如下:

                Intent intent = new Intent(Intent.ACTION_PICK);
                intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,"image/*");
                startActivityForResult(intent,1);

在onActivityResult方法中的代码如下:

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        if(requestCode==1){
            //获取真实路径,防止在某些机型,如小米中,获取的路径为空
            Uri uri=Uri.parse(PathUtils.getRealUri(ChatActivity.this,data.getData()));
            //转化为file文件
            File imageFile=new File(uri.toString());
            //构造图片消息对象
            IMMessage message = MessageBuilder.createImageMessage(account,SessionTypeEnum.P2P, imageFile, imageFile.getName());
            //发送图片消息
            NIMClient.getService(MsgService.class).sendMessage(message, false);
            mAdapter.addNewItem(new MessageEntity(null,data.getData().toString(),2,true));
            mRecyclerView.scrollToPosition(list.size());
        }
    }

好了,我们现在已经可以发送消息了,接下来就是消息的接收,消息的接收按照官方推荐,采用观察者模式,在onCreate 方法中注册,同时要根据收到的消息类型作一下判断,看收到的消息是文本消息还是图片消息,然后再更新adapter 的数据。代码如下:

private void initMessageObserver(){
        // 处理新收到的消息,为了上传处理方便,SDK 保证参数 messages 全部来自同一个聊天对象。
        //消息接收观察者
        incomingMessageObserver = new Observer<List<IMMessage>>() {
            @Override
            public void onEvent(List<IMMessage> messages) {
                // 处理新收到的消息,为了上传处理方便,SDK 保证参数 messages 全部来自同一个聊天对象。
                IMMessage imMessage = messages.get(0);

                if(imMessage.getMsgType().equals(MsgTypeEnum.text)){//文本消息
                    String messageStr=imMessage.getContent();
                    mAdapter.addNewItem(new MessageEntity(messageStr,null,1,false));
                }else if(imMessage.getMsgType().equals(MsgTypeEnum.image)){//图片消息
                    ImageAttachment msgAttachment=(ImageAttachment)imMessage.getAttachment();

                    String uri=msgAttachment.getThumbUrl();
                    mAdapter.addNewItem(new MessageEntity(null,uri,2,false));
                }

                account = imMessage.getFromAccount();
            }
        };
        //注册消息接收观察者,
        //true,代表注册.false,代表注销
        NIMClient.getService(MsgServiceObserve.class)
                .observeReceiveMessage(incomingMessageObserver, true);
    }

注意在获取图片消息这里,看官方文档好久没明白,消息的接收(除了文本消息)这里说的有点简略,然后一直纠结于怎么获取ImageAttachment对象,后来跑去官方例子demo中看源码才找到,原来只需要强制转换一下就行,然后得到ImageAttachment 对象后,我们就可以获取路径,然后交给适配器去处理了
最后这里的消息接收观察者,官方推荐在onDestroy里注销一下,如下

@Override
protected void onDestroy() {
    super.onDestroy();
    //注销消息接收观察者.
    NIMClient.getService(MsgServiceObserve.class)
        .observeReceiveMessage(incomingMessageObserver, false);
}

至此,核心功能基本都实现了,剩下的就是些细枝末节的东西了,比如动态权限的申请,发送图片时部分机型的图片旋转问题,消息的背景图等等,还有互踢下线的功能官方文档也提供了详细的解决方案。
看下最终的图片发送效果
消息的发送方:

消息的接收方如下

然后在实现完单聊功能的时候,云信还默认帮我们实现了通知栏的推送,最后运行的时候,你会发现,如果你当前的应用不在前台的话,就会接收到相关的消息推送,点击推送,即可进入对应的聊天界面,效果如下

结语

好了,本篇告一段落,嘻嘻嘻,项目当中当然肯定还有很多不足的地方,不过有了雏形,后续就各自发挥啦,初次接触云信IM的朋友可以参考参考,同时,云信非常的贴心,还提供了UI组件供开发者直接使用,不过我没考虑使用,因为我想自己动手做,实际开发的话,还是使用云信提供的UI组件比较好,毕竟是人家封装的,功能细节完善程度都是很好的,另外对于本文中的例子有什么疑问的,欢迎留言交流,我基本每天在线。

不知道为啥我对即时通讯好像特别感兴趣,之前写过一个简单的socket通信demo,感觉socket通信入门也不是特别难,准备考虑后期自己搭一个socket通信的服务器,然后将云信IM部分也自己手动来实现,到时候做完了,再整理一篇博客。

最后附上一下后来整理的之前写的QQ简仿的博客,有兴趣的可以去瞅瞅,毕竟这是我学Android时做的第一个小app,到现在都没舍得删,静静的躺在我的手机里,嘿嘿!
安卓开发个人小作品(2)- QQ简仿

源码下载

本博客例子源码下载:
源码下载

猜你喜欢

转载自blog.csdn.net/hq942845204/article/details/81191979