search
尋找貓咪~QQ 地點 桃園市桃園區 Taoyuan , Taoyuan

58 同城 iOS 客戶端 IM 系統演變歷程

【編者按】58 同城 App 自 1.0 版本開始,便一直致力於自研 IM 系統。在這過程中,發現如何降低 IM 系統層次和頁面間的耦合,減少 IM 系統的複雜性,是降低技術成本提高研發效率的關鍵。對此,本文作者對 iOS 客戶端 IM 系統架構演變的過程以及經驗進行了總結,希望能夠給設計或改造優化 IM 模塊的開發者提供一些參考。

對於 58 同城 App 這樣以信息展示及交易為主體的平台而言,App 內的 IM 即時消息功能,相比電話和簡訊,在促成商品/服務交易上更有著舉足輕重的地位。也正因如此,自 1.0 版本開始,便一直致力於自研 IM 系統。在自研過程中,我們發現如何降低 IM 系統層次和頁面間的耦合,減少 IM 系統的複雜性,是降低技術成本提高研發效率的關鍵。

為此,本文將主要從兩個方面闡述 58 同城 ios 客戶端 IM 系統架構的變遷過程。一是 IM 系統如何解除對資料庫和 Socket 介面的依賴;二是 IM 聊天頁面從傳統的 MVC 模式走向面向協議的新型架構。希望給具有相似業務場景的開發者提供一些借鑒。

老版本 IM 系統遇到的問題

58 App 在項目早期就自研了 IM 系統,但只實現了文本消息、圖片消息、音頻等基本類型。雖然業務需求場景簡單,卻還是遇到了如下問題。

數據擴展性差

數據格式使用的是 Google ProtocolBuffer(以下簡稱 PB),是因為這種數據格式相比 XML 和 JSON 相同的數據形式,體積更小,解析更加迅速。但 PB 是用 C++實現的,使用起來相對繁瑣。需要對不同的消息類型編寫不同的 PB 數據結構,每種 PB 結構還需要單獨的數據解析方法。由於 58 業務的發展,這種數據協議增加了系統的複雜性。

代碼封裝性差,研發成本高

在數據發送前,為了安全,還需要將待傳輸的數據通過特定的加密演算法進行加密,再利用 AsyncSocket 做數據傳輸。相對應的,每接到一種消息類型,就需要解密,將 PB 格式轉換成對象模型。這種方案,每次新增消息類型時都比較痛苦,要寫加密演算法,寫 PB 模型解析器。這樣不僅代碼的擴展性很差,開發難度也比較大。

代碼耦合性強

每次如果有新增消息類型,要在 DB 層寫個介面對新消息的數據解析並存儲。同樣,在 Socket 傳輸層也要新增收發介面與之對應。這種設計方式,開發過程中耦合性很大。

代碼可讀性差

App 內只有一種消息類型,叫 WBMessagModel。在消息類型判斷上,是通過 WBMessageModel 里的特定欄位來進行區分的,比如根據 mtype、isOnlineTip、m_msgtype 等欄位判斷,方式相對混亂。

為了解決上面的問題,打造一款低耦合、可擴展性強的 IM 系統,我們決定重構。

新版本的 IM 系統

框架演進

老的 IM 系統由於代碼耦合性嚴重,一旦遇到問題難於追查。並且擴展性差,每個版本的需求研發,都從底層修改到業務層,影響研發進度。結合之前 IM 開發過程中遇到的問題,新的 IM 系統亟需解決如下問題。

  • 簡化調用流程

業務開發過程中做到與「底層 DB+數據加密+數據加密+數據傳輸」的分離,通過調用底層介面就可以做到收發、存儲消息。

  • 設計低耦合的中間層介面

中間層介面要做到承上啟下對接,業務層和底層介面無任何耦合。如果做到這些,以後在 IM 底層升級甚至更換時,只需調整業務介面與底層介面的重新對接,讓頂層的業務無感知,做到無感知的迭代。

  • 設計單一職能的模型和介面

