本篇主要是分享基于unity的客户端socket网络通信方案。关于服务器的c#-socekt搭建放在了这里《基于C#的Tcp服务端通信》。其中关于socekt粘包断包的处理放在这里分享了《C# socket粘包断包处理》。

目录

整体设计

TcpClient

连接建立

消息发送

消息接收

关闭连接

完整代码

TcpClientMgr-业务处理

消息发送

消息接收

心跳和心跳超时

消息等待和超时

完整代码


整体设计

如图所示,一共采用了两层封装来处理整个客户端的逻辑。

首先TcpClient脚本只处理最基础的连接建立,消息的发送和接收。

TcpClientMgr管理和穿件TcpClient。在利用好连接建立,消息收发的基础上再处理业务上的需求:心跳、消息等待、事件传递等。

TcpClient

该层分为四个部分:

  1. 连接建立
  2. 消息发送
  3. 消息接收
  4. 关闭连接

连接建立

连接的创建这块采用的是异步连接Socket.ConnectAsync,并且通过Timer来处理连接超时的情况。

            // 创建套接字mClientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);SocketAsyncEventArgs connectArgs = new SocketAsyncEventArgs();connectArgs.UserToken = mClientSocket;// 设置ip和端口号connectArgs.RemoteEndPoint = new IPEndPoint(IPAddress.Parse(strIP), intPort);// 设置完成回调connectArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnConnect);// 启动异步连接mClientSocket.ConnectAsync(connectArgs);

超时的设计是,当开启异步连接的同时创建一定时器,定时器3秒之后执行。执行的内容为关闭连接。若期间连接上了则将定时器任务关闭。

            // 连接超时定时器System.Timers.Timer waitTimeOut;  // 设置超时waitTimeOut = new System.Timers.Timer();waitTimeOut.AutoReset = false;waitTimeOut.Elapsed += OnConnectTimeOut;waitTimeOut.Interval = 3000;waitTimeOut.Start();// 超时处理private void OnConnectTimeOut(object sender, System.Timers.ElapsedEventArgs e){if (mClientSocket != null && !mClientSocket.Connected){FDebug.LogError("客户端连接超时");Close();}}

