2018-10-27 20:07:32 lincsdnnet 阅读数 1290
  • Moya 在Swift开发中起着重要的网络交互作用,但是还有不如之处,比如网络不可用时,返回的 Responsenil,这时还得去解析相应的 Error
  • Codable 可以帮助我们快速的解析数据,但是一旦声明的属性类型与json中的不一致,将无法正常解析; 而且对于模型中自定义属性名的处理也十分繁琐

解决的方案有很多,不过我比较习惯使用 MoyaMapper ,不仅可以解决上述问题,还提供了多种模型转换数据互转多种数据类型任意存储的便捷方法。掌控Moya的网络请求、数据解析与缓存简直易如反掌。

MoyaMapper是基于Moya和SwiftyJSON封装的工具,以Moya的plugin的方式来实现间接解析,支持RxSwift

GitHub: MoyaMapper

? 详细的使用请查看手册 https://MoyaMapper.github.io

特点

  • 支持jsonModel 自动映射 与 自定义映射
  • 无视 json 中值的类型,Model 中属性声明的是什么类型,它就是什么类型
  • 支持 json字符串Model
  • 插件方式,全方位保障Moya.Response,拒绝各种网络问题导致 Responsenil,将各式各样的原因导致的数据加载失败进行统一处理,开发者只需要关注 Response
  • 可选 - 支持数据随意缓存( JSONNumberStringBoolMoya.Response )
  • 可选 - 支持网络请求缓存

数据解析

一、插件注入

附:插件 MoyaMapperPlugin 的详细使用

1、定义适用于项目接口的 ModelableParameterType

// statusCodeKey、tipStrKey、 modelKey 可以任意指定级别的路径,如: "error>used"
struct NetParameter : ModelableParameterType {
    var successValue = "000"
    var statusCodeKey = "retStatus"
    var tipStrKey = "retMsg"
    var modelKey = "retBody"
}

2、在 MoyaProvider 中使用 MoyaMapperPlugin 插件,并指定 ModelableParameterType

let lxfNetTool = MoyaProvider<LXFNetworkTool>(plugins: [MoyaMapperPlugin(NetParameter())])

❗ 使用 MoyaMapperPlugin 插件是整个 MoyaMapper 的核心所在!

二、Model声明

Model 需遵守 Modelable 协议

  • MoyaMapper 支持模型自动映射 和 自定义映射
  • 不需要考虑源json数据的真实类型,这里统一按 Model 中属性声明的类型进行转换

1、一般情况下如下写法即可

struct CompanyModel: Modelable {
    
    var name : String = ""
    var catchPhrase : String = ""
    
    init() { }
}

2、如果自定义映射,则可以实现方法 mutating func mapping(_ json: JSON)

struct CompanyModel: Modelable {
    
    var name : String = ""
    var catchPhrase : String = ""
    
    init() { }
    mutating func mapping(_ json: JSON) {
        self.name = json["nickname"].stringValue
    }
}

3、支持模型嵌套

struct UserModel: Modelable {
    
    var id : String = ""
    var name : String = ""
    var company : CompanyModel = CompanyModel()
    
    init() { }
}
三、Response 解析

1、以下示例皆使用了 MoyaMapperPlugin ,所以不需要指定 解析路径

2、如果没有使用 MoyaMapperPlugin 则需要指定 解析路径,否则无法正常解析

ps: 解析路径 可以使用 a>b 这种形式来解决多级路径的问题

解析方法如下列表所示

方法 描述 (支持RxSwift)
toJSON Response 转 JSON ( toJSON | rx.toJSON)
fetchString 获取指定路径的字符串( fetchString | rx.fetchString)
fetchJSONString 获取指定路径的原始json字符串 ( fetchJSONString | rx.fetchJSONString )
mapResult Response -> MoyaMapperResult (Bool, String) ( mapResult | rx.mapResult )
mapObject Response -> Model ( mapObject | rx.mapObject)
mapObjResult Response -> (MoyaMapperResult, Model) ( mapObjResult | rx.mapObjResult)
mapArray Response -> [Model]( mapArray | rx.mapArray)
mapArrayResult Response -> (MoyaMapperResult, [Model]) ( mapArrayResult | rx.mapArrayResult)

❗除了 fetchJSONString 的默认解析路径是根路径之外,其它方法的默认解析路径为插件对象中的 modelKey

如果接口请求后 json 的数据结构与下图类似,则使用 MoyaMapper 是最合适不过了

// Normal
let model = response.mapObject(MMModel.self)
print("name -- \(model.name)")
print("github -- \(model.github)")

// 打印json
print(response.fetchJSONString())

// Rx
rxRequest.mapObject(MMModel.self)
    .subscribe(onSuccess: { (model) in
        print("name -- \(model.name)")
        print("github -- \(model.github)")
    }).disposed(by: disposeBag)

附: fetchJSONString的详细使用

// Normal
let models = response.mapArray(MMModel.self)
let name = models[0].name
print("count -- \(models.count)")
print("name -- \(name)")

// 打印 json 模型数组中第一个的name
print(response.fetchString(keys: [0, "name"]))

// Rx
rxRequest.mapArray(MMModel.self)
    .subscribe(onSuccess: { models in
        let name = models[0].name
        print("count -- \(models.count)")
        print("name -- \(name)")
    }).disposed(by: disposeBag)

附:mapArray的详细使用说明

// Normal
let (isSuccess, tipStr) = response.mapResult()
print("isSuccess -- \(isSuccess)")
print("tipStr -- \(tipStr)")

// Rx
rxRequest.mapResult()
    .subscribe(onSuccess: { (isSuccess, tipStr) in
        print("isSuccess -- \(isSuccess)") // 是否为 "000"
        print("retMsg -- \(retMsg)") // "缺少必要参数"
    }).disposed(by: disposeBag)

附:mapResult的详细使用说明

统一处理网络请求结果

在APP的实际使用过程中,会遇到各种各样的网络请求结果,如:服务器挂了、手机无网络,此时 Moya 返回的 Response 为 nil,这样我们就不得不去判断 Error。但是使用 MoyaMapperPlugin 就可以让我们只关注 Response