在具體業務層處理上,要做到模型分離,設計統一。模型上,將之前的只有一個 IM 模型根據各自的類型拆分。介面上,通過底層、中間層業務層的結構劃分,每層介面各司其職。

  • 可擴展性強

利用面向協議方式抽象和組織代碼,做到按照協議新增消息。利用 UITableView 的類別做到現有及新增的消息類型 Cell 能夠自動計算高度。通過這種業務上的設計方式,能夠快速定位問題。如有新增的消息類型,只需關注新增的消息模型和與之對應的消息界面即可,完全無需關注視圖的填充時機以及如何計算視圖的高度等。確定了這些設計原則,才能保證在業務研發過程中做到快速迭代,進而滿足日益增長的用戶需求。

基於上面的目標,重構后的 IM 整體架構圖 1 所示。

圖 1 新版 IM 架構設計

新的 IM 系統整體架構包含底層、介面服務層、業務層三個部分。底層主要進行數據收發、存儲等相關處理,並抽象出通用底層介面,與介面服務層交互。介面服務層主要負責合理地將底層的數據傳遞到業務層,同樣,業務層的數據能夠通過介面服務層傳遞給底層。清晰明了的介面服務層不僅可以讓業務層處理數據變得更簡單,還能極大地降低業務層和底層的耦合。業務層主要針對具體需求場景,如何合理使用數據進行視圖的展示。基於這樣的設計,下面詳細介紹一下各個層次之間的具體實現。

設計調用流程簡潔的底層介面

新的 IM 底層採用了全新的設計思路,如圖 2 所示。在底層,為了數據的可擴展性,放棄了之前 PB 的數據協議,而是採用傳統的 JSON 格式作為 Socket 端數據的收發協議。

圖 2 底層架構設計

在消息模型上,摒棄了之前只有一種消息模型的策略,而是根據消息類型劃分出文本消息模型、圖片消息模型等基本消息模型。

58 App 將 DB 和 Socket 的內部處理封裝成 SDK,對外只暴露 IMClient 底層介面。頂層所有消息相關的事件都是和底層 IMClient 的介面交互,內部流程完全不用關心。這樣業務層完全感知不到數據是如何收發和存儲的,極大地簡化了接入和使用成本。

但是讀者也許會有疑問,IM SDK 里內置了如此多的類型消息,那以後有新增 SDK 里沒有的消息類型該怎麼辦?為了解決這個問題,58 App 採用了一種和 iOS 自定義對象歸解檔相似的策略——任意定義一種新的消息,只要它繼承自基礎的消息類型,並遵循 IMMessageCoding 協議。這個協議里定義了 encode 和 decode 方法,其中,encode 方法用於將新類型消息里的數據存儲到資料庫中(當然,這個過程並不需要上層開發者關注,他們只需在這個函數里返回待存儲的數據即可);decode 方法用於將資料庫中的數據恢復成相應的消息模型。現在,我們有了消息類型的定義方式,又如何使用呢?為了讓底層能夠感知到自定義的消息類型,需要在統一介面層 IMClient 初始化之後,立即註冊給它,註冊后 IM 底層就知道當前的消息類型,並且明白如何存儲和恢複數據。基於這種設計方式,目前 58 App 的 IM 底層可以任意擴展其他消息類型,而底層的代碼完全不用修改。

底層代碼不僅有良好的擴展性,並且在設計時還為一些基礎的場景提供了很多協議。這些協議都是可動態定製或移除的。例如,當聯繫人列表發生變化時,需要修改聯繫人頭像,就可以訂製底層IMClientConversationListUpdateDelegate協議。使用時,業務方通過註冊協議addUpdateConversationListDelegate:,當監聽到聯繫人更新回掉后,執行頭像更新操作。當不需要時,可通過removeUpdateConversationListDelegate:方式,解除監聽。類似的場景還有消息接收協議、在線狀態變化協議等。通過這種方式,就可靈活配置業務代碼對 IM 的某些狀態變化的監聽。

