一天天太能心血来潮,昨天在看UDP的时候突然手痒想写一个基于UDP的聊天app,想着挺简单结果搞了很久才搞出来。话不多说,上代码。
这个项目使用Jetpack框架搭建,Kotlin编写。
1. UDP通信工具类
import android.text.format.Formatter
import android.util.Log
import com.psychedelic.udpchat.ChatEntity
import com.psychedelic.udpchat.FROM_OTHERS
import com.psychedelic.udpchat.FROM_SELF
import com.psychedelic.udpchat.TAG
import com.psychedelic.udpchat.listener.UdpMessageListener
import com.psychedelic.udpchat.listener.UdpMessageSendListener
import java.io.IOException
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.SocketException
class UdpManager(ipAddress: Int,listener:UdpMessageListener) {
private var mLocalIp:String ?=null
private val mIntIpAddress = ipAddress
private val mPort = 8211
private val mListener = listener
fun sendUdpMsg(data: String,sendListener: UdpMessageSendListener) {
if (data.isEmpty() || data.isBlank()){
return
}
/*这一步就是将本机的IP地址转换成xxx.xxx.xxx.255*/
val broadCastIP = mIntIpAddress or -0x1000000
mLocalIp = "/${Formatter.formatIpAddress(mIntIpAddress)}"
Log.d(TAG, "sendUdpMsg ip = $broadCastIP")
var sendSocket: DatagramSocket? = null
try {
val server: InetAddress = InetAddress.getByName(Formatter.formatIpAddress(broadCastIP))
Log.d(TAG, "sendUdpMsg server = $server")
sendSocket = DatagramSocket()
val msg = String(data.toByteArray(),Charsets.UTF_8)
Log.d(TAG,"msg = $msg")
val theOutput = DatagramPacket((msg).toByteArray(), msg.toByteArray().size, server, mPort)
Log.d(TAG, "mLocalIp = $mLocalIp")
sendSocket.send(theOutput)
sendListener.sendSuccess()
Log.d(TAG, "sendUdpMsg send !!!")
} catch (e: IOException) {
e.printStackTrace()
} finally {
sendSocket?.close()
}
}
fun receiverUdpMsg() {
Log.d(TAG, "receiverUdpMsg")
val buffer = ByteArray(1024)
/*在这里同样使用约定好的端口*/
var server: DatagramSocket? = null
try {
server = DatagramSocket(mPort)
val packet = DatagramPacket(buffer, buffer.size)
while (true) {
try {
server.receive(packet)
val content = String(packet.data, 0, packet.length, Charsets.UTF_8)
Log.d(TAG,"content = $content ")
Log.d(TAG, "get ip = ${packet.address} mLocalIP = $mLocalIp")
val msg = ChatEntity().apply { text = content }
if (packet.address.toString() == mLocalIp){
msg.fromWho = FROM_SELF
}else{
msg.fromWho = FROM_OTHERS
}
mListener.onMessageReceive(msg)
Log.d(TAG,"address : " + packet.address + ", port : " + packet.port + ", content : " + content)
} catch (e: IOException) {
e.printStackTrace()
}
}
} catch (e: SocketException) {
Log.d(TAG, "err")
e.printStackTrace()
} finally {
server?.close()
}
}
}
通过WifiManager获取本地IP,然后给路由器发送UDP包,路由器会全频段广播,那么只要其他设备监听了这个端口就能收到消息,端口我写死了,以后会改成可设置的,这样也能监听其他设备的UDP广播。
除了利用路由器发送广播的方式,也可以遍历0到255所有的IP地址查找局域网中的设备,获取到对方的IP地址后可以定向发,也可以去建立稳定的TCP连接,这里不展开了。广播的UDP消息自己也能收到,为了区分对比IP地址,收到的包IP地址如果和本机相同就是自己发的。
2. XML页面
写一个简单的聊天页面:
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".MainActivity">
<androidx.appcompat.widget.Toolbar
android:id="@+id/chat_tool_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/chat_recycle_view"
android:elevation="15dp"
android:background="#F2F2F2"
app:titleTextColor="#bfbfbf"
app:navigationIcon="@mipmap/back"
>
</androidx.appcompat.widget.Toolbar>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chat_recycle_view"
app:layout_constraintTop_toBottomOf="@+id/chat_tool_bar"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toTopOf="@+id/chat_bottom_bar"
android:layout_width="match_parent"
android:layout_height="0dp"
android:elevation="5dp"
/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/chat_bottom_bar"
android:layout_width="match_parent"
android:layout_height="56dp"
app:layout_constraintTop_toBottomOf="@+id/chat_recycle_view"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:elevation="15dp"
android:background="#F2F2F2"
>
<Button
android:id="@+id/chat_button_send"
android:layout_width="60dp"
android:layout_height="40dp"
android:layout_marginEnd="10dp"
android:onClick="sendMessageButtonClick"
android:background="@drawable/chat_send_btn_selector"
android:text="@string/button_send"
android:textColor="#ffffff"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toRightOf="@+id/chat_edit_text"
app:layout_constraintRight_toRightOf="parent"
/>
<EditText
android:id="@+id/chat_edit_text"
android:layout_width="0dp"
android:layout_height="40dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginStart="20dp"
android:layout_marginEnd="10dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@+id/chat_button_send"
android:background="#ffffff"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
RecyclerView的Item布局,一个是收到消息的布局,一个是自己发送的消息布局
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="item"
type="com.psychedelic.udpchat.ChatEntity" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/chat_activity_ll_receive_chat_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="300dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:minHeight="43dp"
android:layout_margin="20dp"
android:gravity="center_vertical"
android:background="@mipmap/chatfrom_bg_normal"
android:text="@{item.text}"
android:textSize="20sp"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="item"
type="com.psychedelic.udpchat.ChatEntity" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/chat_activity_ll_receive_chat_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="300dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:minHeight="43dp"
android:layout_margin="20dp"
android:gravity="center_vertical"
android:background="@mipmap/chatto_bg_normal"
android:text="@{item.text}"
android:textSize="20sp"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
3. Activity
package com.psychedelic.udpchat
import android.content.Context
import android.content.pm.PackageManager
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import com.psychedelic.udpchat.databinding.ActivityMainBinding
import com.psychedelic.udpchat.listener.MainActivityObserver
import com.psychedelic.udpchat.listener.UdpMessageListener
import com.psychedelic.udpchat.listener.UdpMessageSendListener
import com.psychedelic.udpchat.mvvm.MainViewModel
import com.psychedelic.udpchat.util.StatusBarUtil
const val TAG = "MainActivity"
class MainActivity : AppCompatActivity(),UdpMessageListener {
private val mContext = this
private var mList = ArrayList<ChatEntity>()
private lateinit var mAdapter: ChatRvAdapter
private lateinit var mBinding: ActivityMainBinding
private lateinit var mWifiManager: WifiManager
private lateinit var mWifiInfo: WifiInfo
private lateinit var mViewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
StatusBarUtil.setStatusTextColor(true, this)
window.statusBarColor = resources.getColor(R.color.bar_color)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
mViewModel = ViewModelProvider(this).get(MainViewModel::class.java)
mWifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
mWifiInfo = mWifiManager.connectionInfo
Log.d(TAG, "SSID = ${mWifiInfo.ssid}")
mBinding.chatToolBar.title = mWifiInfo.ssid
setSupportActionBar(mBinding.chatToolBar)
mAdapter = ChatRvAdapter(this, mList, BR.item)
mBinding.chatRecycleView.layoutManager = LinearLayoutManager(this)
mBinding.chatRecycleView.adapter = mAdapter
mBinding.chatToolBar.setNavigationOnClickListener {
finish()
}
lifecycle.addObserver(MainActivityObserver(mContext,mWifiManager,mViewModel,this))
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == REQUEST_PERMISSIONS) {
for ((index, permission) in permissions.withIndex()) {
if (grantResults[index] != PackageManager.PERMISSION_GRANTED) {
Log.d(
TAG,
"permission = $permission grantResults[index] = ${grantResults[index]}"
)
}
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun refreshNewMessage(msg: ChatEntity) {
Log.d(TAG, "refreshNewMessage msg = ${msg.text}")
runOnUiThread {
mList.add(msg)
mAdapter.refreshData(mList)
scrollToEnd()
}
}
fun sendMessageButtonClick(view: View) {
if (mBinding.chatEditText.text.isNotEmpty()) {
mViewModel.sendUdpMsg(mBinding.chatEditText.text.toString(),
object : UdpMessageSendListener {
override fun sendSuccess() {
runOnUiThread {
mBinding.chatEditText.text.clear()
}
}
})
}
}
private fun scrollToEnd() {
//刷新消息的时候需要将RecycleView滚动到最后一行以显示最新消息
if (mBinding.chatRecycleView.adapter!!.itemCount > 0) {
mBinding.chatRecycleView.smoothScrollToPosition(mBinding.chatRecycleView.adapter!!.itemCount)
}
}
override fun onMessageReceive(msg: ChatEntity) {
refreshNewMessage(msg)
}
}
使用了lifeCycle,这里碰到了点坑,我以前不用ActionBar或者ToolBar,都是自己写的布局。这次用了ToolBar,发现其使用的时候逻辑顺序有严格要求,比如title设置必须在setSupportActionBar之前,setNavigationOnClickListener则必须要在setSupportActionBar之后,否则设置无效。
还有我把当前Wifi的SSID也就是名称作为Title,一开始发现无论怎么获取得到的SSID都是空的,后来上网查发现Android 8.0之后需要添加上网络定位权限才能通过WifiManager获取到SSID,加上权限之后就好了。
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
注意需要获取动态运行时权限,我放在LifeCycleObserver中了
4. MainActivityObserver
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import com.psychedelic.udpchat.REQUEST_PERMISSIONS
import com.psychedelic.udpchat.mvvm.MainViewModel
class MainActivityObserver(context: Context,wifiManager: WifiManager,viewModel:MainViewModel,listener: UdpMessageListener):LifecycleObserver {
private val mContext = context
private val mListener = listener
private val mViewModel = viewModel
private var mWifiManager: WifiManager = wifiManager
private val permissions = arrayOf<String>(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun create(){
requestPermission()
if (!lackPermission()){
val ipAddress = mWifiManager.connectionInfo.ipAddress
mViewModel.startReceiveUdpMsg(ipAddress,mListener)
}else{
Toast.makeText(mContext,"缺少网络权限,请授权后重试",Toast.LENGTH_LONG).show()
(mContext as Activity).finish()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun destroy(){
mViewModel.shutDownExecutor()
}
private fun requestPermission(){
if (lackPermission()) {
ActivityCompat.requestPermissions(
mContext as Activity,
permissions,
REQUEST_PERMISSIONS
)
}
}
private fun lackPermission():Boolean{
for (permission in permissions){
if (ContextCompat.checkSelfPermission(mContext,permission)!= PackageManager.PERMISSION_GRANTED){
return true
}
}
return false
}
}
5. MainViewModel
UDPManager的调用放在了ViewModel中
import androidx.lifecycle.ViewModel
import com.psychedelic.udpchat.listener.UdpMessageListener
import com.psychedelic.udpchat.listener.UdpMessageSendListener
import com.psychedelic.udpchat.net.UdpManager
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class MainViewModel():ViewModel(){
private lateinit var mReceiveExecutor: ExecutorService
private lateinit var mSendExecutor: ExecutorService
private lateinit var mUdpManager: UdpManager
fun startReceiveUdpMsg(intIpAddress:Int,listener:UdpMessageListener){
mUdpManager = UdpManager(intIpAddress,listener)
mReceiveExecutor = Executors.newSingleThreadExecutor()
mSendExecutor = Executors.newFixedThreadPool(5)
mReceiveExecutor.submit { mUdpManager.receiverUdpMsg()}
}
fun sendUdpMsg(msg:String,listener: UdpMessageSendListener){
mSendExecutor.submit {
mUdpManager.sendUdpMsg(msg,listener)
}
}
fun shutDownExecutor(){
mSendExecutor.shutdown()
mReceiveExecutor.shutdown()
}
}
最后附上RecyclerView Adapter的代码
6. ChatRvAdapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.RecyclerView
import com.psychedelic.udpchat.databinding.ChatItemFromBinding
import com.psychedelic.udpchat.databinding.ChatItemToBinding
const val CHAT_TYPE_SEND_TXT = 1
const val CHAT_TYPE_GET_TXT = CHAT_TYPE_SEND_TXT + 1
class ChatRvAdapter(context: Context, list: ArrayList<ChatEntity>, variableId: Int) :
RecyclerView.Adapter<ChatRvAdapter.ViewHolder>() {
private val mContext = context
private var mList: ArrayList<ChatEntity> = list
private val mVariableId = variableId
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private var binding: ViewDataBinding? = null
fun getBinding(): ViewDataBinding {
return binding!!
}
fun setBinding(binding: ViewDataBinding) {
this.binding = binding
}
}
fun refreshData(list:ArrayList<ChatEntity>){
mList = list
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int {
return mList[position].fromWho
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
if (viewType == CHAT_TYPE_SEND_TXT) {
val chatSendBinding = DataBindingUtil.inflate<ChatItemToBinding>(
LayoutInflater.from(mContext),
R.layout.chat_item_to,
parent,
false
)
val viewHolder = ViewHolder(chatSendBinding.root)
viewHolder.setBinding(chatSendBinding)
return viewHolder
} else {
val chatFromBinding = DataBindingUtil.inflate<ChatItemFromBinding>(
LayoutInflater.from(mContext),
R.layout.chat_item_from,
parent,
false
)
val viewHolder = ViewHolder(chatFromBinding.root)
viewHolder.setBinding(chatFromBinding)
return viewHolder
}
}
override fun getItemCount(): Int {
return mList.size
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.getBinding().setVariable(mVariableId, mList[position])
holder.getBinding().executePendingBindings()
}
}
完整项目Github地址:UdpChat
项目效果:
只要安装此APP那么在局域网下的所有人都可以加入这个聊天室,即使没有连接到因特网也可以,没有去收集发送方的MAC地址,所以这个软件是匿名聊天的,而且聊天记录放在内存中,没有做持久化,推出APP就会销毁。
以后有空会拿这个DEMO用Room去做一下聊天记录存储,用mac标识联系人,并且提供加密传输选项。还是挺好玩的。
Over!