// MoyaMapperPlugin 的初始化方法
public init<T: ModelableParameterType>(
    _ type: T,
    transformError: Bool = true
)

type : ModelableParameterType  用于定义字段路径,做为全局解析数据的依据
transformError : Bool  是否当网络请求失败时,自动转换请求结果,默认为 true
  • 当请求失败的时候,此时的 result.responsenil,根据transformError是否为true 判断是否创建一个自定义的 response 并返回出去。

➡ 本来可以请求到的数据内容

➡ 现在关闭网络,再请求数据

  • 正常情况下,即不做任何不处理的时候, Responsenil

  • 经过 MoyaMapperPlugin 处理的后可得到转换后的 Response ,如图

这里将请求失败进行了统一处理,无论是服务器还是自身网络的问题,retStatus 都为 MMStatusCode.loadFail,但是 errorDescription 会保持原来的样子并赋值给 retMsg

  • retStatus 值会从枚举 MMStatusCode 中取 loadFail.rawValue ,即 700
  • 取 类型为 ModelableParameterTypetypestatusCodeKey 所指定的值 为键名,retMsg也同理

ps: 这个时候可以通过判断 retStatusresponse.statusCode 是否与 MMStatusCode.loadFail.rawValue 相同来判断是否显示加载失败的空白页占位图

enum MMStatusCode: Int {
    case cache = 230
    case loadFail = 700
}

枚举 MMStatusCode 中除了 loadFail ,还有 cache,我们已经知道 loadFail 在数据加载失败的时候会出现,那 cache 是在什么时候出来呢?不急,看下一节就知道了。

数据缓存

一、基本使用
// 缓存
@discardableResult
MMCache.shared.cache`XXX`(value : XXX, key: String, cacheContainer: MMCache.CacheContainer = .RAM)  -> Bool
// 取舍
MMCache.shared.fetch`XXX`Cache(key: String, cacheContainer: MMCache.CacheContainer = .RAM)

缓存成功会返回一个 Bool 值,这里可不接收

XXX 所支持类型
Bool -
Float -
Double -
String -
JSON -
Modelable [Modelable]
Moya.Response -
Int UInt
Int8 UInt8
Int16 UInt16
Int32 UInt32
Int64 UInt64

其中,除了 Moya.Response 之外,其它类型皆是通过 JSON 来实现缓存

所以,如果你想清除这些类型的缓存,只需要调用如下方法即可

@discardableResult
func removeJSONCache(_ key: String, cacheContainer: MMCache.CacheContainer = .RAM) -> Bool

@discardableResult
func removeAllJSONCache(cacheContainer: MMCache.CacheContainer = .RAM) -> Bool

清除 Moya.Response 则使用如下两个方法

@discardableResult
func removeResponseCache(_ key: String) -> Bool

@discardableResult
func removeAllResponseCache() -> Bool

再来看看MMCache.CacheContainer

enum CacheContainer {
    case RAM 	// 只缓存于内存的容器
    case hybrid // 缓存于内存与磁盘的容器
}

这两种容器互不相通,即 即使key相同,使用 hybrid 来缓存后,再通过 RAM 取值是取不到的。

  • RAM : 仅缓存于内存之中,缓存的数据在APP使用期间一直存在
  • hybrid :缓存于内存与磁盘中,APP重启后也可以获取到数据
二、缓存网络请求

内部缓存过程:

  1. APP首次启动并进行网络请求,网络数据将缓存起来
  2. APP再次启动并进行网络请求时,会先返回缓存的数据,等请求成功后再返回网络数据
  3. 其它情况只会加载网络数据
  4. 每次成功请求到数据后,都会对缓存的数据进行更新
// Normal
func cacheRequest(
    _ target: Target, 
    cacheType: MMCache.CacheKeyType = .default, 
    callbackQueue: DispatchQueue? = nil, 
    progress: Moya.ProgressBlock? = nil, 
    completion: @escaping Moya.Completion
) -> Cancellable

// Rx
func cacheRequest(
    _ target: Base.Target, 
    callbackQueue: DispatchQueue? = nil, 
    cacheType: MMCache.CacheKeyType = .default
) -> Observable<Response>

实际上是对 Moya 请求后的 Response 进行缓存。 其实与 Moya 自带的方法相比较只多了一个参数 cacheType: MMCache.CacheKeyType ,定义着缓存中的 key ,默认为 default

下面是 MMCache.CacheKeyType 的定义

/**
 let cacheKey = [method]baseURL/path
 
 - default : cacheKey + "?" + parameters
 - base : cacheKey
 - custom : cacheKey + "?" + customKey
 */
public enum CacheKeyType {
    case `default`
    case base
    case custom(String)
}

如果你想缓存多页列表数据的最新一页数据,此时使用 default 是不合适的,因为 default 使用的 key 包含了 pageIndex,这样就达不到只缓存 最新一页数据 的目的, 所以这里应该使用 base 或者 custom(String)

我们可以来试一下带缓存的请求

/*
 * APP第一次启动并进行网络请求,网络数据将缓存起来
 * APP再次启动并进行网络请求时,会先加载缓存,再加载网络数据
 * 其它情况只会加载网络数据
 * 每次成功请求到数据都会进行数据更新
 */
lxfNetTool.rx.cacheRequest(.data(type: .all, size: 10, index: 1))
    .subscribe(onNext: { response in
        log.debug("statusCode -- \(response.statusCode)")
    }).disposed(by: disposeBag)

// 传统方式
/*
let _ = lxfNetTool.cacheRequest(.data(type: .all, size: 10, index: 1)) { result in
    guard let resp = result.value else { return }
    log.debug("statusCode -- \(resp.statusCode)")
}
*/

打印结果

// 首次使用APP
statusCode -- 200

// 关闭并重新打开APP,再请求一下
statusCode -- 230
statusCode -- 200

// 然后再请求一下
statusCode -- 200

这里的 230 就是 MMStatusCode.cache.rawValue

CocoaPods

  • 默认安装

MoyaMapper默认只安装Core下的文件

pod 'MoyaMapper'
  • RxSwift拓展
pod 'MoyaMapper/Rx'
  • 缓存拓展
pod 'MoyaMapper/MMCache'
  • Rx缓存
