Categories
程式開發

優酷iOS插件化頁面架構方案


一、前言

隨著業務不停地迭代,優酷 APP 用於分發視頻資源的 UI 控件越寫越多,也越來越複雜,並且同時相似相近的代碼也非常多。仔細研究之後,發現是很多耦合導致的問題:

1)佈局代碼耦合數據模型,相似佈局組件各自一套佈局代碼;

2)數據模型、UIView 繼承關係太長,改動時牽一發而動全身,為保險計不得不自立門戶;

3)依賴引入,一個組件在另一 bundle 下使用時將引入連串依賴。

有鑑於此,我們需要尋找一種能夠進一步降低通用能力接入門檻,提升單個組件的開發效率;進一步降低組件與頁面的耦合,建立各類組件的在不同頁面的通用投放能力的架構。

二、插件化頁面架構的探索

我們先來看一份ViewController 代碼節選,ViewController 內實現3 個feature 分別是A,B,C,並且這些稍微複雜的feature 無法一次性單步完成(具體一點的話,可以聯想成這是一些用戶交互的feature、網絡請求等),在某一時機觸發,接著在某回調完成餘下操作,最終構成了一個完整的feature。

@implementation ViewController

- (void)viewDidLoad {
    [featureA step1];
    [featureB step1];
    [featureC step1];
}

- (void)callback_xxx {
    [featureA step2];
    [featureB step2];
}

- (void)callback_yyy {
    [featureC step2];
}

@end

這是一種基本的代碼組織形式,但是面臨著兩個痛點:

一是依賴爆炸問題,每接入一個 feature 就無可避免地引入一批依賴,當 feature 數量上去之後,光是 import 語句都好幾十行;

二是代碼分散問題,同一 feature 相關代碼分散在各處 callback,復用到另一 ViewController 或者將其廢棄下架都必須要求開發者對該 feature 每一步驟甚至每一行代碼都極為熟悉。如何才能解決上述痛點是我們在做架構藍圖時的一個突破口。這時,試圖把圍繞 ViewContorller 的代碼組織形式轉變成圍繞 feature 代碼組織形式,那麼就可得到下面 3 段代碼節選:

@implementation FeatureA

- (void)recvViewDidLoad {
     [self step1];
}

- (void)recvCallback_xxx {
     [self step2];
}

@end
@implementation FeatureB

- (void)recvViewDidLoad {
     [self step1];
}

- (void)recvCallback_xxx {
     [self step2];
}

@end
@implementation FeatureC

- (void)recvViewDidLoad {
     [self step1];
}

- (void)recvCallback_yyy {
     [self step2];
}

@end

不難發現,代碼經過重新組織之後分散的問題已經迎刃而解。依賴爆炸的問題在單個feature 上來看,多個依賴已收斂到feature 內部,接入feature 的時候依賴已從N 個降至1 個,只要使用得當的方式,也可把最後一個依賴也一併消除。

此時需要發揮一下我們的想像力,把每個 feature 想像成是一個電器,它們都配有統一規格的插頭。 ViewController 好比一個插線板,電器無論插在哪個板上也是可以工作的。推而廣之,不僅 ViewController 是一塊插線板,任意一個類也看看作為一塊插線板,它們的功能業務邏輯依然以 feature 的模式來組織。插件化頁面架構的基調就被確定了。

插件化是業內普遍使用的解耦方案之一,我們不約而同地朝著這一方向來對現架構的改造,同時結合優酷的實際情況,得出一套以模塊化、插件化、數據Key-Value化為特點的頁面架構框架。

1)模塊化 – 業務實體進行模塊化,模塊與模塊呈現一定的組織形式;

2)插件化 – 功能單元插件化,滿足功能單元可組合、可拆解、可替換;

3)數據 Key-Value 化 – 極簡數據組織形式,減除因數據模型引入的依賴。

三、從業務模塊梳理到架構概述

我們結合優酷 APP 業務將 UI 元素從大到小進行模塊的劃分,依次是頁面、抽屜、組件和坑位。組件由數個相同的坑位組合而成,同理,若干個組件組合成抽屜,若干個抽屜組成頁面。

優酷iOS插件化頁面架構方案 1

不同層級的模塊都各自的功能單元,如下表:

模塊層級 功能單元
父頁面 頁卡容器、埋點統計(PV)
頁面 NavigationBar
列表容器(CollectionView/TableView)
上下拉刷新
提示面板(空數據、網絡異常)
頁面級網絡數據請求
頁面級數據緩存
埋點統計(PV)
抽屜 列表容器抽屜級佈局管理(平鋪、多 Tab 翻頁
抽屜級網絡數據請求
組件 列表容器組件級佈局管理(多行多列平鋪、瀑布流、橫滑、輪播)
組件級網絡數據請求
坑位 UI 單元(即具體的、局部的 UI 實現)
手勢響應(單擊、雙擊、長按)
路由跳轉
埋點統計(點擊、曝光、播放)

大模塊由若干個小模塊組合而成,將這些大大小小模塊用線段來連成一體,則可以得到一個龐大的樹狀結構,每個模塊相當於樹里面的個節點。功能單元則是跟這裡的每個節點有著聯繫,將一個功能單元對應一個或多個插件。模塊的功能單元代碼由插件承載,模塊內外的功能單元通過事件傳遞消息和數據,再加上Key-Value 化數據存儲,這樣我們就可以得出這個架構的雛形,綜合整理後得出四大核心Manager:

1)ModuleManager 負責模塊的生命週期和關係管理;

2)PluginManager 負責模塊與插件的關係管理;

3)EventManager 負責模塊內外,插件與插件之間的消息通信;

4)DataManager 負責模塊的數據管理。

在此基礎上,我們將常用的列表容器、UI 佈局邏輯、埋點統計邏輯、網絡請求邏輯、用戶交互手勢邏輯、路由跳轉邏輯等通用邏輯進行抽象插件化改造,最終形成4+N 的架構組成。

優酷iOS插件化頁面架構方案 2

四、模塊表示與管理

如何表示一個模塊,是我們首要解決的問題。在現實世界中,我們用身份證 ID 來區分每一個人,同樣地每個模塊都應有唯一標識的 ID。模塊 ID 在整個架構體系中屬於核心中的核心,使用上也非常頻繁,如數據的讀取、消息的傳遞、實體之間的關聯和綁定。我們用 Context 類的對象來表示一個模塊,最簡單的 Context 類有且僅有一個 ID 屬性。在這裡我們特別地定義和引入了 ModuleProtocol,如果其他一般類也遵守這個協議,那麼我們就可以把這樣的實例對像看作與該同一模塊 ID 所表示的模塊有所關聯。

@protocol SCModuleProtocol  //注:SC 为代码的统一前缀,下同

@property (nonatomic, strong) NSString *scModule; ///模块Id,全局唯一

@end

@interface SCContext : NSObject 

@end

我們根據業務模塊頁面、抽屜、組件、坑位四級劃分,分別制定 PageContext/CardContext/ComponentContext/ItemContext,同時在 Context 類內建立弱引用屬性來方便各層級下不同模塊之間的使用。歸納起來 Context 類兩大作用:一是表示模塊本身,二是模塊關係的語法糖。

ModuleManager 負責模塊的生命週期管理和模塊的關係管理,包含註冊模塊、註銷模塊、查詢模塊的上下級模塊等接口。

@interface SCModuleManager : NSObject

+ (instancetype)sharedInstance;

- (void)registerModule:(NSString *)module supermodule:(NSString *)supermodule;
///注册模块

- (void)unregisterModule:(NSString *)module; ///注销模块

- (NSString *)querySupermodule:(NSString *)module; ///查询父模块

- (NSArray *)querySubmodules:(NSString *)module; ///查询子模块


@end

五、Key-Value 化數據存儲

為了減除數據模型引入的依賴,採用了Key-Value 存儲方案,用字符串作Key,並約定Value 只使用基本數據類型( int/double/bool 等)、字符串( NSString )、集合類型( NSArray /NSMutableArray/NSDictionary/NSMutableDictionary )和其他系統提供的數據類型(NSValue 等),在數據的使用上弱化自定義數據模型(協議)的使用。

//写入数据
[[SCDataManager sharedInstance] setdata:propertyValue forKey:propertyKey
moduleId:moduleId];

//读取数据
[[SCDataManager sharedInstance] dataForKey:propertyKey moduleId:moduleId];

每個模塊的數據都存放在數據中心內。數據中心為每個模塊開闢一塊獨立的空間存放數據,這是保證不同模塊數據不串擾又同時保證同一模塊內數據共享。同一模塊下只需字段名參數便可讀寫數據;不同模塊下也只是多增加一項目標模塊 ID 參數便可讀取數據。即:

在數據中心使用上,必須注意的是:

1)Key-Value 化存儲目的是減除數據模型的依賴,應避免 Value 使用自定義類型,否則失去了 Key-Value 化本身的價值;

2)不是所有的數據都需要存放在數據中心,只將公開化數據放入數據中心,而私有化數據(如臨時變量等)則不建議放入數據中心。

在數據中心的能力設計上,我們提供了:

1)提供強引用和弱引用兩種存儲方案,開發者按需使用;

2)安全的讀寫接口,對數據進行常規易錯的類型檢查、合法性檢查等。

六、功能單元插件化

用 ViewController 來舉例,在野蠻生長 iOS 開發時代,把列表邏輯、網絡請求邏輯、 Navigationbar 邏輯等諸多功能單元都攤開在 ViewController 來實現。 ViewController 實現個各式各樣的協議,以至於 ViewController 的代碼越來越臃腫。到了後來為這個問題,明確劃定功能單元的邊界,加入了各種 Manager,各功能單元邏輯實現在 Manager 內部,ViewController 只負責諸多 Manager 之間來回調度,臃腫的問題得以緩解。

日益豐富和復雜的業務邏輯下,只解決代碼臃腫是不夠的,還需解決靈活調用、代碼復用的問題。在實際實踐中,常常遇到下列問題:

1)功能單元接口設計變形,之間不時出現相互調用造成“你中有我,我中有你”的高度耦合,維護成本越來越高;

2)功能單元個性化定制引出繼承鏈的問題:不同業務的子類太多,父類牽一發動全身,不好改也不敢改,補丁補上補;

3)功能單元復用成本高,復用一小塊,依賴一大片,造成代碼復用意願低。接入方寧願重寫一遍或將相關代碼 Copy&Rename 一遍。

功能單元插件化目標是進一步降低功能單元之間的耦合。插件化思路和原則需要保證上述問題得到有效解決。

1)輕量化接入。減少甚至消滅類與類,類與協議引用依賴;

2)插件可組合、可拆解、可替換,業務邏輯上下游相關方能做到無感知;

3)插件邊界清晰,明確輸入輸出。

  1. 事件機制 – 更靈活的通信方式

事件機制採用“發布-訂閱”設計模式,功能單元通過發布事件來驅動信息的流轉,通過訂閱事件來接收並處理信息。信息收發雙方按事前約定的事件名進行通信,事件處理中樞負責事件的派發,因此收發雙方不存在直接依賴。值得留意的是事件機制中的信息接收方可以是多個。

EventManager 擔當起事件處理中樞的角色,發布者通過 EventManager 發布事件, EventManger 以訂閱優先級從高到低把事件分發到訂閱者。高優先級訂閱者處理完事件後將返回值(如有)交給EventManager,EventManager 將上一訂閱者返回值(如有)和發布者入參一同分發到下一訂閱者,如此往復直到所有訂閱者處理完畢,此時EventManager 將最終返回值(如有)輸出給發布者。圖示如下:

優酷iOS插件化頁面架構方案 3

事件發布與事件訂閱及處理的代碼示例:

//事件发布
NSString *eventName = @"demoEvent";
NSString *moduleId = ...;
NSDictionary *params = @{...};

NSDictionary *response = [[SCEventManager sharedInstance] fireEvent:eventName

module:moduleId

params:params];

//事件订阅、处理
+ (NSArray *)scEventHandlerInfo
{
   return @[@{@“event":     @"demoEvent",
              @"selector":   @"receiveDemoEvent:",
              @"priority":   @500},
              ];
}

- (void)receiveDemoEvent:(SCEvent *)event
{
  //do something
  ...
  event.responseInfo = @{...}; //返回值(可选);
}
  1. 在插件中使用事件機制

我們把插件當作是事件機制用訂閱者,同時允許在處理事件的實現中,發起一個新的事件。這樣就可以使得插件與插件之間通過事件串聯起來,合力地完成一項完整的業務邏輯。

在插件間的通信上,除了事件機制協議外,就只有事件名的依賴(事件參數中不推薦使用自定義數據類型,否則將重新引入顯式依賴),事件名本身是一串字符串,這可以減少因調用引起的各種功能單元間頭文件依賴。

