阅读 393

Swift 开发 wanandroid 客户端——首页接口与数据的处理

这是我参与更文挑战的第23天,活动详情查看: 更文挑战

先声明一下

昨天收到了一个热心的留言,如下图所示

image.png

用Swift写App,当然是iOS的App啦,只是我写的这个App是玩安卓而已,哈哈。

我在很早的新闻上确实读过一篇文章,是Google曾经确实考虑使用Swift写安卓,不过后来变成了Kotlin,至于Swift和Kotlin,在语言特性与语法上不就是孪生兄弟吗?

好了,扯远了,下面进入正题。

首页的UI与接口

我们先看看首页的UI是一个什么样子的:

RPReplay_Final1624258630.2021-06-21 14_59_25.gif

实际上首页的结构还是非常简单的,列表支持下拉与上拉,有一个轮播图。

接口与JSON

调用的接口有以下几个,而且均是get请求,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的创建与思考。

大家加油!

文章分类
iOS
文章标签