pod 'MoyaMapper/RxCache'
2018-09-13 14:56:27 liujia_2333 阅读数 1436

///获取APP缓存
func getCacheSize()-> String {

    // 取出cache文件夹目录
    let cachePath = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first

    // 取出文件夹下所有文件数组
    let fileArr = FileManager.default.subpaths(atPath: cachePath!)

    //快速枚举出所有文件名 计算文件大小
    var size = 0
    for file in fileArr! {

        // 把文件名拼接到路径中
        let path = cachePath! + ("/\(file)")
        // 取出文件属性
        let floder = try! FileManager.default.attributesOfItem(atPath: path)
        // 用元组取出文件大小属性
        for (key, fileSize) in floder {
            // 累加文件大小
            if key == FileAttributeKey.size {
                size += (fileSize as AnyObject).integerValue
            }
        }
    }

    let totalCache = Double(size) / 1024.00 / 1024.00
    return String(format: "%.2f", totalCache)
}

///删除APP缓存
func clearCache() {
SVProgressHUD.show()
// 取出cache文件夹目录
let cachePath = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first

    let fileArr = FileManager.default.subpaths(atPath: cachePath!)

    // 遍历删除

    for file in fileArr! {

        let path = (cachePath! as NSString).appending("/\(file)")

        if FileManager.default.fileExists(atPath: path) {

            do {

                try FileManager.default.removeItem(atPath: path)

            } catch {



            }

        }

    }
    SVProgressHUD.dismiss()
    self.getCacheSize()
}
2017-11-09 17:19:00 weixin_34049032 阅读数 121

这个系列的目录:

用Swift实现一款天气预报APP(一)

用Swift实现一款天气预报APP(二)

用Swift实现一款天气预报APP(三)

 

通过前面的学习,一个天气预报的APP已经基本可用了。至少可以查看现在当前的天气情况和未来几个小时的天气预报了。但是,还不够完善。如果用户想要知道他要去的地方的天气怎么办。明显我们的APP在目前来说无法满足用户的这个需求。而我们的APP需要获取其他城市的天气却非常的简单。通过查看天气的API,发现只要把城市的名称作为参数就可以获得当地城市的天气预报。API:

api.openweathermap.org/data/2.5/find?q=London&type=like&mode=xml

q=London就是在API中指明地点的参数。但是,从这里也可以单出。这个城市的名称显示是需要英文的,不是“北京”这样的汉字,也就是说在城市列表中显示的是汉字,但是传给API的url使用的时这个城市的英文名称,或者可以说是拼音字母。

在这一篇中,我们主要要实现的功能是切换城市,和刷新天气预报数据。首先在Storyboard中添加一个叫做城市列表的Controller。我们需要在Controller中显示一个城市列表,这里需要用到一个在iOS的开发中很常用的控件:UITableView。添加一个UIViewController之后,拖动一个UITableView到这个ViewController上。这样,在界面上来说就齐全了。

下面,创建代码,选择Source,先在Subclass of选择你的超类为UIViewController,然后命名你的类的名称。这里是"CityListViewController"。最后在语言一项选择Swift。你应该不会选择Objective-C的。如图:

之后一路的Next一直到Done。

很多的教程在讲到UITableView的时候总是喜欢用UITableViewController,这个是对用包含一个UITableView的UIViewController的封装。有的时候,系统封装了过多的东西对于开发者来说并不是什么好事。尤其是开发者单独处理UITableView也不会耗费太多的时间。所以,这里使用的是UIViewController和UITableView的组合。

在你的ViewController文件中添加准备绑定的UITableView对象的属性:

@IBOutlet weak var tableView: UITableView!

之后在Controller里选择之后,在右边栏里选择左数第三个选项,然后在下面的Class里选择你刚刚创建的CityListViewController。一般,在你选择完了

Controller之后,Class下面的Module会自动设定为Current-Swift_Weather。也就是会自动选择你的项目名称。如果没有选择的话,你需要手动添加你的项目名称到Module里。否则,这个ViewController是不可用的。Swift中引入了Module(模块)这个概念。默认的你的APP就是一个Module。类都是从你的应用的Module里查找的。如果没有这个Module名称的话,应用无法找到你给这个ViewController关联的代码。

 

 

这些操作完成之后,你已经把Storyboard的ViewController和对应的代码关联在了一起。下面还需要关联上前文提到的UITableView控件。点击你刚刚选中的controller然后在右边栏中选择最右边的箭头按钮你会看到里面会出现一个tableView,他后面的小圆圈还没有和Storyboard的TableView关联起来。鼠标放在小圆圈上,按下Ctrl同时移动鼠标到Storyboard的UITableView上。

到目前为止都很完美,但是这个TableView还不能用。TableView需要知道有多少个TableViewCell要显示出来,每一个Cell上面显示什么内容。每一个Cell有多高,有多少个Section。哪个Cell被选中,哪个Cell从被选中变成了么有被选中等等。。。这些都是通过TableView的代理实现的。这样的代理一共有两个,一个叫做UITableViewDelegate,一个叫做UITableViewDataSource

所以,还需要把UITableView的两个代理和UITableView所在的Controller关联。选中Storyboard的UITableView,然后选择右边栏的最后一个选项。就是最后的那个选项。你会看到

把鼠标放在小圆圈上,同时按下Ctrl键,移动鼠标到这个UITableView所在的Controller上,准确的说是移动到这里:。两个都是这样的操作。完成之后就给这个UITableView关联好了UITableViewDelegateUITableViewDataSource

到目前为止,我们在Storyboard中创建了一个Controller(Scene),在上面放了一个UITableView。创建了一个UIViewController代码,并且和刚刚创建的Controller关联到了一起。并且把UITableView也关联到了一起,同时关联的还有这个UITableView的UITableViewDelegateUITableViewDataSourceUITableViewDelegateUITableViewDataSource在Swift的语法上来说都是protocol,也就是其他的语言,如Java、C#的接口,哪里继承了哪里就要给出实现。既然UITableView的这delegate和datasource都制定在了他所在的Controller上,那么我们代码里的CityListViewController就需要继承UITableViewDelegateUITableViewDataSource并实现这两个protocol。

