作者|郭玉鵬
前言介紹
在軟件架構領域,框架的功能類似于基礎設施服務,是為實現某個業界標準而形成的組件規范。簡單理解,框架就是制定一套規范或者規則,開發同學在該規范或者規則下工作。本文通過剖析框架實體 ServiceKit/Adapter ,來窺探其底層結構和架構設計。
背景描述
隨著抖音業務的發展,為保障整體工程演進和迭代計劃的高效運行,體系化建設已加速提上日程,Codebase(可通稱為產物)融合是其中項目之一。該項目主要為開發同學提供底層復用能力、增強研發團隊效能,致力于幫助開發同學輕松高效地研發、管理代碼。
Codebase 融合過程中,技術團隊在各個業務線方向進行著差異化探索;演進路程上,業務線間耦合越來越強,開發同學迫切需要一套解決方案來做差異化代碼隔離。如下圖抖音與抖音極速版模塊差異所示。
回顧痛點,在過往的開發中,開發者們一般使用宏隔離( isLite or isPad )來區分不同產物之間的差異,但這種方式嚴重破壞了整個抖音工程的架構體系,以下從幾個維度分析。
研發效率:需要支持不同宏變量進行 lint ,有重復 lint ,單個組件很難區分項目控制二進制發版頻率,二進制需要頻繁更新,宏會導致很多混編二進制,影響編譯效率,如果以單個文件作為編譯緩存單元,宏隔離也會降低編譯緩存命中率。
可擴展性:擴展性差,缺乏動態能力和插件能力,添加新功能和修改原有功能會導致類實現的代碼急劇膨脹。
圈復雜度:宏隔離的代碼分散,修改和重構成本高。
組件粒度:無法支持項目間差異業務獨立成組件,背離高內聚、低耦合原則。
我們的目標愿景是要做一套符合抖音工程架構體系,具備高效、通用、便捷能力的框架規范,讓開發同學在標準規則下進行編碼工作。
架構設計
啟蒙圖紙
啟蒙設計是著手做事之前的抽象意識,如下圖,在多個產物的研發環境下,將共同代碼能高效的復用,差異性代碼優雅的隔離開。
為了幫助新同學快速入手架構框架,筆者在做此框架 Swift 建設的過程中,基于近段時間經歷的幾個項目經驗,總結出了一套系統性的腦圖,下面和大家分享下框架系統化的全景。
框架全景思維
內容較多,但是全景思維還是想要在這里提一下,說不定在哪個階段上給你靈感;建議從樹的根節點出發,選擇性的去了解它;如想大致了解,只用進入到 3 層左右,如想深入了解,請走到葉子節點(為了不影響閱讀體驗,更加細節的節點已經被裁剪掉)。
基于上述框架系統化的思路鋪開,整個篇章會先介紹一些設計思想,再進行性能等相關的技術細節。由于篇幅有限,我們將精簡出我們認為比較重要的技術點進行重點講解。
設計思想
適配器模式
在設計模式中,適配器模式(adapter pattern)有時候也稱包裝樣式或者包裝。將一個類的接口轉接成用戶所期待的。一個適配使得因接口不兼容而不能在一起工作的類能在一起工作,做法是將類自己的接口包裹在一個已存在的類中。
—— 維基 百科 適配器模式
開發同學不用關心各個模塊的復雜度、業務的邏輯性、是選擇類對象還是實例對象、如何初始化各自單元等,僅需要基于包裝好的適配器來做各自的任務調度,類似于萬能充電器( 90 后同學時代的產物 :> ),無需關注電池是華為的,還是 OPPO 的,即插即用。
注冊與發現
服務注冊 - 服務發現思想
- 服務演進
下面三個圖簡單描述了 web 服務時代從傳統服務到微服務時代的歷程(傳統服務 -> 并發服務 -> 分布式微服務),大家感興趣可以了解下,這里不過多介紹。
- 微服務
微服務是一種以業務功能為主的服務設計概念,每一個服務都具有自主運行的業務功能,對外開放不受語言限制的 API ,應用程序則是由一個或多個微服務組成。
—— 維基百科,微服務
簡單了解微服務后,以服務角度來看,多個 Target 產物根據各業務模塊可劃分為多個 Adapter 服務,搭配綁定多個適配器協議,這樣能達成一對多效果。
我們深入性的介紹下內部設計思路。在使用階段,一個主類可以向多個適配器類發送消息;在注冊過程,一個適配器類可以綁定到多個適配器協議,并且滿足兩種場景:一是多產物必須實現的接口,可以放在一個公共的協議上,二是單個產物必須實現的接口放在獨立的協議上,公共協議 + 獨立協議可以進行組合,由同一個有上下文關聯的適配器類來實現。
提到微服務,我們不得不了解下兩個概念,服務注冊與發現。
服務注冊
- 服務注冊:是將提供某個服務的模塊信息注冊到一個公共的組件上去。(如下示例代碼更加容易理解)
//服務注冊
ServiceKit.register(AModuleServer);
服務發現
- 服務發現:是指使用一個注冊中心來記錄分布式系統中的全部服務的信息,以便其他服務能夠快速的找到這些已注冊的服務;不管是服務新增和服務刪減都能實現自動發現。(如下示例代碼更加容易理解)
//服務發現
ServiceKit.get(AModuleServer);
進階圖紙
- 藍色框:抖音 Target
- 黑色框:抖音極速版 Target
- aXXXDOUYINAdapter:是 XXXDOUYINAdapterImpl 的服務實例。
- XXXDOUYINAdapterImpl:是訂閱者,發布者是持有 XXXDOUYINAdapterImpl 實例 XXXDOUYINAdapter 的主類。
- <>XXXDOUYINAdapter:面向協議編程,抽象 Protocol 接口,抽離各自差異性、公共性代碼的接口。
上圖再一步概括了整個項目背景(抖音、抖音極速版的兩套代碼,有重復也有差異,如何將重復的代碼繼續共用,并且將差異性的代碼隔離到各自的Target產物中,不再耦合)、我們要做的過程(通過適配器模式來做任務調度,面向協議編程,抽離共用、差異性的代碼為接口形式,在各自Target中,實現各自的協議Impl),以及達成的結果(通過便利性腳手架、輔助工具能讓使用者低成本學習和理解,容易上手操作)。
關系圖紙
工程視角
從抖音現有工程架構視角,了解設計。
流程實戰
接下來我們進行下流程性實戰演練。
代碼實戰中,訂閱類在 App 內存創建一個實例,訂閱者的生命周期由所有關聯的發布者決定,比如多個控制器匯總埋點邏輯到一個加工者, 或比如一個父控制器對應多個子控制器。
技術細節
上述了解設計性圖紙之后,我們深入淺出的剖析內部技術細節。
編譯插拔
常規思路下,注冊會放到 App 啟動階段,但這樣做容易拖緩 App 的啟動速度。要想做到在最早的時機注冊但又不影響啟動速度,需要基于編譯器特性:__attribute__((section("name"))) 實現,通過 attribute 指令,編譯時期寫在 .data 段,然后在運行時期讀出來。下圖介紹編譯注解的簡單流程。
代碼示例
__attribute((used, section(_DY_SEGMENT "," _DY_MSG_ASSOCIATE_SUBSCRIBER_SECTION ))) static _dy_message_pair _DY_MSG_UNIQUE_VAR = \
{\
&_DY_MSG_ASSOCIATE_PROTOCOL_METHOD(INDEX),\
&_DY_MSG_ASSOCIATE_LOGIC_METHOD,\
};
利用上述編譯注解的能力,搭配協議反射,就能達到在使用的時候,get 協議進而讀取到存儲在 .data 段中的內存地址來加載,這個能力也稱為懶加載。
支持切面
核心思路如下(偽代碼),在注冊階段暴露出代碼塊模型,可以在塊中做類似 AB 的邏輯切面。
isABTest = YES;
Register {
if (isABTest) {
return <ObjectABProtocol>ObjectA.new;
} else {
return <ObjectABProtocol>ObjectB.new;
}
}
循環引用
為了防止 subscriber 與 publisher 在 block 使用或者主類與適配器的關聯情況下導致循環引用,適配器底層運用了 NSProxy 來實現。如以下的 case 無需關心內存不釋放問題。
- 場景例一
@implementation DYAudioViewForDOUYIN
RegisterAdapters(DYFeedInteractionControllerPrivateProtocol,DYFeedContaineAudioAdapter) {
if (GET_AB_TEST_CASE(enableAutoPlay)) {
return nil;
} else {
return [[DYAudioViewForDOUYIN alloc] init];
}
}
- (void)stopAudio:(BOOL)immediate
{
[[self weakTarget] refresh:^{
[[self weakTarget] refresh];
[self stop];
}];
}
- (void)stop
{
//do something
....
}
@end
- 場景例二
@implementation DYFeedContainer
GetAdapters(DYFeedContaineAudioAdapter,DYFeedContaineVideoAdapter, DYFeedModuleConfig)
- (void)stopPlay
{
id <DYFeedContaineVideoAdapter> adapter = [self DYFeedContaineVideoAdapter];
[[self DYFeedContaineVideoAdapter] stopVideo:^{
[adapter refreshView];
}];
self.myBlock = ^(){
[adapter refreshView];
};
}
@end
綁定關聯
綁定關聯共分為兩部分,強關聯與弱關聯。
- 強關聯:將各適配器強綁定關聯在主類上,這樣能實現適配器的生命周期跟隨主類自動釋放,在使用適配器對象時讓內存持續處于最優狀態。
- 弱關聯:將主類弱關聯在適配器上,這樣能實現在隔離出來的附屬類中,通過 Key ( self = 適配器)拿到主類,達到反向通信的效果。
多語言適配
Swift 環境下不能在注冊階段友好的使用 attribute 編譯指令,去自定義段能力,要想高性能的使用懶注冊能力只能另辟蹊徑。
將注冊代碼塊直接放到 MachO 文件中的代碼區,通過繼承協議 SwiftAdapter ,實現層實現 + (id)lazyRegister 類方法,runtime 的 Api 映射出 A 類對象,在服務發現的階段來調用 A 類方法代碼,這樣能解決“懶注冊”問題;然后改造底層框架,控制內部保證只會初始化一次,用戶視角無需關心。
E.g.
class ModuleADouYinLiteAdapter: NSObject,SwiftAdapterProtocol {
class func lazyRegister() -> NSObjectProtocol {
return ModuleADouYinLiteAdapter.init()
}
}
便利腳手架
在各語言環境對服務發現與注冊接口制造腳手架,使其用起來更加簡便。
- Objective - C 宏
接口均用宏來封裝。
//服務注冊
RegisterAdapters(ModuleDouYinLiteAdapter) {
return ModuleDouYinLiteAdapter.new;
}
//服務發現
GetAdapters(ModuleDouYinLiteAdapter)
- SwiftProtocol 擴展
Swift 環境下不能友好的使用宏封裝,此時我們可以通過對 Protocol 進行擴展,以達到封裝效果。
//服務注冊
class func lazyRegister() -> NSObjectProtocol,ModuleDouYinLiteAdapterProtocol {
return ModuleDouYinLiteAdapter.init()
}
//服務發現
Protocol.getAdapter(self,ModuleDouYinLiteAdapterProtocol.self)
使用視角
OC編碼
共有接口差異代碼情景
服務注冊
?服務發現
Swift編碼
獨有接口差異代碼情景
服務注冊
- 前置抽象協議接口,懶注冊,支持切面。
- 支持在各個 Adapter 實現層中獲取 WeakTarget (主類)。
服務發現
輔助工具
就如很多人都喜歡玩的網游地下城與勇士( DNF ),輔助工具“連發”(顧名思義,連續發動,可以聯想到傳統單發步槍與自動步槍的區別)不僅讓玩家節省了不少的按鍵成本,而且在連招上增強了打擊節奏感。同樣的道理,我們推薦使用 Xcode 自定義模板工具編程,讓使用者減少打出代碼的時間成本,在開發中更加聚焦處理編碼邏輯。
使用規范
為讓開發同學更加規范使用,我們在代碼靜態檢查階段進行代碼的攔截矯正,同時基于現狀列一下幾個 Badcase 。
場景例一
- 只進行了分支判斷邏輯隔離,沒做到代碼隔離,這樣會將判斷邏輯帶到主類,使讓包大小增加。
// E.g. 錯誤示例
- (void)masterFunction {
if ([self DYFeedAModuleLiteAdapter]) {
// lite code
} else {
// douyin or other Target code
}
}
//--------------------------------------------------------------------------
//E.g.正確示例
- (void)masterFunction {
[[self DYFeedAModuleAdapter] runFunction];
}
//各自Target實現runFunction協議方法
//in douyin
- (void)runFunction {
// code
}
//in Lite
- (void)runFunction {
// code
}
場景例二
- 在同一個產物內,一個協議被多個類實現( Debug 環境編譯階段會通過斷言進行第一次攔截)。
// E.g. 錯誤示例( douyin targer)
@interface AModuleAdapter<AModuleAdapter>
@interface BModuleAdapter<AModuleAdapter>
//E.g.正確示例(douyin targer)
@interface AModuleAdapter<AModuleAdapter>
@interface BModuleAdapter<BModuleAdapter>
場景例三
- Adapter 方法在不同產品線下可能返回空值,如果想拿 Adapter 做 一些邏輯編碼,需要提前判斷是否為空。
// E.g. 錯誤示例
- (DYAModuleFeedType)getType {
return [[self DYModuleAdapter] checkType];
}
//E.g.正確示例
- (DYAModuleFeedType)getType {
return [self DYModuleAdapter] ? [[self DYModuleAdapter] checkType]:/* 兜底邏輯 */;
}
生態建設
目前為止,多產物適配器框架實體 Adapter 已經在抖音數個平臺業務線中批量使用,大部分 OC 業務場景均已覆蓋,而且 Swift 場景能力也已建設完畢,框架母體 ServiceKit 已接入 20 + 個 App 。
寫在最后
穩扎穩打
對于核心框架,我們寫出的也許只有一行代碼,但是會有幾百萬行甚至上千萬行代碼會經過它,一定要慎重思考。