美文网首页iOS好文iOS
【iOS】即时消息技术剖析与实战客户端技术点总结

【iOS】即时消息技术剖析与实战客户端技术点总结

作者: 酷酷的小虎子 | 来源:发表于2020-12-17 17:44 被阅读0次

    架构与特性:一个完整的IM系统是怎样的?

    即时消息有别于其他业务系统的四大特性:
    实时性:保证消息实时触达是互动场景的必备能力
    可靠性:“不丢消息”和“消息不重复”是系统值得信赖的前置条件
    一致性:“多用户”“多终端”的一致性体验能大幅提升 IM 系统的使用体验
    安全性:“数据传输安全”“数据存储安全”“消息内容安全“三大保障方面提供全面隐私保护


    轮询与长连接:如何解决消息的实时到达问题?(实时性)

    解决“消息实时性”经历过的几个代表性阶段:
    短轮询场景:定期、高频地轮询服务端的新消息,如果有新消息就会将新消息返回给客户端,如果没有新消息就返回空列表,并关闭连接
    劣势:
    频率一般较高,但大部分请求实际上是无用的,客户端既费电也费流量
    服务端资源的压力也较大,一是大量服务器用于扛高频轮询的 QPS(每秒查询率),二是对后端存储资源也有较大压力

    长轮询场景:当本次请求没有获取到新消息时,并不会马上结束返回,而是会在服务端“悬挂(hang)”,等待一段时间,如果在等待的这段时间内有新消息产生,就能马上响应返回(应用场景:对实时性要求比较高,但是整体用户量不太大)
    劣势:
    只是降低了入口请求的 QPS,并没有减少对后端资源轮询的压力
    仍然没有完全解决客户端“无效”请求的问题

    由于短轮询和长轮询是基于 HTTP 协议实现的,由于 HTTP 是一个无状态协议,服务端在有新消息产生时,没有办法直接向客户端进行推送,所以没法做到基于事件的完全的“边缘触发(当状态变化时,发生一个 IO 事件)”

    WebSocket:
    WebSocket 是一种服务端推送的技术代表
    随着 HTML5 的出现,基于单个 TCP 连接的全双工通信的协议 WebSocket 在 2011 年成为 RFC 标准协议,逐渐代替了短轮询和长轮询的方式
    基于 WebSocket 实现的 IM 服务,客户端和服务端只需要完成一次握手,就可以创建持久的长连接,并进行随时的双向数据传输。当服务端接收到新消息时,可以通过建立的 WebSocket 连接,直接进行推送,真正做到“边缘触发”,也保证了消息到达的实时性
    优势:
    支持服务端推送的双向通信,大幅降低服务端轮询压力
    数据交互的控制开销低,降低双方通信的网络开销
    Web 原生支持,实现相对简单

    TCP 长连接衍生的 IM 协议:
    除了 WebSocket 协议,还有其他一些常用的基于 TCP 长连接衍生的通信协议,如 XMPP 协议、MQTT 协议以及各种私有协议
    XMPP: XMPP协议虽然比较成熟、扩展性也不错,但基于 XML 格式的协议传输上冗余比较多,在流量方面不太友好,而且整体实现上比较复杂,在如今移动网络场景下用的并不多

    MQTT:轻量级的 MQTT 基于代理的“发布 / 订阅”模式,在省流量和扩展性方面都比较突出,在很多消息推送场景下被广泛使用,但这个协议并不是 IM 领域的专有协议,因此对于很多 IM 下的个性化业务场景仍然需要大量复杂的扩展和开发,比如不支持群组功能、不支持离线消息

    对于开发人力相对充足的大厂,目前很多是基于 TCP(或者 UDP)来实现自己的私有协议,一方面私有协议能够贴合业务需要,做到真正的高效和省流;另一方面私有协议相对安全性更高一些,被破解的可能性小


    ACK机制:如何保证消息的可靠投递?(可靠性)

    可靠投递主要是指:消息在发送接收过程中能够做到不丢消息、消息不重复两点

    不丢消息:
    发消息大概整体上分为两部分:
    第一部分:
    用户 A 发送消息到 IM 服务器,服务器将消息暂存,然后返回成功的结果给发送方 A(步骤 1、2、3)
    丢失消息情况:
    用户 A 在把消息发送到 IM 服务器的过程中,由于网络不通等原因失败了
    IM 服务器接收到消息进行服务端存储时失败了
    用户 A 等待 IM 服务器一定的超时时间,但 IM 服务器一直没有返回结果
    解决方案:
    通过客户端 A 的超时重发和 IM 服务器的去重机制,基本就可以解决问题

    第二部分:
    IM 服务器接着再将暂存的用户 A 发出的消息,推送给接收方用户 B(步骤 4)
    丢失消息情况:
    服务端出现掉电,导致消息不能成功推送给用户 B
    用户 B 的设备在接收后的处理过程出现问题,也会导致消息丢失
    解决方案:
    业界一般参考 TCP 协议的 ACK 机制,实现一套业务层的 ACK 协议,通过“ACK+ 超时重传 + 去重”的组合机制,能解决大部分用户在线时消息推送丢失的问题

    ps:假设一台 IM 服务器在推送出消息后,由于硬件原因宕机了,这种情况下,如果这条消息真的丢了,由于负责的 IM 服务器宕机了无法触发重传,导致接收方 B 收不到这条消息。当用户 B 再次重连上线后,可能并不知道之前有一条消息丢失的情况
    解决方案:
    通过“兜底”的完整性检查机制来及时发现消息丢失的情况并进行补推修复,消息完整性检查可以通过时间戳比对或者全局自增序列等方式来实现


    消息序号生成器:如何保证你的消息不会乱序?(一致性)

    更多的场景下,我们可能需要面对的是多发送方、多接收方、服务端多线程并发处理的情况,所以保证消息的时序一致比较困难。
    保证消息的时序一致性的一个关键问题是:我们是否能找到这么一个时序基准,使得我们的消息具备“时序可比较性”

    如何找到时序基准?
    发送方的本地序号和本地时钟作为“时序基准”,不适合
    IM 服务器的本地时钟作为“时序基准”,不适合
    可以通过全局的序号生成器来确定。常见的实现方式包括支持单调自增序号的资源生成,或者分布式时间相关的 ID 生成服务生成。两种方式各有一些限制,不过,你都可以根据业务自身的特征来进行选择。

    有了通过时序基准确定的消息序号,由于 IM 服务器差异和多线程处理的方式,不能保证服务端的消息一定能按顺序推到接收方。我们可以通过“服务端包内整流”机制来保证需要“严格有序”批量消息的正确执行;或者,接收方根据消息序号来进行消息本地整流,从而确保多接收方的最终一致性


    HttpDNS和TLS:你的消息聊天真的安全吗?(安全性)

    消息安全性的三个维度:
    消息传输安全性、消息存储安全性、消息内容安全性

    消息传输安全性
    在消息传输过程中主要关注两个问题:“访问入口安全”“传输链路安全”,这也是两个基于互联网的即时消息场景下的重要防范点

    访问入口安全
    常见的问题就是 DNS 劫持:
    1.路由器的 DNS 设置被非法侵入篡改了
    这种问题常见于一些家用宽带路由器,由于安全性设置不够(比如使用默认密码),导致路由器被黑客或者木马修改了,DNS 设置为恶意的 DNS 地址,这些有问题的 DNS 服务器会在你访问某些网站时返回仿冒内容,或者植入弹窗广告等
    解决方案:一般会重置一下路由器的配置,然后修改默认的路由管理登录密码,基本上都能解决

    2.运营商 LocalDNS 可能会导致接入域名的解析被劫持
    ①LocalDNS 是部分运营商为了降低跨网流量,缓存部分域名的指向内容,把域名强行指向自己的内容缓存服务器的 IP 地址
    ②运营商可能会修改 DNS 的 TTL(Time-To-Live,DNS 缓存时间),导致 DNS 的变更生效延迟,影响服务可用性。我们之前一个线上业务域名的 TTL 在某些省市能达到 24 小时
    ③一些小运营商为了减轻自身的资源压力,把 DNS 请求转发给其他运营商去解析,这样分配的 IP 地址可能存在跨运营商访问的问题,导致请求变慢甚至不可用
    解决方案:HttpDNS
    HttpDNS 绕开了运营商的 LocalDNS,通过 HTTP 协议(而不是基于 UDP 的 DNS 标准协议)来直接和 DNS 服务器交互,能有效防止域名被运营商劫持的问题
    由于 HttpDNS 服务器能获取到真实的用户出口 IP,所以能选择离用户更近的节点进行接入,或者一次返回多个接入 IP,让客户端通过测速等方式选择速度更快的接入 IP,因此整体上接入调度也更精准

    传输链路安全
    消息在传输链路中的安全隐患:
    1.中断,攻击者破坏或者切断网络,破坏服务可用性
    解决方案:可以采取多通道方式来提升链路可用性,比如很多 IM 系统的实现中,如果主链路连接不通或者连接不稳定,就会尝试自动切换到 failover 通道,这个 failover 通道可以是:
    ①从 HttpDNS 服务返回的多个“接入 IP”中选择性进行切换
    ②从当前数据传输协议切换到其他传输协议
    2.截获,攻击者非法窃取传输的消息内容,属于被动攻击
    3.篡改,攻击者非法篡改传输的消息内容,破坏消息完整性和真实语义
    4.伪造,攻击者伪造正常的通讯消息来模拟正常用户或者模拟 IM 服务端
    解决方案:利用私有协议TLS 的技术来进行防控
    私有协议:采用二进制私有协议的即时消息系统本身由于编码问题天然具备一定的防窃取和防篡改的能力

    TLS:TLS 巧妙地把“对称加密算法”“非对称加密算法”“秘钥交换算法”“消息认证码算法”“数字签名证书”“CA 认证”进行结合,有效地解决了消息传输过程中的截获、篡改、伪造问题
    实现过程:
    1.非对称加密算法和秘钥交换算法用于保证消息加密的密钥不被破解和泄露
    2.对称加密算法对消息进行加密,保证业务数据传输过程被截获后无法破解,也无法篡改消息
    3.数字签名和 CA 认证能验证证书持有者的公钥有效性,防止服务端身份的伪造

    消息存储安全性
    账号密码存储安全:“单向散列”算法:
    针对账号密码的存储安全一般比较多的采用“高强度单向散列算法”(比如:SHA、MD5 算法)和每个账号独享的“盐”(这里的“盐”是一个很长的随机字符串)结合来对密码原文进行加密存储

    消息内容存储安全:端到端加密:
    1.消息内容采用“端到端加密”(E2EE),中间任何链路环节都不对消息进行解密(国内的大部分即时消息软件如 QQ、微信等由于网络安全要求,目前暂时还没有采用“端到端加密”)
    2.消息内容不在服务端存储

    消息内容安全性
    内容安全性主要是指针对消息内容的识别和传播的控制,一般都依托于第三方的内容识别服务来进行”风险内容“的防范
    1.建立敏感词库,针对文字内容进行安全识别
    2.依托图片识别技术来对色情图片和视频、广告图片、涉政图片等进行识别处置
    3.使用“语音转文字”和 OCR(图片文本识别)来辅助对图片和语音的进一步挖掘识别
    4.通过爬虫技术来对链接内容进行进一步分析,识别“风险外链”
    并且可以配合“联动惩罚处置”来进行风险识别的后置闭环


    分布式锁和原子性:你看到的未读消息提醒是真的吗?

    会话未读和总未读单独维护:
    “总未读”在很多业务场景里会被高频使用,比如每次消息推送需要把总未读带上用于角标未读展示。如果每次都通过聚合所有会话未读来获取,一旦用户的互动会话数比较多,由于需要多次从存储获取,容易出现某些会话未读由于超时等原因没取到,导致总未读数计算少了,所以许多 App 内会通过定时轮询的方式来同步客户端和服务端的总未读数

    为什么会造成会话未读和总未读不一致?
    两个未读的变更不是原子性的,会出现某一个成功另一个失败的情况,也会出现由于并发更新导致操作被覆盖的情况。所以要解决这些问题,需要保证两个未读更新操作的原子性

    分布式锁
    可以依赖 DB 的唯一性、约束来通过某一条固定记录的插入成功与否,来判断锁的获取,也可以通过一些分布式缓存来实现
    具备较好普适性,但执行效率较差,锁的管理也比较复杂,适用于较小规模的即时消息场景;

    支持事务功能的资源
    事务提供了一种“将多个命令打包, 然后一次性按顺序地执行”的机制, 并且事务在执行的期间不会主动中断,服务器在执行完事务中的所有命令之后,才会继续处理其他客户端的其他命令
    不需要额外的维护锁的资源,实现较为简单,但基于乐观锁的 watch 机制在较高并发场景下失败率较高,执行效率比较容易出现瓶颈


    智能心跳机制:解决网络的不确定性

    为了保证“可靠投递”和“实时性,大部分 IM 系统会通过“长连接”的方式来建立收发双方的通信通道,这些基于 TCP 长连接的通信协议,在用户上线连接时,会在服务端维护好连接到服务器的用户设备和具体 TCP 连接的映射关系,通过这种方式服务端也能通过这个映射关系随时找到对应在线的用户的客户端,通过“服务端推送”,提供更加实时的消息下发,而且这个长连接一旦建立就一直存在,除非网络被中断

    相对于短连接方式也能省略 TCP 握手和 TLS 握手的几个 RTT 的时间开销(节约不必要的资源开销),在用户体验和实时性上也会更好

    “长连接”底层使用的 TCP 连接并不是一个真正存在的物理连接,实际上只是一个无感知的虚拟连接,中间链路的断开连接的两端不会感知到,因此维护好这个“长连接”一个关键的问题在于能够让这个“长连接”能够在中间链路出现问题时,让连接的两端能快速得到通知,然后通过“重连”来重新建立新的可用连接,从而让我们这个“长连接”一直保持“高可用”状态,这个“快速”“不间断”识别连接可用性的机制,被称为“心跳机制”

    心跳机制在长连接维护中的必要性:

    • 降低服务端连接维护无效连接的开销。
    • 支持客户端快速识别无效连接,自动断线重连。
    • 连接保活,避免被运营商 NAT 超时断开。

    心跳检测的几种实现方式:
    TCP Keepalive
    TCP 的 Keepalive 作为操作系统的 TCP/IP 协议栈实现的一部分,对于本机的 TCP 连接,会在连接空闲期按一定的频次,自动发送不携带数据的探测报文,来探测对方是否存活。操作系统默认是关闭这个特性的,需要由应用层来开启
    操作系统 TCP/IP 协议栈自带,无需二次开发,使用简单,不携带数据网络流量消耗少。但存在灵活性不够(一台服务器某一时间只能调整为固定间隔的心跳)和无法判断应用层是否可用(虽然能够用于连接层存活的探测,但并不代表真正的应用层处于可用状态)的缺陷

    应用层心跳
    使用应用层心跳来提升探测的灵活性和准确性。应用层心跳实际上就是客户端每隔一定时间间隔,向 IM 服务端发送一个业务层的数据包告知自身存活
    应用自己实现心跳机制,需要一定的代码开发量,网络流量消耗稍微多一点,但由于需要在应用层进行发送和接收的处理,因此更能反映应用的可用性,而不是仅仅代表网络可用,且心跳间隔的灵活性好(可以根据实际网络的情况,来灵活设置心跳间隔,按照固定频率发送心跳包或者客户端在发送数据空闲后才发送心跳包),配合智能心跳机制,可以做到“保证 NAT 不超时的情况下最大化节约设备资源消耗”,同时也能更精确反馈应用层的真实可用性


    HTTP Tunnel:复杂网络下消息通道高可用设计的思考

    对于即时消息系统来说,消息的通道主要承载两部分流量:一部分是用户发出的消息或者触发的行为,我们称为上行消息一部分是服务端主动下推的消息和信令,我们称为下行消息

    在解决“通道连不上”的问题上:
    多端口访问:
    计算机端口范围是 0 ~ 65535,主要分成三大类:公认端口(0 ~ 1023)、注册端口(1024 ~ 49151)、动态或私有端口(49152 ~ 65535),业界确认比较安全的端口基本上只有 80、8080、443、14000 这几个,因此如果开发一个外网服务,我们应当尽量选用这几个端口来对外进行暴露,还可以通过同时暴露这几个端口中的某几个,来进一步提升可连通性
    HTTP Tunnel:
    解决某些网络情况下只允许 HTTP 协议的数据传输的问题,通过 HTTP Tunnel 的方式来对网络代理进行穿透,其实就是通过 HTTP 协议,来封装其他由于网络原因不兼容的协议(比如 TCP 私有协议)
    多接入点 IP 列表:
    通过 HttpDNS(不仅能解决 DNS 劫持的问题,还能通过返回多个接入点 IP 来解决连通性的问题) 和客户端预埋的方式,提供多个可选的通道接入点,让某些接入点在连不上时还能尝试其他接入点

    在解决“通道连接慢”的问题上:
    解决跨网延迟:
    以通过支持多运营商机房接入点,来避免用户的跨运营商网络访问
    跑马竞速:
    对于提供的多接入点,客户端还可以通过“跑马竞速”的方式优先使用连接速度更快的接入点来访问,所谓的“跑马竞速”,你可以理解为类似赛马一样,我们一次放出多匹马参与比赛,最终跑得最快的马胜出

    在解决“通道不稳定”的问题上:
    通道和业务解耦:
    我们主要从服务端的架构设计着手,让我们的通道层服务和变化频繁的业务进行解耦,避免业务频繁变动导致通道层服务不稳定
    上下行通道隔离:
    对于消息下行通道压力大的业务场景,还可以隔离消息上下行通道,避免消息的上行被压力大的下行通道所影响
    独立多媒体上传下载:
    另外,将多媒体的上传下载通道和消息收发的核心通道进行隔离,避免传输量大的多媒体消息造成通道的阻塞,影响消息收发


    分片上传:如何让你的图片、音视频消息发送得更快?

    从 IM 中图片、语音、视频等多媒体消息的发送场景的优化出发,分析了目前业界比较常用的一些优化手段

    分片上传:
    分片上传是指在客户端把文件按照一定规则,分成多个数据块并标记序号,利用“并行”的方式来同时上传多个分片,服务端按照序号重新将多个数据块组装成文件

    断点续传:
    分片上传机制,就能相对简单地实现“断点续传”功能,给每一次上传行为分配一个唯一的操作标识,每个分片在上传时除了携带自己的序号外,还需要带上这个操作标识,服务端针对接收到的同一个操作标识的分片进行“暂存”,即使由于某个原因暂停上传了,这些“暂存”的分片也不会马上清理掉,而是保留一定的时间,续传时继续以之前同一个操作标识来上传,客户端先检查服务端已有分片的情况,如果没有过期就继续从上次的位置续传,否则需要重新从头开始上传

    秒传机制:
    客户端针对要上传的文件计算出一个特征值(特征值一般是一段较短的字符串,不同文件的特征值也不一样),真正上传前先将这个特征值提交到服务端,服务端检索本地已有的所有文件的特征值,如果发现有相同特征值的记录,就认定本次上传的文件已存在,后续就可以返回给客户端已存在文件的相关信息,客户端本次上传完成
    特征值的计算一般采用“单向 Hash 算法”来完成,如 MD5 算法、SHA-1 算法。但这些算法都存在“碰撞”问题,也就是会有极低概率出现“不同文件的特征值一样的情况”。所以可以使用多种单向 Hash 算法,在都一致的情况下才判断为重复


    CDN加速:如何让你的图片、视频、语音消息浏览播放不卡?

    从提升用户图片浏览及音视频播放体验的角度出发,介绍了一些在即时消息场景中,业界比较通用的优化策略

    CDN 加速:
    CDN(Content Delivery Network,内容分发网络)加速技术,是将客户端上传的图片、音视频发布到多个分布在各地的 CDN 节点的服务器上,当有用户需要访问这些图片和音视频时,能够通过 DNS 负载均衡技术,根据用户来源就近访问 CDN 节点中缓存的图片和音视频消息,如果 CDN 节点中没有需要的资源,会先从源站同步到当前节点上,再返回给用户。这种优化手段可以让用户和资源实现物理位置上的相邻,以此降低远程访问的耗时,提升下载性能

    边下边播:
    在播放器下载完视频的格式信息、关键帧等信息后,播放器其实就可以开始进入播放,同时结合 HTTP 协议自带支持的 Range 头按需分片获取后续的视频流,从而来实现边下边播
    支持边下边播需要有两个前提条件:
    1.格式信息和关键帧信息在文件流的头部
    2.服务端支持 Range 分片获取

    H.265 转码:
    视频的码率是数据传输时单位时间传送的数据 BPS。同一种编码格式下,码率越高,视频越清晰;反之码率太低,视频清晰度不够,用户体验会下降。但码率太高,带宽成本和下载流量也相应会增加
    主流的视频格式采用 H.264 编码,H.265(又名 HEVC)是 2013 年新制定的视频编码标准。同样的画质和同样的码率,H.265 比 H.264 占用的存储空间要少 50%,但 H.265 的编码复杂度远高于 H.264(10 倍左右)。针对热门的小视频采用 H.265 转码,在保证画质的同时,降低带宽成本并加快视频加载

    预加载:
    可以对视频流进行“部分提前加载”,达到视频播放“秒开”的效果,比如 WiFi 场景下,在用户打开聊天会话页时,自动触发当前页中的小视频进行预加载,为了平衡流量和播放体验,一般只需要预加载部分片段,后续如果用户继续观看,就可以通过边下边播的方式再去请求后面的视频流
    预加载可以按时间或者大小来限制。比如,我们可以设定预加载 3s 的视频流,或者设定预加载 512KB 的视频流


    APNs:聊一聊第三方系统级消息通道的事

    iOS 端的系统推送服务 APNs:

    1.app 向 iOS 系统申请远程消息推送权限
    2.iOS 系统向 APNs 服务器请求手机端的 deviceToken,并告诉 app
    3.app 接收到手机端的 deviceToken
    4.app 将收到的 deviceToken 传给服务器端
    5.服务器端产生远程消息,先经过 APNs 服务器
    6.APNs 服务器将远程通知根据 deviceToken 推送给相应的手机

    DeviceToken 是固定不变的吗?
    一般来说,在同一台设备上,设备的 DeviceToken 是不会发生变化的,除了以下几种情况:

    • iOS 系统升级后
    • APNs 出于安全等原因,禁用了这个 DeviceToken
      我们的 IM 服务端可以在每次启动 App 时,都去请求 APNs 服务器进行注册,来获取 DeviceToken。客户端在首次获取到 DeviceToken 之后,会先缓存到本地,如果下次获取到 DeviceToken 后,它没有发生变化,那么就不需要再调用 IM 服务端进行更新了

    静默推送?
    除了通知栏弹窗的强提醒推送外,APNs 还支持“静默推送”
    静默推送是 iOS 7 之后推出的一种远程系统推送类型,它的特色就是没有文字弹窗,没有声音,也没有角标,可以在不打扰用户的情况下,悄无声息地唤醒 App 来进行一些更新操作

    APNs 的缺陷:
    可靠性低:APNs 并不能保证这条消息能真正推送到用户设备上,而且也无法保障消息不发生延迟(可能丢消息和延迟高)
    离线消息的支持差:用户的设备离线或者关机时,向你这台设备发送多条推送时,会启动它的 QoS(Quality of Service,服务质量)机制,只保留给最新的一条消息,无法保障离线消息的存储。(由于存储成本方面的考虑,APNs 就会丢掉一部分离线消息)
    角标累加问题:对于角标的未读数,APNs 不支持累计 +1 操作,只支持覆盖原来的角标未读数


    Cache:多级缓存架构在消息系统中的应用

    同样是 1MB 的数据读取,从磁盘读取的耗时比从内存读取的耗时多近 100 倍,因此业界常说“处理高并发的三板斧是缓存、降级和限流”

    缓存的分布式算法:取模求余和一致性哈希
    取模求余:使用 ID 对缓存实例的数量进行取模求余,ID 哈希后对缓存节点取模求余,余数是多少,就缓存到哪个节点上
    问题:如果某一个节点宕机或者加入新的节点,节点数量发生变化后,Hash 后取模求余的结果就可能和以前不一样了。导致加减节点后,缓存命中率下降严重

    一致性哈希:把全量的缓存空间分成 2 的 32 次方个区域,这些区域组合成一个环形的存储结构;每一个缓存的 ID 通过哈希算法转化为一个 32 位的二进制数,也就是对应这 2 的 32 次方个缓存区域中的某一个;缓存的节点也遵循同样的哈希算法(比如利用节点的 IP 来哈希),这些缓存节点也都能被映射到 2 的 32 次方个区域中的某一个

    如何让 ID 和具体的缓存节点对应起来呢?
    每一个映射完的 ID,按顺时针旋转,找到离它最近的同样映射完的缓存节点,该节点就是 ID 对应的缓存节点
    相对于取模求余的问题的优化:如果某一个节点宕机的话,一致性哈希也能保证,只会有小部分消息的缓存归属节点发生变化,大部分仍然能保持不变


    相关文章

      网友评论

        本文标题:【iOS】即时消息技术剖析与实战客户端技术点总结

        本文链接:https://www.haomeiwen.com/subject/esgliktx.html