class CityListViewController: UIViewController, UITableViewDelegate, UITableViewDataSource

下面是UITableViewDelegateUITableViewDataSource中部分必要的方法的实现。注意,这些只是一个UITableView正常显示的必要方法,还有很多的方法暂时没有用到。

第一行,是指定这个UITableView中有多少个section的,section分区,一个section里会包含多个Cell。这里,是只有一个section。

第二行,是指定每一个section里面有多少个Cell的。因为我们只有一个section,所以,有多少个城市可选就有多少个Cell。这个是视不同情况定的。

第三行,初始化每一个Cell。一个Cell长什么样子就由这个方法决定。

第四行,是选中一个Cell后执行的方法。当用户选择了一个Cell的时候,我们需要知道是哪一个,并把这个Cell的城市的英文名称(或者是拼音的字母)发送到主界面中用于获取该城市的天气数据。

这些,就是使用一个UITableView时的全部了。首先创建一个放UITableView的Controller(Scene)然后拖动一个UITableView在上面。二,创建一个对应于这个Scene的Controller的Swift代码,并在代码中添加UITableView属性。关联Scene和Swift的Controller,关联代码的UITableView属性和Storyboard中的UITableView。三,关联UITableView的delegate和datasource到Swift代码的Controller,并在其中继承和实现UITableViewDelegateUITableViewDataSource。这一部分需要多练习并且熟记。因为你会发现没有一个应用不用到UITableView的。如果你找不出UITableView那也可能是开发者对于UITableView的定制比较深,直观上看不出来而已。

这里必须强调的一点,就是第三行的创建Cell的方法。UITableView的Cell不是每次用到都去创建的。手机如今的内存已经有2G的了,CPU也是几核心的。但是,其资源还是相对比较紧缺。如果,每个Cell都新建一个。那么,用户在上下滑动UITableView的时候会非常的卡顿。这对于一个好的APP时绝对不允许的。所以苹果也推荐了一种使用Cell的方法,如果Cell还没有被创建的话就创建一个。如果Cell已经创建了,那么就对这个Cell重新赋值。这里一点很关键,如果一个Cell已经创建了,只重新赋值而不再创建!参见下面的代码:

复制代码
    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        var cell = tableView.dequeueReusableCellWithIdentifier("CellIdentifier") as? UITableViewCell
        if cell == nil {
            cell = UITableViewCell(style: UITableViewCellStyle.Default, reuseIdentifier: "CellIdentifier")
        }
        
        cell?.textLabel.text = self.cityList.values.array[indexPath.row]
        
        return cell!
    }
复制代码

首先按照Cell的Identifier从UITableView的Cell重用队列获取Cell。如果为空则创建一个Cell,并指定这个Cell的Identifier。如果Cell不为空的话给这个Cell的textLabel的text属性重新赋值。但是,既然我们用了Storyboard了,就用的彻底一点。Cell为什么不用Storyboard来创建呢。这样又会省去很多的代码,比如我们这里的重用Cell的部分。在右边栏中找到UITableViewCell,并拖动到UITableView上。给这个Cell的Identifier起个名字就叫"cityCell"。Style选择Custom,表示我们要自定义这个Cell。如图:

接下来,添加为这个Cell添加新的Swift代码文件。首先,source->Cocoa Touch Class。之后选择

给Cell绑定Swift代码类。选中Storyboard的这里

 

 

 

 

 

之后->

 

 

 

这个Cell需要一个UILabel来显示城市的中文名称(这个UILabel只是为了表明在Storyboard中自定义一个Cell的时候该如何处理,一般来说Cell中有一个textLabel可以显示文本)。那么先在代码中添加一个label的属性:

@IBOutlet weak var cityNameLabel: UILabel!

添加完属性之后,关联这cityNameLabel和Storyboard中的Cell中刚刚添加的Label。重复上面说到的第一步,选中这个cell。然后选择第二步中最上面选项中的最后一项。你就会看到cityNameLabel和他后面是小圆圈。你应该知道怎么办了。关联属性和Storyboard的Label之后,回到前面说道的创建Cell的方法。

    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        var cityListCell = tableView.dequeueReusableCellWithIdentifier("cityCell", forIndexPath: indexPath) as CityListTableViewCell
        cityListCell.cityNameLabel.text = self.cityList.values.array[indexPath.row]
        return cityListCell
    }

看到有什么不同的么。是的,这里不用再从TableView的Cell的复用队列中获取Cell了。因为,这些都在Storyboard中处理好了。我们只要每次给Cell重新赋值就可以了。

到目前为止,这个城市列表还是不能用的。因为,我们还没有把这个列表和天气预报的主界面关联起来。是的,用户从哪里进入这个城市列表呢。现在我们就把主界面和城市列表关联起来。首先,在Storyboard上拖入一个UINavigationController。删掉后面的RootViewController,并把这个UINavigationControllerRootViewController和我们刚刚创建的城市列表Controller关联起来。这一类似操作在第一部分中讲过。不清楚的话可以重新看看第一部分。

之后,找到主界面的City按钮,连接到新添加的UINavigationController上,在弹出的Action Segue中选择modal。这个时候运行APP,点击City就会出现刚刚创建的CityListController了,用户可以点这上面的某一行,但是。。。回不去了。现在就来处理这个问题。开发这个工作就是“逢山开路,遇水搭桥”。在MainViewController中添加如下代码

    @IBAction func dismissCityListController(segue: UIStoryboardSegue){

        println("dismiss controller")

    }

名字可以任意起,但是参数必须是UIStoryboardSegue类型的。然后,在Storyboard的天气预报主界面中右击Exit,在出现的菜单中你会看到刚刚添加的方法dismissCityListController。在这个方法有个小圆圈。Storyboard里就是充满了这样的小圆圈。把这个小圆圈连接到CityListController的Cell上,在弹出的小菜单中选择selection。也就是说在用户选择了UITableView的一个Cell的时候(selection)CityListController就会“Exit”退出。

这个方法就是传说中的“unwind segue”给这个segue设定一个identifier为“backToMain”。连接好以后再运行APP。在用手指点了一个City以后CityListController就会隐藏起来。用户可以在选择了城市以后返回继续操作。