目前,通過對底層代碼的抽象,提供頂層介面與內部數據處理分離,且很多 IM 服務都可定製化實現,由此就做到了和具體業務無耦合。通過這樣的底層設計,完全可以作為基礎的 IM SDK,給其他 App 使用,快速集成 IM 功能。

設計低耦合、職責單一的中間層介面

為了業務層和底層能夠通信,並且互不耦合,我們創建了中間介面層用以承上啟下。根據實際的業務場景,中間介面層分了三種情況,即為登錄相關的介面、消息收發相關的介面以及消息查詢相關介面,分別和底層統一介面對接。通過業務場景的劃分,開發過程中可以快速定位相關業務對應的模塊。對於底層提供的消息模型,並沒有直接使用,究其原因是底層的消息模型完全不關心視圖展示屬性,比如行高、重用標識等屬性(下節會詳細介紹)。而 MVVM 中 VM 部分屬性需要和視圖關聯,因此將底層的消息模型轉換成了聊天 Cell 直接可用的消息模型。通過這樣的業務介面劃分和消息模型的轉換,即使之後底層統一介面或消息模型發生變化,只要做好中間介面的重新對接和消息模型的重新轉換,頂層業務就完全感知不到下面的變化。

設計可擴展性強的業務層

由於老的 IM 系統項目是早期搭建的,處理的業務場景簡單,擴展性不足。例如所有消息都使用同一個數據模型,就會造成隨著業務場景的擴展,模型的代碼體積越來越大,使用時好多屬性冗餘不堪。在設計上,老架構使用了 MVC 設計模式,由於在聊天場景下,VC 要處理的聊天視圖類型較多,VC 內部十分臃腫。因為之前架構的局限性,這就對新的 IM 業務架構提出了要求,怎樣設計出低耦合、擴展性強的業務層?接下來介紹一下具體的實現方案。

  • 拆分 IM 消息模型:明確了上面的問題,現在 58 App 把之前只有一個消息模型,拆分成了文本、圖片、語音、提醒、音頻、視頻等消息模型,它們統一繼承基類消息的模型,基類消息模型存儲了 IM 所需的必要數據,如聊天用戶的信息、消息發送的狀態等。

  • 使用 MVVM 架構:為了降低 VC 和各個聊天視圖之間的耦合。VC 管理各種消息模型,消息模型中存儲視圖展示時需要的數據。在消息視圖和消息模型之間,實現了雙向數據綁定。實現的方式是在聊天視圖裡存儲與之對應的消息模型,這樣當聊天視圖變化並需要消息模型做數據更新時,直接對消息模型賦值即可。當聊天視圖要根據消息模型屬性變化而變化時,則通過 KVO 的方式實現這一功能。例如在 IM 場景中,我們發送一條消息,消息模型中的發送狀態是發送中,當發送狀態變化時(如發送成功或失敗),聊天視圖就可以根據改變后的值進行更新;

  • 使用面向協議組織 IM 模型和視圖:通過面向協議的方式,組織 IM 模型和視圖,可以增強 IM 消息模型和視圖的擴展性。下文會結合具體的技術細節,闡述面向協議的設計在 58 IM 系統中的重要作用。

技術細節

聊天列表頁技術細節

由於 IM 模塊的特點,伴隨著業務需求的發展,IM 的類型會越來越多。為了避免在研發過程中每次都要花費很多精力計算 UITableView 中 Cell 的高度,為此我們在 App 內利用 XIB 創建不同的 Cell,並使用 AutoLayout 的方式給 Cell 中的視圖布局。當然,你也可以通過手寫代碼的方式,然後利用 AutoLayout 布局。而 App 在 IM 中利用 XIB 布局,目的是為了讓視圖的布局更直觀地展示,以及更好地讓視圖部分和 VC 分離。當 Cell 中所有布局合理完成後,就可以通過調用系統的 systemLayoutSizeFittingSize:方法,獲得 Cell 的高度。基於這種思路,58 App 內部給 UITabelView 增加了自動計算 Cell 高度的能力,代碼如下:

#import

#import "WBAutoCalculateTableViewDelegate.h"