用插件來承載業務邏輯的實現上具有非常靈活的特性,開發者可根據自己的判斷來決定插件的規模,插件的粒度可大可小,插件內部實現也可隨時中止使用事件機制並轉回其他一般的類與類、類與協議機制來實現具體的業務邏輯。

在插件的使用上具有非常靈活的特性,因此我們約定插件邊界必須清晰,必須做到單一職責原則,輸入輸出明確並足夠簡單,如果不滿足以上條件,則表示該插件有拆解細分的可能性和必要。

  1. 插件與模塊的結合

插件、功能單元和模塊的關係有以下 4 點:

1)一個模塊實例關聯多個插件實例,但一個插件實例僅對應一個模塊實例;

2)模塊初始化時,完成全部所屬插件的掛載,插件的生命週期與模塊的生命週期基本同步,不允許中途某一時刻外掛或卸載某一插件;

3)單一模塊內的一項業務功能,即一個功能單元,由一個或多個插件組成承載;

4)跨模塊的一項業務功能,即一個跨模塊功能單元,由分屬多個模塊的多個插件協同承載。

插件與模塊之間的聯繫通過配置文件聲明,每個模塊在初始化之時,通過配置文件的記載,把與之關聯的插件進行初始化和綁定,插件訂閱具體事件並開始運作事件機制,直到模塊被註銷,插件取消訂閱所有事件並結束生命週期。

七、架構實踐

本章節用圖來說明如何使用插件化來編寫一個按鈕功能。一個頁面上有一個按鈕並支持點擊跳轉。

我們將這個功能看作一個單元整體簡單地用一個插件實現:

1)在 ViewController 初始化的時候進行模塊註冊,通過一系列 Manager 初始化 ButtonPlugin;

2)在 ButtonPlugin 內收斂所有 Button 相關邏輯,ViewController 不會直接出現與 Button 有關的代碼;

3)ViewController 發送 ViewDIDLoad 事件來驅動其他插件工作;

4)ButtonPlugin 接收 ViewDIDLoad 事件,進行初始化、添加到 ViewController 等操作,當用戶點擊屏幕時,自行處理 Tap 操作。

優酷iOS插件化頁面架構方案 4

按鈕的點擊會涉及到統計和跳轉兩部分邏輯,所以 ButtonPlugin 實際上可拆出為另外 2 個插件來分別實現其邏輯。

優酷iOS插件化頁面架構方案 5

我們可以看見點擊行為拆分為跳轉和統計 2 個插件後,插件的職責更加單一,可複用性大大得到了提升。若遇到產品提出新的點擊需求,如跳轉前必須檢查是否登錄狀態,未登錄者需要先登錄再繼續後續的操作。那麼我們在現有基礎上只需要多增加一個 LoginCheckPlugin 來處理這些邏輯並且不需要修改原有 plugin 代碼,這也是插件化其中的一個優勢。

八、結束語

只有合適的架構,沒有最好的架構。插件化頁面架構有利也有弊,它顛覆了 MVC 架構的開發體驗,增加了開發者學習成本,編譯器也無法幫助開發者編譯時(事件名錯配等)校驗。因此,我們充分發揮它的面向切面編程能力,在開發過程中,我們通過插件的形式加入調試類和監控類邏輯來緩解架構的不足,另一方面則建立標準化插件管理平台對所有插件進行系統化管理。與此同時,標準化事件的開發方式使得存在統一的邏輯收口,極大地方便了代碼調試、線上問題定位等工具的建設。

優酷 APP 主要場景已接入插件化頁面架構,包括首頁、熱點、會員、個人中心、搜索、播放頁等六大板塊。沉澱了 CollectionView、網絡請求、手勢處理、路由跳轉、埋點統計等各系列系統性插件。

在搭建新頁面時,將上述各系列插件通過以配置加調參的形式即可快速接入和實現已有功能。同時也得益於越來越完善的列表佈局插件,使得在開發如橫滑、瀑布流、輪播等複雜佈局組件與開發平鋪組件時效一致。據粗略的測算,組件的開發效率提升了 30%以上。同時通過統一的配置格式使得客戶端具備組件跨頁面、跨板塊投放能力,打破了 framework 間的依賴界限。插件化頁面架構是一個很好的起點,我們將會持續地完善和深挖它的能力,最終讓其更穩定且高效地支撐業務發展。

作者 | 阿里文娛高級無線開發工程師 未熙