但是,选择了城市以后主界面显示的还是原来的城市,并没有更换。那时因为,我们并没有添加相关的代码。上面添加的只是让界面可以在segue的引导之下跳转,但是没有更换城市后重新请求天气预报数据。下面就完成这一部分功能。

UIViewController之间传递数据,我们这里是从CityListController传递选择好的城市给MainViewController。方法有很多,比如,以后你会经常用到的Notification的方法。用户选择一个城市之后发出一个Notification,在MainviewController中捕获这个Notification并做相应的处理。看似很简单把。不过我们这里不用这个方法。用Notification的方法会给代码的维护造成一定的困扰。哪里发送,哪里接受都是分开写的,不容易维护代码。我们这里要将的时用代理的方式传递数据。这个中方法在自定义控件,和ViewController之间都会经常用到。具体到我们的天气APP这里,我们需要从CityListController传递数据给MainViewController,那么就在CityListController文件中定义一个接口(protocol)。苹果一般的命名规则是你的Controller的名字后面加Delegate。这样非常好辨认哪里是定义Delegate的,哪里是用到这个Delegate的。

@objc protocol CityListViewControllerDelegate{
    func cityDidSelected(cityKey: String)
}

这个@objc不是一定要加的,一般是和其他的Objective-C代码共用的时候加。在CityListViewController中添加一个叫做delegate的属性。这个属性会在CityListViewController的UITableView里的某个Cell选中时被执行。

@IBOutlet weak var delegate: CityListViewControllerDelegate?

这里用weak时因为,我们不希望这个代理强引用(strong)其他的Controller。这样会造成循环引用,使得连个Controller的引用计数不减少,从而无法在不用的时候从内存清除。在选中的时候执行

复制代码
    func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        println("did select row \(indexPath.row)")
        
        // set current selected index of city list
        self.selectedIndex = indexPath.row
        if self.selectedIndex != nil && self.delegate != nil {
            var cityKey = self.cityList.keys.array[self.selectedIndex!]
            self.delegate?.cityDidSelected(cityKey)
        }
        tableView.deselectRowAtIndexPath(indexPath, animated: true)
    }
复制代码

在CityListViewController中执行代理的方法的话,就需要在MainViewController中实现这个protocol。

复制代码
class MainViewController: UIViewController, CLLocationManagerDelegate, CityListViewControllerDelegate{

//MARK: city changed delegate func cityDidSelected(cityKey: String){ println("selected city \(cityKey)") }
}
复制代码

上面的代码是一个大概是实现。具体的代码可以在示例工程中查看。

运行APP,并选择一个其他的城市的时候,这段代码就会执行。在Console中会出现选择的城市的英文名称。现在我们需要来真的了。查看之前的代码,有一个方法func updateWeatherInfo(latitude: CLLocationDegrees, longitude: CLLocationDegrees)会在获取用户的地理位置后请求服务器获得天气预报。我们现在是需要根据城市的名称获取天气预报了。那么,我们就来Over Load方法updateWeatherInfoOver Load就是定义一个和某个别的方法同名但是参数不同的方法。

func updateWeatherInfo(cityName: String)

以后的事情就是在前文中关于HTTP请求的url字符串问题了。这里不多做叙述。

还有一个功能没有完成。你很快会想到:刷新(refresh)。用户在选择了其他的城市的时候,需要很快回到用户点击刷新时所在位置的天气预报。点击Refresh的时候获取用户的最新地理位置数据,并请求天气预报数据。这个功能已经实现。在用户进入主界面的时候就会自动获取用户的位置,并请求天气预报。刷新功能只需要在在refreshAction方法中重新获取用户的地理位置就可以,请求天气预报会在获得用户位置后自动执行。这里需要提到的一点是,当成功获取了天气预报之后,就应该停止获取用户地理位置。因为这样会给用户省电!省电也是用户体验的一部分。作为一个开发者,如果你的APP太多费电,而且还不是一个很好玩的游戏的话实在是说不过去的。

本系列文章的代码在这里

延伸阅读:AFNetworking是cocoaPods加载进来的。了解更多cocoaPods,请看这里

本文所使用的代码的原始版本是来自Github的这里。我们现在的代码已经比原作者的丰富的多了。不过还是要感谢原作者。

 

欢迎加群互相学习,共同进步。QQ群:iOS: 58099570 | Android: 572064792 | Nodejs:329118122 做人要厚道,转载请注明出处!
















本文转自张昺华-sky博客园博客,原文链接:http://www.cnblogs.com/sunshine-anycall/p/4151304.html,如需转载请自行联系原作者
2016-08-21 12:52:20 yangmeng13930719363 阅读数 1675

说明

首先声明,今日头条是我经常用的 app 之一,模仿今日头条也是因为感兴趣,代码仅用于学习交流。对于项目中的数据接口都是通过 Charles 抓包获得,基本每个界面都是有数据请求,不会抓包的朋友可以看我 这一篇文章

项目中有的地方代码写的不是很简洁,毕竟自己能力有限,对 Swift 使用不是很熟练,还请各位朋友不喜勿喷。下面有项目的完整源码,喜欢的朋友可以下载下来,如果您感觉我写的代码对您有所帮助,还请在 github 给个 star,非常感谢您的支持!~

对于代码中出现的问题,可以及时联系我,我会继续修改。

github 地址

CodeData 地址

环境设置

  • 项目环境

    • Xcode 9.2(低于这个版本会报错)。
    • Swift 4
    • iOS 11.0
  • 使用 cocoaPods 管理第三方库, 如果电脑没有安装 cocoapods,请先安装 cocoapods。安装方式可参考:最新版 CocoaPods 的安装流程

  • 项目中使用到的第三方库

    • SnapKit: 布局
    • Kingfisher: 缓存图片
    • SVProgressHUD:提示框
    • FDFullscreenPopGesture:侧滑
    • Alamofire :网络请求
    • SwiftyJSON:解析 json
    • MJRefresh: 上拉刷新和下拉刷新