@interface NSObject (WBAutoCalculateTableView)

@property (nonatomic,assign) CGFloat kid_height;

@end

@interface UITableView(WBAutoCalculateTableView)

- (CGFloat)heightForRowWithReuseIdentifier:(NSString *indentifiercellEntity:(NSObject *)cellEntity;

@end

首先我們給 NSObject 增加了類別,並在類別里添加了 kid_height 屬性,目的是在計算完 Cell 的高度后,將其緩存好。這樣下次重新載入 UITableView 時,就直接返回緩存過的高度。

其次,我們給 UITableView 添加了類別。利用heightForRowWithReuseIndentifier: cellEntity:這個 API,在傳入當前消息 Cell 的重用標識和當前的消息模型后,就返回當前 Cell 的高度。而調用者完全不用關心高度計算細節,計算完成後,立即將高度利用 NSObject 的類別屬性緩存在消息模型中。

為了解決不同類型的消息 Cell 填充數據方式不一致的問題,我們引入了如下協議:

#import

@protocol WBCellConfigDelegate

@required

- (void)setModel:(id)cellEntity;

@end

如此,讓 UITableView 中所有的消息 Cell 都遵循此協議,此協議規範了不同的消息 Cell 之間填充數據的統一性。不同的消息 Cell 使用不同類型的消息模型, 但卻可以使用相同的填充規範。

@protocol WBAutoCalculateCellViewModelProtocol

@required

- (NSString *)cellReuseIndentifier;

- (void)registerCellForTableView:(UITableView *)tableView;

@optional

- (CGFloat)cellHeight;

@end

為了解決消息視圖在即將展示時,還要根據當前的消息類型,去判斷該使用哪種視圖的模板,58 App 採用讓每個消息模型遵循上面的協議,每個消息模型都存儲與之對應的重用標識。因為 Cell 的註冊方式有多種,如通過類註冊或 Nib 註冊,這裡設計成靈活的介面,註冊 Cell 方式完全交由開發者決定。

下面的可選協議,在此還要著重在介紹一下- (CGFloat)cellHeight。這個協議是這樣的,雖然大部分場景能夠自動計算某個 Cell 的高度,但有些消息類型的高度是固定的,根本無需計算。為了解決這個問題,我們給消息模型增加了可選的 cellHeight 協議,如果消息模型實現這個協議,則 Cell 的高度就不自動計算了,通過此方法的返回值決定。

做項目有時就像搭積木一樣,通過上面的介紹,我們已經有了很多小的解決方案,就像有了很多積木零件,如何將這些方案組織在一起,下面到了將這些「積木」組裝到一起的時候了。因為我們是通過 UITableView 組織和管理聊天頁面視圖的,而tableView:heightForRowAtIndexPath:是其重要的代理方法,目前實現如下:

#pragma mark UITableViewDelegate

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{

CGFloat cellHeight = 0;

id cellEntity = self.viewModel.dataSource[indexPath.row];

//向 tableview 中註冊 cell 通過 cellindentifier

if ([cellEntity conformsToProtocol:@protocol(WBAutoCalculateCellViewModelProtocol)]) {

if(!self.tableViewRegisters[[cellEntity cellReuseIndentifier]]) {

[cellEntity registerCellForTableView:tableView];

self.tableViewRegisters[[cellEntity cellReuseIndentifier]] = @(1);

}

}

if ([cellEntity respondsToSelector:@selector(cellHeight)]) {

cellHeight = [cellEntity cellHeight];

}else{

cellHeight = [tableView heightForRowWithReuseIdentifier:[cellEntitycellReuseIndentifier] cellEntity:(NSObject *)cellEntity];

}

return cellHeight;

}

在這個方法中,我們看到了每個cellEntity(消息模型),都遵循了上面介紹的 WBAutoCalculateCellViewModelProtocol。在此方法里,讓每個消息模型去註冊自己的 Cell 類型,然後計算 Cell 的高度,如果消息模型有 cellHeight 方法,則通過此方法計算高度,否則通過上面提到的自動算高的方式,返回 Cell 的高度。

在 Cell 的展示處理上,UITableView 的數據源方法tableview:cellForRowAtIndexPath:是核心的方法,目前實現如下:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{

id model = self.viewModel.dataSource[indexPath.row];

NSString *cellIndentifier = [model cellReuseIndentifier];

UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIndentifier];

if (cell &&[cellconformsToProtocol:@protocol(WBCellConfigDelegate)]) {

[cell setModel:model];

}

if (!cell) {

cell = (UITableViewCell *)[[UITableViewCell alloc]init];

}

return cell;

}

