Swift-MVVM 簡單演練(二)

宮城良田發表於2017-08-24

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

歡迎來我的簡書看看:紅鯉魚與綠鯉魚與驢___

相關文章