Swift-MVVM 简单演练(一)
Swift-MVVM 简单演练(三)
Swift-MVVM 简单演练(四)
根据接口文档,下拉刷新是返回ID比since_id大的微博(即比since_id时间晚的微博)
。因此,我们需要在网络请求方法里增加两个参数。since_id
和max_id
,分别对应下拉刷新所需参数和上拉加载所需参数。
既然要修改网络请求方法,当然是从我们自己抽取的HQNetWorkManager+Extension
和HQStatusListViewModel
这两个地方入手考虑。这里不太建议在HQStatusListViewModel
中处理。因为所有的viewModel
中都是处理网络请求得到的数据,以及处理一些小的业务逻辑的。网络请求的方法如果有扩展,还是尽量放在我们抽取出来的专门放各种网络请求的HQNetWorkManager+Extension
中比较好。统一所有的网络请求都在这里处理,改起来也就比较容易。
因此对HQNetWorkManager+Extension
代码进行扩展
/// 微博数据字典数组
///
/// - Parameters:
/// - since_id: 返回ID比since_id大的微博(即比since_id时间晚的微博),默认为0
/// - max_id: 返回ID小于或等于max_id的微博,默认为0
/// - completion: 微博字典数组/是否成功
func statusList(since_id: Int64 = 0, max_id: Int64 = 0, completion: @escaping (_ list: [[String: AnyObject]]?, _ isSuccess: Bool)->()) {
let urlString = "https://api.weibo.com/2/statuses/home_timeline.json"
// `swift`中,`Int`可以转换成`Anybject`,但是`Int 64`不行
let para = [
"since_id": "\(since_id)",
"max_id": "\(max_id)"
]
tokenRequest(URLString: urlString, parameters: para as [String : AnyObject]) { (json, isSuccess) in
/*
从`json`中获取`statuses`字典数组
如果`as?`失败,`result = nil`
*/
let result = (json as AnyObject)["statuses"] as? [[String: AnyObject]]
completion(result, isSuccess)
}
}
修改完以后,再对HQStatusListViewModel
中代码进行下拉刷新的逻辑处理。
lazy var statusList = [HQStatus]()
/// 加载微博数据字典数组
///
/// - Parameters:§
/// - completion: 完成回调,微博字典数组/是否成功
func loadStatus(completion: @escaping (_ isSuccess: Bool)->()) {
// 取出微博中已经加载的第一条微博(最新的一条微博)的`since_id`进行比较,对下拉刷新做处理
let since_id = statusList.first?.id ?? 0
HQNetWorkManager.shared.statusList(since_id: since_id, max_id: 0) { (list, isSuccess) in
guard let array = NSArray.yy_modelArray(with: HQStatus.classForCoder(), json: list ?? []) as? [HQStatus] else {
completion(isSuccess)
return
}
print("刷新到 \(array.count) 条数据")
// FIXME: 拼接数据
// 下拉刷新
self.statusList = array + self.statusList
completion(isSuccess)
}
}
而做完了上面两个步骤以后,你会发现,并没有在HQAViewController
中进行任何的代码改动,对Controller
完全无侵害。
因为since_id
对应下拉刷新,而max_id
对应上拉加载。而之前我们做下拉刷新的时候把max_id
的默认值设置成0
,这样是不会返回之前的老数据的。
所以我们需要判断好逻辑,在loadStatus
中,增加一个是否是上拉的参数pullup: Bool
since_id
设置为0
,max_id
设置成取微博数据的最后一条的id
max_id
设置为0
,since_id
设置成取微博数据的第一条的id
这里用三目运算就会很简单明了,swift
中如果能用三目判断的,大家可以多用一下。能使很多逻辑简单许多。
/// 加载微博数据字典数组
///
/// - Parameters:§
/// - completion: 完成回调,微博字典数组/是否成功
func loadStatus(pullup: Bool, completion: @escaping (_ isSuccess: Bool)->()) {
// 取出微博中已经加载的第一条微博(最新的一条微博)的`since_id`进行比较,对下拉刷新做处理
let since_id = pullup ? 0 : (statusList.first?.id ?? 0)
// 上拉刷新,取出数组的最后一条微博`id`
let max_id = !pullup ? 0 : (statusList.last?.id ?? 0)
HQNetWorkManager.shared.statusList(since_id: since_id, max_id: max_id) { (list, isSuccess) in
guard let array = NSArray.yy_modelArray(with: HQStatus.classForCoder(), json: list ?? []) as? [HQStatus] else {
completion(isSuccess)
return
}
print("刷新到 \(array.count) 条数据")
// FIXME: 拼接数据
// 下拉刷新
if pullup {
// 上拉刷新结束后,将数据拼接在数组的末尾
self.statusList += array
} else {
// 下拉刷新结束后,将数据拼接在数组的最前面
self.statusList = array + self.statusList
}
completion(isSuccess)
}
}
接下来,如果你仔细观察。可能会遇到这样的问题,一次加载20条微博数据,第20条在上拉加载后出现了两次。
原因:
若指定
max_id
参数,则返回ID小于或等于max_id
的微博,默认为0。
返回的是小于或等于的,每次返回的都是上一个20条的最后一条是下一个20条的第一条。因此出现了重叠现象。
解决办法:
我们需要处理一下max_id
的取值,当max_id
有值时,取max_id - 1
,否则,max_id
取0。
let para = [
"since_id": "\(since_id)",
"max_id": "\(max_id > 0 ? (max_id - 1) : 0)"
]
因为微博对未通过审核的应用刷新有限制,大概连续刷新143条数据就不会再有新数据返回了。而如果我们不做限制的话,当表格滚动到最后一行的位置就自动且频繁的调用刷新数据。但是返回的数据都是0条。微博就会对我们的帐号进行暂时的封锁,网络请求不能再拿到任何数据。
Error Domain=com.alamofire.error.serialization.response Code=-1011
"Request failed: forbidden (403)" UserInfo={
com.alamofire.serialization.response.error.response=<NSHTTPURLResponse: 0x6000000267c0> {
URL: https://api.weibo.com/2/statuses/home_timeline.json?access_token=2.00It5tsGQ6eDJE4ecbf2d825DCpbBD&max_id=0&since_id=0 }
{ status code: 403,
headers {
"Content-Encoding" = gzip;
"Content-Type" = "application/json;charset=UTF-8";
Date = "Fri, 21 Jul 2017 08:03:51 GMT";
Server = "nginx/1.6.1";
Vary = "Accept-Encoding";
}
},
NSErrorFailingURLKey=https://api.weibo.com/2/statuses/home_timeline.json?access_token=2.00It5tsGQ6eDJE4ecbf2d825DCpbBD&max_id=0&since_id=0,
com.alamofire.serialization.response.error.data=<7b226572 726f7222 3a225573 65722072 65717565 73747320 6f757420 6f662072 61746520 6c696d69 7421222c 22657272 6f725f63 6f646522 3a313030 32332c22 72657175 65737422 3a222f32 2f737461 74757365 732f686f 6d655f74 696d656c 696e652e 6a736f6e 227d>,
NSLocalizedDescription=Request failed: forbidden (403)
}
如果你刷新次数过多的话,极有可能就给你forbidden(403)
了。我被冻结了大概十几个小时的样子,才解除冻结。如果你被冻结帐号了,不要着急,在创建一个程序,换一个Access Token
就好了。因为都是你自己微博下面的程序,所以拿到的微博数据都是一样的,不耽误你继续进行。
因此,我们需要处理一下,如果用户刷新数据为0条,刷新三次以后在上拉加载数据就不走网络请求的方法。
/// 上拉刷新的最大次数
fileprivate let maxPullupTryTimes = 3
/// 上拉刷新错误次数
fileprivate var pullupErrorTimes = 0
if pullup && pullupErrorTimes > maxPullupTryTimes {
completion(true, false)
print("超出3次 不再走网络请求方法")
return
}
if pullup && array.count == 0 {
self.pullupErrorTimes += 1
print("这是第 \(self.pullupErrorTimes) 次 加载到 0 条数据")
completion(isSuccess, false)
} else {
completion(isSuccess, true)
}
HQAViewController
里面加载数据代码做如下改动
/// 加载数据
override func loadData() {
listViewModel.loadStatus(pullup: self.isPullup) { (isSuccess, shouldRefresh) in
print("最后一条微博数据是 \(self.listViewModel.statusList.last?.text ?? "")")
self.refreshControl?.endRefreshing()
self.isPullup = false
if shouldRefresh {
self.tableView?.reloadData()
}
}
}
然后我们最好再打断点调试一下,以免逻辑上出现问题
微博现在不提供提醒接口了,但是之前的接口还能用。接口地址如下:
https://rm.api.weibo.com/2/remind/unread_count.json
必选参数:
[
"token": token,
"uid": uid
]
uid
是指用户微博的uid
,每个用户都唯一,按照下面的方法去找:
返回数据格式
{
"all_cmt" = 0;
"all_follower" = 0;
"all_mention_cmt" = 0;
"all_mention_status" = 0;
"attention_cmt" = 0;
"attention_follower" = 0;
"attention_mention_cmt" = 0;
"attention_mention_status" = 0;
badge = 0;
"chat_group_client" = 0;
"chat_group_notice" = 0;
"chat_group_pc" = 0;
"chat_group_total" = 0;
cmt = 0;
dm = 0;
"fans_group_unread" = 0;
follower = 0;
group = 0;
"hot_status" = 0;
invite = 0;
"mention_cmt" = 0;
"mention_status" = 0;
"message_flow_agg_at" = 0;
"message_flow_agg_attitude" = 0;
"message_flow_agg_comment" = 0;
"message_flow_agg_repost" = 0;
"message_flow_aggr_wild_card" = 0;
"message_flow_aggregate" = 0;
"message_flow_follow" = 0;
"message_flow_unaggr_wild_card" = 0;
"message_flow_unaggregate" = 0;
"message_flow_unfollow" = 0;
notice = 0;
"page_friends_to_me" = 0;
"pc_viedo" = 0;
photo = 0;
status = 5;
"status_24unread" = 100;
voip = 0;
}
然后又到写网络请求方法了,依旧是写在HQNetWorkManager+Extension
中,还是那句话,方便管理。
/// 未读微博数量
///
/// - Parameter completion: unreadCount
func unreadCount(completion: @escaping (_ count: Int)->()) {
guard let uid = uid else {
return
}
let urlString = "https://rm.api.weibo.com/2/remind/unread_count.json"
let para = ["uid": uid]
tokenRequest(URLString: urlString, parameters: para as [String : AnyObject]) { (json, isSuccess) in
let dict = json as? [String: AnyObject]
let count = dict?["status"] as? Int
completion(count ?? 0)
}
}
写好网络请求方法以后,我们需要在哪个控制器里调用呢,这是我们应该想的问题。因为这个未读数量,是微博所有的未读数量,不仅仅是首页未读微博的数,还有可能是其它的未读数,比如别人和你说话的未读数、私信的未读数等等。所以,如果我们直接就写在微博的首页控制器HQAViewController
里就不太有好了。我们应该将它写在HQMainViewController
中。
HQNetWorkManager.shared.unreadCount { (count) in
print("有 \(count) 条新微博")
}
以上我们只是测试了如何获取新的未读微博,但是我们最终的目的是希望,能在程序里定期去请求数据,得到未读微博数量,如果有未读微博,那么我们就在tabBar
上显示出未读数量,给用户以提醒。
用一个定时器(Timer)
,每隔固定时间发一次网络请求,获取未读微博数量。
值得注意的是,创建的定时器以后,一定要记得销毁定时器。
/// 定时器
fileprivate var timer: Timer?
deinit {
// 销毁定时器
timer?.invalidate()
}
这里创建定时器的方法,我们选择scheduledTimer(timeInterval:
这个方法。是因为该方法执行是在主运行循环的默认模式下。
// MARK: - 定时器相关方法
extension HQMainViewController {
fileprivate func setupTimer() {
timer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(updateTimer), userInfo: nil, repeats: true)
}
/// 定时器触发方法
@objc fileprivate func updateTimer() {
HQNetWorkManager.shared.unreadCount { (count) in
print("检测到 \(count) 条微博")
self.tabBar.items?[0].badgeValue = count > 0 ? "\(count)" : nil
}
}
}
applicationIconBadgeNumber
显示数字(APP 右上角显示未读微博数量)/// 定时器触发方法
@objc fileprivate func updateTimer() {
HQNetWorkManager.shared.unreadCount { (count) in
print("检测到 \(count) 条微博")
self.tabBar.items?[0].badgeValue = count > 0 ? "\(count)" : nil
UIApplication.shared.applicationIconBadgeNumber = count
}
}
同时需要在AppDelegate
中设置获取用户授权。特别是iOS 10.0
以后的版本。代码会稍有不同。
extension AppDelegate {
fileprivate func setupNotification(application: UIApplication) {
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound, .carPlay]) { (sucess, error) in
print("授权" + (sucess ? "成功" : "失败"))
}
} else {
// Fallback on earlier versions
let notificationSettings = UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)
application.registerUserNotificationSettings(notificationSettings)
}
}
}
UITabBarControllerDelegate
代理方法解决之前存在的点击+
按钮的容错点问题之前有通过设置增大按钮的宽度,覆盖住容错点。防止出现意外情况的问题。之前代码如下:
// 减`1`是为了是按钮变宽,覆盖住系统的容错点
let w = tabBar.bounds.size.width / count - 1
通过代理方法直接设置的话,就不用在做减1的判断了。判断选择的控制器是否是UIViewController
的子类。如果是的话,就不跳转到对应的控制器。
// MARK: - UITabBarControllerDelegate
extension UITabBarController: UITabBarControllerDelegate {
public func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
print("将要切换到 \(viewController)")
return !viewController.isMember(of: UIViewController.classForCoder())
}
}
TabBar
滚动到顶部,并且加载数据// MARK: - UITabBarControllerDelegate
extension UITabBarController: UITabBarControllerDelegate {
public func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
// 获取当前控制器在数组中的索引
let index = childViewControllers.index(of: viewController)
if selectedIndex == 0 && index == selectedIndex {
// 获取到当前控制器
let nav = childViewControllers[0] as! UINavigationController
let vc = nav.childViewControllers[0] as! HQAViewController
// 滚动到顶部
vc.tableView?.setContentOffset(CGPoint(x: 0, y: -64), animated: true)
// 增加延迟,目的是为了保证表格先滚动到顶部,然后再刷新,这样显示不会有问题
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1, execute: {
vc.loadData()
})
}
return !viewController.isMember(of: UIViewController.classForCoder())
}
}
userLogon
标记转移到网络管理工具中在网络请求工具类中,定义一个计算型属性userLogon
,方便各控制器根据此判断是否已经登录。如果登录就进入主界面,如果未登录就进入访客视图界面。
/// 用户登录标记(计算型属性)
var userLogon: Bool {
return accessToken != nil
}
在HQBaseViewController
中的用户登录标记userLogon
就可以删除掉了。在HQBaseViewController
的setupUI()
中,根据登录与否的方法判断视图的逻辑。
HQNetWorkManager.shared.userLogon ? setupTableView() : setupVistorView()
至此,还存在着两个问题。一是,用户在未登录的情况下,界面显示访客视图,但是实际上,还是走了网络请求的方法(虽然网络请求什么都拿不到)。我们需要在HQBaseViewController
的viewDidLoad()
方法里根据计算型属性userLogon
来判断是加载数据还是什么都不做的逻辑。
HQNetWorkManager.shared.userLogon ? loadData() : ()
还有一个问题就是,定时器的问题。我们开了定时器以后,不管用户是否登录,定时器都定时向服务器发起请求。但是,其实我们没有必要做到,用户未登录就直接不开启Timer
,因为不管是否登录都开启定时器,如果用户从未登录到登录状态以后,就可以不用再考虑登录后再重新开启Timer
的问题了。
而且,Timer
本身并不耗太多的性能。
/// 定时器触发方法
@objc fileprivate func updateTimer() {
if !HQNetWorkManager.shared.userLogon {
return
}
HQNetWorkManager.shared.unreadCount { (count) in
print("检测到 \(count) 条微博")
self.tabBar.items?[0].badgeValue = count > 0 ? "\(count)" : nil
UIApplication.shared.applicationIconBadgeNumber = count
}
}
在iOS
中监听方法有以下几种:
Delegate
Block
Notification
KVO
webView
和UI
的混排,webView
监听scrollView
的contentOffset
,contentOffset
随时更改高度。一般KVO
只用于监听属性变化这一类情况。这里我们选择用通知处理,因为需要用户登录的场景可能比较多,用通知处理起来比较方便。
在登录按钮的点击方法里发送登录的通知
// MARK: - 注册/登录 点击事件
extension HQBaseViewController {
@objc fileprivate func login() {
NotificationCenter.default.post(name: NSNotification.Name(rawValue: HQUserShouldLoginNotification), object: nil)
}
而且我们要选择在HQMainViewController
中监听通知,因为不可能在每个子控制里面去实现。而且,HQBaseViewController
仅仅是一个基类而已,并没有被实例化,没有内存地址。还有就是这种全局相关的逻辑最好是放在主控制器中去处理逻辑比较方便。
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(login), name: NSNotification.Name(rawValue: HQUserShouldLoginNotification), object: nil)
}
// MARK: - 监听方法
@objc fileprivate func login(n: Notification) {
print("用户登录通知 \(n)")
}
因为登录控制器我采用的是模态视图,直接模态的话没有导航栏,不好处理返回,所以这里建议嵌套一个导航控制器比较好。
在HQMainViewController
中,进行跳转到登录页面的逻辑处理。
// MARK: - 监听方法
@objc fileprivate func login(n: Notification) {
let nav = UINavigationController(rootViewController: HQLoginController())
present(nav, animated: true, completion: nil)
}
登录这里我还是喜欢把它单独抽出来一个模块。这样的话,写好了一个,以后只要界面不差的太多都可以直接用的。
创建一个登录控制器HQLoginController
class HQLoginController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
title = "登录"
navigationItem.leftBarButtonItem = UIBarButtonItem(hq_title: "关闭", target: self, action: #selector(close))
navigationItem.rightBarButtonItem = UIBarButtonItem(hq_title: "注册", target: self, action: #selector(registe))
setupUI()
}
@objc fileprivate func close() {
dismiss(animated: true, completion: nil)
}
@objc fileprivate func registe() {
print("注册")
}
}
懒加载所需的控件
class HQLoginController: UIViewController {
// MARK: - 私有控件
fileprivate lazy var logoImageView: UIImageView = UIImageView(hq_imageName: "logo")
fileprivate lazy var accountTextField: UITextField = UITextField(hq_placeholder: "13122223333")
fileprivate lazy var carve01: UIView = {
let carve = UIView()
carve.backgroundColor = UIColor.lightGray
return carve
} ()
lazy var passwordTextField: UITextField = UITextField(hq_placeholder: "123456", isSecureText: true)
fileprivate lazy var carve02: UIView = {
let carve = UIView()
carve.backgroundColor = UIColor.lightGray
return carve
}()
fileprivate lazy var loginButton: UIButton = UIButton(hq_title: "登录", normalBackColor: UIColor.orange, highBackColor: UIColor.hq_color(withHex: 0xB5751F), size: CGSize(width: UIScreen.hq_screenWidth() - (margin * 2), height: buttonHeight))
}
注意,这里需要提醒的是,在extension
里面不能定义存储型属性stored properties
。之前我为了让代码更加有秩序,我打算把属性的定义也放到extension
里,类似如下:
// 这是错误的做法
extension HQLoginController {
// Extensions may not contain stored properties
fileprivate lazy var logoImageView: UIImageView = UIImageView(hq_imageName: "logo")
}
然后就会报如下错误:
Extensions may not contain stored properties
解决办法就是不要放在这里,老老实实放在class
里就好了。
class HQLoginController: UIViewController {
}
界面布局采用SnapKit
,我提前定义了两个常量
fileprivate let margin: CGFloat = 16.0
fileprivate let buttonHeight: CGFloat = 40.0
// MARK: - 设置登录控制器界面
extension HQLoginController {
fileprivate func setupUI() {
view.addSubview(logoImageView)
view.addSubview(accountTextField)
view.addSubview(carve01)
view.addSubview(passwordTextField)
view.addSubview(carve02)
view.addSubview(loginButton)
logoImageView.snp.makeConstraints { (make) in
make.top.equalTo(view).offset(margin * 7)
make.centerX.equalTo(view)
}
accountTextField.snp.makeConstraints { (make) in
make.top.equalTo(logoImageView.snp.bottom).offset(margin * 2)
make.left.equalTo(view).offset(margin)
make.right.equalTo(view).offset(-margin)
make.height.equalTo(buttonHeight)
}
carve01.snp.makeConstraints { (make) in
make.left.equalTo(accountTextField)
make.bottom.equalTo(accountTextField)
make.right.equalTo(view)
make.height.equalTo(0.5)
}
passwordTextField.snp.makeConstraints { (make) in
make.top.equalTo(accountTextField.snp.bottom)
make.left.equalTo(accountTextField)
make.right.equalTo(accountTextField)
make.height.equalTo(accountTextField)
}
carve02.snp.makeConstraints { (make) in
make.left.equalTo(carve01)
make.bottom.equalTo(passwordTextField)
make.right.equalTo(carve01)
make.height.equalTo(carve01)
}
loginButton.snp.makeConstraints { (make) in
make.top.equalTo(passwordTextField.snp.bottom).offset(margin * 2)
make.left.equalTo(passwordTextField)
make.right.equalTo(passwordTextField)
make.height.equalTo(passwordTextField)
}
}
}
上面有一点需要注意的是,我在创建Button
的时候,是通过传入颜色,然后通过颜色创建图片,再设置Button
的backgroudImage
的。在HQButton
文件里:
extension UIButton {
/// 标题 + 字号 + 背景色 + 高亮背景色
///
/// - Parameters:
/// - hq_title: title
/// - fontSize: fontSize
/// - normalBackColor: normalBackColor
/// - highBackColor: highBackColor
/// - size: size
convenience init(hq_title: String, fontSize: CGFloat = 16, normalBackColor: UIColor, highBackColor: UIColor, size: CGSize) {
self.init()
setTitle(hq_title, for: .normal)
titleLabel?.font = UIFont.systemFont(ofSize: fontSize)
let normalIamge = UIImage(hq_color: normalBackColor, size: CGSize(width: size.width, height: size.height))
let hightImage = UIImage(hq_color: highBackColor, size: CGSize(width: size.width, height: size.height))
setBackgroundImage(normalIamge, for: .normal)
setBackgroundImage(hightImage, for: .highlighted)
layer.cornerRadius = 3
clipsToBounds = true
// 注意: 这里不写`sizeToFit()`那么`Button`就显示不出来
sizeToFit()
}
}
// MARK: - 创建`Button`的扩展方法
extension UIButton {
/// 通过颜色创建图片
///
/// - Parameters:
/// - color: color
/// - size: size
/// - Returns: 固定颜色和尺寸的图片
fileprivate func creatImageWithColor(color: UIColor, size: CGSize) -> UIImage {
let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
UIGraphicsBeginImageContext(rect.size)
let context = UIGraphicsGetCurrentContext()
context?.setFillColor(color.cgColor)
context?.fill(rect)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image!
}
}
这里简单处理了,没做太复杂的。因为这里不是太重要的地方。
loginButton.addTarget(self, action: #selector(login), for: .touchUpInside)
将按钮的点击事件都放到同一个extension
里面,方便管理
// MARK: - Target Action
extension HQLoginController {
/// 登录
@objc fileprivate func login() {
HQNetWorkManager.shared.loadAccessToken(account: accountTextField.text ?? "", password: passwordTextField.text ?? "")
// dismiss(animated: false, completion: nil)
}
/// 注册
@objc fileprivate func registe() {
print("注册")
}
/// 关闭
@objc fileprivate func close() {
dismiss(animated: true, completion: nil)
}
}
建立一个用户帐号模型HQUserAccount
,专门存放用户帐号数据的内容。
class HQUserAccount: NSObject {
/// Token
var token: String? //= "2.00It5tsGKXtWQEfb6d3a2738ImMUAD"
/// 用户代号
var uid: String?
/// `Token`的生命周期,单位是`秒`
var expires_in: TimeInterval = 0
override var description: String {
return yy_modelDescription()
}
}
建立一个userAccount.json
,拖入到项目中,直接从Bundel
加载。模拟网络加载,userAccount.json
内数据如下
{
"token" : "2.00It5tsGKXtWQEfb6d3a2738ImMUAD",
"expires_in" : 157679999,
"remind_in" : 157679999,
"uid" : "6307922850"
}
将HQNetWorkManager.swift
中的accessToken
和uid
移除掉,因为我们可以从userAccount.json
中加载到。建立HQUserAccount
模型属性。同时修改之前用到accessToken
和uid
的地方。
/// 用户账户的懒加载属性
lazy var userAccount = HQUserAccount()
/// 用户登录标记(计算型属性)
var userLogon: Bool {
return userAccount.token != nil
}
guard let token = userAccount.token else {
// FIXME: 发送通知,提示用户登录
print("没有 token 需要重新登录")
completion(nil, false)
return
}
/// 未读微博数量
///
/// - Parameter completion: unreadCount
func unreadCount(completion: @escaping (_ count: Int)->()) {
guard let uid = userAccount.uid else {
return
}
let urlString = "https://rm.api.weibo.com/2/remind/unread_count.json"
建立一个专门用于加载Token
的网络请求方法
// MARK: - 请求`Token`
extension HQNetWorkManager {
/// 根据`帐号`和`密码`获取`Token`
///
/// - Parameters:
/// - account: account
/// - password: password
func loadAccessToken(account: String, password: String) {
// 从`bundle`加载`data`
let path = Bundle.main.path(forResource: "userAccount.json", ofType: nil)
let data = NSData(contentsOfFile: path!)
// 从`Bundle`加载配置的`userAccount.json`
guard let dict = try? JSONSerialization.jsonObject(with: data! as Data, options: []) as? [String: AnyObject]
else {
return
}
// 直接用字典设置`userAccount`的属性
self.userAccount.yy_modelSet(with: dict ?? [:])
print(self.userAccount)
}
}
打印输出用户信息
<HQSwiftMVVM.HQUserAccount: 0x6080002c0f50> {
expiresDate = 2022-08-01 01:59:09 +0000;
expires_in = 157679999;
token = "2.00It5tsGKXtWQEfb6d3a2738ImMUAD";
uid = "6307922850"
}
到此为止,就可以模仿网络加载数据,拿到用户帐号信息了。下一步我们进行用户信息存储。
数据存储方式:
plist
/json
FMDB
/CoreData
)SSKeyChain
)这里我们练习一下使用json
存储到沙盒里面
要进行用户信息保存,要经过以下几个步骤:
expires_in
值data
先进行模型转字典
var dict = self.yy_modelToJSONObject() as? [String: AnyObject] ?? [:]
此时dict
中存储的信息为
▿ Optional<Dictionary<String, AnyObject>>
▿ some : 4 elements
▿ 0 : 2 elements
- key : "expiresDate"
- value : 2022-08-01T10:35:53+0800
▿ 1 : 2 elements
- key : "token"
- value : 2.00It5tsGKXtWQEfb6d3a2738ImMUAD
▿ 2 : 2 elements
- key : "uid"
- value : 6307922850
▿ 3 : 2 elements
- key : "expires_in"
- value : 157679999
我们需要将不需要的字段expires_in
删除掉
dict?.removeValue(forKey: "expires_in")
字典序列化data
guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [])
else {
return
}
let filePath = String.hq_appendDocmentDirectory(fileName: "useraccount.json")
写入磁盘
(data as NSData).write(toFile: filePath, atomically: true)
这里说明一下,保存到沙盒的Documents
目录的时候,我并没有正常的步骤去写代码获取路径,而是像创建Button
那样,自己又封装了一个方法,快速拼接路径的HQPath
HQPath
内部代码大概是酱紫的
import UIKit
extension String {
/// DocumentDirectory 路径
///
/// - Parameter fileName: fileName
/// - Returns: DocumentDirectory 内文件路径
static func hq_appendDocmentDirectory(fileName: String) -> String {
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
return (path as NSString).appendingPathComponent(fileName)
}
/// Caches 路径
///
/// - Parameter fileName: fileName
/// - Returns: Cacher 内文件路径
static func hq_appendCachesDirectory(fileName: String) -> String {
let path = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)[0]
return (path as NSString).appendingPathComponent(fileName)
}
/// Tmp 路径
///
/// - Parameter fileName: fileName
/// - Returns: Tmp 内文件路径
static func hq_appendTmpDirectory(fileName: String) -> String {
let path = NSTemporaryDirectory()
return (path as NSString).appendingPathComponent(fileName)
}
}
使用方法也特别简单,例如
let filePath = String.hq_appendDocmentDirectory(fileName: "fileName.xxx")
let filePath = String.hq_appendCachesDirectory(fileName: "fileName.xxx")
let filePath = String.hq_appendTmpDirectory(fileName: "fileName.xxx")
在HQNetWorkManager.swift
中,下面的代码逻辑是保证用户是否能拿到token
也是登录成功与否的关键。
/// 用户账户的懒加载属性
lazy var userAccount = HQUserAccount()
/// 用户登录标记(计算型属性)
var userLogon: Bool {
return userAccount.token != nil
}
根据用户登录标记userLogon
判断是否登录,而控制userLogon
的关键是用户账户的懒加载属性userAccount
,所以我们只要找到userAccount
的构造方法,并且在其构造方法里从磁盘Documents
加载。
如果能加载到,就证明登录过。就不用再登录了,直接取出token
等相关信息直接使用就可以了(暂时不考虑token
过期问题)。
如果加载不到,证明没有登录过。需要用户进行登录操作(暂时不考虑token
过期问题)。
接下来我们就写代码,取用户数据。我先演示一个错误的做法,看看大家谁能发现哪里有问题。
因为存用户数据的时候要用到文件名,取得时候也要用到,其它地方指不定什么时候还要用到。所以我把文件名抽取了一个常量,用着方便。
fileprivate let fileName = "useraccount.json"
override init() {
super.init()
let path = String.hq_appendDocmentDirectory(fileName: fileName)
let data = NSData(contentsOfFile: path)
let dict = try? JSONSerialization.jsonObject(with: data! as Data, options: []) as! [String: AnyObject]
yy_modelSet(with: dict ?? [:])
}
上面的代码,根据之前存储的文件名找到路径,然后再转换成Data
,再转成字典,再用yy_modelSet
的方法,将字典转成用户帐号模型HQUserAccount
,看起来没什么问题,而且运行也暂时不会出现任何问题。
值得注意的是,怎么就取完值,一个yy_modelSet
就搞定了呢。下面我们来分析一下原因,及调用的堆栈
在yy_modelSet(with: dict ?? [:])
处设置一个断点,
可以看出,上一个方法是HQUserAccount.__allocating_init()
再之前调用的一个方法就是用户账户属性userAccount
的懒加载
再上一层的调用方法是userLogon
的getter
方法
再上一层的调用方法就是HQBaseViewController
的setupUI()
方法
总结起来说就是
setupUI
HQNetWorkManager.shared.userLogon.getter
HQNetWorkManager.shared.userAccount.getter
HQNetWorkManager.shared.userAccount.__allocating_init()
HQUserAccount.init()
yy_modelSet(with: dict ?? [:])
方法帮我们把存储到Documents
的account.json
文件的二进制数据转换成模型字典并赋值了。因此,执行完这句话以后,打印输出HQUserAccount
就会输出
<HQSwiftMVVM.HQUserAccount: 0x6080002c00e0> {
expiresDate = 2022-08-01 08:30:19 +0000;
expires_in = 0;
token = "2.00It5tsGKXtWQEfb6d3a2738ImMUAD";
uid = "6307922850"
}
下面说下我之前的错误,因为之前我自己写的拼接路径的方法不严谨,只要输入文件名,那么拼接得到的路径就默认以为一定存在了。我没有设置成可选。导致我在写override init()
的方法的时候,直接写成了这样
let path = String.hq_appendDocmentDirectory(fileName: fileName)
let data = NSData(contentsOfFile: path)
let dict = try? JSONSerialization.jsonObject(with: data! as Data, options: []) as! [String: AnyObject]
yy_modelSet(with: dict ?? [:])
这样导致的问题就是,如果程序是第一次启动,或者已经存储的useraccount.json
文件被删除,那么,程序就会崩溃。
删除后再重新运行程序,就会出现野指针的问题。
而此时,如果进行强行guard let
守护,又是会有问题的。直接爆红,提示你,守护的必须是可选类型。
Initializer for conditional binding must have Optional type, not 'String'
因此,为了严谨一点,我只能把之前的HQPath
里面的返回值都设置成可选类型。
/// DocumentDirectory 路径
///
/// - Parameter fileName: fileName
/// - Returns: DocumentDirectory 内文件路径
static func hq_appendDocmentDirectory(fileName: String) -> String? {
let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
return (path as NSString).appendingPathComponent(fileName)
}
HQUserAccount
的构造方法修改如下
override init() {
super.init()
guard let path = String.hq_appendDocmentDirectory(fileName: fileName),
let data = NSData(contentsOfFile: path),
let dict = try? JSONSerialization.jsonObject(with: data as Data, options: []) as? [String: AnyObject]
else {
return
}
yy_modelSet(with: dict ?? [:])
}
token
过期开发者在开发过程中要做到每一个分支都测试到,虽然token
时效性我们不能控制,但是我们可以模拟token
的过期日期。
模拟将时间倒退5
年
// 模拟日期过期
expiresDate = Date(timeIntervalSinceNow: -3600 * 24 * 365 * 5)
如果账户过期我们需要清空用户信息,并且删除之前保存用户信息的useraccount.json
文件
// 判断`token`是否过期
if expiresDate?.compare(Date()) != .orderedDescending {
print("账户过期")
// 清空`token`
token = nil
uid = nil
// 删除文件
try? FileManager.default.removeItem(atPath: path)
}
到此为止,可以做到登录成功,并且保存好用户信息token
等,但是登录完成回调还没有做,下一步我们处理登录的完成回调,并切换页面到首页。
之前这里并没有完成的回调,现在增加一个完成回调,使其处理登录成功以后的逻辑
// MARK: - 请求`Token`
extension HQNetWorkManager {
/// 根据`帐号`和`密码`获取`Token`
///
/// - Parameters:
/// - account: account
/// - password: password
/// - completion: 完成回调
func loadAccessToken(account: String, password: String, completion: (_ isSuccess: Bool)->()) {
// 从`bundle`加载`data`
let path = Bundle.main.path(forResource: "userAccount.json", ofType: nil)
let data = NSData(contentsOfFile: path!)
// 从`Bundle`加载配置的`userAccount.json`
guard let dict = try? JSONSerialization.jsonObject(with: data! as Data, options: []) as? [String: AnyObject]
else {
return
}
// 直接用字典设置`userAccount`的属性
self.userAccount.yy_modelSet(with: dict ?? [:])
self.userAccount.saveAccount()
// 完成回调
completion(true)
}
}
在HQLoginController
里,登录的点击事件增加完成回调。
/// 登录
@objc fileprivate func login() {
HQNetWorkManager.shared.loadAccessToken(account: accountTextField.text ?? "", password: passwordTextField.text ?? "") { (isSuccess) in
if !isSuccess {
SVProgressHUD.showInfo(withStatus: "网络请求失败")
} else {
// 发送登录成功的通知
NotificationCenter.default.post(
name: NSNotification.Name(rawValue: HQUserLoginSuccessNotification),
object: nil)
// 关闭窗口
close()
}
}
}
登录成功以后,发送了通知,那么在哪里监听这个通知呢,这是一个值得考虑的问题。因为我们可能在任何一个界面点击登录然后弹出登录页面,如果登录成功,我们要回到这个页面。
不能说我在个人中心页点击登录,登录成功了结果回到了首页,这是不太合逻辑的。
因此,监听登录成功的通知的重要任务就想到交给HQBaseViewController
去做比较靠谱。这是一个基类,所有的主控制器都继承自这个基类,而且基类在程序中不占内存。用于处理一些通用的逻辑比较合适。
在HQBaseViewController
的viewDidLoad()
方法里添加监听
override func viewDidLoad() {
super.viewDidLoad()
HQNetWorkManager.shared.userLogon ? loadData() : ()
NotificationCenter.default.addObserver(
self,
selector: #selector(loginSuccess),
name: NSNotification.Name(rawValue: HQUserLoginSuccessNotification),
object: nil)
}
deinit {
NotificationCenter.default.removeObserver(
self,
name: NSNotification.Name(rawValue: HQUserLoginSuccessNotification),
object: nil)
}
监听到登录成功以后,执行的方法
/// 登录成功
@objc fileprivate func loginSuccess(n: Notification) {
print("登录成功 \(n)")
}
在登录成功执行的方法loginSuccess
里,执行页面切换的逻辑
这里有一个比较巧妙的办法。使得我们可能不会挖空心思去想如何重新设置界面或者将原来的界面移除掉。那就是直接将view
置为nil
,因为view
一旦为nil
了,那么就会调用loadView()
方法,loadView()
方法执行完毕以后又会重新执行viewDidLoad()
方法。
/// 登录成功
@objc fileprivate func loginSuccess(n: Notification) {
print("登录成功 \(n)")
// 在访问`view`的`getter`时,如果`view` == nil,会调用`loadView()`->`viewDidLoad()`
view = nil
}
登录页面的leftBarButtonItem
和rightBarButtonItem
显示的是注册
和登录
,登录成功显示对应的界面以后就不应该再显示这个里。我们需要将其置为nil
,这样在其再次执行viewDidLoad()
方法时又会按照正确的显示设置
/// 登录成功
@objc fileprivate func loginSuccess(n: Notification) {
print("登录成功 \(n)")
navItem.leftBarButtonItem = nil
navItem.rightBarButtonItem = nil
}
还有一点容易遗漏的就是,之前在viewDidLoad()
方法里面有过注册监听登录成功HQUserLoginSuccessNotification
的通知,虽然view
置为nil
了,但是注册的通知并没有销毁,再次执行viewDidLoad()
的时候,还会再注册一个同样的通知,相当于注册了两次,那么监听到事件的时候,执行方法也会执行两次,就没必要了。因此,我们在将view = nil
的时候将通知移除
/// 登录成功
@objc fileprivate func loginSuccess(n: Notification) {
print("登录成功 \(n)")
// 注销通知,因为重新执行`viewDidLoad()`会再次注册通知
NotificationCenter.default.removeObserver(
self,
name: NSNotification.Name(rawValue: HQUserShouldLoginNotification),
object: nil)
}
token
过期,重新发送登录通知首先,假如token
为nil
的时候(比如用户点击了退出登录,我们可能会将token
置为nil
),这种情况下,我们需要使得用户再进行网络请求的时候,直接弹出登录界面
/// 带`token`的网络请求方法
func tokenRequest(method: HQHTTPMethod = .GET, URLString: String, parameters: [String: AnyObject]?, completion: @escaping (_ json: Any?, _ isSuccess: Bool)->()) {
// 判断`token`是否为`nil`,为`nil`直接返回,程序执行过程中,一般`token`不会为`nil`
guard let token = userAccount.token else {
// FIXME: 发送通知,提示用户登录
print("没有 token 需要重新登录")
NotificationCenter.default.post(
name: NSNotification.Name(rawValue: HQUserShouldLoginNotification),
object: nil)
completion(nil, false)
return
}
任何情况都需要测试,我们随便找一个控制器的viewDidLoad()
方法里,将token
置为nil
class HQDViewController: HQBaseViewController {
override func viewDidLoad() {
super.viewDidLoad()
HQNetWorkManager.shared.userAccount.token = nil
}
这样,当我们进入到HQDViewController
中,token
就已经被置为nil
了,再有网络交互的话,就会弹出登录页面。
token
失效的处理在返回状态码是403
的位置,发送通知
/// 封装 AFN 的 GET/POST 请求
///
/// - Parameters:
/// - method: GET/POST
/// - URLString: URLString
/// - parameters: parameters
/// - completion: 完成回调(json, isSuccess)
func request(method: HQHTTPMethod = .GET, URLString: String, parameters: [String: AnyObject]?, completion: @escaping (_ json: Any?, _ isSuccess: Bool)->()) {
let success = { (task: URLSessionDataTask, json: Any?)->() in
completion(json, true)
}
let failure = { (task: URLSessionDataTask?, error: Error)->() in
if (task?.response as? HTTPURLResponse)?.statusCode == 403 {
print("token 过期了")
// FIXME: 发送通知,提示用户再次登录
NotificationCenter.default.post(
name: NSNotification.Name(rawValue: HQUserShouldLoginNotification),
object: "bad token")
}
在HQMainViewController
里处理跳转到登录界面的方法
// MARK: - 登录监听方法
@objc fileprivate func login(n: Notification) {
print("用户登录通知 \(n)")
if n.object != nil {
SVProgressHUD.setDefaultMaskType(.gradient)
SVProgressHUD.showInfo(withStatus: "登录超时,请重新登录")
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2) {
SVProgressHUD.setDefaultMaskType(.clear)
let nav = UINavigationController(rootViewController: HQLoginController())
self.present(nav, animated: true, completion: nil)
}
}
DEMO传送门:HQSwiftMVVM