用Swift搭建數據驅動型iOS架構
來自: http://mrpeak.cn/blog/swift-dda/
上篇博客里介紹了一種架構iOS App應用層的方式, Context Driven Design 。CDD可以讓應用層UIViewController的結構以細粒度,低耦合的方式組合,不過CDD只能適用于應用層,對于具備一定業務規模的App來說有些捉襟見肘。這次我們嘗試來用Swift搭建一個完整的數據驅動型架構,這種架構將有更清晰的層次結構和數據流向,當然也能支撐更復雜的業務系統。核心思想是基于數據驅動的觀察者模型,我們就將之名為DDA(Data Driven Architecture)。
1.數據驅動
數據驅動是一種思想, 數據驅動型編程 是一種編程范式。基于數據驅動的編程,基于事件的編程,以及近幾年業界關注的響應式編程,本質其實都是觀察者模型。數據驅動定義了data和acton之間的關系,傳統的思維方式是從action開始,一個action到新的action,不同的action里面可能會觸發data的修改。數據驅動則是反其道而行之,以data的變化為起點,data的變化觸發新的action,action改變data之后再觸發另一個action。如果data觸發action的邏輯夠健壯,編程的時候就只需要更多的去關注data的變化。思考問題的起點不同,效率和產出也不同。
在CDD的介紹文章里提到過,設計數據驅動的關鍵在于怎么定義數據的變化,其變化的方式將直接影響觀察者的響應方式。我們需要通過精簡,通用的方式來告訴我們的觀察者數據的那一部分發生了變化。
怎么定義數據的變化?
我們在用數據表述業務模型的時候,一般用到兩種類型。一是single instance,二是collection type。single instance是單個的model實例。collection type是model實例的集合,array,set,dictionary都是屬于集合類。這兩者的基礎變化離不開 CRUD ,也就是我們常說的增刪改查。把這四種行為定義成事件,再附帶上變化的數據部分就可以描述“數據的改變”了。
- single instance 。在Objective C當中,我們可以通過KVO的方式來監聽instance的property變化,十分方便。但Swift當中并沒有KVO的實現,如果硬要使用就必須引入Objective C的runtime,不美。Swift作為一門表現力更強的新語種,當然會有更好的方式來實現。
- collection type 。在Objective C的CDD實現當中,我們定義了新的基類(比如CDDMutableArray)來傳遞元素的變化。這種方式缺點是繁瑣,需要針對每個集合類重新實現,Swift當中也有更好的方式,而且能與single instance統一。
Observable-Swift
Observable-Swift 可以用事件來統一描述single instance和collection type的變化,是Swift世界里比KVO更通用的方案。
對于single instance的property變化可以用這段代碼描述:
struct Person {
let first: String
var last: Observable<String>
init(first: String, last: String) {
self.first = first
self.last = Observable(last)
}
}
var ramsay = Person(first: "Ramsay", last: "Snow")
ramsay.last.afterChange += { println("Ramsay ($0) is now Ramsay ($1)") }
ramsay.last <- "Bolton"</code></pre>
afterChange是名字改變的事件,println是事件的觀察者所產生的行為。每次給ramsay.last賦值,println都會被觸發。
對于collection type,Observable-Swift沒有原生的支持,不過我們可以自己定義:
class DataEvent<T>: NSObject {
var insert = EventReference<T>()
var delete = EventReference<T>()
var update = EventReference<T>()
var refresh = EventReference<T>()
}
DataEvent包含增,刪,改,刷新等常見事件。因為查操作一般不會產生action,所以可以不用定義。每次collection type里的元素發生改變的時候,只需要調用對應事件的notify()就可以通知觀察者了。
2.分層架構
我們將DDA的架構分為三層:
這三層每一層都向下依賴,每一層之間通過面相接口編程的方式產生關聯。
Application Layer
在CDD的討論里已經詳細的介紹過應用層(Application Layer)的實現方式和數據流向。DDA里應用層的實現差不多,只不過實現語言換成了Swift。這一層主要由我們熟悉的UIViewController組成,工作職責包括采集用戶數據和展示UI。采集數據是指數據從Application Layer流向Service Layer,展示UI是指觀察Service Layer流入的數據變化并改變UI。可以假設這樣一個業務場景來說明Application Layer的工作:用戶在SettingController里改變自己的用戶名。
數據的流出(采集數據)
用戶在SettingController的輸入框里輸入新的用戶名,產生newName: String,newName需要傳輸到Server,收到成功回執之后再改變Controller當中的展示。這個完整的流程當中newName就是我們所關心的業務數據,在newName流向Service Layer之前我們可能需要進行校驗(名字是否為空或超過了最大長度),這部分的邏輯更貼近界面的工作,且不涉及任何網絡和DataBase操作,所以可以放在應用層。如果通過了校驗,下一步就是將newName通過請求告訴Server,所有的網絡和DataBase操作都發生在Service Layer,所以我們只需要將newName傳輸到Service Layer,到這一步就完成了數據的流出。
數據的流入(改變UI)
Application Layer將newName輸出到Service Layer之后,接下來只需要作為觀察者監控user: UserProfile這個model當中name property的變化。user model是一個viewModel,使用上和MVVM當中的ViewModel概念一致,ViewModel定義在應用層,但會通過事件觀察者的方式綁定到Service Layer當中的RawModel。ViewModel負責把RawModel當中的數據轉化成View所需要的樣式,View在完成UI的配置之后就不需要維護其它的業務邏輯了。
Service Layer
Servicec Layer負責所有的網絡請求實現,DataBase操作實現,以及一些公用的系統資源使用接口(比如GPS,相冊權限,Push權限等)。對于Application Layer來說Service Layer就像是一個0ms延遲的Server,所有的服務都通過protocol的方式暴露給Application Layer。Service Layer和Data Access Layer(DAL)使用相同的RawModel定義,RawModel定義在DAL,從sqlite當中讀出數據之后就會被馬上轉化成RawModel。RawModel不要和View進行直接綁定,通過ViewModel中轉可以將數據改變的核心邏輯放在同一的地方管理,調試的時候會很有用。上面修改用戶名的例子傳入的newName,在這一層通過ModifyUserNameRequest通知Server。ModifyUserNameRequest成功回調之后將user model的name property修改為最新值。name一修改Application Layer對應的View立刻會收到數據改變的事件并展示新的name。Service Layer接下來需要把newName保存到數據庫當中,涉及到和sqlite的交互。所有和sqlite直接打交道的工作都是交給Data Access Layer來做。
Data Access Layer(DAL)
DAL層對下負責和數據庫直接交互,對上通過protocol的方式提供數據操作的接口給Service Layer。數據庫我們使用sqlite。DAL層不涉及任何具體的業務邏輯,只提供基礎的CRUD接口,這樣一旦DAL層穩定下來,項目中后期出現業務bug基本就可以省去在DAL層調試。RawModel也定義在DAL,有些項目會在Service Layer和DAL各自定義自己的model,但每多一層model定義,就多了一次轉換和維護的邏輯,對于大部分的項目來說其實沒這個必要。DAL除了提供CRUD之外,還需要搭建線程模型,讀寫要分線程,而且需要同時提供同步異步兩套接口。
這樣初步進行職責劃分后,我們可以得到一個細一點的層次圖。
接下來是show the code,我們通過一個具體的demo例子來討論DDA更多的細節。
3.Trim
Trim是用DDA架構搭建的一個Demo,其目標是將一些不同來源的數據以Feed流的方式展示在同一個界面當中,這樣就不用打開n個不同的app去刷新查看了,這些數據可以來自任何有第三方開放平臺的產品,比如微博,GitHub等。現階段Demo已經實現的功能是拉取GitHub賬戶Repository的信息。這個功能已經包含完整的數據流,可以完整的體現DDA的思路。 點擊GitHub地址 。
Demo的目標是從GitHub上將某個賬戶所有的Repository拉取下來,形態如下:
先來看Feed對應的應用層邏輯,工程結構如下:
可以看到我們通過Context Driven Design的方式將FeedStreamController分成了很多不同職責的類,避免Massive View Controller。FeedStreamDH對應CDD當中的DataHandler,FeedStreamBO對應Business Object。View劃分的粒度比較細,對于界面復雜的UI來說,細粒度的View能很好的提高代碼可閱讀性。Model當中存放上面提到的ViewModel,不同的View或者Cell當中的控件都直接和ViewModel中的property綁定,cell只負責繪制,業務數據的轉換邏輯放在ViewModel里。我們就Repository Cell的展示邏輯看下完整的數據驅動流程。
Application Layer
Repository Cell里每個UI控件(4個UILabel)與ViewModel property的綁定代碼如下:
override func configCellWithItem(item: FeedItem) {
if let repo = item as? FeedItemGitHubRepo {
//let's bind property
bindUIProperty(&repo.repoName, lbName, true, handler: { (nV: String) -> () in
self.lbName.text = nV
self.lbName.sizeToFit()
})
bindUIProperty(&repo.repoOpenIssue, lbOpenIssue, true, handler: { (nV: String) -> () in
self.lbOpenIssue.text = nV
self.lbOpenIssue.sizeToFit()
})
bindUIProperty(&repo.repoStar, lbStar, true, handler: { (nV: String) -> () in
self.lbStar.text = nV
self.lbStar.sizeToFit()
})
bindUIProperty(&repo.repoFork, lbFork, true, handler: { (nV: String) -> () in
self.lbFork.text = nV
self.lbFork.sizeToFit()
})
}
}</code></pre><code data-lang="">
對于每個控件來說,ViewModel當中property的值都可以直接拿來使用,不需要做任何轉換,轉換的邏輯發生在ViewModel當中。綁定的代碼雖然很簡單,但bindUIProperty里面其實需要處理更多的場景。
- 綁定到新的property的時候,需要把舊的綁定解除(cell重用的時候會有舊的綁定存在)。
- 當控件被釋放的時候,需要把綁定解除。
- 綁定發生的時候需要針對初始值觸發一次。
- 綁定的添加和移除是線程安全的。
再看下ViewModel里面的處理邏輯。Repository Cell對應的ViewModel是FeedItemGitHubRepo.swift,也就是上面綁定所使用的model類。
init(repo: GitHubRepository) {
super.init(aType: .FeedItemGitHubRepo)
//bind value, custom logic may apply
bindProperty(&repo.name, self) { (nV: String) -> () in
self.repoName <- nV
}
bindProperty(&repo.star, self) { (nV: Int64) -> () in
self.repoStar <- "star: \(nV)"
}
bindProperty(&repo.fork, self) { (nV: Int64) -> () in
self.repoFork <- "fork: \(nV)"
}
bindProperty(&repo.openIssue, self) { (nV: Int64) -> () in
self.repoOpenIssue <- "open issue: \(nV)"
}
}</code></pre><code data-lang="">
FeedItemGitHubRepo作為ViewModel必須由RawModel來初始化,FeedItemGitHubRepo當中的每個property都需要一一對應綁定到RawModel的property當中,bindProperty提供一個handler的閉包回調來添加額外的業務邏輯,可以做property值與類型的轉換。這樣每次Service Layer改變RawModel的任何property的時候,FeedItemGitHubRepo就能立刻收到事件并改變自身對應property的值,之后configCellWithItem當中的綁定也會被觸發,UILabel等控件的值也隨之更新。完成一個應用層的數據驅動鏈路。bindProperty添加的綁定與model本身的生命周期關聯即可,因為不存在model被復用的情況。
Service Layer
應用層的數據綁定確立以后,接下來是處理Service Layer的數據邏輯。RawModel的改變都發生在這一層,Application Layer不應該直接處理RawModel,所有的數據處理邏輯都應該通過Service Layer暴露接口來實現。Service Layer的結構如下:
Service Layer的組織方式按照面向接口的方式,ServiceFactory.swift是工廠類提供各種Service的實例。每種Service都有對應的protocol定義其可供Application Layer使用的接口。GitHubServiceImp存放真正的實現。采用面向接口的方式除了和應用層解耦之外,還有另外兩個好處:
Model Cache
每個Service除了有Imp實現類之外,還可以定義一個Cache子類。GitHubServiceImp就對應了一個GitHubServiceCache
class GitHubServiceCache: GitHubServiceImp {
private var cachedRepos = [String: GitHubRepository]()
private var sema_repos = dispatch_semaphore_create(1)
}
model的查詢會頻繁的發生在應用層,如果每次都從db里去獲取,disk io帶來的性能損耗必然會影響應用層的整體表現。所以對于業務頻次高的模塊需要建立對應的Model Cache。這個Cache對業務邏輯來說是透明的。比如加載所有repository的接口:
override func loadRepositories() -> [GitHubRepository] {
let repos = super.loadRepositories()
dispatch_semaphore_wait(sema_repos, DISPATCH_TIME_FOREVER)
for repo in repos {
cachedRepos[repo.id^] = repo
}
dispatch_semaphore_signal(sema_repos)
return repos
}</code></pre>
只要調用super.loadRepositories()就完成Imp當中的業務實現,cache的發生完全獨立于業務邏輯。當然我們需要把ServiceFactory當中的實例替換成Cache對應的實例:
let githubService = GitHubServiceCache()
Unit Test
和Model Cache的添加方式類似,我們還可以針對每個Service的接口添加Unit Test。對于GitHubServiceImp來說,我們只需要添加一個GitHubServiceTest子類,再把需要測試的接口override并添加具體的測試邏輯即可。
比如我們想監控上面loadRepositories方法每次執行的時間,可以做如下實現:
class GitHubServiceTest: GitHubServiceCache {
override func loadRepositories() -> [GitHubRepository] {
var repos: [GitHubRepository]?
measure { () -> () in
repos = super.loadRepositories()
}
return repos!
}
}</code></pre>
同樣測試的代碼邏輯完全獨立于具體的業務邏輯,和Cache的邏輯也沒有任何關聯。通過這種方式我們可以對每個關鍵的api都添加對應的Unit Test邏輯。當然ServiceFactory當中的實例也需要做替換:
let githubService = GitHubServiceTest()
所有的這些對Application Layer來說都是不可見,透明的。Application Layer使用的只是protocol當中定義的接口,對于具體是哪個Service的實例并不關心。
Unique Model Instance
如果要做到ViewModel和RawModel之間property的一一綁定,關鍵的一點是要保證RawModel在Service Layer當中的唯一性。也就是說每個業務場景對應的RawModel在Service Layer只有一個實例對象。這樣Application Layer不同的業務模塊就能綁定到同一個model實例,model某個property變化的時候,各個業務模塊都能收到相同的事件通知和數據。為Service實例添加Model Cache可以很好的保證Unique Model Instance。
如果RawModel沒有存在Service Layer,ViewModel和RawModel之間的綁定就不成立,那么應用層需要通過另一種方式來監聽數據的改變。
Data Access Layer(DAL)
DAL是與database(我們使用sqlite)直接打交道的部分。這一層是數據變化最可靠的源頭,因為所有的數據只有持久化到database之后才算真正安全。如果無法建立ViewModel與RawModel的綁定,那么應用層就需要一種方式可以監聽database數據的變化。依照CRUD原則,我們可以將這種變化定義為table row的增刪改查,每一種row數據的變化都會觸發一種對應事件。所以我們只需要定義一種數據結構同時描述table name和row change event即可。
DAL工程結構如下:
DataEvent當中定義我們上面所說的數據變化,看下具體代碼:
let _Event = EventSource.sharedInstance
class DataEvent<T>: NSObject {
var insert = EventReference<T>()
var delete = EventReference<T>()
var update = EventReference<T>()
var refresh = EventReference<T>()
}
class EventSource: ObservableEx {
static var sharedInstance = EventSource()
var githubEvent = DataEvent<GitHubRepository>()
}</code></pre>
每個table對應一個DataEvent實例,每個DataEvent實例包含row change的事件,insert,delete,update對應增刪改,refresh是出現大量數據更新,通知應用層直接刷新整個界面的事件。
所以當我們從GitHub Server拉取到新的Repository的時候只需要對應觸發insert事件,比如我們在DAL層保存新的Repository邏輯如下:
func saveRepo(repo: GitHubRepository) {
do {
if getRepo(repo.id~) != nil {
try db.run(tableRepo.filter(repo_id == repo.id~).update(
repo_name <- repo.name~,
repo_star <- repo.star~,
repo_fork <- repo.fork~,
repo_openIssue <- repo.openIssue~
))
_Event.githubEvent.update.notify(repo)
}
else {
try db.run(tableRepo.insert(
repo_id <- repo.id~,
repo_name <- repo.name~,
repo_star <- repo.star~,
repo_fork <- repo.fork~,
repo_openIssue <- repo.openIssue~
))
_Event.githubEvent.insert.notify(repo)
}
} catch {
log("saveRepo err")
}
}</code></pre>
保存的時候會觸發insert或者update事件。
應用層如果要監聽新的插入數據,只需要注冊綁定:
_Event.register(self, event: _Event.githubEvent.insert) { (repo: GitHubRepository) -> () in
let repoItem = FeedItemGitHubRepo(repo: repo)
let dataHandler = self.weakContext!.dataHandler as! FeedStreamDH
dataHandler.insertNewFeedItem(repoItem)
}
這樣所有數據的變化都可以被應用層監聽到。
這里DataEvent雖然對應用層可見,但這種model和事件的跨層是可以接受的,這種方式可以省去通過Service層中轉的麻煩,而且DataEvent并不包含具體的業務邏輯,只描述數據最基礎的變化,不會有業務依賴耦合帶來的問題。
到這里可以看出Application Layer被驅動的方式有兩種:
- 被Service Layer的RawModel property變化驅動,這種驅動方式需要建立Model Cache,保證應用層的ViewModel都有一份對應的RawModel Cache。
- 被DAL的DataEvent驅動,這種驅動方式更通用,不需要建立Cache,也可以被Service Layer監聽。
一般場景下我們使用第二種數據驅動方式,在對性能要求較高的場景我們也可以使用第一種property binding的方式。
經過上面更細節的討論后,我們總結下新的結構圖:
更多的細節請查看代碼。
</code></code></code></code></code></code></code></code></code></code></code></code></div>