用Swift搭建數據驅動型iOS架構

cvsz8648 8年前發布 | 17K 次閱讀 Swift Apple Swift開發

來自: 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>

 本文由用戶 cvsz8648 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!