# GoChat
**Repository Path**: MUYS/GoChat
## Basic Information
- **Project Name**: GoChat
- **Description**: 安卓局域网聊天 Socket私聊 UDP聊天室
- **Primary Language**: Java
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 3
- **Forks**: 1
- **Created**: 2022-08-11
- **Last Updated**: 2025-03-16
## Categories & Tags
**Categories**: Uncategorized
**Tags**: Android
## README
# GoChat 安卓局域网聊天
## 简介
功能:实现在统一局域网下手机与手机之间的相互通信,实现指定用户发送文字以及图片消息。
## 功能
1. 每次用户打开app,可以将自己已上线的消息广播到局域网中
2. 同局域网之间用户可以互相搜索到,同时可以更新新上线的设备
3. 扫描到设备可以选择指定用户聊天,可以添加未扫描到的用户 IP 聊天
4. 用户可以自行更改用户名
5. 聊天界面可以发送文字消息,可以发送图片消息
## 实现
> 本人写代码的水平比较菜,不喜勿喷
### 一 、界面
#### 主界面实现
这是主界面,主要用于承载 Fragment
```xml
```
其中的 `b_n_v_menu` 文件为自定义的 `menu` 文件,用于显示底部导航栏按钮
```xml
```
这是子界面,负责显示:消息、群聊、附近设备页面。这里只放一个页面,需要详细信息请查看我的代码
```xml
```
接下来是实现 ViewPager2 的适配器,让其可以加载 Fragment 碎片
```java
/**
* ViewPager2 中添加 Fragment 的适配器
*/
public class Vp2Adapter extends FragmentStateAdapter {
private List mFragments;
/**
* 构造方法
* @param fragmentManager 碎片管理器
* @param lifecycle 生命周期
* @param fragments fragment列表
*/
public Vp2Adapter(@NonNull FragmentManager fragmentManager, @NonNull Lifecycle lifecycle, List fragments) {
super(fragmentManager, lifecycle);
this.mFragments = fragments;
}
/**
*
* @param position 当前创建 fragment 索引值
* @return 返回当前创建的 fragment
*/
@NonNull
@Override
public Fragment createFragment(int position) {
return mFragments.get(position);
}
/**
* 获取 fragment 的长度
* @return fragment 的长度
*/
@Override
public int getItemCount() {
return mFragments.size();
}
}
```
接下来在 `MainActivity.java`的 `onCreate`方法中加入如下代码给获取到的 `ViewPager2`
```java
//1.将三个 fragment 添加到 ViewPager2 中
//1.1创建三个 Fragment
MessageFragment messageFragment = new MessageFragment();
ContactFragment contactFragment = new ContactFragment();
NearbyFragment nearbyFragment = new NearbyFragment();
//1.2将他们添加到集合中
ArrayList fragments = new ArrayList<>();
fragments.add(messageFragment);
fragments.add(contactFragment);
fragments.add(nearbyFragment);
//设置适配器
mViewPager2.setAdapter(new Vp2Adapter(getSupportFragmentManager(),getLifecycle(),fragments));
```
绑定 `ViewPager2` 和 `BottomNavigationView`,是其中一个翻页或点击就可以影响到另一个控件翻页或点击
```java
//设置翻页时底部导航栏动作
mViewPager2.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
final int[] ids = {R.id.message,R.id.contact,R.id.nearby};
mBottomNavigationView.setSelectedItemId(ids[position]);
}
});
//设置点击底部导航栏翻页
mBottomNavigationView.setOnItemSelectedListener(item -> {
int itemId = item.getItemId();
switch (itemId){
case R.id.message:
mViewPager2.setCurrentItem(0);
break;
case R.id.contact:
mViewPager2.setCurrentItem(1);
break;
case R.id.nearby:
mViewPager2.setCurrentItem(2);
break;
}
return true;
});
```
#### 聊天界面实现
主要还是`RecyclerView`就不贴代码了,附上显示聊天记录的 item:`item_chat_list.xml`
### 二 、功能实现
#### 协议
作为一个通信功能,首先得自定义一个协议,用来承载信息,方便对信息的处理
1. 定义消息类型
```java
public class MsgType {
//文字消息
public static final String MESSAGE = "message";
//文字消息
public static final String RECEIPT = "receipt";
//请求昵称
public static final String GET_NAME = "get_name";
//图片消息
public static final String IMAGE = "image";
//群消息
public static final String GROUP_MESSAGE = "group_message";
}
```
2. 首先是请求协议 `RvResponseProtocol.java`
```java
/**
* 自定义协议
* 以 json 形式呈现
*/
public class RvRequestProtocol {
//消息类型
private String type;
//消息内容
private T data;
}
```
3. 响应协议`RvResponseProtocol.java`继承了请求协议,多了响应成功抓状态码功能
```java
/**
* 自定义协议
* 以 json 形式呈现
*/
public class RvResponseProtocol extends RvRequestProtocol{
public static final Integer OK = 200;
public static final Integer FAIL = 500;
//状态码
private Integer status;
}
```
#### 广播实现
1. 需要`udp`服务端用于接收广播:`UDPSocketServerThread.java`
> 服务端接收到广播后,判断接收到的消息类型,并通过`Handle`通知`UI`线程更新相关内容。并保存发送者的用户信息
>
> ```java
> try {
> byte[] bytes = dp.getData();
> bytes = StrZipUtil.uncompressA(bytes);
> bytes = StrZipUtil.uncompressA(bytes);
> String json = StrZipUtil.uncompress(bytes);
> LogUtil.d("接收到",json);
> RvRequestProtocol rvRequestProtocol = gson.fromJson(json, RvRequestProtocol.class);
> String hostAddress = dp.getAddress().getHostAddress();
> String data = rvRequestProtocol.getData();
> String type = rvRequestProtocol.getType();
> if (type.equals(MsgType.MESSAGE)){
> DataUtil.getNameMap().put(hostAddress, data);
> Message message = new Message();
> message.what = Status.SUCCESS;
> mHander.sendMessage(message);
> }else if (type.equals(MsgType.GROUP_MESSAGE)){
> Message message = new Message();
> message.what = Status.GROUP_SUCCESS;
> message.obj = new InputMsgBean(hostAddress,rvRequestProtocol);
> mHander.sendMessage(message);
> }
> LogUtil.d(TAG,"接收到来自 " + hostAddress + " 的数据:" + data);
> } catch (JsonSyntaxException | IOException e) {
> e.printStackTrace();
> }
> ```
2. 需要`udp`客户端用户发送广播:`UDPSocketClientThread.java`
> 在客户端中,每隔 5 秒发送一次广播,通知局域网设备在线,发送的内容为当前设备名称
>
> ```java
> do {
> RvRequestProtocol requestProtocol = new RvRequestProtocol<>(MsgType.MESSAGE, DataUtil.getUsername());
> String json = gson.toJson(requestProtocol);
> byte[] bytes = StrZipUtil.compress(json);
> bytes = StrZipUtil.compress(bytes);
> byte[] compress = StrZipUtil.compress(bytes);
> DatagramPacket dp = new DatagramPacket(compress,compress.length, adds, DataUtil.getUDPPort());
> ds.send(dp);
> LogUtil.d(TAG,"发送消息");
> sleep(5 * 1000);
> } while (true);
> ```
3. 需要一个类用户发送`UDP`消息:`UDPSendMsgThread.java`,用户发送群聊消息
4. 创建一个`UDP`服务:`UDPSocketService.java`,在启动时调用`UDPSocketServerThread`打开`UDP`服务端和服务端,同时创建一个线程,检查当前所有设备是否在线,每分钟检测一次。
```java
new Thread(() -> {
while (true){
try {
Thread.sleep(60 * 1000);
LogUtil.d(TAG, "开始检测设备");
Map nameMap = DataUtil.getNameMap();
ArrayList keys = new ArrayList<>();
nameMap.forEach((key,val) -> {
try {
Thread.sleep(200);
Socket socket = new Socket();
InetSocketAddress inetSocketAddress = new InetSocketAddress(key, DataUtil.getTCPPort());
try {
socket.connect(inetSocketAddress,200);
} catch (IOException e) {
LogUtil.d(TAG,"设备掉线:" + key);
keys.add(key);
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
keys.forEach(nameMap::remove);
Message message = new Message();
message.what = Status.SUCCESS;
mHandler.sendMessage(message);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
```
5. 使用 `EventBus`通知`UI`线程更新显示
```java
EventBus.getDefault().post(new BusToGroupEvent(Status.SUCCESS));
```
6. 更新`UI`代码见源码
7. 群聊(消息显示见私聊)
- 发送文字消息,直接将文字消息封装成协议对象,发送。
- 发送图片消息,因为`UDP`的限制,图片不能太大,我先将图片压缩,再转为 `base64`,将最后的协议内容压缩为二进制数据发送。接收端解析,并显示图片。
#### TCP 扫描与私聊
##### 服务端
创建服务`SocketServerService.java`,启动服务端:`TCPSocketServerThread.java`。用于接收消息,判断消息类型,通知`UI`主线程。详情看代码
##### 扫描附近用户功能实现
`UDP TCP`结合,`UDP`接收到上线广播在扫描界面展示,`TCP`主动在局域网内扫描设备,核心代码:
```java
public void run() {
//创建长度为 50 的线程池
ExecutorService executorService = Executors.newFixedThreadPool(254);
final CountDownLatch latch = new CountDownLatch(254);
LogUtil.d(TAG, "scanIp: 开始扫描 " + mIps + " 网段");
//扫描局域网内的设备
for (int i = 1; i <= 255; i++) {
String ip = mIps + i;
if (ip.equals(mHostIp)) {
continue;
}
executorService.execute(() -> {
Socket socket = new Socket();
InetSocketAddress inetSocketAddress = new InetSocketAddress(ip, DataUtil.getTCPPort());
//消息对象,给主线程发送消息
Message message = new Message();
message.obj = ip;
try {
socket.connect(inetSocketAddress,200);
message.what = Status.SUCCESS;
} catch (IOException e) {
message.what = Status.ERROR;
} finally {
mHandler.sendMessage(message);
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
latch.countDown();
});
}
try {
latch.await();
executorService.shutdown();
//完成
LogUtil.d(TAG, "完成");
Message message = new Message();
message.what = Status.FINISH;
mHandler.sendMessage(message);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
```
两种方式的区别,若所处的网络环境稳定几乎不会出以下问题
`UDP`:能接收到在不同网段的消息,但缺点是不够稳定
`TCP`:稳定,但是想全网段扫描的话极其耗费资源,并且快速扫描会被路由器拦截
我将二者结合,使其在校园网内运行更稳定
##### 私聊
1. 发送文字消息时,调用`TCPSendMsgThread.java`内部的方法,封装成协议内容,发送消息。
2. 发送图片消息时,因为`TCP`对大数据的支持良好,所以可以直接将图片转为`base64`直接发送,直接解析
3. 接收到消息显示(群聊同理):
- 首先判断是否对方消息,来显示左右,
- 再判断是否图片消息,来显示图片文字
```java
if (dialogBean.isMine()) {
left.setVisibility(View.GONE);
TextView time = itemView.findViewById(R.id.right_time);
TextView content = itemView.findViewById(R.id.right_content);
ImageView imageView = itemView.findViewById(R.id.right_image);
if (dialogBean.getDataType() == DataType.WORD){
imageView.setVisibility(View.GONE);
time.setText(dialogBean.getTime());
String text = (String) dialogBean.getContent();
content.setText(text);
content.setOnLongClickListener(view -> {
ClipboardManager clipboardManager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clipData = ClipData.newPlainText(text, text);
clipboardManager.setPrimaryClip(clipData);
Toast.makeText(ChatActivity.this, "复制成功", Toast.LENGTH_SHORT).show();
return true;
});
}else {
content.setVisibility(View.GONE);
time.setText(dialogBean.getTime());
Bitmap bit = (Bitmap) dialogBean.getContent();
imageView.setImageBitmap(bit);
imageView.setOnClickListener(view -> {
Intent intent = new Intent(this, PhotoViewActivity.class);
intent.putExtra("ip",mIp);
intent.putExtra("position",position);
startActivity(intent);
});
imageView.setOnLongClickListener(v -> {
saveBitmap(bit);
return true;
});
}
} else {
right.setVisibility(View.GONE);
TextView time = itemView.findViewById(R.id.left_time);
TextView content = itemView.findViewById(R.id.left_content);
ImageView imageView = itemView.findViewById(R.id.left_image);
if (dialogBean.getDataType() == DataType.WORD){
imageView.setVisibility(View.GONE);
time.setText(dialogBean.getTime());
String text = (String) dialogBean.getContent();
content.setText(text);
content.setOnLongClickListener(view -> {
ClipboardManager clipboardManager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clipData = ClipData.newPlainText(mIp, text);
clipboardManager.setPrimaryClip(clipData);
Toast.makeText(ChatActivity.this, "复制成功", Toast.LENGTH_SHORT).show();
return true;
});
}else {
content.setVisibility(View.GONE);
time.setText(dialogBean.getTime());
Bitmap bit = (Bitmap) dialogBean.getContent();
imageView.setImageBitmap(bit);
imageView.setOnClickListener(view -> {
Intent intent = new Intent(this, PhotoViewActivity.class);
intent.putExtra("ip",mIp);
intent.putExtra("position",position);
startActivity(intent);
});
imageView.setOnLongClickListener(v -> {
saveBitmap(bit);
return true;
});
}
}
```
## 总结
很Ok