实现的功能

  1. 获取今日头条的接口
  2. 完成首页的布局和数据的显示
  3. 实现首页顶部导航栏滚动
  4. 新闻详情界面简单实现
  5. 点击屏蔽按钮,弹出屏蔽视图(坐标有一些问题)
  6. 完成视频界面顶部导航栏滚动
  7. 完成视频界面布局和数据获取
  8. 用户界面简单实现
  9. 完成关注界面布局和数据的获取
  10. 完成关注界面,添加关注功能
  11. 完成搜索功能
  12. 完成个人界面的布局
  13. 完成设置界面的布局
  14. 完成离线下载界面布局
  15. 活动界面简单实现
  16. 登录界面的简单实现
  17. 启动界面的简单实现

数据请求

今日头条的接口文件请看: news.json,需要提前安装 postman,然后把该文件导入到 postman 进行查看,可以打开谷歌浏览器,找到扩展程序,添加新的扩展,搜索 postman。

下载地址请看 postman,下载完成后,直接拖入到谷歌浏览器的扩展程序界面即可。

数据请求的具体方式,请看 YMNetworkTool.swift

首页

YMHomeViewController.md

首页-1

1.首先,首页的状态栏的颜色是白色,所以调用了下面的方法:

override func preferredStatusBarStyle() -> UIStatusBarStyle {
    return .LightContent
}

但是,经过测试,上面的代码不起作用,对于 YMMineViewController.swift 上面的代码是起作用的。

唯一的区别是就是在 YMMineViewController.swift 中隐藏了导航条。所以经过查阅资料,得到下面的结论:

1.不管是调用了系统的 UINavigationController 还是使用自己继承自 UINavigationController,如果 navigationBar 没有被隐藏的话,那么导航控制器的 rootController 以及它 push 的控制器的 preferredStatusBarStyle() 方法都不会被调用。
2.如果在当前控制器手动设置了 navagationBarbarStyle.Black 或者 .Default 或者使用下面的代码手动设置:

// 方式1
navigationController?.setNavigationBarHidden(true, animated: false)
// 方式2
navigationController?.navigationBarHidden = true

那么 preferredStatusBarStyle() 就会被正常调用了。

还有一点关于隐藏导航栏的注意点请看 YMMineViewController.swift

2.关于导航栏的 titleView

在首页首页顶部标题的时候,直接设置 titleView 的宽度为屏幕的宽,但是两边总是会留出 10 的间距,这个时候需要重写父类的 setFrame 方法,在 OC 里面可以使用下面的方法:


- (void)setFrame:(CGRect)frame  
{  
    CGRect newFrame = CGRectMake(0, 0, SCREENW, 44);
    [super setFrame:frame];  
} 

但是在 swift 中不能这样写,要使用下面的方式:

/// 重写 frame
override var frame: CGRect {
    didSet {
        let newFrame = CGRectMake(0, 0, SCREENW, 44)
        super.frame = newFrame
    }
}

这样设置,运行程序,发现 titleView 在屏幕两边不在留有间距。

3.子控制器

YMHomeTopicController.swift 作为 YMHomeViewController.swift的子控制器,显示新闻数据。

YMHomeTopicController.md

该类注册了四种 cell,分别表示中间三张图片,右边一张图片,中间一张大图,中间一张视频大图,没有图片的情况。

以下是四种情况:





tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell 中,根据不同情况对要显示的 cell 进行判断,显示对应的 cell。

具体判断情况请看 Model 里的 YMNewsTopic.swift 类。

YMHomeDetailController.swift

详情有下面几种方式:




为了实现简单,这里使用 webView 来实现

YMPopPresentationController.md

iOS 8 以后推出的专门负责转场动画的控制器。

在 Xcode 7 以上的版本中,UIPresentationController 有一个 bug,见下图:

野指针

presentingViewController 会报一个野指针的错误,这是 Xcode 的 bug。

UIPresentationController 中有两个方法可以布局子视图,分别是:

// 即将布局转场子视图时调用
public func containerViewWillLayoutSubviews()
// 布局完成转场子视图时调用
public func containerViewDidLayoutSubviews()

可以在两个方法里设置 UIPresentationController 的容器视图 containerView 和 被展现的视图 presentedView()

containerView-presentedView

YMHomeTopicCell.md

这个类主要作为四种类型 cell 的父类,主要定义了 标题、头像、昵称、评论、关闭按钮。

显示图片交给其子类各自实现。

当时考虑过使用一个类来实现四种类型的 cell,但是经过测试,由于 cell 的重用机制,始终不能达到想要的结果,所以才分别创建了四种不同的 cell,使用一个类的方式是 YMTopicTableViewCell.swift 这个类,大家可以参考一下。

由于首页的情况比较多,cell 的显示比较复杂,而且今日头条的接口也不是很规范,所以这几个类实现起来比较麻烦,而且代码写的不是很简洁,用了很多 if 判断,可能看起来不是很美观。

判断的情况与 YMNewsTopic.swift 类相同,具体请看 YMNewsTopic.swift

我觉得使用不同 cell 的情况还比较简单理解。

如果各位朋友有什么更好的实现方法,欢迎给我留言或『Pull Request』,非常感谢您的留言和建议。

YMScrollTitleView.md

这个类和和视频顶部标题的类有些类似,对于数据和按钮点击的回调使用闭包的方式。而在视频的标题 YMVideoTitleView.swift 里使用代理来代替闭包,实现的功能是相同的,但是实现的方式不同,可以对比看一下。

对控件的布局方式还是使用的 SnapKit 来进行布局。

这个类里需要首先从服务器获取标题数据,服务器返回一个数组,根据这个数组循环创建标题的 label,然后设置好 label 的位置以及 scrollViewcontentSize

标题 label 的点击通过添加手势来实现监听点击操作,titleLabelOnClick 为标题点击的方法,当点击的时候,根据索引进行相应的偏移,调用 adjustTitleOffSetToCurrentIndex 来改变 label 的位置。

adjustTitleOffSetToCurrentIndex(currentIndex: Int, oldIndex: Int) 方法里,需要获取之前点击 label 的索引以及刚刚点击 label 的索引,改变形变,计算当前的偏移量。

重写 frame,来设置导航栏不再有两边的间距。请看具体代码 206 行。

YMNewsTopic.md

这个类是我觉得最麻烦的一个类了,有很多种情况,所以判断也比较多。

