前言
可能很多人谈到即时通信就望而却步,包括我之前也是一样,长链接、自动重连、保活、消息存储等等,感觉每个都是个大项目,一般我都是转头就去找第三方平台。
这种想法可能在前几年,没有 OkHttp,没有各种封装的数据库的时候,确实是比较麻烦的,几年前也写过一篇使用 Netty
实现的聊天 demo Android 长连接初体验(基于netty)
而现在有了这些优秀的开源框架,站在巨人的肩膀上,我们也可以实现一个完整的即时通信应用。
希望这篇文章,能让大家觉得即时通信也没什么难的,不再依靠第三方平台。
功能拆分
长链接
我们知道,WebSocket
近些年在客户端的应用非常广泛,而且现在OkHttp
也可以方便快捷的使用WebSocket
,因此我们也使用WebSocket
作为通信的桥梁自动重连
移动设备是无法保证网络质量的,因此我们需要支持断线自动重连保活
时至今日,已经没有真正意义的保活了,不得不说,这也是国内 Android 环境的进化,那么微信等是怎么做到保活的呢,是因为微信的影响力太大,各大手机厂商都开了后门
保活的目的是为了让应用进入后台后,仍然可以畅通无阻的收到消息,现如今各大厂商都已经提供了系统级推送,当应用进入后台之后,我们利用厂商推送进行消息提醒,无需再做保活
消息存储
服务端的消息是实时发送的,为了方便用户查看历史消息,我们需要将消息存储在本地数据库,而且一般聊天都支持账号切换,因此还需要考虑多数据库存储离线消息
当应用被系统 kill,或者设备断网,期间的消息将无法收到,因此还需要获取离线消息的功能,保证用户收到的消息是完整的消息展示
上面的功能是我们实现聊天的基础,而聊天最终是用户交互的,这里主要介绍会话列表页和聊天页
长链接
OkHttp
3.5 开始支持 WebSocket
,你只需要一个 ws 链接,即可快速与服务器链接
object WebSocketManager {
private const val WS_URL = "ws://x.x.x"
private val httpClient by lazy {
OkHttpClient().newBuilder()
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.pingInterval(40, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
private var webSocket: WebSocket? = null
private fun connect() {
val request = Request.Builder()
.url(WS_URL)
.build()
httpClient.newWebSocket(request, wsListener)
}
private val wsListener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
super.onOpen(webSocket, response)
// 连接建立
}
override fun onMessage(webSocket: WebSocket, text: String) {
super.onMessage(webSocket, text)
// 收到服务端发送来的 String 类型消息
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
super.onClosing(webSocket, code, reason)
// 收到服务端发来的 CLOSE 帧消息,准备关闭连接
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
super.onClosed(webSocket, code, reason)
// 连接关闭
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
super.onFailure(webSocket, t, response)
// 出错了
}
}
}
是不是非常简单?
不过到这里我们仅仅是实现了与服务端的连接,接下来添加自动重连
自动重连
我们知道,移动设备可能经常遇到网络差或者移动 / WiFi 网络切换,这时长链接将会断开,我们需要在合适的时机重新连接服务器
用户登录
这个由业务决定,一般是监听登录状态,登录成功即连接,退出登录即断连
网络从断开切换到连接状态
这个很好理解,主要发生在设备从无网到有网,从移动网络切换到 WiFi 网络,这里注册网络状态监听即可
object NetworkStateManager : CoroutineScope by MainScope() {
private const val TAG = "NetworkStateManager"
private val _networkState = MutableLiveData(false)
val networkState: LiveData = _networkState
@JvmStatic
fun init(context: Context) {
_networkState.postValue(NetworkUtils.isNetworkConnected(context))
val filter = IntentFilter()
filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION)
context.registerReceiver(NetworkStateReceiver(), filter)
}
class NetworkStateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null || intent == null) {
return
}
val isConnected =
intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false).not()
Log.d(TAG, "network state changed, is connected: $isConnected")
launch {
_networkState.postValue(isConnected)
}
}
}
}
提供 LiveData
监听网络状态
应用从后台切换到前台
部分厂商在设备开启节能模式后可能会限制应用后台联网,即应用进入后台就无法连接网络,但是设备并没有断网,因此网络状态监听失效,这种场景我们可以在应用切回前台后尝试重连
object AppForeground : Application.ActivityLifecycleCallbacks {
private var foregroundActivityCount = 0
private val appForegroundInternal = MutableLiveData(false)
val appForeground: LiveData = appForegroundInternal
fun init(application: Application) {
application.registerActivityLifecycleCallbacks(this)
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
}
override fun onActivityStarted(activity: Activity) {
foregroundActivityCount++
if (appForegroundInternal.value == false) {
appForegroundInternal.value = true
}
}
override fun onActivityResumed(activity: Activity) {
}
override fun onActivityPaused(activity: Activity) {
}
override fun onActivityStopped(activity: Activity) {
foregroundActivityCount--
if (foregroundActivityCount == 0 && appForegroundInternal.value == true) {
appForegroundInternal.value = false
}
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
}
override fun onActivityDestroyed(activity: Activity) {
}
}
定时重连
有这样一种场景,用户连接了无效的 WiFi 网络,即网络为连接状态,但是却无法连接互联网,或者服务器秃然宕机,导致无法连接成功,因此我们需要定时重连机制
为了避免重复的无效连接,我们可以使用斐波那契数列作为重连的时间,但是也不能无限变大,需要一个最大重连时间
同时为了避免服务器宕机后,每个设备使用相同的重连间隔,导致服务器恢复后所有设备同时连接,连接数瞬间达到峰值,很可能导致服务器再次宕机,我们需要使用一个随机重连时间
private const val MAX_INTERVAL = DateUtils.HOUR_IN_MILLIS
private var lastInterval = 0L
private var currInterval = 1000L
private fun getReconnectInterval(): Long {
if (currInterval >= MAX_INTERVAL) {
return MAX_INTERVAL
}
val interval = lastInterval + currInterval
lastInterval = currInterval
currInterval = interval
return interval
}
private fun resetReconnectInterval() {
lastInterval = 0
// 使用随机数,避免服务器宕机后所有人同时连接,再次宕机
currInterval = Random.nextLong(1000, 2000)
}
整理下长链接和自动重连部分的完整代码
object WebSocketManager {
private const val WS_URL = "ws://x.x.x"
private lateinit var threadHandler: Handler
private val httpClient by lazy {
OkHttpClient().newBuilder()
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.pingInterval(40, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
private var webSocket: WebSocket? = null
private const val MAX_INTERVAL = DateUtils.HOUR_IN_MILLIS
private var lastInterval = 0L
private var currInterval = 1000L
private val _connectState = MutableLiveData(ConnectState.DISCONNECT)
val connectState: LiveData = _connectState
enum class ConnectState {
CONNECTING,
CONNECTED,
DISCONNECT
}
fun init(context: Context) {
val handlerThread = HandlerThread(TAG)
handlerThread.start()
threadHandler = Handler(handlerThread.looper)
NetworkStateManager.networkState.observeForever { isConnected ->
if (isConnected && connectState.value == ConnectState.DISCONNECT) {
resetReconnectInterval()
// 判断网络状态有延时,延迟重连
connect(1000)
}
}
// APP 回到前台,尝试重连
AppForeground.appForeground.observeForever { foreground ->
Log.d(TAG, "app foreground state changed, is foreground: $foreground")
if (foreground && connectState.value == ConnectState.DISCONNECT) {
connect(1000)
}
}
}
private fun connect(delay: Long = 0) {
removeCallbacks(connectRunnable)
runInThread(connectRunnable, delay)
}
private fun autoReconnect() {
val interval = getReconnectInterval()
removeCallbacks(connectRunnable)
runInThread(connectRunnable, interval)
}
private val connectRunnable = Runnable {
if (connectState.value != ConnectState.DISCONNECT) {
Log.w(TAG, "connect cancel cause state error")
return@Runnable
}
if (!NetworkUtils.isNetworkConnected(context)) {
Log.w(TAG, "connect cancel cause network disconnect")
return@Runnable
}
removeBindTimeoutRunnable()
realConnect()
}
private fun realConnect() {
_connectState.postValue(ConnectState.CONNECTING)
val request = Request.Builder()
.url(WS_URL)
.build()
httpClient.newWebSocket(request, wsListener)
}
private val wsListener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
super.onOpen(webSocket, response)
// 连接建立
runInThread {
[email protected] = webSocket
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
super.onMessage(webSocket, text)
// 收到服务端发送来的 String 类型消息
runInThread {
handleMessage(text)
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
super.onClosing(webSocket, code, reason)
// 收到服务端发来的 CLOSE 帧消息,准备关闭连接
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
super.onClosed(webSocket, code, reason)
// 连接关闭
onFailure(webSocket, IllegalStateException("web socket closed unexpected"), null)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
super.onFailure(webSocket, t, response)
// 出错了
runInThread {
[email protected] = null
_connectState.postValue(ConnectState.DISCONNECT)
autoReconnect()
}
}
}
private fun release() {
webSocket?.cancel()
}
private fun getReconnectInterval(): Long {
if (currInterval >= MAX_INTERVAL) {
return MAX_INTERVAL
}
val interval = lastInterval + currInterval
lastInterval = currInterval
currInterval = interval
return interval
}
private fun resetReconnectInterval() {
lastInterval = 0
// 使用随机数,避免服务器宕机后所有人同时连接,再次宕机
currInterval = Random.nextLong(1000, 2000)
}
private fun runInThread(r: Runnable) {
runInThread(r, 0)
}
private fun runInThread(r: Runnable, delay: Long) {
threadHandler.postDelayed(r, delay)
}
private fun removeCallbacks(r: Runnable) {
threadHandler.removeCallbacks(r)
}
}
消息存储
我们首先梳理表结构
聊天整理分为会话
和消息
两部分,我们可以以此来划分数据库表,分为2张表,一张存储会话,一张存储消息
有些人可能有疑问,私聊和单聊是否需要区分?
在我看来,私聊和单聊只是两种聊天形式,存储的数据没有区别,因此无需区分
会话表
由于一个会话只会有一个聊天对象,私聊是对方,群聊是群组,因此可以使用聊天对象 ID 作为主键消息表
消息表中,消息和聊天对象是多对多的存在,我们可以使用消息 ID 或自增 ID 作为主键
对于账号切换,我们需要支持不同数据库切换,可使用用户 ID 作为数据库名称,用户登录成功后切换到对应的数据上
Google
在 JetPack
中提供了 Room
数据库,帮助我们方便的操作 SqlLite
数据库,不过考虑到快速切换数据库的便捷性
,最终选择了郭神的 LitePal
你们要的多数据库功能终于来了
会话表
data class ConversionBean(
// 聊天对象 ID
@Column(unique = true, index = true, nullable = false)
private val chat_id: String? = null,
// 会话类型,私聊 or 群聊
@Column(nullable = false, defaultValue = ChatType.PERSON)
private val chat_type: String? = null,
// 会话名称
@Column(nullable = true)
var name: String? = null,
// 会话头像
@Column(nullable = true)
var avatar: String? = null,
// 最近一条消息
@Column(nullable = true)
var last_message: String? = null,
// 未读数
@Column(nullable = false, defaultValue = "0")
var unread_count: Int? = null,
// 最近一条消息的时间
@Column(nullable = false, defaultValue = "0")
var update_time: Long? = null,
) : LitePalSupport()
消息表
data class MessageBean(
// 消息 ID
@Column(index = true, nullable = false, defaultValue = "0")
var msg_id: Long? = null,
// 聊天对象 ID
@Column(index = true, nullable = false)
private val chat_id: String? = null,
// 会话类型,私聊 or 群聊
@Column(nullable = false, defaultValue = ChatType.PERSON)
private val chat_type: String? = null,
// 消息类型
@Column(nullable = false, defaultValue = MsgType.TEXT)
private val msg_type: String? = null,
// 消息发送者 ID
@Column(nullable = false)
val from_uuid: String? = null,
// 消息发送者昵称
@Column(nullable = true)
val from_nickname: String? = null,
// 消息发送者头像
@Column(nullable = true)
val from_avatar: String? = null,
// 消息内容
@Column(nullable = true)
val content: String? = null,
// 是否已读
@Column(index = true, nullable = false, defaultValue = "0")
var is_read: Int? = null,
// 消息发送状态
@Column(defaultValue = MsgStatus.SUCCESS)
var status: String? = null,
// 消息发送时间
@Column(index = true, nullable = false, defaultValue = "0")
val time: Long? = null,
) : LitePalSupport()
数据库操作
object IMDatabase {
fun init(context: Context) {
LitePal.initialize(context)
loginState.observeForever { isLogin ->
if (isLogin) {
onLogin()
} else {
onLogout()
}
}
}
/**
* 登录成功,打开数据库
*/
fun onLogin() {
if (uuid.isNotEmpty()) {
val litePalDB = LitePalDB.fromDefault("im#${uuid}")
LitePal.use(litePalDB!!)
}
}
/**
* 注销登录,关闭数据库
*/
fun onLogout() {
LitePal.useDefault()
}
/**
* 查询会话列表
*/
fun queryConversionList(): List {
return LitePal.order("update_time desc").find(ConversionBean::class.java)
}
/**
* 获取会话对象
*/
fun getConversion(chatId: String): ConversionBean? {
return LitePal.where("chat_id = ?", chatId).findFirst(ConversionBean::class.java)
}
/**
* 保存会话,用于本地新建会话
*/
fun saveConversion(
chatId: String,
chatType: String,
name: String? = null,
avatar: String? = null,
lastMsg: String? = null,
unreadCount: Int? = null,
updateTime: Long? = null
): ConversionBean? {
val conversion = ConversionBean(
chat_id = chatId,
chat_type = chatType,
name = name,
avatar = avatar,
last_message = lastMsg,
unread_count = unreadCount,
update_time = updateTime ?: System.currentTimeMillis()
)
conversion.save()
return conversion
}
/**
* 更新会话信息
*/
fun updateConversion(
chatId: String,
name: String? = null,
avatar: String? = null,
lastMsg: String? = null,
unreadCount: Int? = null,
unreadCountAdd: Int? = null,
updateTime: Long? = null
) {
val conversion = getConversion(chatId) ?: return
if (name != null) {
conversion.name = name
}
// ...
conversion.save()
}
/**
* 删除会话
*/
fun deleteConversion(chatId: String) {
LitePal.deleteAll(ConversionBean::class.java, "chat_id = ?", chatId)
}
/**
* 会话设为已读
*/
fun setRead(chatId: String) {
LitePal.where("chat_id = ?", chatId)
.findFirst(ConversionBean::class.java)?.apply {
unread_count = 0
save()
}
MessageBean(is_read = 1).updateAllAsync("chat_id = ? AND is_read = ?", chatId, "0")
}
/**
* 查询消息
*/
fun queryMessageList(chatId: String, offset: Int, limit: Int): MutableList {
return LitePal.where("chat_id = ?", chatId)
.order("time desc")
.offset(offset)
.limit(limit)
.find(MessageBean::class.java)
}
/**
* 保存消息
*/
fun saveMessage(message: MessageBean) {
message.save()
}
/**
* 批量保存消息
*/
fun saveMessageList(msgList: List) {
LitePal.saveAll(msgList)
}
/**
* 更新消息发送状态
*/
fun updateMessageStatus(id: Long, status: String, msg_id: Long? = null) {
val bean = MessageBean(status = status, msg_id = msg_id)
bean.update(id)
}
/**
* 删除会话消息
*/
fun deleteMessages(chatId: String) {
LitePal.deleteAll(MessageBean::class.java, "chat_id = ?", chatId)
}
}
离线消息
在长链接建立成功后通过 API 接口获取离线消息即可
可以向服务器提供最新一条消息的 ID,获取所有离线消息
由于这里和具体业务有关,因此仅提供实现思路
消息展示
为了便于逻辑层和 UI 层交互,我们将和 UI 相关的逻辑抽象出接口,提供给 UI
根据功能划分,我们可以提供 会话服务
、消息接收服务
、消息发送服务
会话服务
interface ConversionService : IMService {
// 全部未读数,一般是在入口处展示
val totalUnreadCount: LiveData
// 会话列表
val conversionList: LiveData>
/**
* 获取会话
*/
fun getConversion(chatId: String): ConversionBean?
/**
* 发起新会话
*/
fun newConversion(
chatId: String,
chatType: String,
name: String?,
avatar: String?
): ConversionBean?
/**
* 进入会话,不再提示新消息
*/
fun onEnterConversion(chatId: String)
/**
* 离开会话,继续提示新消息
*/
fun onExitConversion(chatId: String)
/**
* 更新会话信息
*/
suspend fun updateConversionInfo(
chatId: String,
name: String?,
avatar: String?
)
/**
* 删除会话
*/
suspend fun deleteConversion(chatId: String)
/**
* 清空会话消息
*/
suspend fun deleteMessages(chatId: String)
}
消息接收服务
typealias MessageObserver = (msgList: List) -> Unit
interface MessageReceiveService : IMService {
/**
* 添加新消息监听
*/
fun addMessageObserve(observer: MessageObserver)
/**
* 移出新消息监听
*/
fun removeMessageObserve(observer: MessageObserver)
/**
* 查询消息
*/
suspend fun queryMessageList(chatId: String, offset: Int, limit: Int): List
}
消息发送服务
typealias SendMessageCallback = (result: Result) -> Unit
typealias MessageStatusObserver = (msg: MessageBean) -> Unit
interface MessageSendService : IMService {
/**
* 添加消息状态监听
*/
fun addMessageStatusObserve(observer: MessageStatusObserver)
/**
* 移出消息状态监听
*/
fun removeMessageStatusObserve(observer: MessageStatusObserver)
/**
* 发送文本消息
*/
fun sendTextMessage(uuid: String, chatType: String, text: String, callback: SendMessageCallback)
/**
* 发送图片消息
*/
fun sendImageMessage(uuid: String, chatType: String, file: File, callback: SendMessageCallback)
/**
* 重试发送
*/
fun resendMessage(msg: MessageBean, callback: SendMessageCallback?)
}
目前仅实现了发送文本和图片消息,还可以扩展更多的消息类型
服务的实现类也比较简单,主要是消息发送、接收逻辑处理,和数据库接口的调用
有了这些服务接口,对于 UI 来说就比较简单了,无需感知具体实现
会话列表
监听 ConversionService#conversionList
的数据更新,更新 UI 即可
IM.getService().conversionList.observe(this) {
adapter.refresh(it)
if (it.isEmpty()) {
showEmpty()
} else {
showSuccess()
}
}
聊天页面
从数据库获取历史消息
lifecycleScope.launch {
val list = IM.getService()
.queryMessageList(conversion.getChatId(), messageList.size, QUERY_MSG_COUNT)
messageList.clear()
messageList.addAll(list)
adapter.notifyDataSetChanged()
scrollToBottom()
}
添加新消息监听,收到新消息后添加到消息列表
IM.getService().addMessageObserve(messageObserver)
private val messageObserver: MessageObserver = { list ->
list.forEach { msg ->
if (msg.getChatId() == conversion.getChatId()) {
onNewMessage(msg)
}
}
}
private fun onNewMessage(msg: MessageBean) {
messageList.add(msg)
adapter.notifyDataSetChanged()
scrollToBottom()
}
监听输入框和发送按钮,调用 API 接口发送消息
private fun sendTextMsg() {
val text = viewBinding.etInput.text.toString()
viewBinding.btnSend.isEnabled = false
IM.getService()
.sendTextMessage(conversion.getChatId(), conversion.getChatType(), text) { result ->
viewBinding.btnSend.isEnabled = true
if (result.isSuccess) {
onNewMessage(result.getOrNull()!!)
viewBinding.etInput.text = null
} else {
"发送失败,请稍后再试".toast()
}
}
}
对于不同类型的消息,可以使用 RecyclerView
的 viewType
区分展示,其实消息的很多属性都是通用的,比如头像、昵称、时间等,因此我们可以封装一个消息基类,不同类型的消息继承该基类,只需要关心消息内容的渲染即可
消息 Item 基类
abstract class MessageBaseViewHolder(
private val binding: ItemChatMessageBaseBinding,
private val listener: OnMessageEventListener? = null
) : RecyclerView.ViewHolder(binding.root) {
protected val content: View
protected lateinit var msg: MessageBean
init {
content = LayoutInflater.from(binding.root.context)
.inflate(getContentResId(), binding.content, false)
binding.content.addView(content)
val onClickListener = ClickListener()
binding.ivPortraitRight.setOnClickListener(onClickListener)
binding.ivPortraitLeft.setOnClickListener(onClickListener)
binding.ivMessageStatus.setOnClickListener(onClickListener)
}
@LayoutRes
protected abstract fun getContentResId(): Int
fun onBind(msg: MessageBean) {
this.msg = msg
setGravity()
setPortrait()
refreshContent()
setNickname()
setTime()
setStatus()
}
protected fun isReceivedMessage(): Boolean {
return msg.isFromMe().not()
}
protected abstract fun refreshContent()
protected open fun isCenterMessage(): Boolean {
return false
}
protected open fun isShowNick(): Boolean {
return msg.getChatType() == ChatType.GROUP && isReceivedMessage() && isCenterMessage().not()
}
protected open fun isShowTime(): Boolean {
return msg.isShowTime == true
}
private fun setGravity() {
val gravity = if (isCenterMessage()) {
Gravity.CENTER_HORIZONTAL
} else if (isReceivedMessage()) {
Gravity.LEFT
} else {
Gravity.RIGHT
}
binding.contentWithStatus.gravity = gravity
}
private fun setPortrait() {
binding.ivPortraitRight.visibleOrGone(false)
binding.ivPortraitLeft.visibleOrGone(false)
var show: ImageView? = null
if (isReceivedMessage() && !isCenterMessage()) {
binding.ivPortraitLeft.visibleOrGone(true)
show = binding.ivPortraitLeft
} else if (!isReceivedMessage() && !isCenterMessage()) {
binding.ivPortraitRight.visibleOrGone(true)
show = binding.ivPortraitRight
}
show?.loadAvatar(getPortraitUrl())
}
private fun setNickname() {
binding.tvNickname.text = if (isShowNick()) this.msg.getFromNickname() else null
}
private fun setTime() {
binding.tvTime.visibleOrGone(isShowTime())
binding.tvTime.text = this.msg.time.dateFriendly()
}
private fun setStatus() {
binding.ivMessageStatus.visibleOrGone(msg.isFromMe() && msg.status == MsgStatus.FAIL)
}
private fun getPortraitUrl(): String? {
if (isReceivedMessage()) {
return msg.from_avatar
} else {
return UserCenter.userInfoState.value?.avatar
}
}
}
文本消息 Item
open class MessageTextViewHolder(
binding: ItemChatMessageBaseBinding,
listener: OnMessageEventListener? = null
) : MessageBaseViewHolder(binding, listener) {
protected val tvMessageContent: TextView by lazy {
content.findViewById(R.id.tvMessageContent)
}
override fun getContentResId(): Int {
return R.layout.item_chat_message_text
}
override fun refreshContent() {
tvMessageContent.isSelected = !isReceivedMessage()
val content = msg.content
tvMessageContent.text = content
}
}
至此,一个简单的即时通信功能已基本完成。
总结
本文以个人亲身经历带大家梳理了 APP 即时通信的主要功能拆分,和简单的实现方式,由于涉及项目代码,因此不便贴出源码。
如果对大家有帮助,请点赞支持,如果大家在开发过程中遇到问题也可以在文章下评论留言,我会尽可能帮大家解答。