教你用Carthage+RXSwift+MVVM+Moya+Ro
写了几个月Android后,我又回到了iOS了,经历过写Vue和Android后,我对这些平台的开发框架有了更深层次的认识,对三者的布局方式也有了很深的理解。相对于其他两个平台,iOS开发者更倾向于用代码来生成UI,再用Masory&Snapkit来写约束布局。在这种情况下,UI代码和逻辑代码都写在同一个Controller里,导致ViewController臃肿不堪,对于复杂的页面,代码行数上千并不罕见。这也是MVC框架让人诟病的一个因素之一。针对这种情况,一种解决方案就是用
Storyboard&Xib
布局,还有就是用MVVM框架将逻辑代码从ViewController
里分离出来,而现在RXSwift的出现使得分离逻辑代码更加便捷了。下面我就教大家用Carthage+RXSwift+MVVM+Moya+Router
写一个简单的小说阅读APP来感受RXSwift
和其他框架如何实现iOS的MVVM框架。Demo 在此:Novel小说阅读
目前苹果更新了Swift4, 在XCode9下有些第三方库不支持Swift4,项目不能正常启动,但XCode8是没有问题的,这是Carthage的一个小坑。
首先给大家直接上图,让大家能更直观看到这个APP的操作逻辑和布局。
小说书签
首先给大家简单介绍上面的技术
Carthage
开发一个APP第三方库不是可缺少的,而目前最主流的iOS第三方库管理工具是CocoaPods
,使用起来简单方便,而Carthage
就要轻量很多,它也要一个叫做 Cartfile
描述文件,但 Carthage
不会对我们的项目结构进行任何修改,更不多创建 workspace
。它只是根据我们描述文件中配置的第三方库,将他们下载到本地,然后使用 xcodebuild
构建成 framework
文件。然后由我们自己将这些库集成到项目中。Carthage
使用的是一种非侵入性的哲学。
好了,下面就是怎么安装Carthage
了,我使用的是Homebrew
安装。假定你的Mac已经安装了Homebrew
, 如果你没装Homebrew
? 那么赶紧的,参考Mac下使用国内镜像安装Homebrew先安装好Homebrew
。
再用下面的命令
brew update
brew install carthage
然后就等Carthage
安装好就能用了,然后cd到你的项目根目录建立Cartfile
文件
touch Catfile
然后就是编辑这个文件了,你可以用编辑器修改也能用vim,在Cartfile文件里面加入以下内容:
github "Alamofire/Alamofire" ~> 4.0
github "tid-kijyun/Kanna" ~> 2.1.0
github "youngsoft/TangramKit" ~> 1.0.0
github "onevcat/Kingfisher" ~> 3.10.0
github "hackiftekhar/IQKeyboardManager"
github "devSC/WSProgressHUD"
github "ReactiveX/RxSwift"
github "Moya/Moya"
github "devxoul/URLNavigator"
github "RxSwiftCommunity/RxDataSources"
这些都是这个小说APP需要用到的库
然后再使用命令
carthage update --platform iOS
这个时侯再去喝茶吧,要等好一会Carthage
才能将这些库下载过来再奖其编译成动态库。
注意小说APP用了MJRefresh
,但是好像MJRefresh
好像不技术Carthage
,我不想再用Cocoapods
,于是直接将这个库的文件放到项目内里面了。等待Carthage
编译完成后,你就可以在项目目录->Carthage->Build->iOS
里面看到这些framework
库了,把这些库拖到项目target
的General
的Linked Frameworkds and Libraries
里面
注意,因为这里有些库需要依赖其他的库,所以也是要一并拖进来的。
再就是最后一步了设置Build Phases
选中你的工程target
,到'Build Phases' tab
下,点击 '+' 选择'New Run Script Phase'
,创建一个Script
,添加以下内容:
/usr/local/bin/carthage copy-frameworks
然后添加相应的内容到下面的 'Input Files'
:
$(SRCROOT)/Carthage/Build/iOS/Alamofire.framework
$(SRCROOT)/Carthage/Build/iOS/TangramKit.framework
......
Build Phase
这样就大功告成了,但是Carthage
让人不爽的是每次update
都会Build
都会重新编译一次,非常费时间。我特地问了别人何解,他说如果添加新的库,�将原先的库注释再使用命令Update
就行,这样就不会将原先的库重新编译更多关于Carthage
的内容,请看Carthage 使用 / 如何给自己的项目添加 Carthage 支持等文章。
RXSwift 和 MVVM
RXSwift
和MVVM
包含的内容可就多了,可以大书特书。网上的文章也是特别多,这里就不做说明了。我只想说了下为什么要用RXSwift
来实现iOS开发MVVM
。众所周知,MVVM
最核心理念的就是数据双向绑定,用触发一个UI
事件后,通常会更新其对应的数据,经过某些逻辑处理后,再更新数据来驱动UI更新,通常不用手动调用代码的方式直接操作UI
。遗憾的是,相对于其他两个平台,iOS
对MVVM
理念的支持最差的。对于Android
,Web
开发者来说,很难想象使用代码生成UI然后再绑定到ViewModel
这样的操作,繁琐又低效。更别说iOS本身不提供绑定方法,也没有响应式的API和控件属性,现加上很多控件都是以Delegate
的方式来设置样式和提供数据的。这样使得用MVVM
很难适用在iOS
开发上。而RXSwift
的出现使得Swift
语言具有响应式编程的能力,再加上RXCocoa
封装了很多的Cocoa UI
控件,使得它们的关键属性都有了响应式特性。这样就可以轻松将各种UI
控件的属性或者事件绑定到ViewModel
的属性和命令上,再通过响应式的API来操作数据,使得iOS开发更为高效和直观了。
当然,使用RXSwift
和MVVM
来开发APP缺点也不能忽视,主要有下面几点:
- 调试和
Debug
难度大了不少。堆栈数据不再像以前那么直观了,很难找到正确的调试位置。 - 开发者的不适应。基于响应式编程的
MVVM
开发框架和平时使用MVC
开发框架完全是两回事,在数据处理,UI
操作等开发方式完全不同,你需要重新学习RXSwift
,RXCocoa
和其他的RX
库,提升了学习成本。 - API支持不完整。可能有少量的
UI
控件属性没有封装,或者有一些第三方库不支持,这些都需要自己权衡和处理。
总之目前iOS
最好的MVVM
解决方案就是搭配RXSwift
的一系统库来开发,基本满足了目前的开发需要。如果项目复杂不高,或者只是个人开发,可以使用这套方案,但如果需要开发大型商业项目,或者你的团队不熟悉MVVM
,那么还是用传统的MVC
方案更稳妥一些。
Moya
Moya
是要和RXSwift
,Alamofire
和ObjectMapper
搭配一起使用的,Moya
可以十分简洁优雅的帮你完成网络请求。先上代码:
import Moya
enum APIManager { //先定义一个枚举,里面规定了这些请求的名称和参数
case GetSearch(String,Int) //搜索小说,如果参数多,建议传字典
case GetSection(String) //获取小说章节
case GetNovel(String) //获取小说内容
}
extension APIManager:TargetType{ // 扩展APIManager,让它实现Moya的TargetType协议
var baseURL: URL{ //获取BaseURL,一般来说,同一个项目BaseURL是相同的,但会根据使用CDN或者使用一些第三方服务而有不同
switch self {
case .GetSearch(_, _):
return URL(string: "http://zhannei.baidu.com")! //搜索小说使用此域名
case .GetSection(_),.GetNovel(_):
return URL(string: "http://www.37zw.net")! //获取小说章节和内容使用此域名
}
}
var path: String{ //获取BaseURL后面的路径
switch self {
case .GetSearch(_, _): //搜索小说使用此路径
return "/cse/search"
case .GetSection(let path),.GetNovel(let path): //获取小说章节和内容用自定义路径
return path
}
}
var method: Moya.Method { //这三个请求都用Get请求
return .get
}
var parameterEncoding: ParameterEncoding { //这三个请求都用默认编码
return URLEncoding.default
}
var sampleData: Data { //这里是当API还没有开发好时自定义一些模拟数据
return "".data(using: String.Encoding.utf8)!
}
var task: Moya.Task { //如果要设置请求参数,可以在这个属性里设置
switch self {
case .GetSearch(let key, let index): //设置搜索的Key和index页码
let params = ["q":key,
"p":index,
"isNeedCheckDomain":1,
"jump":"1",
"s":"2041213923836881982"] as [String : Any]
return .requestParameters(parameters: params, encoding: URLEncoding.default)
case .GetSection(_),.GetNovel(_): //这两个不需要其他参数
return .requestPlain
}
}
var validate: Bool { //是否需要执行 Alamofire 验证
return false
}
var headers: [String : String]?{ //设置HTTP 的Head内容,这里看后台的需求了
return nil
}
}
从上面的代码看出,我写 了三个请求接口。基本上相似的功能都可以写在同一个枚举里面。然后再设置请求方式,请求参数等数据就OK了
下一步就是发送请求了
let provider = RxMoyaProvider<APIManager>() //定义一个provider
provider.request(.GetSection(url)) //发送获取小说章节的请求
.filterSuccessfulStatusCodes() //�过滤失败的状态码
.mapSectionInfo() //转换成Model
.subscribe({ [weak self] (str) in //订阅这个Observable
switch(str){
case let .success(result): //处理成功 数据
self!.currentSection.sectionContent = result.data as! String
self?.arrSection.value += [self!.currentSection]
self?.pageIndex += 1
self!.currentSection = self!.arrSectionUrl![self!.pageIndex]
case let .error(err): //处理失败情况
Log(message: err)
GrandCue.toast(err.localizedDescription)
}
}).addDisposableTo(self.bag)
没错,处理请求请求就是这么简单。你可以添加各种中间件来处理数据,非常方便。
ObjectMapper
在这里并没有用到,因为这个APP抓取的是HTML
网页,而不是JSON
。所以我使用Kana
来解析HTML
extension PrimitiveSequence where TraitType == SingleTrait, ElementType == Response{ //扩展PrimitiveSequence,然后里面的方法就可以用来处理Moyo返回的数据了
func mapSectionInfo() -> Single<ResultInfo> { //将HTML解析成ResultModel
var result = ResultInfo()
return flatMap { res -> Single<ResultInfo> in
let code = CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))
let str = String(data: res.data, encoding: String.Encoding(rawValue: code))
guard let doc = HTML(html: str!, encoding: .utf8) else{
result.code = 10
result.message = "解析HTML错误"
return Single.just(result)
}
let divs = doc.xpath("//div[@id='list']").first!.css("dl > dd")
if divs.count <= 0{
result.data = [SectionInfo]()
return Single.just(result)
}
var arrSections = [SectionInfo]()
for link in divs{
let section = SectionInfo()
section.sectionName = link.css("a").first?.text ?? ""
section.sectionUrl = link.css("a").first?["href"] ?? ""
section.id = section.sectionUrl.hash
arrSections.append(section)
}
result.data = arrSections
return Single.just(result)
}
}
}
相比于JSON
处理HTML
更麻烦一些,好在Model
的字段并不多,所以不都需要写很多代码。
Router
最后再介绍Router
,用了MVVM
框架,再用iOS
的传统导航方式就不合适了。因为导航这样的处理还是要放在ViewModel
里面的,而ViewModel
并不继承ViewController
。而且navigationController
会耦合各个页面的参数,增加修改成本。使用Router
可以很好地解决这些问题。
小说APP
我使用了URLNavigator
,它是一个轻量级的iOS
路由库。它提供了一个优雅的方式来处理导航,使用起来也很简单。
extension NovelContentViewController:URLNavigable{
convenience init?(navigation: Navigation) {
guard let dict = navigation.navigationContext as? [String:Any] else { return nil }
self.init()
novelInfo = dict["novelInfo"] as? NovelInfo
currentSection = dict["currentSection"] as? SectionInfo
arrSectionUrl = dict["arrSectionUrl"] as? [SectionInfo]
}
}
首先让需要导航的页面实现URLNavigable协议,实现init方法。
然后写一个初始化RouterMap的类
import UIKit
import URLNavigator
struct NavigationMap{
static func initialize(){
Navigator.map(Routers.bookmark, BookmarkViewController.self) //注册这三个页面实现导航
Navigator.map(Routers.sectionList, SectionListViewController.self)
Navigator.map(Routers.novelContent, NovelContentViewController.self)
}
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
......
NavigationMap.initialize() //在APPDelegate注册
......
return .true
}
在AppDelegate
的Launch
方法里注册后就可以使用了,比如点击了小说的某一章节导航到小说内容页面
tb.rx.itemSelected.subscribe(onNext: { (index) in
guard let section = wkself?.modelObserable.value[index.row] else{
return
}
let dict = ["novelInfo":wkself!.novelInfo.value,"currentSection":section,"arrSectionUrl":wkself!.modelObserable.value] as [String : Any] //设置导航的参数
Navigator.push(Routers.novelContent, context: dict, from: nil, animated: true) //发起导航
}, onError: nil, onCompleted: nil, onDisposed: nil).addDisposableTo(bag)
上面就是这个APP的主要的技术点了,下面将重点讲RXSwift
和MVVM
。
ViewController和ViewModel怎么互动
以首页为例,和以前一样,ViewController
只生成UI
和布局。小说搜索页面比较简单,直接用代码生成即可。
接下来分析这个页面需要绑定哪些控件和属性
- 首页最核心的内容就是显示搜索出来的小说了,那么自然
UITableView
成为了交互的核心,对于这种情况,我们可以在ViewModel
里直接声明一个UITableView
对象来引用ViewController
的TableView
,然后这在里面进行各种绑定属性的命令操作。 - 网络请求也是在
ViewModel
里面完成,所以需要声明一个RxMoyaProvider
对象用来请求网络,它是APIManager
的泛型。 - 小说的搜索结果需要保存在一个数组里,这里我使用了
Variable<[NovelInfo]>
类型来保存。它是一个可观察的数组,当对数组操作时,绑定了该数组的TableView
会根据数组的变化更新Table
的Cell
- 小说搜索还支持下拉刷新和下拉加载更多,这里使用了
PublishSubject<Bool>
类型来实现,它是一个命令,使用Bool
来区分操作类型 - 搜索框
UITextField
的text
属性绑定了ViewModel
的key
属性,当有输入文字发生改变时,key
也会跟随更新,在这里我让它驱动keyStr
更新。 - 搜索按钮也需要绑定一个搜索事件,在这里我用
Driver<Void>
类型来绑定UIButon
的点击事件 - 最后就是更新
UITableView
刷新状态了,Variable<RefreshStatus>(.none)
驱动UITableView
更新刷新状态
所以根据上面的情况,可以写出以下代码。
其实写ViewModel
在最开始的情况下,很难列举出全部的属性和命令,都是后面一步一步加上去的。
var bag : DisposeBag = DisposeBag()
let provider = RxMoyaProvider<APIManager>(requestClosure:MoyaProvider.myRequestMapping)
var modelObserable = Variable<[NovelInfo]> ([])
var refreshStateObserable = Variable<RefreshStatus>(.none) //绑定到Table的刷新显示状态
let requestNewDataCommond = PublishSubject<Bool>() //绑定到MJRefresh的上拉刷新和下拉加载更多事件
var pageIndex = 0
var tb : UITableView
var key :Driver<String> // key和搜索输入框绑定
var keyStr = Variable<String>.init("") //搜索key变量,被key驱动
var searchCommand :Driver<Void> //搜索命令,和键盘事件,搜索按钮点击事件绑定
在ViewController里面加入ViewModel并绑定相关事件
var vm : NovelSearchViewModel? //声明对应的ViewModel
override func viewDidLoad() {
super.viewDidLoad()
weak var wkself = self
......
vm = NovelSearchViewModel(input: (tb,txtSearch.rx.text.orEmpty.asDriver(),btnSearch.rx.tap.asDriver())) //实例化该ViewModel,传入必要参数
txtSearch.rx.controlEvent([.editingDidEndOnExit]).subscribe(onNext: {
wkself?.tb.mj_header.beginRefreshing() //绑定搜索事件到上拉刷新
}, onError: nil, onCompleted: nil, onDisposed: nil).addDisposableTo(vm!.bag)
tb.mj_header = MJRefreshNormalHeader(refreshingBlock: {
wkself?.vm?.requestNewDataCommond.onNext(true) //绑定下拉刷新事件
})
tb.mj_footer = MJRefreshAutoNormalFooter(refreshingBlock: {
wkself?.vm?.requestNewDataCommond.onNext(false) /绑定下拉加载更多事件
})
......
}
最后在ViewModel处理绑定事件和数据逻辑。
func bind(){
weak var wkself = self
tb.register(NovelTbCell.self, forCellReuseIdentifier: cellID) 注册UITableViewCell
tb.tableFooterView = UIView()
modelObserable.asObservable().bind(to: tb.rx.items(cellIdentifier: cellID, cellType: NovelTbCell.self)){ row , model , cell in
cell.novelIndo = model
}.addDisposableTo(bag) //将列表数据绑定到Table的单元格上面
tb.rx.itemSelected.subscribe(onNext: { (index) in //Table的单元格点击事件
guard let novel = wkself?.modelObserable.value[index.row] else{
return
}
Navigator.push(Routers.sectionList, context: novel, from: nil, animated: true) //跳转到小说章节列表页面
}, onError: nil, onCompleted: nil, onDisposed: nil).addDisposableTo(bag)
requestNewDataCommond.subscribe { [weak self](event) in
Tool.hiddenKeyboard()
if event.element!{
self?.pageIndex = 0
self?.provider.request(.GetSearch(self!.keyStr.value,self!.pageIndex)).filterSuccessfulStatusCodes().mapNovelInfo().subscribe({ (str) in //使用Moya发起网络请求,网络请求的相关参数都在APIManager中设置好了
switch(str){
case let .success(result):
self?.modelObserable.value = result.data! as! [NovelInfo] //更新数据,这个赋值操作可以触发UITableView更新数据
self?.refreshStateObserable.value = .endHeaderRefresh
case let .error(err):
Log(message: err)
self?.refreshStateObserable.value = .endHeaderRefresh
GrandCue.toast(err.localizedDescription)
}
}).addDisposableTo(self!.bag)
}
else{
}
}.addDisposableTo(bag)
searchCommand.drive(onNext: {
refreshStateObserable.value = .beginHeaderRefresh //搜索命令触发MJRefresh下拉刷新
}, onCompleted: nil, onDisposed: nil).addDisposableTo(bag)
refreshStateObserable.asObservable().subscribe(onNext: { (status) in //订阅刷新状态,刷新状态改变,将触发MJRefresh相关操作
switch(status){
case .beginHeaderRefresh:
wkself?.tb.mj_header.beginRefreshing()
case .endHeaderRefresh:
wkself?.tb.mj_header.endRefreshing()
wkself?.tb.mj_footer.resetNoMoreData()
case .beginFooterRefresh:
wkself?.tb.mj_footer.beginRefreshing()
case .endFooterRefresh:
wkself?.tb.mj_footer.endRefreshing()
case .noMoreData:
wkself?.tb.mj_footer.endRefreshingWithNoMoreData()
default:
break
}
}, onError: nil, onCompleted: nil, onDisposed: nil).addDisposableTo(bag)
}
从上面的代码可以看出,主要是处理UITableView
相关事件,为Table
提供数据和单元格点击事件,并且修改MJRefresh
状态。
上面就是ViewMode
和ViewControlle
交互三部曲:
- 在
ViewModel
定义需要绑定的属性和一些逻辑操作属性 - 添加
ViewModel
到ViewController
并传递需要绑定的属性,并且同时将想着事件绑定到ViewModel
的命令上 - 最后就是在
ViewModel
里更新逻辑,在修改属性(数据)的同时,也会更新UI
。
其实MVVM
的理念并不难理解,我感觉可能比MVC
更简单,但是在iOS上
写MVVM
确是一件不简单的事。如果在没有RXSwift
和其他相关RX
库的帮助的情况下,强行用MVVM
理念来开发iOS
,其复杂度可能要比传统的MVC
开发高很多。��理论上,使用基于RX
响应式编程的MVVM
框架非常适合开发大型项目。在你使用RXSwift
和其他RX
库非常熟练的情况下,使用MVVM
开发大型商业项目应该也不成问题了。
最后再次给出Demo :Novel小说阅读,
如果读者想认真学习MVVM
框架,一定要自己尝试写一个小项目来体验一下。如果你不知道怎么下手,建议Clone
此项目然后再仿照写一次,相信你会对MVVM
有一个完整认识了。如果此项目对你有帮助的话,不要忘记Star哦。