连接成功之后就可以开启发送和接收任务了(线程),若连接收到的状态不是Success则执行连接失败的回调方法。

        private void OnConnect(object sender, SocketAsyncEventArgs e){// 终止超时设置waitTimeOut.Stop();if (e.SocketError == SocketError.Success){// 开启发送线程// 开启接收线程// 发送一个连接请求SendConnect();FDebug.Log("客户端连接成功!");}else{FDebug.LogError("客户端连接失败"+e.SocketError.ToString());mFnOnConnectFailed?.Invoke((int)e.SocketError);}}

中间有一个处理是SendConnect(),是给服务器推送一条连接协议。这里面的设计方案是,若服务器收到并返回一个连接协议,才将我们的连接状态标记改为true。

业务层TcpClientMgr的处理都依赖于这个标记为true。主要是为了确保我们的消息接发正常再跑业务逻辑。

消息发送

设置一个消息发送队列

外部只管向队列中放入我们约定的消息结构TcpData

内部开启一个线程不停的从队列中取得数据并通过socket发送出去

关于TcpData的定义可以参考一下这里《基于C#的Tcp服务端通信》

                // 发送消息线程Thread threadSend = null;// 发送消息队列private BlockQueue<TcpData> mSendQueue = new BlockQueue<TcpData>(50);                // 开启发送线程threadSend = new Thread(HandleSend);threadSend.IsBackground = true;threadSend.Start();// 发送任务private void HandleSend(){while (bAlive){try{if (mSendQueue.TryDequeue(out var tcpData)){byte[] bytesData = tcpData.Get();mClientSocket.Send(bytesData);}}catch(Exception ex){FDebug.Log("客户端发送消息异常:" + ex.Message);Close();}}}

消息接收

同样的设置一个消息接收队列

内部开启一个线程去接收来自服务器的消息,收到消息后转换为TcpData存储到队列中

外部主线程定时的从队列中取得消息

                // 接收消息线程Thread threadRecive = null;// 接收消息队列private BlockQueue<TcpData> mRecvQueue = new BlockQueue<TcpData>(50);// 开启接收线程threadRecive = new Thread(HandleRecieve);threadRecive.IsBackground = true;threadRecive.Start();// 接收消息任务private void HandleRecieve(){while (bAlive){try{SocketError error;int intLength = mClientSocket.Receive(mArryBytesRecMsg, 0 , mArryBytesRecMsg.Length, SocketFlags.None, out error);if (intLength == 0)continue;if (!bSocketError){bSocketError = error != SocketError.Success;}byte[] arryBytes = CommonTool.SubArry(mArryBytesRecMsg, 0, intLength);// 处理粘包断包后if (mTcpReciever.HandleRecMessage(arryBytes)){TcpData tcpData = null;while (mTcpReciever.TryGetReciveTcpData(out tcpData)){// 处理连接消息if (!mBConnected && tcpData.protocol == TcpProtocol.TCP_S2C_CONNECT){mBConnected = true;mFnOnConnectSuccess?.Invoke();FDebug.Log("客户端建立起通讯!");}// 处理业务消息else{mRecvQueue.Enqueue(tcpData);}}}}catch (Exception ex){FDebug.Log("客户端接收消息异常:" + ex.Message);Close();}}}

关闭连接

关闭这块没啥特别的,主要是要把线程的bAlive标记设置为false让线程自动结束掉。然后重置各种变量。

        public void Close(){mBConnected = false;bAlive = false;mRecvQueue.Close();mSendQueue.Close();if (mClientSocket != null && mClientSocket.Connected){mClientSocket.Shutdown(SocketShutdown.Both);mClientSocket.Close();}Thread.Sleep(50);threadSend = null;threadRecive = null;}

完整代码

using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using GYSQ.Tool;
using GYSQ.Base;namespace GYSQ.Net.Tcp.Client
{// tcp客户端public class TcpClient{// 消息缓冲大小private const int RECV_BUFFER = 2 * 1024 * 1024;// 缓冲字节数组private byte[] mArryBytesRecMsg;// 套接字private Socket mClientSocket = null;// 连接标记private bool mBConnected = false;// 连接超时等待System.Timers.Timer waitTimeOut;public bool bConnected{get{return mBConnected;}}// 网络错误public bool bSocketError = false;// 激活标记private bool bAlive;// 发送消息线程Thread threadSend = null;private BlockQueue<TcpData> mSendQueue = new BlockQueue<TcpData>(50);// 接收消息线程Thread threadRecive = null;private BlockQueue<TcpData> mRecvQueue = new BlockQueue<TcpData>(50);// 连接失败回调private Action<int> mFnOnConnectFailed;// 连接成功回调private Action mFnOnConnectSuccess;// tcp数据接收器private TcpMsgRecv mTcpReciever = new TcpMsgRecv();public void Init(Action<int> fnOnConnectFailed, Action fnOnConnectSuccess){mFnOnConnectFailed = fnOnConnectFailed;mFnOnConnectSuccess = fnOnConnectSuccess;mArryBytesRecMsg = new byte[RECV_BUFFER];waitTimeOut = new System.Timers.Timer();waitTimeOut.AutoReset = false;waitTimeOut.Elapsed += OnConnectTimeOut;}public void Connect(string strIP, int intPort){// 重置状态bAlive = true;bSocketError = false;mRecvQueue.Reset();mSendQueue.Reset();mClientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);SocketAsyncEventArgs connectArgs = new SocketAsyncEventArgs();connectArgs.UserToken = mClientSocket;connectArgs.RemoteEndPoint = new IPEndPoint(IPAddress.Parse(strIP), intPort);connectArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnConnect);mClientSocket.ConnectAsync(connectArgs);// 设置超时waitTimeOut.Interval = 3000;waitTimeOut.Start();}// 超时处理private void OnConnectTimeOut(object sender, System.Timers.ElapsedEventArgs e){if (mClientSocket != null && !mClientSocket.Connected){FDebug.LogError("客户端连接超时");Close();}}private void OnConnect(object sender, SocketAsyncEventArgs e){// 终止超时设置waitTimeOut.Stop();if (e.SocketError == SocketError.Success){// 开启发送线程threadSend = new Thread(HandleSend);threadSend.IsBackground = true;threadSend.Start();// 开启接收线程threadRecive = new Thread(HandleRecieve);threadRecive.IsBackground = true;threadRecive.Start();// 发送一个连接请求SendConnect();FDebug.Log("客户端连接成功!");}else{FDebug.LogError("客户端连接失败"+e.SocketError.ToString());mFnOnConnectFailed?.Invoke((int)e.SocketError);}}private void HandleSend(){while (bAlive){try{if (mSendQueue.TryDequeue(out var tcpData)){byte[] bytesData = tcpData.Get();mClientSocket.Send(bytesData);}}catch(Exception ex){bSocketError = false;FDebug.Log("客户端发送消息异常:" + ex.Message);}}}private void HandleRecieve(){while (bAlive){try{int intLength = mClientSocket.Receive(mArryBytesRecMsg, 0 , mArryBytesRecMsg.Length, SocketFlags.None);if (intLength == 0)continue;byte[] arryBytes = CommonTool.SubArry(mArryBytesRecMsg, 0, intLength);// 处理粘包断包后if (mTcpReciever.HandleRecMessage(arryBytes)){TcpData tcpData = null;while (mTcpReciever.TryGetReciveTcpData(out tcpData)){// 处理连接消息if (!mBConnected && tcpData.protocol == TcpProtocol.TCP_S2C_CONNECT){mBConnected = true;mFnOnConnectSuccess?.Invoke();FDebug.Log("客户端建立起通讯!");}// 处理业务消息else{mRecvQueue.Enqueue(tcpData);}}}}catch (Exception ex){bSocketError = false;FDebug.Log("客户端接收消息异常:" + ex.Message);}}}public void Close(){mBConnected = false;bAlive = false;mRecvQueue.Close();mSendQueue.Close();if (mClientSocket != null && mClientSocket.Connected){mClientSocket.Shutdown(SocketShutdown.Both);mClientSocket.Close();}Thread.Sleep(50);threadSend = null;threadRecive = null;}public void Send(TcpData tcpData){mSendQueue.Enqueue(tcpData);}public bool TryGetRecieve(out TcpData tcpData){return mRecvQueue.TryDequeue(out tcpData);}public bool HasRecieveData(){return mRecvQueue.count > 0;}private void SendConnect(){TcpData tcpData = TcpData.GetPooled();tcpData.Build(TcpProtocol.TCP_C2S_CONNECT, " ");mSendQueue.Enqueue(tcpData);}}
}

TcpClientMgr-业务处理

该脚本包含了四块处理

  1. 消息发送
  2. 消息接收并通过事件系统转发到其他业务系统
  3. 心跳和心跳超时
  4. 消息等待和消息等待超时

心跳那块是比较通用的一个业务处理,主要是双方可以通过心跳来判定连接是否继续存在。

消息等待超时这块主要是为了满足以下的情况:客户端向服务器请求了某个消息,客户端在该消息来之前需要锁定用户的操作和socekt通信直到服务器返回指定的消息为止。

比如客户端登录之后请求玩家信息,如果玩家信息没有返回,则不能做后续的处理。那么发送完请求之后需要设置好等待的消息,再继续处理后续业务。

消息发送

发送前判定一下连接是否建立好就可以了。

    // 发送网络消息public void Send(int protocol, string jsonData = null, int waitProtocol = -1){if (!mTcpClient.bConnected){return;}TcpData tcpData = TcpData.GetPooled();tcpData.Build(protocol, jsonData);mTcpClient.Send(tcpData);if (waitProtocol != -1){SetTcpWait(waitProtocol);}}

消息接收

消息的接收需要放在unity的update中。

每次update从TcpClient的接收队列中取得数据。

取得数据后直接推送到事件系统即可。

            // 获取网络消息if (mTcpClient.HasRecieveData()){mTcpClient.TryGetRecieve(out var tcpData);// 发送业务事件TcpEvent.instance.NotifyEvent(tcpData.protocol, tcpData.GetJsonContent());// 回收数据包tcpData.Dispose();}

这里有一个注意的地方,就是我们从队列中取得数据前先判定有没有数据,有再去取得。

这里为什么不直接调用TryGetRecieve去取得呢。

主要是我们的接收和发送消息队列都是基于多线程来处理的(有兴趣可以看一下这篇《Unity游戏开发 基于多线程的Http网络通信》,里面有一个BlockQueue(多线程队列))。

我们队列去Dequeue的时候如果当前没有数据会将调用的线程挂起等待有数据再执行。

如果我们再unity主线程直接调用Dequeue的话主线程就会被阻塞。

另外就是要注意再非主线程,如果用了while(true)这样的逻辑将我们多线程队列的操作包起来的话则反而不能先判定数据有没有再取得。

因为这样相当于执行了一个while(true){if(xxx);}。当前线程就会处于满载状态,因此这种情况必须直接用我们多线程队列的接口去取得数据。

或者也可以自己处理一下线程的调度,当取不到数据的时候手动将当前线程挂起,等待有数据再唤醒。

心跳和心跳超时

简单的说就是,定时向服务器推送一个消息A,服务器会回复一个消息B。这样双方可以确认连接还在。

如果客户端超过一定时间没有收到消息B则判定超时,断开连接。

同理服务器超过一定时间没有收到消息A则判定超时,断开连接。

这块的逻辑也是写在unity的update里面的。

                // 心跳发送mFPingTimer += Time.deltaTime;if (mFPingTimer > PING_SEND_TIME){Send(TcpProtocol.TCP_C2S_HEART);mFPingTimer = 0f;}// 心跳超时mFPongTimer += Time.deltaTime;if (mFPongTimer > PONG_TIME_OUT){FDebug.LogError("客户端接收心跳超时!");HandleLostConnection();}

消息等待和超时

当我们发送一条消息的同时设置了需要等待的消息id的时候。

客户端打开网络层遮罩屏蔽用户的输入并启动计时器,超过一定时间不返回则弹出消息等待窗口,如果继续超时就断开连接。

这里启动屏蔽层和消息等待窗口都是用事件通知的形式处理的。

    // 发送网络消息public void Send(int protocol, string jsonData = null, int waitProtocol = -1){if (!mTcpClient.bConnected){return;}TcpData tcpData = TcpData.GetPooled();tcpData.Build(protocol, jsonData);mTcpClient.Send(tcpData);if (waitProtocol != -1){SetTcpWait(waitProtocol);}}// 设置网络等待private void SetTcpWait(int protocol){mWaitTimer = 0f;mBWaiting = true;mWaitProtocol = protocol;// 通知游戏业务网络请求等待中GameEvent.instance.NotifyEvent(GameEventId.TCP_WAIT);}// 重置网络等待public void ResetTcpWait(){mBWaiting = false;mWaitTimer = 0f;// 通知游戏业务解除请求等待GameEvent.instance.NotifyEvent(GameEventId.TCP_RESET_WAIT);GameEvent.instance.NotifyEvent(GameEventId.TCP_HIDE_LOCK);}// 处理消息等待if (mBWaiting &&mWaitProtocol == tcpData.protocol){ResetTcpWait();}

完整代码

这里可以看到TcpClientMgr是一个单例。

因为客户端一般不需要一次监听多个端口所有搞个单例方便使用。

using System;
using GYSQ.Net.Tcp.Client;
using GYSQ.Net.Tcp;
using UnityEngine;// tcp客户端业务管理器
public class TcpClientMgr : MonoSingleTon<TcpClientMgr>
{// tcp客户端通信private TcpClient mTcpClient = new TcpClient();// 工作状态private bool mBWorking = false;// tcp消息等待处理private int mWaitProtocol;private float mWaitTimer = 0f;private bool mBWaiting = false;private const float WAIT_TIME_OUT = 5f;private const float WAIT_SHOW_LOCK = 0.5f;// 连接相关业务回调private Action mFnConnectSuccess;private Action mFnConnectFailed;private Action mFnLostConnection;// 心跳private float mFPingTimer = 0f;private float mFPongTimer = 0f;// 心跳发送间隔private const float PING_SEND_TIME = 5f;// 心跳超时间隔private const float PONG_TIME_OUT = 15;// 初始化public override void Init(){mTcpClient.Init(OnConnectFailed, OnConnectSuccess);TcpEvent.instance.Init();}private void Update(){if (!mTcpClient.bConnected){return;}// 处理网络异常if (mTcpClient.bSocketError){HandleLostConnection();}else{// 获取网络消息if (mTcpClient.HasRecieveData()){mTcpClient.TryGetRecieve(out var tcpData);// 处理消息等待if (mBWaiting &&mWaitProtocol == tcpData.protocol){ResetTcpWait();}// 处理心跳else if (tcpData.protocol == TcpProtocol.TCP_S2C_HEART){mFPongTimer = 0f;}FDebug.LogClientTcp(tcpData.protocol, tcpData.bytesData);// 发送业务事件TcpEvent.instance.NotifyEvent(tcpData.protocol, tcpData.GetJsonContent());// 回收数据包tcpData.Dispose();}// 处理工作状态if (mBWorking){// 心跳发送mFPingTimer += Time.deltaTime;if (mFPingTimer > PING_SEND_TIME){Send(TcpProtocol.TCP_C2S_HEART);mFPingTimer = 0f;}// 心跳超时mFPongTimer += Time.deltaTime;if (mFPongTimer > PONG_TIME_OUT){FDebug.LogError("客户端接收心跳超时!");HandleLostConnection();}}// 处理等待状态if(mBWaiting){mWaitTimer += Time.deltaTime;if (mWaitTimer > WAIT_TIME_OUT){FDebug.LogError("客户端等待消息超时!");HandleLostConnection();}else if (mWaitTimer > WAIT_SHOW_LOCK){GameEvent.instance.NotifyEvent(GameEventId.TCP_SHOW_LOCK);}}}}// 请求连接public void Connect(string ip,int port,Action fnSuccess,Action fnFailed,Action fnLost){mTcpClient.Connect(ip, port);mFnConnectSuccess = fnSuccess;mFnConnectFailed = fnFailed;mFnLostConnection = fnLost;}// 断开连接public void BreakConnection(){mBWorking = false;mBWaiting = false;mWaitTimer = 0f;mTcpClient.Close();FDebug.LogError("客户端主动断开连接!");}// 连接失败private void OnConnectFailed(int intErrorCode){mFnConnectFailed?.Invoke();}// 连接成功private void OnConnectSuccess(){mBWorking = true;mBWaiting = false;mWaitTimer = 0f;mFnConnectSuccess?.Invoke();}// 处理响应超时private void HandleLostConnection(){mBWorking = false;mBWaiting = false;mWaitTimer = 0f;mTcpClient.Close();mFnLostConnection?.Invoke();FDebug.LogError("丢失连接!");}// 发送网络消息public void Send(int protocol, string jsonData = null, int waitProtocol = -1){if (!mTcpClient.bConnected){return;}TcpData tcpData = TcpData.GetPooled();tcpData.Build(protocol, jsonData);mTcpClient.Send(tcpData);if (waitProtocol != -1){SetTcpWait(waitProtocol);}}// 设置网络等待private void SetTcpWait(int protocol){mWaitTimer = 0f;mBWaiting = true;mWaitProtocol = protocol;// 通知游戏业务网络请求等待中GameEvent.instance.NotifyEvent(GameEventId.TCP_WAIT);}// 重置网络等待public void ResetTcpWait(){mBWaiting = false;mWaitTimer = 0f;// 通知游戏业务解除请求等待GameEvent.instance.NotifyEvent(GameEventId.TCP_RESET_WAIT);GameEvent.instance.NotifyEvent(GameEventId.TCP_HIDE_LOCK);}
}

unity游戏开发-socket网络通信相关推荐

  1. Unity游戏开发大师班

    大小解压后:8.63G 持续时间19h 包含项目文件 1280X720 MP4 语言:英语+中英文字幕(根据原英文字幕机译更准确) Unity游戏开发大师班 信息: 要求 –没有课程要求,展示了开发过 ...

  2. 最全面的Unity游戏开发指南视频教程 第2卷

    最全面的Unity游戏开发指南视频教程 第2卷 流派:电子学习| MP4 |视频:h264,1280×720 |音频:AAC,44.1 KHz 语言:英语+中英文字幕(根据原英文字幕机译更准确)|大小 ...

  3. Unity 游戏开发技巧集锦之使用cookie类型的纹理模拟云层的移动

    Unity 游戏开发技巧集锦之使用cookie类型的纹理模拟云层的移动 使用cookie类型的纹理模拟云层的移动 现实生活中,当阳光直射大地,而天空中又有很多云时,云层的影子总是会投射在大地上,风吹着 ...

  4. Unity 游戏开发技巧集锦之创建透明的材质

    Unity 游戏开发技巧集锦之创建透明的材质 Unity创建透明的材质 生活中不乏透明或者半透明的事物.例如,擦的十分干净的玻璃,看起来就是透明的:一些塑料卡片,看起来就是半透明的,如图3-23所示. ...

  5. Unity 游戏开发技巧集锦之创建部分光滑部分粗糙的材质

    Unity 游戏开发技巧集锦之创建部分光滑部分粗糙的材质 创建部分光滑部分粗糙的材质 生活中,有类物体的表面既有光滑的部分,又有粗糙的部分,例如丽江的石板路,如图3-17所示,石板的表面本来是粗糙的, ...

  6. Unity 游戏开发技巧集锦之创建自发光材质

    Unity 游戏开发技巧集锦之创建自发光材质 创建自发光材质 自发光材质(self-illuminated material)是指自己会发光的材质.生活中与之相似的例子,就是液晶显示屏上显示的信息,文 ...

  7. ​Unity 游戏开发技巧集锦之制作一个望远镜与查看器摄像机

    ​Unity 游戏开发技巧集锦之制作一个望远镜与查看器摄像机 Unity中制作一个望远镜 本节制作的望远镜,在鼠标左键按下时,看到的视图会变大:当不再按下的时候,会慢慢缩小成原来的视图.游戏中时常出现 ...

  8. Unity游戏开发技巧集锦2.1.3实现效果

    Unity游戏开发技巧集锦2.1.3实现效果 将此脚本加到Camera对象上,选中此对象,即可查看对象上此脚本组件中的各项属性,如图2-4所示. 图2-4  对象脚本组件里的各项属性          ...

  9. 删除 jar 的 asset_【unity游戏开发】SDK学习(1)-C#与jar交互

    引言 通常一款游戏开发到后期,一般都会涉及到第三方SDK的接入与集成 对于不熟悉SDK接入的同学来说,接SDK每次都是云里雾里, 而熟悉SDK接入的同学又觉得不断地重复做接入SDK工作这样没有成就感, ...

最新文章

  1. 有没有什么高效「炼丹」神器可以推荐?复旦fastNLP团队祭出内部调参利器fitlog...
  2. R语言ggplot2可视化设置不同的图像主题(theme):使用各种不同的主题(theme)可视化数据、单的黑白主题theme_bw主题(theme)、默认的主题(theme)可视化数据
  3. OCH\OMS\OTS\MSP\RS\SPI解释
  4. 程序通过定义学生结构体变量,存储学生的学号、姓名和3门课的成绩。函数fun的功能是:对形参b所指结构体变量中的数据进行修改,并在主函数中输出修改后的数据。...
  5. async 打包异常_重新打包流中的异常
  6. window8下安装RabbitMQ
  7. 我们应该如何写好HTMLCSS
  8. 多目标跟踪全解析,全网最全
  9. 怎么不能锁门_镜子能不能对着床
  10. 照片编辑工具 Affinity Photo for Mac 1.7.1
  11. Js判断数组中是否有某值
  12. 刷百度权重那些不为人知的事情
  13. 区块链技术从入门到实践
  14. MOSFET的SOA或者ASO是什么?
  15. day1-python基础1
  16. android 常用的代码
  17. Git追加本次提交到上次提交
  18. python-字典及其三种定义方法
  19. Pytorch+PyG实现GraphSAGE
  20. 高效的CSS代码(1)

热门文章

  1. 华数机器人码垛_华数工业机器人码垛路径操作教程
  2. 测试点击屏幕次数的软件_无需越狱,iOS 任意摆放主屏幕软件图标方法
  3. 英特尔迅驰二代风尚盛典亲身体验
  4. Catch That Cow(详解)
  5. 命里有时终须有与我命由我不由天
  6. 电脑翻译软件-在线电脑实时翻译软件
  7. 《潜能成功学》----如何建立自信
  8. 三菱四节传送带控制梯形图_三菱plc控制传送带三级 编程 fx2n 模拟四节传送带控制实验三菱...
  9. 终身会员卡上线三重大优惠!
  10. Python语音识别,让人工智能给你读读书,你是想听——萝莉音?御姐音?大叔音?正太音?这些任你选择哦~~~