今日头条返回的数据中,有这四个字段,image_listmiddle_imagelarge_image_listvideo_detail_info,在 cell 里面分别对应 YMHomeSmallCell.swiftYMHomeMiddleCell.swiftYMHomeLargeCell.swift

image_list 这是一个数组,表示中间有三种图的情况;

middle_image 这是一个字典,表示图片在右侧的情况;

右边显示一张图片的情况

large_image_list 这是一个数组,表示中间是一张大图;

video_detail_info 这是一个字典,表示是视频,中间也用一张大图表示,这种情况和大图的情况基本相同,但是视频中间多了播放按钮。

还有最后一种情况就是没有图片的情况,比如置顶的专题,但是置顶的专题和上面在举报按钮的地方也有所区别,置顶的新闻没有举报按钮,其他情况有举报按钮,需要根据 一个字段 label 来进行判断。

上面五种情况出现的依赖关系,也需要进行判断,

下面说一下,具体的判断过程:

image_list middle_image large_image_list video_detail_info
nil nil nil nil
nil 不为 nil 不为 nil nil
nil 不为 nil nil nil
不为 nil 不为 nil 不为 nil 不为 nil
不为 nil 不为 nil 不为 nil nil
不为 nil 不为 nil nil 不为 nil
不为 nil 不为 nil nil nil

还有一些其他情况,比如有个数据里没有 image_list 这个字段,这种情况我没做判断,一般程序崩溃都是因为这个原因。但是实际上,我是先判断 image_list 是否有值,如果有值,则显示三张图片,如果为 nil,再判断 middle_image 的情况。

如果各位朋友有什么更好的实现方法,欢迎给我留言或『Pull Request』,非常感谢您的留言和建议。

YMPopViewAnimator.md

负责转场动画的代理

自定义转场动画需要集成两个代理协议,分别是 UIViewControllerTransitioningDelegateUIViewControllerAnimatedTransitioning

如果需要自定义转场动画,那么所有的操作需要自己完成,系统不再处理。

UIViewControllerTransitioningDelegate

UIViewControllerTransitioningDelegate 共有五个代理方法,这里我用到了三个代理方法,分别是:

// MARK: - UIViewControllerTransitioningDelegate
    /**
     告诉系统由哪个控制器来实现代理
     - parameter presented:  被展现的视图
     - parameter presenting: 展现的视图
     - returns: YMPopPresentationController iOS 8 以后推出的专门负责转场动画的控制器
     */
    func presentationControllerForPresentedViewController(presented: UIViewController, presentingViewController presenting: UIViewController, sourceViewController source: UIViewController) -> UIPresentationController?
    /**
     告诉系统谁来负责 modal 的展现动画
     - parameter presented:  被展现的视图
     - parameter presenting: 展现的视图
     - returns: 由谁管理
     */
    func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning?
    /**
     告诉系统谁来负责 modal 的消失动画
     - parameter dismissed: 消失的控制器
     - returns: 由谁管理
     */
    func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? 

UIViewControllerAnimatedTransitioning

用到了两个代理方法:

