Swift-MVVM 簡單演練(二)

Swift-MVVM 簡單演練(一)nginx

Swift-MVVM 簡單演練(三)git

Swift-MVVM 簡單演練(四)github

處理下拉刷新邏輯

根據接口文檔,下拉刷新是返回ID比since_id大的微博(即比since_id時間晚的微博)。所以,咱們須要在網絡請求方法裏增長兩個參數。since_idmax_id,分別對應下拉刷新所需參數和上拉加載所需參數。web

既然要修改網絡請求方法,固然是從咱們本身抽取的HQNetWorkManager+ExtensionHQStatusListViewModel這兩個地方入手考慮。這裏不太建議在HQStatusListViewModel中處理。由於全部的viewModel中都是處理網絡請求獲得的數據,以及處理一些小的業務邏輯的。網絡請求的方法若是有擴展,仍是儘可能放在咱們抽取出來的專門放各類網絡請求的HQNetWorkManager+Extension中比較好。統一全部的網絡請求都在這裏處理,改起來也就比較容易。數據庫

所以對HQNetWorkManager+Extension代碼進行擴展json

/// 微博數據字典數組
///
/// - 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中代碼進行下拉刷新的邏輯處理。swift

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徹底無侵害。api

上拉刷新邏輯處理

由於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

歡迎來個人簡書看看:紅鯉魚與綠鯉魚與驢___

相關文章
相關標籤/搜索