这是我参与更文挑战的第23天,活动详情查看: 更文挑战
先声明一下
昨天收到了一个热心的留言,如下图所示
用Swift写App,当然是iOS的App啦,只是我写的这个App是玩安卓而已,哈哈。
我在很早的新闻上确实读过一篇文章,是Google曾经确实考虑使用Swift写安卓,不过后来变成了Kotlin,至于Swift和Kotlin,在语言特性与语法上不就是孪生兄弟吗?
好了,扯远了,下面进入正题。
首页的UI与接口
我们先看看首页的UI是一个什么样子的:
实际上首页的结构还是非常简单的,列表支持下拉与上拉,有一个轮播图。
接口与JSON
调用的接口有以下几个,而且均是get请求,JSON的例子为了数据过长,数组的数据都只取了一个。
- 首页banner:www.wanandroid.com/banner/json
JSON:
{
"data": [
{
"desc": "享学~",
"id": 29,
"imagePath": "https://wanandroid.com/blogimgs/7a8c08d1-35cb-43cd-a302-ce9b0f89fc59.png",
"isVisible": 1,
"order": 0,
"title": "重构了app的我…",
"type": 0,
"url": "https://mp.weixin.qq.com/s/TThOsHSHkVbJA-x31LIjKg"
}
],
"errorCode": 0,
"errorMsg": ""
}
复制代码
JSON:
{
"data": [
{
"apkLink": "",
"audit": 1,
"author": "扔物线",
"canEdit": false,
"chapterId": 249,
"chapterName": "干货资源",
"collect": false,
"courseId": 13,
"desc": "",
"descMd": "",
"envelopePic": "",
"fresh": true,
"id": 12554,
"link": "https://mp.weixin.qq.com/s/CFWznkSrq6JmW1fZdqdlOg",
"niceDate": "刚刚",
"niceShareDate": "2020-03-23 16:36",
"origin": "",
"prefix": "",
"projectLink": "",
"publishTime": 1587657600000,
"selfVisible": 0,
"shareDate": 1584952597000,
"shareUser": "",
"superChapterId": 249,
"superChapterName": "干货资源",
"tags": [],
"title": "【扔物线】消失了半年,这个 Android 界的第一骚货终于回来了",
"type": 1,
"userId": -1,
"visible": 1,
"zan": 0
}
],
"errorCode": 0,
"errorMsg": ""
}
复制代码
JSON:
{
"data": {
"curPage": 2,
"datas": [
{
"apkLink": "",
"audit": 1,
"author": "程序亦非猿",
"canEdit": false,
"chapterId": 428,
"chapterName": "程序亦非猿",
"collect": false,
"courseId": 13,
"desc": "",
"descMd": "",
"envelopePic": "",
"fresh": false,
"id": 12856,
"link": "https://mp.weixin.qq.com/s/FKHeZ1fFHdVcOaCWHpMj4A",
"niceDate": "2020-04-13 00:00",
"niceShareDate": "2天前",
"origin": "",
"prefix": "",
"projectLink": "",
"publishTime": 1586707200000,
"selfVisible": 0,
"shareDate": 1586745025000,
"shareUser": "",
"superChapterId": 408,
"superChapterName": "公众号",
"tags": [
{
"name": "公众号",
"url": "/wxarticle/list/428/1"
}
],
"title": "借助 AIDL 理解 Android Binder 机制——AIDL 的使用和原理分析",
"type": 0,
"userId": -1,
"visible": 1,
"zan": 0
}
],
"offset": 20,
"over": false,
"pageCount": 415,
"size": 20,
"total": 8296
},
"errorCode": 0,
"errorMsg": ""
}
复制代码
模型整理
根据之前编写的BaseModel与Page,我们可以归纳总结出一下的模型,注意置顶文章的data的元素数据与首页文章列表中datas中的数据是一模一样的,这个模型可以复用。
整理我们需要的模型:
struct BaseModel<T: Codable>: Codable {
let data : T?
let errorCode : Int?
let errorMsg : String?
}
extension BaseModel {
/// 请求是否成功
var isSuccess: Bool { errorCode == 0 }
}
复制代码
/// 有分页的基础模型
struct Page<Content: Codable> : Codable {
let curPage : Int?
let datas : [Content]?
let offset : Int?
let over : Bool?
let pageCount : Int?
let size : Int?
let total : Int?
}
复制代码
/// 单个信息模型,用于首页,项目,公众号,搜索关键词,体系,收藏夹
struct Info : Codable {
let title : String?
let id : Int?
let link: String?
let apkLink : String?
let audit : Int?
let author : String?
let canEdit : Bool?
let chapterId : Int?
let chapterName : String?
let collect : Bool?
let courseId : Int?
let desc : String?
let descMd : String?
let envelopePic : String?
let fresh : Bool?
let niceDate : String?
let niceShareDate : String?
let origin : String?
let prefix : String?
let projectLink : String?
let publishTime : Int?
let selfVisible : Int?
let shareDate : Int?
let shareUser : String?
let superChapterId : Int?
let superChapterName : String?
let tags : [Tag]?
let type : Int?
let userId : Int?
let visible : Int?
let zan : Int?
}
struct Tag : Codable {
let name : String?
let url : String?
}
复制代码
Moya的接口服务编写
- Api:
struct Api {
/// baseUrl
static let baseUrl = "https://www.wanandroid.com/"
private init() {}
}
extension Api {
/// 首页 queryKeyword是post请求 其他的是get请求
enum Home {
static let banner = "banner/json"
static let topArticle = "article/top/json"
static let normalArticle = "article/list/"
static let hotKey = "hotkey/json"
static let queryKeyword = "article/query/"
}
}
复制代码
- HomeService:
enum HomeService {
case banner
case topArticle
case normalArticle(_ page: Int)
case hotKey
case queryKeyword(_ keyword: String, _ page: Int)
}
extension HomeService: TargetType {
var baseURL: URL {
return URL(string: Api.baseUrl)!
}
var path: String {
switch self {
case .banner:
return Api.Home.banner
case .topArticle:
return Api.Home.topArticle
case .normalArticle(let page):
return Api.Home.normalArticle + page.toString + "/json"
case .hotKey:
return Api.Home.hotKey
case .queryKeyword(_, let page):
return Api.Home.queryKeyword + page.toString + "/json"
}
}
var method: Moya.Method {
switch self {
case .queryKeyword:
return .post
default:
return .get
}
}
var sampleData: Data {
return Data()
}
var task: Task {
switch self {
case .banner:
return .requestParameters(parameters: Dictionary.empty, encoding: URLEncoding.default)
case .topArticle:
return .requestParameters(parameters: Dictionary.empty, encoding: URLEncoding.default)
case .normalArticle(_):
return .requestParameters(parameters: Dictionary.empty, encoding: URLEncoding.default)
case .hotKey:
return .requestParameters(parameters: Dictionary.empty, encoding: URLEncoding.default)
case .queryKeyword(let keyword, _):
return .requestParameters(parameters: ["k": keyword], encoding: URLEncoding.default)
}
}
var headers: [String : String]? {
return nil
}
}
复制代码
- homeProvider
let homeProvider: MoyaProvider<HomeService> = {
let stubClosure = { (target: HomeService) -> StubBehavior in
return .never
}
return MoyaProvider<HomeService>(stubClosure: stubClosure, plugins: [RequestLoadingPlugin()])
}()
复制代码
ViewModel的编写
这里我先把ViewModel中的请求接口的操作写出来,因为有个难点需要单独拿出来说明,所以先一步步的来。
//MARK:- 网络请求
private extension HomeViewModel {
/// 重置PageNum与上拉组件
private func resetCurrentPageAndMjFooter() {
pageNum = 0
refreshSubject.onNext(.resetNomoreData)
}
/// 下拉刷新操作
func refresh() -> Single<BaseModel<Page<Info>>> {
resetCurrentPageAndMjFooter()
return requestData(page: pageNum)
}
/// 上拉加载操作
func loadMore() -> Single<BaseModel<Page<Info>>> {
pageNum = pageNum + 1
return requestData(page: pageNum)
}
/// 普通列表数据
/// - Parameter page: 页码
/// - Returns: Single<BaseModel<Page<Info>>>
func requestData(page: Int) -> Single<BaseModel<Page<Info>>> {
let result = homeProvider.rx.request(HomeService.normalArticle(page))
.map(BaseModel<Page<Info>>.self)
return result
}
/// 置顶文章
/// - Returns: Single<[Info]>
func topArticleData() -> Single<[Info]> {
let result = homeProvider.rx.request(HomeService.topArticle)
.map(BaseModel<[Info]>.self)
.map{ $0.data }
.compactMap { $0 }
.asObservable()
.asSingle()
return result
}
/// 轮播图
/// - Returns: Single<BaseModel<[Banner]>>
func bannerData() -> Single<[Banner]> {
let result = homeProvider.rx.request(HomeService.banner)
.map(BaseModel<[Banner]>.self)
.map{ $0.data }
.compactMap { $0 }
.asObservable()
.asSingle()
return result
}
}
复制代码
这里实际上有3个方法使用了做请求的requestData(page: Int)
、topArticleData()
、bannerData()
,而另外的三个方法——重置、下拉、上拉不过都是行为操作。
其实关键的问题是刷新这个操作,刷新的本质是一口气调用bannerData(), topArticleData(), refresh()这三个接口,并将数据返回。
等到请求全部结束后才返回数据如何处理?
这就是我说的难点。
GCD使用group,信号量,这些都是可行的方法,不过我们在RxSwift框架有更好的选择,那就是运算函数zip
!
我们来写HomeViewModel的主体:
import RxSwift
import RxCocoa
import Moya
enum ScrollViewActionType {
case refresh
case loadMore
}
class HomeViewModel {
private var pageNum: Int
private let disposeBag: DisposeBag
init(pageNum: Int = 1, disposeBag: DisposeBag) {
self.pageNum = pageNum
self.disposeBag = disposeBag
}
/// outputs
let dataSource = BehaviorRelay<[Info]>(value: [])
let banners = BehaviorRelay<[Banner]>(value: [])
let refreshSubject: BehaviorSubject<MJRefreshAction> = BehaviorSubject(value: .begainRefresh)
/// inputs
func loadData(actionType: ScrollViewActionType) {
switch actionType {
case .refresh:
/// 合并请求
Single.zip(bannerData(), topArticleData(), refresh())
.subscribe { event in
/// 订阅事件
self.refreshSubject.onNext(.stopRefresh)
switch event {
case .success(let tuple):
let items = tuple.0
let topInfos = tuple.1
let noramlPageModel = tuple.2
/// 合并数组并赋值
if let normalInfos = noramlPageModel.data?.datas {
self.dataSource.accept(topInfos + normalInfos)
}
if let curPage = noramlPageModel.data?.curPage, let pageCount = noramlPageModel.data?.pageCount {
/// 如果发现它们相等,说明是最后一个,改变foot而状态
if curPage == pageCount {
self.refreshSubject.onNext(.showNomoreData)
}
}
self.banners.accept(items)
case .error(_):
break
}
}
.disposed(by: disposeBag)
case .loadMore:
loadMore()
/// 由于需要使用Page,所以return到$0.data这一层,而不是$0.data.datas
.map{ $0.data }
/// 解包
.compactMap { $0 }
/// 转换操作
.asObservable()
.asSingle()/// 订阅
.subscribe { event in
/// 订阅事件
self.refreshSubject.onNext(.stopLoadmore)
switch event {
case .success(let pageModel):
/// 解包数据
if let datas = pageModel.datas {
self.dataSource.accept(self.dataSource.value + datas)
}
/// 解包curPage与pageCount
if let curPage = pageModel.curPage, let pageCount = pageModel.pageCount {
/// 如果发现它们相等,说明是最后一个,改变foot而状态
if curPage == pageCount {
self.refreshSubject.onNext(.showNomoreData)
}
}
case .error(_):
/// error占时不做处理
break
}
}.disposed(by: disposeBag)
}
}
}
复制代码
通过 Single.zip
函数,我们将三个接口的结果合成在一起,然后通过.subscribe
去订阅事件。
事件中的success的回调不在是一个单一的元素,而是一个元组,里面有返回的轮播图、置顶文章、首页文章的第一页数据。
我们将轮播图的数据单独用banners
去接收。
而将置顶文章、首页文章的第一页数据通过整理合并,作为列表的数据源,这便是dataSource
。
最后通过首页文章外部包裹的Page<Info>
中的Page模型去判断刷新状态,并通过refreshSubject
去接收。
到此,HomeViewModel的refresh操作已经完成,而loadMore操作仅仅是调用loadMore()
方法,数据简单单一,基本上积分列表页面一致,这里就不用太多笔墨。
注意,我在这里特地用了一个枚举去区分刷新与上拉操作:
enum ScrollViewActionType {
case refresh
case loadMore
}
复制代码
总结
本篇主要说明了首页接口与数据的处理:
文档->Model->Api->HomeService->HomeProvider->ViewModel
其中最难的地方就是ViewModel中刷新操作中,对于3个接口的请求回调与数据处理,刷新状态的处理,我们通过RxSwift中对于序列的zip
函数轻松化解。
RxSwift的设计博大精深,我也不过是到了某个场景才意识需要某种功能。
明日继续
这一篇把HomeViewModel写完了,那么下一步就是构建HomeController了吧?
别急,在写HomeController之前,请容我讲讲BaseViewController、BaseTableViewController的创建与思考。
大家加油!