通過消息模型找出重用標識。因為已經在tableView:heightForRowAtIndexPath:時註冊過了 Cell,所有通過重用隊列一定能返回該消息類型下的 Cell。而消息 Cell 都遵循WBCellConfigDelegate協議,使得數據在填充時具有統一的方式。

通過面向協議的設計方式,我們在 VC 里 tableView 的代理和數據源方法就變得如此簡單。而且以後如果在擴充新的消息類型時,繼續遵循相應的協議,VC 里的代碼是一行都不用修改的,開發人員只要關和注新增的消息模型和視圖即可。

圖 3 承上啟下的業務中間層設計

處理離線 Voip 消息的技術細節

實際開發過程中,我們遇到了一個問題,當 B 不在線時,B 的聊天對象可能向 B 發起音視頻消息,伺服器為了信令消息的完備性,會建立一個隊列,將所有向 B 發消息的信令記錄下來。過了一段時間,當 B 登錄時,Server 會把 B 離線期間所有的通話信令發過來。由於剛開始設計時沒有考慮到這一點,造成一個問題就是當 B 啟動時,A 發送了一個視頻消息過來時,B 接受到第一個視頻信令是離線期間的視頻消息信令(如果有)。這就造成了 B 嘗試連接一個早已不存在的視頻通道,而讓 A-B 視頻聊天連接不上。客戶端為了也支持這種信令序列,利用條件鎖技術有序地處理視頻連接信令,如圖 4 所示。

圖 4 通話信令序列設計

具體解決方案如下:

  • 首先,我們創建一個 Concurrent Queue,當有信令信號傳給客戶端時,就放在 Concurrent Queue 里執行;

  • 為了保證 Voip 信令能有序執行,我們引入了條件鎖 NSCondition, 并行隊列在處理 Voip 信號時,先獲取條件鎖,獲取完畢后,我們將 isAvLockActive Bool 變數標記為 YES,然後對信號進行初步處理,初步處理完畢后 Unlock 條件鎖;

  • 由於 Unlock 了條件鎖,隊列里其他的 Voip 信令就有了處理的機會。處理時,檢測 isAvLockActive 狀態,如果為 YES,說明此前有 Voip 信令還沒有處理完畢,則執行條件鎖的 wait 方法;

當某個 Voip 信號事件完全處理完畢后,會觸發條件鎖 Signal, 這時,隊列里其他等待條件鎖的信號就可以得到處理。這時我們又返回步驟 2,直至隊列里沒有待處理的 Voip 信號。

總結

這次 IM 系統重構,通過底層介面分離使得 IM SDK 耦合性降低,利用面向協議設計方式使得聊天頁面可擴展性增強,所以短時間內 App 內部擴展了富文本、圖片、地理位置、簡歷、卡片等類型消息。希望通過 58 App IM 的重構歷程,能給設計或改造優化 IM 模塊的開發者提供一些參考。未來,我們會在如何提高頁面性能和降低用戶流量上進一步調優,繼續完善 IM 的各個細節。

作者: 蔣演,58 同城 iOS 高級研發工程師,專註於 App IM 系統的架構研發以及性能優化,主導了 58 同城 App 的 IM 系統架構以及研發。



熱門推薦

本文由 yidianzixun 提供 原文連結

寵物協尋 相信 終究能找到回家的路
寫了7763篇文章,獲得2次喜歡
留言回覆
回覆
精彩推薦