導讀:為了應對視頻編輯類工具應用複雜的交互,度咔iOS借鑑了Flux架構模式的設計思想,參考有向無環圖的拓撲概念,將事件進行集中化管理,從開發體驗上實現了舒適清爽、容易駕馭的“單向流”模式;在這種調度模式下,事件的變化和追蹤變得清晰可預測,並且顯著的增加了業務的可擴展性。
全文6882字,預計閲讀時間18分鐘。
一、架構背景
視頻編輯工具類應用往往交互複雜,大部分操作是在同一個主界面上進行,而這個界面同時存在較多的視圖區域(預覽區、軸區、undo redo、操作面板等等),每個區域既要接收用户手勢,又要跟隨用户操作聯動更新狀態。同時除支持主場景編輯功能外,還要同時支持其他特色功能,比如度咔的通用編輯、快速剪輯、主題模板等,都需要使用預覽和編輯功能;於是對架構的可擴展和可複用能力自然有了很高的要求。
經過調研,度咔iOS最終借鑑了Flux架構模式的設計思想,參考有向無環圖的拓撲概念,將事件進行集中化管理,從開發體驗上實現了舒適清爽、容易駕馭的“單向流”模式;在這種調度模式下,事件的變化和追蹤變得清晰可預測,並且顯著的增加了業務的可擴展性。
二、播放預覽複用
度咔通用編輯以及很多衍生工具、功能都需要依賴於預覽、素材編輯這一類基礎能力。
比如下列這些功能都依賴於同一套預覽播放邏輯,需要將這些基礎能力抽象為一個base控制器。
baseVC結構為:
三、功能模塊複用
預覽播放複用的問題解決了,如何在這套邏輯上添加各樣的素材編輯功能,比如貼紙、文字、濾鏡等功能,並且使這些功能與VC解耦,最終達到複用的目的?
最終我們使用插拔式設計理念,把每一個子功能抽象成一個plugin,採用直接調用依賴層的方式把controller、view、timeline、streamingContext、liveWindow 這寫90%場景下會用到的屬性通過weak直接賦值給plugin。
protocol BDTZEditPlugin: NSObjectProtocol {
// 組織控制器
var editViewController: BDTZEditViewController? { get set }
// 所有添加到控制器View上的控件 加到這個View上,解決層級問題
var mainView: BDTZEditLevelView? { get set }
// 編輯場景的時間軸實體,由軌道組成,可以有多個視頻軌道和音頻軌道,由視頻軌道決定長度
var timeline: Timeline? { get set }
// 流媒體上下文 包含時間線、預覽窗口、採集、資源包管理等相關信息集合的對象
var streamingContext: StreamingContext? { get set }
// 視頻預覽窗口控件
var liveWindow: LiveWindow? { get set }
/// 插件初始化
func pluginDidLoad()
/// 插件卸載
func pluginDidUnload()
}
只要實現這個協議,並且通過調用baseVC的add:方法添加plugin後,那麼相應的plugin就會拿到對應的屬性進行調用,避免使用單例或者通過層層回調到VC去處理。
func addPlugin(_ plugin: BDTZEditPlugin) {
plugin.pluginWillLoad()
plugin.editViewController = self
plugin.mainView = self.view
plugin.liveWindow = liveWindow
plugin.streamingContext = streamingContext
plugin.timeline = timeline
if plugin.conforms(to: BDTZEditViewControllerDelegate.self) {
pluginDispatcher.add(subscriber: plugin as! BDTZEditViewControllerDelegate)
}
plugin.pluginDidLoad()
}
func removePugin(_ plugin: BDTZEditPlugin) {
plugin.pluginWillUnload()
plugin.editViewController = nil
plugin.mainView = nil
plugin.liveWindow = nil
plugin.streamingContext = nil
plugin.timeline = nil
if plugin.conforms(to: BDTZEditViewControllerDelegate.self) {
pluginDispatcher.remove(subscriber: plugin as! BDTZEditViewControllerDelegate)
}
plugin.pluginDidUnload()
}
plugin是具體功能和VC之間的一箇中間層,可以接受VC的生命週期事件、預覽播放事件、拿到VC中的關鍵對象、調用VC的內部所有public接口能力。作為插在VC上的一個獨立子功能單元,具有編輯能力、素材能力、網絡UI交互等能力。
plugin分為service層和UI層,同時在設計之初,基於該架構的plugin不僅僅能在度咔app內使用,廠內其他app僅需要極少工作量就能立即接入plugin。
所有功能能分散到插件中,按需組裝和複用。
同時可以對外輸出的不僅僅單個plugin、還是可以是多個plugin的組合。以封面功能為例,封面編輯是一個以coverVC為組織的控制器,它包含多個plugin,比如已存在的文字plugin和貼紙plugin;coverVC除了作為獨立功能應用之外,把它包裝成一個封面plugin只需少量數據對接代碼(上圖的通用剪輯數據對接plugin)就可以集成到通用剪輯VC,像堆樂高積木一樣進行拼裝組合。
四、事件狀態管理
編輯工具app因交互的複雜性非常依賴於狀態更新,通常來説在iOS開發中通知對象狀態變化一般採用以下幾種方式:
- Delegate
- KVO
- NotificationCenter
- Block
這四種方式都可以管理狀態的變化,但是都存在一些問題。Delegate和Block,往往會在組件之間創建強依賴關係;KVO 和 Notifications,會創建不可見的依賴項,如果某些重要消息被移除或更改,也很難被發現,從而降低應用穩定性。
即使是蘋果的MVC模式,也只提倡數據層及其表示層的分離,沒有提供任何工具代碼、指導架構。
4.1 為什麼選擇Flux架構模式
於是我們借鑑Flux架構模式的思想。Flux 是一種非常輕量級的架構模式,Facebook 將其用於客户端 Web 應用程序,用於避開MVC,支持單向數據流(後面也是列舉的前端的mvc數據流向圖)。核心思想是中心化控制,它讓所有的請求與改變都只能通過 action 發出,統一 由 dispatcher 來分配。好處是 View 可以保持高度簡潔,它不需要關心太多的邏輯,只需要關心傳入的數據。中心化還控制了所有數據,發生問題時可以方便查詢定位。
- Dispatcher:處理事件分發,維持 Store 之間的依賴關係
- Store:負責存儲數據和處理數據相關邏輯
- Action:觸發 Dispatcher
- View:視圖,負責顯示用户界
通過上圖可以看出來,Flux 的特點就是單向數據流:
- 用户在 View 層發起一個 Action 對象給 D ispatcher
- Dispatcher 接收到 Action 並要求 Store 做相應的更改
- Store 做出相對應更新,然後發出一個 changeEvent
- View 接收到 changeEvent 事件後,更新頁面
- 基本的MVC數據流
-
複雜的MVC數據
- 簡單的Flux數據流
- 複雜Flux數據流
相比MVC模式,Flux多出了更多的箭頭跟圖標,但是有個關鍵性的差別是:所有的箭頭都指向一個方向,在整個系統中形成一個事件傳遞鏈。
4.2 應用Flux思想來實現狀態管理
狀態分為兩種:
- 以組織控制器發出的事件產生狀態變化,比如:控制器的生命週期ViewDidLoad()等等、基礎編輯預覽能力的回調,例如seek、progress、playState變化等等
- 各個組件的之間事件傳遞產生的狀態變化,下圖中plugin協議抽象來描述上圖中的Store作用
控制器持有EventDispatch能力的對象dispatcher,並通過這個dispatcher傳遞事件。
Dispatcher
class WeakProxy: Equatable {
weak var value: AnyObject?
init(value: AnyObject) {
self.value = value
}
static func == (lhs: WeakProxy, rhs: WeakProxy) -> Bool {
return lhs.value === rhs.value
}
}
open class BDTZActionDispatcher<T>: NSObject {
fileprivate var subscribers = [WeakProxy]()
public func add(subscriber: T) {
guard !subscribers.contains(WeakProxy(value: subscriber as AnyObject)) else {
return
}
subscribers.append(WeakProxy(value: subscriber as AnyObject))
}
public func remove(subscriber: T) {
let weak = WeakProxy(value: subscriber as AnyObject)
if let index = subscribers.firstIndex(of: weak) {
subscribers.remove(at: index)
}
}
public func contains(subscriber: T) -> Bool {
var res: Bool = false
res = subscribers.contains(WeakProxy(value: subscriber as AnyObject))
return res
}
public func dispatch(_ invocation: @escaping(T) -> ()) {
clearNil()
subscribers.forEach {
if let subscriber = $0.value as? T {
invocation(subscriber)
}
}
}
private func clearNil() {
subscribers = subscribers.filter({ $0.value != nil})
}
}
通過泛型的多重代理方式把事件分發給subscribers內部的對象(上面代碼塊中的 addPlugin:內部添加subscribers),當然也可以通過註冊Block的方法去實現。
Dispatcher實例
聲明一個protocol 繼承要分發的能力
@objc protocol BDTZEditViewControllerDelegate: BDTZEditViewLifeCycleDelegate, StreamingContextDelegate, BDTZEditActionSubscriber {
// BDTZEditViewLifeCycleDelegate 控制器聲明週期
// StreamingContextDelegate 預覽編輯能力回調
// BDTZEditActionSubscriber plugin之間的通訊協議
}
控制器事件分發
public class BDTZEditViewController: UIViewController {
// 實例化的 BDTZEditViewControllerDelegate
var pluginDispatcher = BDTZEditViewControllerDelegateImp()
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
pluginDispatcher.dispatch { subscriber in
subscriber.editViewControllerViewDidAppear?()
}
}
public override func viewDidLoad() {
super.viewDidLoad()
/***省略部分代碼**/
setupPlugins()
//放最後調用
pluginDispatcher.dispatch { subscriber in
subscriber.editViewControllerViewDidLoad?()
}
}
/***...**/
/// seek進度回調
func didSeekingTimelinePosition(_ timeline: Timeline!, position: Int64) {
pluginDispatcher.dispatch { subscriber in
subscriber.didSeekingTimelinePosition?(timeline, position: position)
}
}
/***...**/
}
plugin之間事件傳遞
plugin之間的事件傳遞就要用到上面的BDTZEditActionSubscriber協議了。
@objc protocol BDTZEditAction {
}
@objc protocol BDTZEditActionSubscriber {
@objc optional func update(action: BDTZEditAction)
}
BDTZEditAction 是一個空協議,可以是任何類繼承它來描述想要傳遞的任何信息。結合編輯工具的特點(雖然交互複雜但是素材類型和操作都是有限的)只需要少量的action就能描述所有狀態。目前我們使用選中action、各種素材action、面板起落action、前進回退action等等這些事件來描述素材的添加、刪除、移動、剪裁、保存草稿一些列的操作。我們以選中action(選中某個片段的事件)舉例:
當APlugin 發出了一個選中事件,BPlugin、CPlugin等等都會收到這個事件,從而做出相應的狀態改變。
//APlugin
func sendAction(model: Any?) {
let action = BDTZClipSeleteAction.init(event: .selected, type: .sticker, actionTarget: model)
editViewController?.pluginDispatcher.dispatch({ subscriber in
subscriber.update?(action: action)
})
}
//BPlugin
extension BDTZTrackPlugin: BDTZEditActionSubscriber {
func update(action: BDTZEditAction) {
if let action = action as? BDTZClipSeleteAction {
handleSelectActionDoSomething()
}
}
}
當預覽區的貼紙被選中,那麼軸區也會隨之被選中,底部區域也要切換成三級菜單。**一個action被派發以後,所有plugin都會收到它,對此action感興趣的plugin會做出相應的狀態變化。
**
五、總結
iOS也有參照flux思想設計的ReSwift框架,但是如果使用純Flux模式來開發,缺點也非常明顯:
- 層級太多,極易產生大量的冗餘代碼。
- 老代碼移植工作量巨大。
對我們來説採用Flux 模式設計理念比某個特定的實現框架更重要,我們根據度咔業務的特點只是取其思想使用單層級結構,用來管理ViewController與Plugin抽象之間的關係和事件傳遞,而沒有把View也加到層級中去,plugin內部可以使用MVC、MVVM等任何架構,只需要把通訊方式統一。
上面只是使用簡單的例子介紹了編輯工具在Flux思想上的應用。但是在實際使用中還應該考慮:
- UI層級遮蓋問題:插件中的某個View需要加到控制器View上,會造成控件層級遮蓋問題。上面代碼中的BDTZEditLevelView就是為了解決這個問題。
- 多線程問題:在開發中我們難免大量的線程異步處理任務,我們必須規定插件通訊之間的線程,Dispatcher內部也應該有線程管理的代碼。
- plugin依賴關係問題:Dispatcher還要維持plugin之間的依賴關係,比如一個action要APlugin先處理修改某些數據或者狀態後,BPlugin再處理,可以採用加標等方式解決。
- action膨脹問題:相對於API直接調用的方式,監聽action雖然寫更少的代碼,但是容易造成action無限增多的情況,所以在定義action要考慮可擴展和結構化。
參考鏈接:
[1]http://reswift.github.io/ReSw...
[2]https://facebook.github.io/flux/
[3]https://redux.js.org
[4]http://blog.benjamin-encz.de/...\_source=swifting.io&utm\_medium=web&utm\_campaign=blog%20post
推薦閲讀:
|iOS 崩潰日誌在線符號化實踐
|百度商業託管頁系統高可用建設方法和實踐
|AI 在視頻領域運用—彈幕穿人
---------- END ----------
百度 Geek 説
百度官方技術公眾號上線啦!
技術乾貨 · 行業資訊 · 線上沙龍 · 行業大會
招聘信息 · 內推信息 · 技術書籍 · 百度周邊
歡迎各位同學關注