Swift-MVVM 简单演练(二)

Swift-MVVM 简单演练(一)

Swift-MVVM 简单演练(三)

Swift-MVVM 简单演练(四)

处理下拉刷新逻辑

根据接口文档,下拉刷新是返回ID比since_id大的微博(即比since_id时间晚的微博)。因此,我们需要在网络请求方法里增加两个参数。since_idmax_id,分别对应下拉刷新所需参数和上拉加载所需参数。

既然要修改网络请求方法,当然是从我们自己抽取的HQNetWorkManager+ExtensionHQStatusListViewModel这两个地方入手考虑。这里不太建议在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设置为0max_id设置成取微博数据的最后一条的id
  • 当下拉的时候max_id设置为0since_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就可以删除掉了。在HQBaseViewControllersetupUI()中,根据登录与否的方法判断视图的逻辑。

HQNetWorkManager.shared.userLogon ? setupTableView() : setupVistorView()复制代码

至此,还存在着两个问题。一是,用户在未登录的情况下,界面显示访客视图,但是实际上,还是走了网络请求的方法(虽然网络请求什么都拿不到)。我们需要在HQBaseViewControllerviewDidLoad()方法里根据计算型属性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
    • 监听对象属性变化,比如webViewUI的混排,webView监听scrollViewcontentOffsetcontentOffset随时更改高度。一般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的时候,是通过传入颜色,然后通过颜色创建图片,再设置ButtonbackgroudImage的。在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中的accessTokenuid移除掉,因为我们可以从userAccount.json中加载到。建立HQUserAccount模型属性。同时修改之前用到accessTokenuid的地方。

/// 用户账户的懒加载属性
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"
}复制代码

到此为止,就可以模仿网络加载数据,拿到用户帐号信息了。下一步我们进行用户信息存储。

用户信息存储

数据存储方式:

  • 1.偏好设置
  • 2.沙盒-归档/plist/json
  • 3.数据库(FMDB/CoreData)
  • 4.钥匙串访问(存储小类型数据,存储时会自动加密,需要使用框架SSKeyChain)

这里我们练习一下使用json存储到沙盒里面

要进行用户信息保存,要经过以下几个步骤:

  • 1.模型转字典
    • 删除expires_in
  • 2.字典序列化data
  • 3.写入磁盘

先进行模型转字典

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+08001 : 2 elements
      - key : "token"
      - value : 2.00It5tsGKXtWQEfb6d3a2738ImMUAD
    ▿ 2 : 2 elements
      - key : "uid"
      - value : 63079228503 : 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的懒加载

再上一层的调用方法是userLogongetter方法

再上一层的调用方法就是HQBaseViewControllersetupUI()方法

总结起来说就是

  • 应用程序启动
    • setupUI
      • HQNetWorkManager.shared.userLogon.getter
        • HQNetWorkManager.shared.userAccount.getter
          • HQNetWorkManager.shared.userAccount.__allocating_init()
            • HQUserAccount.init()

yy_modelSet(with: dict ?? [:])方法帮我们把存储到Documentsaccount.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去做比较靠谱。这是一个基类,所有的主控制器都继承自这个基类,而且基类在程序中不占内存。用于处理一些通用的逻辑比较合适。

HQBaseViewControllerviewDidLoad()方法里添加监听

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
}复制代码

登录页面的leftBarButtonItemrightBarButtonItem显示的是注册登录,登录成功显示对应的界面以后就不应该再显示这个里。我们需要将其置为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过期,重新发送登录通知

首先,假如tokennil的时候(比如用户点击了退出登录,我们可能会将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

欢迎来我的简书看看:红鲤鱼与绿鲤鱼与驴___

转载于:https://juejin.im/post/599f07336fb9a02481205f8a

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值