前言
記得在初學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代表賬號,最後再調用NIMClient
的getService
方法獲取服務,然後調用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簡仿
源碼下載
本博客例子源碼下載:
源碼下載