基於雲信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簡仿

源碼下載

本博客例子源碼下載:
源碼下載

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章