There are many ways to apply MVVM concept, and this is one variant
In this sample, we start working with model and use some utility frameworks for easier life . We use:
- RxSwift & RxCocoa: pub/sub method for data binding & UI binding
- RealmSwift: use for constructing data model
- Alamofire & RxAlamofire: HTTP Networking
Projects structures:
├─ models
├─ stores
├─ views
├─ viewmodels
├─ helpers
-
models: contains all model class
-
store: interface to work with data that stores in local storage, local database or rest-api.
-
views: iOS View & View Controller
-
viewmodels: MVVM view-models
-
helpers: helper components
We use Realm to define model, for exmaple:
class Repo: Object {
dynamic var id = 0;
dynamic var full_name:String = "";
dynamic var language:String? = ""
override class func primaryKey() -> String? {
return "id";
}
}
Interface to work with data that stores in local storage, local database or rest-api. Each store SHOULD BE separeted into two components: protocol and Impl. That help more easier to do unit test later
Protocol
protocol RepoStore {
func fetchRepo(filer: String) -> Driver<[Repo]>
}
Implement
class RepoStoreImpl: RepoStore {
internal func fetchRepo(query: String) -> Driver<[Repo]> {
let query = query.stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet.URLQueryAllowedCharacterSet())!
let url = "https://api.github.com/search/repositories?q=\(query)+in:name";
return JSON(.GET, url)
.asDriver(onErrorJustReturn: [])
.map({ json -> [Repo] in
guard let items = json["items"] as? [AnyObject] else {return []}
return items.map {Repo(value: $0)}
})
}
}
A viewmodel takes apart:
-
ViewModel <~> View: See ViewModel Protocol
- store ViewState
- handle command sent from View
- notify View whenever viewState is changed
-
ViewModel <~> Model
- Call store API to get data
- Handle data event retuns from store API
- Update viewState if any
ViewState is state of view - a structure contains all view's properties. For example:
struct GithubRepoViewState: ViewState {
let isLoading: Bool
let repoData: [Repo]
init(isLoading:Bool, repoData: [Repo]) {
self.isLoading = isLoading;
self.repoData = repoData
}
}
There're two properties in this view-state: isLoading
, reportData
. Those properties will be mapped to UI components later
Important: viewState SHOULD be immutable. Any change will create a new ViewState instance follow flow oldViewState --> execute(command) --> newViewState
. For example, we don't do this:
let vs = GithubRepoViewState(isLoading: true, [Repo]())
vs.isLoading = false //don't do this
We should:
let vs = GithubRepoViewState(isLoading: true, [Repo]())
let newState = GithubRepoViewState(isLoading: false, vs.repoData)
protocol ViewModelProtocol: class {
func execute(command:Any, data:AnyObject?) -> Void;
func getViewState() -> ViewState;
func setViewState(viewstate: ViewState) -> Void;
var viewStateStream:PublishSubject<(ViewState, ViewState)> {get}
}
execute
: UI callexecute
function to send a command to viewModel. This action may make viewState changed:
oldViewState --> execute(command) --> newViewState
- get/set ViewState: View can get/set viewState snapshop. But set viewState directly from UI view is not recommended.
viewStateStream
: View subscribes this event to know when viewState changed
A simple implementation of ViewModel Protocol. When viewState changed, viewStateStream will raise event immediately
class CommonViewModel<T>: ViewModelProtocol {
var onViewStateChanged: ((ViewModelProtocol, ViewState, ViewState?) -> ())?
var viewStateStream = PublishSubject<(ViewState, ViewState)>()
var viewState:T? {
willSet(newState) {
let ns:ViewState = newState as! ViewState
let current = self.viewState as? ViewState
if (current == nil) {
viewStateStream.onNext((ns, ViewStateNull.sharedInstance))
} else {
viewStateStream.onNext((ns, current!))
}
}
}
func execute(command:Any, data:AnyObject? = NSNull()) {}
func getViewState() -> ViewState {
return viewState! as! ViewState
}
func setViewState(vs: ViewState) {
self.viewState = vs as? T
}
}
CommonViewModel
does not manage ViewModel <~> Model flow. We can extend it for specific purpose.
struct GithubRepoViewState: ViewState {
let isLoading: Bool
let repoData: [Repo]
init(isLoading:Bool, repoData: [Repo]) {
self.isLoading = isLoading;
self.repoData = repoData
}
}
class GithubRepoViewModel: CommonViewModel<GithubRepoViewState> {
internal enum Command:Int {
case FetchData = 1
}
internal var repoStore:RepoStore?
private let bag = DisposeBag()
override func execute(command:Any, data:AnyObject? = NSNull()) {
if let command:Command = command as? Command {
switch command {
case .FetchData:
let q:String = data as! String
self.viewState = GithubRepoViewState(isLoading: true, repoData: self.viewState!.repoData)
self.repoStore!
.fetchRepo(q)
.driveNext({[unowned self] (repos) in
self.viewState = GithubRepoViewState(isLoading: false, repoData: repos)
})
.addDisposableTo(bag)
break
}
}
}
func injectRepoStore(repoStore:RepoStore) -> GithubRepoViewModel {
self.repoStore = repoStore
return self
}
}
In this sample, when ViewModel receive event FecthData
, it will contact with RepoStore (a kind of store) to get data. After receiving data from store, it updates ViewState itself.
self.repoStore!
.fetchRepo(q)
.driveNext({[unowned self] (repos) in
self.viewState = GithubRepoViewState(isLoading: false, repoData: repos)
})
.addDisposableTo(bag)
Important: viewModel does not contain StoreImpl directly, it contains Store Protocol. After creating new instance of viewModel, StoreImpl should be injected. This way helps us easy to mock storeA api for testing purpose
let viewModel = GithubRepoViewModel()
.injectRepoStore(RepoStoreImpl())
View is any iOS UIView or UIViewController. View always contains viewModel.
In this snipet code, viewModel instance is allocated, then view subscribe to viewStateStream
to listen any change from viewState
viewModel = GithubRepoTableViewCellViewModel()
viewModel.viewStateStream.subscribeNext { (state) in
let newViewState = state.0 as! GithubRepoTableViewCellViewState
self.repoName.text = newViewState.name
}
.addDisposableTo(bag);
state.0
is new statestate.1
is old state
Any change in view should be come from viewModel State change event. For example, to update repoName.text
(which mapped to viewState.name
(self.repoName.text = newViewState.name
)
self.viewModel.execute(GithubRepoTableViewCellViewModel.Command.UpdateData, data: repo)
In case you wanna check if a property is really changed
let newViewState = state.0 as! AwesomeViewState
let oldViewState = state.1 as? AwesomeViewState
if oldViewState == nil || oldViewState!.something != newViewState.something {
//TODO
}
oldViewState == nil
means this is first time init, and there's no old state