/** 动画时长*/
    func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval 
    /** 负责转场动画的效果*/
    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        if isPresent {
            // 展开
            let toView = transitionContext.viewForKey(UITransitionContextToViewKey)
            // 一定要将视图添加到容器上
            transitionContext.containerView()?.addSubview(toView!)
            // 锚点
            toView?.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0)
            toView?.transform = CGAffineTransformMakeScale(0.0, 0.0)
            UIView.animateWithDuration(transitionDuration(transitionContext), delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, options: 

YMHomeShareView.swift

分享界面:

视频

YMVideoViewController.md

这个控制器主要显示顶部导航标题和帖子控制器的一个容器。

顶部导航标题请看 YMVideoTitleView.swift,帖子控制器请看 YMVideoTopicController.swift

YMVideoTopicController.md

使用的一个 tableView 实现。集成上拉刷新和下拉刷新。实现起来不难。

但是视频播放麻烦一点。需要考虑 cell 的重用机制,由于今日头条返回的数据是一个网址,并不是视频的真实地址,试了一些方法,想把视频的真实地址搞出来,但是没有成功,也是我能力有限。搜易视频播放暂时写了一个地址,播放是这一个视频。

cell的图片是一个 button 的背景图片,通过 button 的点击事件,来判断此时是选中还是没有选中。当点击图片按钮或是播放按钮的时候,在这个按钮上再创建一个 playerView,来播放视频,具体类请看 YMPlayerView.swift

首先提前定义一个 cell,来保存上一次点击的 cell,然后通过 YMPlayerView.swift 的回调, 首先把上一次 cell 的状态,恢复原状,然后再在当前选中的 cell 上,进行新的设置,并添加一个 YMPlayerView

说明:视频播放还是存在问题,后面有时间会优化。

关注

YMNewCareViewController.md

这个类是第三个主控制器,显示关注界面。

这个界面创建了一个 tableView,并且注册了三种不同的 cell,分别是 YMNewCareNoLoginCell.swiftYMNewCareTopCell.swift,以及 YMNewCareBottomCell.swift,可分别打开各自的文件,进行查看。

首先设置 UI,然后 setupRefresh() 是添加上拉和下拉刷新,然后将 tableView 分成了上下两组,上边一组表示自己添加的关注内容,下边一组表示未添加的关注内容,下面一组可以上拉加载更多内容。

今日头条的接口里有一个 concern_time 字段,未添加关注之前,该值为 0,当添加某一关注内容之后,该值变为一个关注的时间,单位是秒。由于今日头条接口里返回的数据是一个数组,即已关注和未关注的内容同时包含在一个数组中,所以可以根据这个参数来区分是已关注还是未关注。具体方法可以参考 setupRefresh 方法里面调用的 loadNewConcernList 方法,以及 loadMoreConcernList 方法,这两个方法实现了对已关注和未关注的拆分。

接口里还有一个参数需要注意,就是 newly 这个字段,对于刚刚关注的内容或是已关注的内容并没有点击相应的 cell,跳转到下一控制器的关注内容,都会在右边显示一个 『NEW』,这个控件的显示与隐藏需要根据 newly 的值进行判断,newly 会返回两种情况,一种是 1, 另一种是 0,即对应显示和隐藏。

上面的参数定义请看 YMConcern.swift.

在下面一组每一个 cell 上都有一个『关注』 按钮,这里我使用代理来实现按钮点击的响应事件,让 YMNewCareViewController 来接收按钮的点击。

当添加关注的时候,会有一个动画效果,这个动画效果暂时还未实现,大家可以参考今日头条的效果。参考一下,有实现的朋友,也可以联系我,也可以给我 『pull request』。

这个界面的实现还算简单,就说明到这里吧~

YMSearchContentViewController.swift

搜索界面

YMBlurImageView.md

点击关注界面的某一个 cell 之后,跳转到下移控制器的顶部视图,有一个模糊效果。

界面实现不算太难,主要是对每个控件的布局,使用 SnapKit。点击按钮的回调使用委托实现。

代码中注释比较详细,这里不再说明。

YMCareheaderView.md

具体代码请看 YMCareheaderView.swift

我的

YMMineViewController.md

隐藏导航栏的方法如下:

// 方式1
navigationController?.setNavigationBarHidden(true, animated: false)
// 方式2
navigationController?.navigationBarHidden = true

需要注意一点,隐藏导航栏的属性写到 viewDidLoad() 里不起作用。

YMSettingViewController.swift

从文件加载 cell 的数据,使用通知的方式,实现了清除缓存,以及改变字体大小,改变下载方式。

YMOfflineTableViewController.swift

我的 -> 离线 -> 离线下载

对于标题的选中与未选中,使用归档的方式,YMHomeTopTitle 附加一个字段来判断选中与未选中,然后存储到沙盒中,具体实现可看代码。

YMActivityController.swift

活动界面

为了实现简单,使用一个 webView 实现。

登录和启动界面

只是简单的搭了一个界面,具体逻辑没有实现:

登录

启动

2015-05-07 09:28:46 kmyhy 阅读数 1989

接下来实现EDStarRating 的编辑。 在loadView 方法中,我们已经配置了EDStarRating的delegate属性,我们仅仅需要实现相关委托方法即可。

打开MasterViewController.swift 在 EDStarRatingProtocol 扩展中添加如下方法:

func starsSelectionChanged(control: EDStarRating!, rating: Float) {

   if let selectedDoc = selectedBugDoc() {

selectedDoc.data.rating = Double(self.bugRating.rating)

   }

}

跟前面几乎一样: 获得用户选定的昆虫模型,用修改后的值赋值给它。

运行程序。需要注意的是,用户设定新的评级后这个值是被持久化的,哪怕你切换到其他昆虫然后有切换回来。


获取本地图片

打开 MasterViewController.xib,拖一个“Push Button” 控件到image view下方。

修改按钮的title 为 “Change Picture”:


如同加号按钮和减号按钮,为Change Picture 按钮创建一个IBAction,命名为 changePicture

这个action在按钮点击时调用。

OS X 有一个特有的控件叫做 IKPictureTaker,允许用户从计算机上选择一张图片,或者从摄像头捕捉一张图片。

当用户选择了图片之后,这个控件会调用指定的delegate方法。

打开MasterViewController.swift 加入以下import 语句:

import Quartz

这个 image picker属于 Quartz 框架。

changePicture方法中,添加代码:

if let selectedDoc = selectedBugDoc() {

   IKPictureTaker().beginPictureTakerSheetForWindow(self.view.window,

withDelegate: self,

     didEndSelector: "pictureTakerDidEnd:returnCode:contextInfo:",

     contextInfo: nil)

}

我们先检查用户是否选择了有效的昆虫,如果是,显示picture taker控件。

然后实现pictureTakerDidEnd(_:returnCode:contextInfo:)方法:

func pictureTakerDidEnd(picker: IKPictureTaker, returnCode: NSInteger, contextInfo: UnsafePointer<Void>) {

   let image = picker.outputImage()

   if image != nil && returnCode == NSOKButton {

self.bugImageView.image = image

if let selectedDoc = selectedBugDoc() {

       selectedDoc.fullImage = image

       selectedDoc.thumbImage = image.imageByScalingAndCroppingForSize(CGSize(width: 44, height: 44))       reloadSelectedBugRow()

}  

   }

}

首先检查用户是否点击了OK (NSOKButton) 以及选择的图片是否有效。

如果是,获取用户选定的昆虫模型,修改昆虫的图片及缩略图,然后更新cell。

运行程序,选择一个昆虫,点击Change Picture, 从本地文件或摄像头中获取一张图片,这张图片将立即在选定的cell中得到更新。

一些细节上的问题

当你运行程序,视图改变窗口大小,你会发现控件并不能自动适应大小。


这是窗口拖大后的效果。


pplns:o="urn:schemas-microsoft-com:office:office"xmlns:w="urn:schemas-microsoft-com:office:word"xmlns:m="http://schemas.microsoft.com/office/2004/12/omml"xmlns="http://www.w3.org/TR/REC-html40">

这是窗口缩小后的效果。

另外,我们还没有为App数据进行持久化。一旦App重启,用户对数据进行的增加和修改都会丢失。

打开MasterViewController.xib,将View的Size缩小至最小能够足以显示所有控件的程度。


在上图中,3个按钮放在了同一排。在右边细节展示区域中,所有的控件都左对齐,且宽度一致(除了ChangePicture按钮)。

然后,我们在中间增加一个分割线。拖一个 VerticalLine 到View的中央。


复原操作

复原操作用于将数据恢复至原来的状态。拖一个Push 按钮在Table View下方,修改其标题为Reset。然后打开Assistant Editor,为按钮创建一个IBAction,名为resetData(确认当前打开的源文件为MasterViewController.swift )。


resetData()方法加入如下代码:

setupSampleBugs() 
updateDetailInfo(nil) 
bugsTableView.reloadData()

setupSampleBugs() 方法调用会恢复所有模型数据。 以nil作为参数值调用updateDetailInfo 方法将清除所有细节字段。然后刷新Table View。

运行程序,添加、删除或修改任意数据。然后点击Reset按钮,所有数据又恢复原样。


Swift的GCD详解

阅读数 0