Swift-MVVM 簡單演練(一)

宮城良田發表於2019-03-04

Swift-MVVM 簡單演練(二)

Swift-MVVM 簡單演練(三)

Swift-MVVM 簡單演練(四)

前言

最近在學習swiftMVVM架構模式,目的只是將自己的學習筆記記錄下來,方便自己日後查詢,僅此而已!!!

如果有任何問題,歡迎和我一起討論。當然如果有什麼存在的問題,歡迎批評指正,我會積極改造的!


這篇文章都寫啥

  • 自定義NavgationBar
  • 抽取便利建構函式
  • 初步的下拉重新整理/上拉載入的簡單處理
  • 未登入邏輯的處理
  • 蘋果原生布局NSLayoutConstraint
  • 如何用VFL佈局(VisualFormatLanguage)
  • 模擬網路載入應用程式的一些配置tabBar的標題和圖片樣式
  • 簡單的網路工具單例的封裝
  • 隔離專案中的網路請求方法
  • 初步的檢視模型的體驗
  • 以及一些遇到的語法問題的簡單探究

GitHub 上建立專案

如有需要,請移步下面兩篇文章


專案配置

  • 刪除ViewController.swiftMain.storyboardLaunchScreen.storyboard
  • 設定APPIconLaunchImage
  • 設定專案目錄結構
    • HQMainViewController繼承自UITabBarController
    • HQNavigationController繼承自UINavigationController
    • HQBaseViewController繼承自UIViewController(基類控制器)

設定子控制器

HQMainViewController中設定四個子控制器

  • extension將程式碼拆分
  • 通過反射機制,獲取子控制器類名,建立子控制器
  • 設定每個子控制的tabBar圖片及標題

HQMainViewController中程式碼如下所示

class HQMainViewController: UITabBarController {

    override func viewDidLoad() {
        super.viewDidLoad()

        setupChildControllers()
    }
}

/*
 extension 類似於 OC 中的分類,在 Swift 中還可以用來切分程式碼塊
 可以把功能相近的函式,放在一個extension中
 */
extension HQMainViewController {

    /// 設定所有子控制器
    fileprivate func setupChildControllers() {

        let array = [
            ["className": "HQAViewController", "title": "首頁", "imageName": "a"],
            ["className": "HQBViewController", "title": "訊息", "imageName": "b"],
            ["className": "HQCViewController", "title": "發現", "imageName": "c"],
            ["className": "HQDViewController", "title": "我", "imageName": "d"]
        ]
        var arrayM = [UIViewController]()
        for dict in array {
            arrayM.append(controller(dict: dict))
        }
        viewControllers = arrayM
    }
    /*
     ## 關於 fileprvita 和 private

     - 在`swift 3.0`,新增加了一個`fileprivate`,這個元素的訪問許可權為檔案內私有
     - 過去的`private`相當於現在的`fileprivate`
     - 現在的`private`是真正的私有,離開了這個類或者結構體的作用域外面就無法訪問了
     */

    /// 使用字典建立一個子控制器
    ///
    /// - Parameter dict: 資訊字典[className, title, imageName]
    /// - Returns: 子控制器
    private func controller(dict: [String: String]) -> UIViewController {

        // 1. 獲取字典內容
        guard let className = dict["className"],
            let title = dict["title"],
            let imageName = dict["imageName"],
            let cls = NSClassFromString(Bundle.main.namespace + "." + className) as? UIViewController.Type else {

                return UIViewController()
        }

        // 2. 建立檢視控制器
        let vc = cls.init()
        vc.title = title

        // 3. 設定影象
        vc.tabBarItem.image = UIImage(named: "tabbar_" + imageName)
        vc.tabBarItem.selectedImage = UIImage(named: "tabbar_" + imageName + "_selected")?.withRenderingMode(.alwaysOriginal)
        // 設定`tabBar`標題顏色
        vc.tabBarItem.setTitleTextAttributes(
            [NSForegroundColorAttributeName: UIColor.orange],
            for: .selected)
        // 設定`tabBar`標題字型大小,系統預設是`12`號字
        vc.tabBarItem.setTitleTextAttributes(
            [NSFontAttributeName: UIFont.systemFont(ofSize: 12)],
            for: .normal)

        let nav = HQNavigationController(rootViewController: vc)
        return nav
    }
}複製程式碼

設定中間加號按鈕

  • 通過增加tabBarItem的方式,給中間留出一個+按鈕的位置
  • 自定義一個UIButton的分類HQButton+Extension,封裝快速建立自定義按鈕的方法

HQButton.swift

extension UIButton {

    /// 便利建構函式
    ///
    /// - Parameters:
    ///   - imageName: 影象名稱
    ///   - backImageName: 背景影象名稱
    convenience init(hq_imageName: String, backImageName: String?) {
        self.init()

        setImage(UIImage(named: hq_imageName), for: .normal)
        setImage(UIImage(named: hq_imageName + "_highlighted"), for: .highlighted)

        if let backImageName = backImageName {
            setBackgroundImage(UIImage(named: backImageName), for: .normal)
            setBackgroundImage(UIImage(named: backImageName + "_highlighted"), for: .highlighted)
        }

        // 根據背景圖片大小調整尺寸
        sizeToFit()
    }
}複製程式碼

HQMainViewController.swift

/// 設定撰寫按鈕
fileprivate func setupComposeButton() {
    tabBar.addSubview(composeButton)

    // 設定按鈕的位置
    let count = CGFloat(childViewControllers.count)
    // 減`1`是為了是按鈕變寬,覆蓋住系統的容錯點
    let w = tabBar.bounds.size.width / count - 1
    composeButton.frame = tabBar.bounds.insetBy(dx: w * 2, dy: 0)

    composeButton.addTarget(self, action: #selector(composeStatus), for: .touchUpInside)
}複製程式碼
// MARK: - 監聽方法
// @objc 允許這個函式在執行時通過`OC`訊息的訊息機制被呼叫
@objc fileprivate func composeStatus() {
    print("點選加號按鈕")
}

// MARK: - 撰寫按鈕
fileprivate lazy var composeButton = UIButton(hq_imageName: "tabbar_compose_icon_add",
                                          backImageName: "tabbar_compose_button")複製程式碼

自定義頂部導航欄

  • 系統本身的絕大多數情況下不能滿足我們的日常需求
  • 有一些系統的樣式本身處理的不好,比如側滑返回的時候,系統的會出現漸溶的效果,這種使用者體驗不太好
  • 需要解決push出一個控制器後,底部TabBar隱藏/顯示問題

Push 出控制器後,底部 TabBar 隱藏/顯示問題

  • 在導航控制器的基類裡面重寫一下push方法
  • 判斷如果不是根控制器,那麼push的時候就隱藏BottomBar
  • 注意呼叫super.pushViewController要在重寫方法之後

HQNavigationController.swift

override func pushViewController(_ viewController: UIViewController, animated: Bool) {

    if childViewControllers.count > 0 {
        viewController.hidesBottomBarWhenPushed = true
    }
    super.pushViewController(viewController, animated: true)
}複製程式碼

抽取 BarButtonItem 便利建構函式

  • 系統的UIBarButtonItem方法不能方便的滿足我們建立所需的leftBarButtonItemrightBarButtonItem
  • 如果自定義建立需要些好幾行程式碼
  • 而這些程式碼又可能在很多地方用到,所以儘量抽取個便利建構函式

一般自定義ftBarButtonItem時候可能會寫如下程式碼

  • 最討厭的就是btn.sizeToFit()這句,如果不加,rightBarButtonItem就顯示不出來
  • 如果封裝起來,就再也不用考慮這問題了
let btn = UIButton()
btn.setTitle("下一個", for: .normal)
btn.setTitleColor(UIColor.lightGray, for: .normal)
btn.setTitleColor(UIColor.orange, for: .highlighted)
btn.addTarget(self, action: #selector(showNext), for: .touchUpInside)
// 最討厭的就是這句,如果不加,`rightBarButtonItem`就顯示不出來
btn.sizeToFit()
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: btn)複製程式碼

如果抽取一個便利建構函式,程式碼可能會簡化成如下

  • 一行程式碼搞定,簡單了許多
navigationItem.rightBarButtonItem = UIBarButtonItem(hq_title: "下一個", target: self, action: #selector(showNext))複製程式碼

便利建構函式的作用:簡化控制元件的建立


解決導航欄側滑返回過程中,按鈕及標題的融合問題

  • 因為側滑返回的時候,leftBarButtonItemtitle的字型有漸融的問題,我們又想解決這樣的問題。
  • 於是乎就要自定義NavigationBar
  • 要想實現這些功能,一定儘量要少動很多控制器的程式碼。如果在某一個地方就可以寫好,對其它控制器的程式碼入侵的越少越好,這是一個程式好的架構的原則

首先,在HQNavigationController中隱藏系統的navigationBar

override func viewDidLoad() {
    super.viewDidLoad()

    navigationBar.isHidden = true
}複製程式碼

其次,在基類控制器HQBaseViewController裡自定義

class HQBaseViewController: UIViewController {

    /// 自定義導航條
    lazy var navigationBar = UINavigationBar(frame: CGRect(x: 0, y: 0, width: UIScreen.hq_screenWidth(), height: 64))
    /// 自定義導航條目 - 以後設定導航欄內容,統一使用`navItem`
    lazy var navItem = UINavigationItem()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupUI()
    }

    override var title: String? {
        didSet {
            navItem.title = title
        }
    }
}

// MARK: - 設定介面
extension HQBaseViewController {

    func setupUI() {

        view.backgroundColor = UIColor.hq_randomColor()
        view.addSubview(navigationBar)
        navigationBar.items = [navItem]
    }
}複製程式碼

注意:這裡有一個小bug

  • push出下一個控制器的時候,導航欄右側會有一段白色的樣式出現
  • 原因是:系統預設的導航欄的透明度太高,自定義設定一個顏色就好了

HQBaseViewController.swift

// 設定`navigationBar`的渲染顏色
navigationBar.barTintColor = UIColor.hq_color(withHex: 0xF6F6F6)複製程式碼

設定左側 leftBarButtonItem

  • 左側都是返回(第二級頁面以下)
  • 或者是上一級title的名稱(只在第二級頁面這樣顯示)

在重寫pushViewController的方法裡面去判斷,如果子控制器的個數childViewControllers.count == 1的時候,就設定返回按鈕文字為根控制器的title

override func pushViewController(_ viewController: UIViewController, animated: Bool) {

    if childViewControllers.count > 0 {
        viewController.hidesBottomBarWhenPushed = true

        /*
         判斷控制器的型別
         - 如果是第一級頁面,不顯示`leftBarButtonItem`
         - 只有第二級頁面以後才顯示`leftBarButtonItem`
         */
        if let vc = viewController as? HQBaseViewController {

            var title = "返回"

            if childViewControllers.count == 1 {
                title = childViewControllers.first?.title ?? "返回"
            }

            vc.navItem.leftBarButtonItem = UIBarButtonItem(hq_title: title, target: self, action: #selector(popToParent))
        }
    }

    super.pushViewController(viewController, animated: true)
}複製程式碼

給 leftBarButtonItem 加上 icon

還是之前的原則,當改動某一處的程式碼時候,儘量對原有程式碼做盡可能小的改動

  • 之前我們已經設定好leftbarButtonItem文字顯示的狀態問題
  • 我們的需求又是在此基礎上直接加一個返回的icon而已
  • 因此,我們如果對自定義快速建立leftBarButtonItem這裡如果能直接改好了就最好

小技巧:

  • 當你想檢視某一個方法都在哪個檔案內被哪些方法呼叫的時候
  • 你可以在這個方法的方法明上右鍵->Find Call Hierarchy
    Hierarchy : 層級

UIBarButtonItem的自定義快速建立leftbarButtonItem的方法擴充套件一下,增加一個引數isBack,預設值是false

/// 字型+target+action
///
/// - Parameters:
///   - hq_title: title
///   - fontSize: fontSize
///   - target: target
///   - action: action
///   - isBack: 是否是返回按鈕,如果是就加上箭頭的`icon`
convenience init(hq_title: String, fontSize: CGFloat = 16, target: Any?, action: Selector, isBack: Bool = false) {

    let btn = UIButton(hq_title: hq_title, fontSize: fontSize, normalColor: UIColor.darkGray, highlightedColor: UIColor.orange)

    if isBack {
        let imageName = "nav_back"
        btn.setImage(UIImage.init(named: imageName), for: .normal)
        btn.setImage(UIImage.init(named: imageName + "_highlighted"), for: .highlighted)
        btn.sizeToFit()
    }

    btn.addTarget(target, action: action, for: .touchUpInside)
    // self.init 例項化 UIBarButtonItem
    self.init(customView: btn)
}複製程式碼

在之前判斷返回按鈕顯示文字的地方重新設定一下,只需要增加一個引數isBack: true

vc.navItem.leftBarButtonItem = UIBarButtonItem(hq_title: title, target: self, action: #selector(popToParent), isBack: true)複製程式碼

經過這樣的演進,我突然發現swift在這裡是比objective-c友好很多的,如果你給引數設定了一個預設值。那麼,就可以不對原方法造成侵害,不影響原方法的呼叫。

但是,objective-c就沒有這麼友好,如果在原方法上增加引數,那麼之前呼叫過此方法的地方,就會全部報錯。如果不想對原方法有改動,那麼就要重新寫一個完全一樣的只是最後面增加了這個需要的引數而已的一個新的方法。

你看swift是不是真的簡潔了許多。

設定 navigationBar 的 title 的顏色

navigationBar.tintColor = UIColor.red這樣是不對的,因為tintColor不是設定標題顏色的。

barTintColor是管理整個導航條的背景色

tintColor是管理導航條上item文字的顏色

titleTextAttributes是設定導航欄title的顏色

如果你找不到設定的方法,最好去UINavigationItem的標頭檔案裡面去找一下,你可以control + 6快速搜尋color關鍵字,如果沒有的話,建議你搜尋attribute試試,因為一般設定屬性的方法都可以解決多數你想解決的問題的。

// 設定`navigationBar`的渲染顏色
navigationBar.barTintColor = UIColor.hq_color(withHex: 0xF6F6F6)
// 設定導航欄`title`的顏色
navigationBar.titleTextAttributes = [NSForegroundColorAttributeName : UIColor.darkGray]
// 設定系統`leftBarButtonItem`渲染顏色
navigationBar.tintColor = UIColor.orange複製程式碼

設定裝置方向

有些時候我們的APP可能會在某個介面裡面需要支援橫屏但是其它的地方又希望它只支援豎屏,這就需要我們用程式碼去設定

override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return .portrait
}複製程式碼

設定支援的方向之後,當前的控制器及子控制器都會遵守這個方向,因此寫在HQMainViewController裡面


利用 extension 隔離 TableView 資料來源方法

在基類設定datasourcedelegate,這樣子類就可以直接實現方法就可以了,不用每個tableView的頁面都去設定tableView?.dataSource = selftableView?.delegate = self了。

  • 基類只是實現方法,子類負責具體的實現
  • 子類的資料來源方法不需要super
  • 返回UITableViewCell()只是為了沒有語法錯誤

HQBaseViewController裡,實現如下程式碼

extension HQBaseViewController: UITableViewDataSource, UITableViewDelegate {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 0
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        return UITableViewCell()
    }
}複製程式碼

設定一個載入資料的方法loadData,在這裡並不去做任何事情,只是為了方便子類重寫此方法載入資料就可以了。

/// 載入資料,具體的實現由子類負責
func loadData() {

}複製程式碼

繫結假資料測試

由於HQBaseViewController裡面實現了tableViewtableViewDataSourcetableViewDelegate以及loadData(自定義載入資料的方法),下一步我們就要在子控制器裡面測試一下效果了。

  • 製造一些假資料
fileprivate lazy var statusList = [String]()

/// 載入資料
override func loadData() {

    for i in 0..<10 {
        statusList.insert(i.description, at: 0)
    }
}複製程式碼
  • 實現資料來源方法
// MARK: - tableViewDataSource
extension HQAViewController {

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return statusList.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath)
        cell.textLabel?.text = statusList[indexPath.row]
        return cell
    }
}複製程式碼

至此,介面上應該可以顯示出資料了,如下所示

但是仔細觀察是存在問題的

  • 第一行應該是從9開始的,說明tableView的起始位置不對
  • 如果資料足夠多的情況下(多到可以超過一個螢幕的資料),可以發現下面也是停在tabBar的後面,底部位置也有問題

解決 TableView 的位置問題

主要在HQBaseViewController裡,重新設定tableViewContentInsets

/*
 取消自動縮排,當導航欄遇到`scrollView`的時候,一般都要設定這個屬性
 預設是`true`,會使`scrollView`向下移動`20`個點
 */
automaticallyAdjustsScrollViewInsets = false複製程式碼
tableView?.contentInset = UIEdgeInsets(top: navigationBar.bounds.height,
                                       left: 0,
                                       bottom: tabBarController?.tabBar.bounds.height ?? 49,
                                       right: 0)複製程式碼

因為一般的公司裡,頁面多數都是ViewController + TableView。所以,類似的需求,直接在基類控制器設定好就可以了。


新增下拉重新整理控制元件

  • 在基類控制器中定義下拉重新整理控制元件,這樣就不用每個子控制器頁面單獨設定了
  • refreshControl新增監聽方法,監聽refreshControlvalueChange事件
  • 當值改變的時候,重新執行loadData方法
  • 子類會重寫基類的loadData方法,因此不用在去子類重寫此方法
// 設定重新整理控制元件
refreshControl = UIRefreshControl()
tableView?.addSubview(refreshControl!)
refreshControl?.addTarget(self, action: #selector(loadData), for: .valueChanged)複製程式碼

模擬延時載入資料

  • 一般網路請求都會有延時,為了模擬的逼真一點,這裡我們也做了模擬延時載入資料。
  • 並且對比一下swiftobjective-c的延遲載入異同點

模擬延遲載入資料

/// 載入資料
override func loadData() {

    // 模擬`延時`載入資料
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) {

        for i in 0..<15 {
            self.statusList.insert(i.description, at: 0)
        }
        self.refreshControl?.endRefreshing()
        self.tableView?.reloadData()
    }
}複製程式碼

swift 延遲載入

// 模擬`延時`載入資料
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5) {

    print("5 秒後,執行閉包內的程式碼")
}複製程式碼

objective-c 延遲載入

/*
 dispatch_time_t when,      從現在開始,經過多少納秒(delayInSeconds * 1000000000)
 dispatch_queue_t queue,    由佇列排程任務執行
 dispatch_block_t block     執行任務的 block
 */
dispatch_time_t when = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC));

dispatch_after(when, dispatch_get_main_queue(), ^{
    // code to be executed after a specified delay
    NSLog(@"5 秒後,執行 Block 內的程式碼");
});複製程式碼

雖然都是一句話,但是swift語法的可讀性明顯比objective-c要好一些。


上拉重新整理

現在多數APP做無縫的上拉重新整理,就是當tableView滾動到最後一行cell的時候,自動重新整理載入資料。

用一個屬性來記錄是否是上拉載入資料

/// 上拉重新整理標記
var isPullup = false複製程式碼

滾動到最後一行 cell 的時候載入資料

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {

    let row = indexPath.row
    let section = tableView.numberOfSections - 1

    if row < 0 || section < 0 {
        return
    }

    let count = tableView.numberOfRows(inSection: section)

    if row == (count - 1) && !isPullup {

        isPullup = true
        loadData()
    }
}複製程式碼

在首頁控制器裡面模擬載入資料的時候,根據屬性isPullup判斷是上拉載入,還是下拉重新整理

/// 載入資料
override func loadData() {

    // 模擬`延時`載入資料
    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) {

        for i in 0..<15 {

            if self.isPullup {
                self.statusList.append("上拉 \(i)")
            } else {
                self.statusList.insert(i.description, at: 0)
            }
        }
        self.refreshControl?.endRefreshing()
        self.isPullup = false
        self.tableView?.reloadData()
    }
}複製程式碼

未登入檢視顯示(訪客檢視)

現實中經常會遇到一些臨時增加的需求,比如登入後顯示的是一種檢視,未登入又顯示另外一種檢視,如果你的公司是面向公司內部的APP,那麼你可能會面對更多的使用者角色。這裡我們暫時只討論已登入未登入兩種狀態下的情況。

還是之前的原則,不管做什麼新功能,增加什麼臨時的需求,我們要做的都是想辦法對原來的程式碼及架構做最小的調整,特別是對原來的Controller裡面的程式碼入侵的越小越好。

在基類控制器的setupUI(設定介面)的方法裡面,我們直接建立了tableView,那麼我們如果有一個標記,能根據這個標記來選擇是建立普通檢視,還是建立訪客檢視。就可以很好的解決此類問題了。

  • 增加一個使用者登入標記
/// 使用者登入標記
var userLogon = false複製程式碼
  • 根據標記判斷檢視顯示
userLogon ? setupTableView() : setupVistorView()複製程式碼
  • 建立訪客檢視的程式碼
/// 設定訪客檢視
fileprivate func setupVistorView() {

    let vistorView = UIView(frame: view.bounds)
    vistorView.backgroundColor = UIColor.hq_randomColor()
    view.insertSubview(vistorView, belowSubview: navigationBar)
}複製程式碼

自定義一個 View,繼承自UIView,在裡面設定訪客檢視的介面

class HQVistorView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)

        setupUI()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

// MARK: - 設定訪客檢視介面
extension HQVistorView {

    func setupUI() {
        backgroundColor = UIColor.white
    }
}複製程式碼

利用原生布局系統定義訪客檢視介面

在自定義訪客檢視HQVistorView中佈局各個子控制元件

  • 懶載入控制元件
/// 影象檢視
fileprivate lazy var iconImageView: UIImageView = UIImageView(hq_imageName: "visitordiscover_feed_image_smallicon")
/// 遮罩檢視
fileprivate lazy var maskImageView: UIImageView = UIImageView(hq_imageName: "visitordiscover_feed_mask_smallicon")
/// 小房子
fileprivate lazy var houseImageView: UIImageView = UIImageView(hq_imageName: "visitordiscover_feed_image_house")
/// 提示標籤
fileprivate lazy var tipLabel: UILabel = UILabel(hq_title: "關注一些人,回這裡看看有什麼驚喜關注一些人,回這裡看看有什麼驚喜")
/// 註冊按鈕
fileprivate lazy var registerButton: UIButton = UIButton(hq_title: "註冊", color: UIColor.orange, backImageName: "common_button_white_disable")
/// 登入按鈕
fileprivate lazy var loginButton: UIButton = UIButton(hq_title: "登入", color: UIColor.darkGray, backImageName: "common_button_white_disable")複製程式碼
  • 新增檢視
addSubview(iconImageView)
addSubview(maskImageView)
addSubview(houseImageView)
addSubview(tipLabel)
addSubview(registerButton)
addSubview(loginButton)

// 取消 autoresizing
for v in subviews {
    v.translatesAutoresizingMaskIntoConstraints = false
}複製程式碼
  • 原生布局

自動佈局本質公式 : A控制元件的屬性a = B控制元件的屬性b * 常數 + 約束

firstItem.firstAttribute {==,<=,>=} secondItem.secondAttribute * multiplier + constant複製程式碼
let margin: CGFloat = 20.0

/// 影象檢視
addConstraint(NSLayoutConstraint(item: iconImageView,
                                 attribute: .centerX,
                                 relatedBy: .equal,
                                 toItem: self,
                                 attribute: .centerX,
                                 multiplier: 1.0,
                                 constant: 0))
addConstraint(NSLayoutConstraint(item: iconImageView,
                                 attribute: .centerY,
                                 relatedBy: .equal,
                                 toItem: self,
                                 attribute: .centerY,
                                 multiplier: 1.0,
                                 constant: -60))
/// 小房子
addConstraint(NSLayoutConstraint(item: houseImageView,
                                 attribute: .centerX,
                                 relatedBy: .equal,
                                 toItem: iconImageView,
                                 attribute: .centerX,
                                 multiplier: 1.0,
                                 constant: 0))
addConstraint(NSLayoutConstraint(item: houseImageView,
                                 attribute: .centerY,
                                 relatedBy: .equal,
                                 toItem: iconImageView,
                                 attribute: .centerY,
                                 multiplier: 1.0,
                                 constant: 0))
/// 提示標籤
addConstraint(NSLayoutConstraint(item: tipLabel,
                                 attribute: .centerX,
                                 relatedBy: .equal,
                                 toItem: iconImageView,
                                 attribute: .centerX,
                                 multiplier: 1.0,
                                 constant: 0))
addConstraint(NSLayoutConstraint(item: tipLabel,
                                 attribute: .top,
                                 relatedBy: .equal,
                                 toItem: iconImageView,
                                 attribute: .bottom,
                                 multiplier: 1.0,
                                 constant: margin))
addConstraint(NSLayoutConstraint(item: tipLabel,
                                 attribute: .width,
                                 relatedBy: .equal,
                                 toItem: nil,
                                 attribute: .notAnAttribute,
                                 multiplier: 1.0,
                                 constant: 236))
/// 註冊按鈕
addConstraint(NSLayoutConstraint(item: registerButton,
                                 attribute: .left,
                                 relatedBy: .equal,
                                 toItem: tipLabel,
                                 attribute: .left,
                                 multiplier: 1.0,
                                 constant: 0))
addConstraint(NSLayoutConstraint(item: registerButton,
                                 attribute: .top,
                                 relatedBy: .equal,
                                 toItem: tipLabel,
                                 attribute: .bottom,
                                 multiplier: 1.0,
                                 constant: margin))
addConstraint(NSLayoutConstraint(item: registerButton,
                                 attribute: .width,
                                 relatedBy: .equal,
                                 toItem: nil,
                                 attribute: .notAnAttribute,
                                 multiplier: 1.0,
                                 constant: 100))
/// 登入按鈕
addConstraint(NSLayoutConstraint(item: loginButton,
                                 attribute: .right,
                                 relatedBy: .equal,
                                 toItem: tipLabel,
                                 attribute: .right,
                                 multiplier: 1.0,
                                 constant: 0))
addConstraint(NSLayoutConstraint(item: loginButton,
                                 attribute: .top,
                                 relatedBy: .equal,
                                 toItem: registerButton,
                                 attribute: .top,
                                 multiplier: 1.0,
                                 constant: 0))
addConstraint(NSLayoutConstraint(item: loginButton,
                                 attribute: .width,
                                 relatedBy: .equal,
                                 toItem: registerButton,
                                 attribute: .width,
                                 multiplier: 1.0,
                                 constant: 0))複製程式碼

採用 VFL 佈局子控制元件

  • VFL 視覺化語言,多用於連續參照關係,如遇到居中對其,通常多使用參照
  • H水平方向
  • V豎直方向
  • |邊界
  • []包含控制元件的名稱字串,對應關係在views字典中定義
  • ()定義控制元件的寬/高,可以在metrics中指定

VFL 引數的解釋 :

  • views: 定義 VFL 中控制元件名稱和實際名稱的對映關係
  • metrics: 定義 VFL 中 () 內指定的常數對映關係,防止在程式碼中出現魔法數字
let viewDict: [String: Any] = ["maskImageView": maskImageView,
                "registerButton": registerButton]
let metrics = ["spacing": -35]

addConstraints(NSLayoutConstraint.constraints(
    withVisualFormat: "H:|-0-[maskImageView]-0-|",
    options: [],
    metrics: nil,
    views: viewDict))
addConstraints(NSLayoutConstraint.constraints(
    withVisualFormat: "V:|-0-[maskImageView]-(spacing)-[registerButton]",
    options: [],
    metrics: metrics,
    views: viewDict))複製程式碼

處理每個子控制器訪客檢視顯示問題

到目前為止,雖然我們只是在基類控制器裡面建立了訪客檢視setupVistorView,只有一個訪客檢視的HQVistorView,但是實際上當我們點選不同的子控制器的時候,每個子控制器都會建立一個訪客檢視。點選四個子控制器的時候,訪客檢視列印的地址都不一樣。

<HQSwiftMVVM.HQVistorView: 0x7fea6970ed30; frame = (0 0; 375 667); layer = <CALayer: 0x608000036ec0>>
<HQSwiftMVVM.HQVistorView: 0x7fea6940d3b0; frame = (0 0; 375 667); layer = <CALayer: 0x600000421e60>>
<HQSwiftMVVM.HQVistorView: 0x7fea6973cf60; frame = (0 0; 375 667); layer = <CALayer: 0x608000036a40>>
<HQSwiftMVVM.HQVistorView: 0x7fea6943d990; frame = (0 0; 375 667); layer = <CALayer: 0x600000423760>>複製程式碼

定義一個屬性字典,把圖片名稱和提示標語傳入到HQVistorView中,通過重寫didSet方法設定

/// 設定訪客檢視資訊字典[imageName / message]
var vistorInfo: [String: String]? {
    didSet {
        guard let imageName = vistorInfo?["imageName"],
            let message = vistorInfo?["message"]
        else {
            return
        }
        tipLabel.text = message
        if imageName == "" {
            return
        }
        iconImageView.image = UIImage(named: imageName)
    }
}複製程式碼

HQBaseViewController定義一個同樣的訪客檢視資訊字典,方便外界傳入。這樣做的目的是外界傳入到HQBaseViewController中資訊字典,可以通過setupVistorView方法傳到HQVistorView中,再重寫HQVistorView中的訪客檢視資訊字典的didSet方法以達到設定的目的。

/// 設定訪客檢視資訊字典
var visitorInfoDictionary: [String: String]?複製程式碼
/// 設定訪客檢視
fileprivate func setupVistorView() {

    let vistorView = HQVistorView(frame: view.bounds)
    view.insertSubview(vistorView, belowSubview: navigationBar)
    vistorView.vistorInfo = visitorInfoDictionary
}複製程式碼

下一步就是研究在哪裡給訪客檢視資訊字典傳值的問題了。

修改設定子控制器的引數配置

  • 修改設定子控制器的配置
fileprivate func setupChildControllers() {

    let array: [[String: Any]] = [
        [
            "className": "HQAViewController",
            "title": "首頁",
            "imageName": "a",
            "visitorInfo": [
                "imageName": "",
                "message": "關注一些人,回這裡看看有什麼驚喜"
            ]
        ],
        [
            "className": "HQBViewController",
            "title": "訊息",
            "imageName": "b",
            "visitorInfo": [
                "imageName": "visitordiscover_image_message",
                "message": "登入後,別人評論你的微博,發給你的資訊,都會在這裡收到通知"
            ]
        ],
        [
            "className": "UIViewController"
        ],
        [
            "className": "HQCViewController",
            "title": "發現",
            "imageName": "c",
            "visitorInfo": [
                "imageName": "visitordiscover_image_message",
                "message": "登入後,最新、最熱微博盡在掌握,不再會與時事潮流擦肩而過"
            ]
        ],
        [
            "className": "HQDViewController",
            "title": "我",
            "imageName": "d",
            "visitorInfo": [
                "imageName": "visitordiscover_image_profile",
                "message": "登入後,你的微博、相簿,個人資料會顯示在這裡,顯示給別人"
            ]
        ]
    ]

    (array as NSArray).write(toFile: "/Users/wanghongqing/Desktop/demo.plist", atomically: true)

    var arrayM = [UIViewController]()
    for dict in array {
        arrayM.append(controller(dict: dict))
    }
    viewControllers = arrayM
}複製程式碼
fileprivate func controller(dict: [String: Any]) -> UIViewController {

    // 1. 獲取字典內容
    guard let className = dict["className"] as? String,
        let title = dict["title"] as? String,
        let imageName = dict["imageName"] as? String,
        let cls = NSClassFromString(Bundle.main.namespace + "." + className) as? HQBaseViewController.Type,
        let vistorDict = dict["visitorInfo"] as? [String: String]

        else {

            return UIViewController()
    }

    // 2. 建立檢視控制器
    let vc = cls.init()
    vc.title = title
    vc.visitorInfoDictionary = vistorDict
}複製程式碼

將陣列寫入plist並儲存到本地

swfit語法裡,並沒有直接將array通過write(toFile:)的方法。因此,這裡需要轉一下,方便檢視資料格式。

(array as NSArray).write(toFile: "/Users/wanghongqing/Desktop/demo.plist", atomically: true)複製程式碼

設定首頁動畫旋轉效果

有幾點需要注意的

  • 動畫旋轉需要一直保持,切換到其它控制器或者退到後臺再回來,要保證動畫仍然能繼續轉動
  • 設定動畫的旋轉週數tiValueM_PIswift 3.0以後已經不能再用了,需要用Double.pi替代
if imageName == "" {
    startAnimation()
    return
}複製程式碼
/// 旋轉檢視動畫
fileprivate func startAnimation() {

    let anim = CABasicAnimation(keyPath: "transform.rotation")
    anim.toValue = 2 * Double.pi
    anim.repeatCount = MAXFLOAT
    anim.duration = 15

    // 設定動畫一直保持轉動,如果`iconImageView`被釋放,動畫會被一起釋放
    anim.isRemovedOnCompletion = false
    // 將動畫新增到圖層
    iconImageView.layer.add(anim, forKey: nil)
}複製程式碼

使用 json 配置檔案設定介面控制器內容

將之前HQMainViewController寫好的配置內容(控制各個控制器標題等內容的陣列)輸出main.json檔案,並儲存。

let data = try! JSONSerialization.data(withJSONObject: array, options: [.prettyPrinted])
(data as NSData).write(toFile: "/Users/wanghongqing/Desktop/main.json", atomically: true)複製程式碼

main.json拖入到檔案中,通過載入這個main.json配置介面控制器內容。

/// 設定所有子控制器
fileprivate func setupChildControllers() {

    // 從`Bundle`載入配置的`json`
    guard let path = Bundle.main.path(forResource: "main.json", ofType: nil),
        let data = NSData(contentsOfFile: path),
    let array = try? JSONSerialization.jsonObject(with: data as Data, options: []) as? [[String: Any]]
        else {
        return
    }

    var arrayM = [UIViewController]()
    for dict in array! {
        arrayM.append(controller(dict: dict))
    }
    viewControllers = arrayM
}複製程式碼

模擬網路載入應用程式配置

現在很多應用程式都是帶有一個配置檔案的.json檔案,當應用程式啟動的時候去檢視沙盒裡面有沒有該.json檔案。

  • 如果沒有
    • 通過網路請求載入預設的.json檔案
  • 如果有
    • 直接使用沙盒裡面儲存的.json檔案
    • 網路請求非同步載入新的.json檔案,等下一次使用者再次啟動APP的時候就可以顯示比較新的配置檔案了

AppDelegate中模擬載入資料

extension AppDelegate {

    fileprivate func loadAppInfo() {

        DispatchQueue.global().async {
            let url = Bundle.main.url(forResource: "main.json", withExtension: nil)
            let data = NSData(contentsOf: url!)
            let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
            let jsonPath = (path as NSString).appendingPathComponent("main.json")
            data?.write(toFile: jsonPath, atomically: true)
        }
    }
}複製程式碼

HQMainViewController中設定

/// 設定所有子控制器
fileprivate func setupChildControllers() {

    /// 獲取沙盒`json`路徑
    let docPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
    let jsonPath = (docPath as NSString).appendingPathComponent("main.json")

    /// 載入 `data`
    var data = NSData(contentsOfFile: jsonPath)

    /// 如果`data`沒有內容,說明沙盒沒有內容
    if data == nil {
        // 從`bundle`載入`data`
        let path = Bundle.main.path(forResource: "main.json", ofType: nil)
        data = NSData(contentsOfFile: path!)
    }

    // 從`Bundle`載入配置的`json`
    guard let array = try? JSONSerialization.jsonObject(with: data! as Data, options: []) as? [[String: Any]]
        else {
        return
    }

    var arrayM = [UIViewController]()
    for dict in array! {
        arrayM.append(controller(dict: dict))
    }
    viewControllers = arrayM
}複製程式碼

解釋一下 try

在之前的程式碼中,json的反序列化的時候,我們遇到了try,下面用幾個簡單的例子說明一下

推薦用法,弱 try->try?

let jsonString = "{\"name\": \"zhang\"}"
let data = jsonString.data(using: .utf8)

let json = try? JSONSerialization.jsonObject(with: data!, options: [])
print(json ?? "nil")

// 輸出結果
{
    name = zhang;
}複製程式碼

如果jsonString的格式有問題的話,比如改成下面這樣

let jsonString = "{\"name\": \"zhang\"]"複製程式碼

則輸出

nil複製程式碼

不推薦用法 強 try->try!

當我們改成強try!並且jsonString有問題的時候

let jsonString = "{\"name\": \"zhang\"]"
let data = jsonString.data(using: .utf8)

let json = try! JSONSerialization.jsonObject(with: data!, options: [])
print(json)複製程式碼

則會直接崩潰,崩潰到try!的地方

Error Domain=NSCocoaErrorDomain Code=3840 "Badly formed object around character 16." UserInfo={NSDebugDescription=Badly formed object around character 16.}: file /Library/Caches/com.apple.xbs/Sources/swiftlang/swiftlang-802.0.53/src/swift/stdlib/public/core/ErrorType.swift, line 182複製程式碼

雖然會將錯誤資訊完整的列印出來,但是程式崩潰對於使用者來說是很不友好的,因此不建議。

do...catch...

對於第二種情況,我們可以採用do...catch...避免程式崩潰。

let jsonString = "{\"name\": \"zhang\"]"
let data = jsonString.data(using: .utf8)

do {
    let json = try JSONSerialization.jsonObject(with: data!, options: [])
    print(json)
} catch {
    print(error)
}複製程式碼

程式可以免於崩潰,但是會增加語法結構的複雜性,並且ARC開發中,編譯器自動新增retainreleaseautorelease,如果用do...catch...一旦不平衡,就會出現記憶體洩露的問題。所以如果當真用的時候要慎重!


監聽註冊和登入按鈕的點選事件

HQVistorView裡將兩個按鈕暴露出來,然後直接在HQBaseViewController中新增監聽方法即可。

/// 註冊按鈕
lazy var registerButton: UIButton = UIButton(hq_title: "註冊", color: UIColor.orange, backImageName: "common_button_white_disable")
/// 登入按鈕
lazy var loginButton: UIButton = UIButton(hq_title: "登入", color: UIColor.darkGray, backImageName: "common_button_white_disable")複製程式碼
vistorView.loginButton.addTarget(self, action: #selector(login), for: .touchUpInside)
vistorView.registerButton.addTarget(self, action: #selector(register), for: .touchUpInside)複製程式碼
// MARK: - 註冊/登入 點選事件
extension HQBaseViewController {

    @objc fileprivate func login() {
        print(#function)
    }
    @objc fileprivate func register() {
        print("bbb")
    }
}複製程式碼

這裡之所以選擇直接addTarget方法,是因為這樣最簡單,如果用代理 / 閉包等方式會增加很多程式碼。代理的合核心是解耦,當一個控制元件可以不停的被複用的時候就選擇代理,比如TableViewDelegate中的didSelectRowAt indexPath:該方法是可以在任何地方只要建立TableView都可能被用到的方法。因此,設定成Delegate

在這裡HQVistorViewHQBaseViewController是緊耦合的關係,HQVistorView可以看成是從屬於HQBaseViewController。基本不會被在其它地方被用到。雖然是緊耦合,但是新增監聽方法特別簡單。是否需要解耦需要根據實際情況判斷,沒必要為了解耦而解耦,為了模式而模式。

總結

  • 使用代理傳遞訊息是為了在控制器和檢視之間解耦,讓檢視能夠被多個控制器複用,如TableView
  • 但是,如果檢視僅僅是為了封裝程式碼,而從控制器中剝離出來的,並且能夠確認該檢視不會被其它控制器引用,則可以直接通過addTarget的方式為該檢視中的按鈕新增監聽方法
  • 這樣做的代價是耦合度高,控制器和檢視繫結在一起,但是省略部分冗餘程式碼

調整未登入時導航按鈕

如果單純的在setupVistorView中設定leftBarButtonItemrightBarButtonItem,那麼在首頁就會出現左側的leftBarButtonItem變成了好友了,再點選好友按鈕push出來的控制器的所有的返回按鈕都變成了註冊

而在未登入狀態下,導航欄上面的按鈕都是顯示註冊登入。登入之後才顯示別的,因此,我們可以將HQBaseViewController中的setupUI方法設定成fileprivate不讓外界訪問到,並且將setupTableView設定成外界可以訪問,如果需要在登入後的控制器裡面顯示所需的樣式,只需要在各子類重寫setupTableView的方法裡重新設定leftBarButtonItem就可以了。

/// 設定訪客檢視
fileprivate func setupVistorView() {

    navItem.leftBarButtonItem = UIBarButtonItem(title: "註冊", style: .plain, target: self, action: #selector(register))
    navItem.rightBarButtonItem = UIBarButtonItem(title: "登入", style: .plain, target: self, action: #selector(login))
}複製程式碼

使用CocoaPods管理一些我們需要用到的第三方工具,這裡跳過。


封裝網路工具單例

swift單例寫法

static let shared = HQNetWorkManager()複製程式碼

objective-c單例寫法

+ (instancetype)sharedTools {

    static HQNetworkTools *tools;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{

        NSURL *baseURL = [NSURL URLWithString:HQBaseURL];
        tools = [[self alloc] initWithBaseURL:baseURL];

        tools.requestSerializer = [AFJSONRequestSerializer serializer];
        tools.responseSerializer.acceptableContentTypes = [NSSet setWithObjects:@"application/json", @"text/json", @"text/javascript", @"text/html", @"text/plain", nil];
    });
    return tools;
}複製程式碼

到此,我們不要急於包裝網路請求方法,應該先測試一下網路請求通不通,實際中我們也是一樣,先把要實現的主要目標先完成,然後再進行深層次的探究。

HQAViewController中載入資料測試

/// 載入資料
override func loadData() {

    let urlString = "https://api.weibo.com/2/statuses/home_timeline.json"
    let para = ["access_token": "2.00It5tsGQ6eDJE4ecbf2d825DCpbBD"]

    HQNetWorkManager.shared.get(urlString, parameters: para, progress: nil, success: { (_, json) in
        print(json ?? "")
    }) { (_, error) in
        print(error)
    }複製程式碼

請求到的資料

{
    ad =     (
    );
    advertises =     (
    );
    "has_unread" = 0;
    hasvisible = 0;
    interval = 2000;
    "max_id" = 4130532835237793;
    "next_cursor" = 4130532835237793;
    "previous_cursor" = 0;
    "since_id" = 4130540976425281;
    statuses =     (
                {
            "attitudes_count" = 0;
            "biz_feature" = 0;
            "bmiddle_pic" = "http://wx3.sinaimg.cn/bmiddle/9603cdd7ly1fhmz6ui42tj20l414a0w7.jpg";
            "comment_manage_info" =             {
                "comment_permission_type" = "-1";
            };
            "comments_count" = 0;
            "created_at" = "Mon Jul 17 16:46:13 +0800 2017";複製程式碼

封裝AFNetworkingGETPOST請求

注意:

如果你的閉包是這樣的寫法

func request(method: HQHTTPMethod = .GET, URLString: String, parameters: [String: Any], completion: (json: Any?, isSuccess: Bool)->()) {複製程式碼

那麼在你呼叫completion這個閉包的時候,你可能會遇到下面的錯誤

Closure use of non-escaping parameter 'completion' may allow it to escape複製程式碼

解決辦法直接按照Xcode的提示就可以改正了,應該是下面的樣子

func request(method: HQHTTPMethod = .GET, URLString: String, parameters: [String: Any], completion: @escaping (_ json: Any?, _ isSuccess: Bool)->()) {複製程式碼

From the Apple Developer docs

A closure is said to escape a function when the closure is passed as an argument to the function, but is called after the function returns. When you declare a function that takes a closure as one of its parameters, you can write @escaping before the parameter’s type to indicate that the closure is allowed to escape.

簡單總結:

因為該函式中的網路請求方法,有一個引數completion: (json: Any?, isSuccess: Bool)->()是閉包。是在網路請求方法執行完以後的完成回撥。即閉包在函式執行完以後被呼叫了,呼叫的地方超過了request函式的範圍,這種閉包叫做逃逸閉包

swift 3.0中對閉包做了改變,預設請款下都是非逃逸閉包,不再需要@noescape修飾。而如果你的閉包是在函式執行完以後再呼叫的,比如我舉例子的網路請求完成回撥,這種逃逸閉包,就需要用@escaping修飾。

如果你先仔細瞭解這方便的問題請閱讀Swift 3必看:@noescape走了, @escaping來了

網路工具類HQNetWorkManager中的程式碼

enum HQHTTPMethod {
    case GET
    case POST
}

class HQNetWorkManager: AFHTTPSessionManager {

    static let shared = HQNetWorkManager()

    /// 封裝 AFN 的 GET/POST 請求
    ///
    /// - Parameters:
    ///   - method: GET/POST
    ///   - URLString: URLString
    ///   - parameters: parameters
    ///   - completion: 完成回撥(json, isSuccess)
    func request(method: HQHTTPMethod = .GET, URLString: String, parameters: [String: Any], completion: @escaping (_ json: Any?, _ isSuccess: Bool)->()) {

        let success = { (task: URLSessionDataTask, json: Any?)->() in
            completion(json, true)
        }

        let failure = { (task: URLSessionDataTask?, error: Error)->() in
            print("網路請求錯誤 \(error)")
            completion(nil, false)
        }

        if method == .GET {
            get(URLString, parameters: parameters, progress: nil, success: success, failure: failure)
        } else {
            post(URLString, parameters: parameters, progress: nil, success: success, failure: failure)
        }

    }
}複製程式碼

調整後的HQAViewController中載入資料的程式碼

let urlString = "https://api.weibo.com/2/statuses/home_timeline.json"
let para = ["access_token": "2.00It5tsGQ6eDJE4ecbf2d825DCpbBD"]

//        HQNetWorkManager.shared.get(urlString, parameters: para, progress: nil, success: { (_, json) in
//            print(json ?? "")
//        }) { (_, error) in
//            print(error)
//        }
HQNetWorkManager.shared.request(URLString: urlString, parameters: para) { (json, isSuccess) in
    print(json ?? "")
}複製程式碼

利用extension封裝專案中網路請求方法

HQAViewController中的網路請求方法雖然進行了一些封裝,但是還是要在控制器中填寫urlStringpara,如果能把這些也直接封裝到一個便於管理的地方,就更好了。這樣,當我們偶一個網路介面的url或者para有變化的話,我們不用花費很長的時間去苦苦尋找到底是在那個Controller中。

還有就是,返回的資料格式是這樣的

{
    ad =     (
    );
    advertises =     (
    );
    "has_unread" = 0;
    hasvisible = 0;
    interval = 2000;
    "max_id" = 4130532835237793;
    "next_cursor" = 4130532835237793;
    "previous_cursor" = 0;
    "since_id" = 4130540976425281;
    statuses =     (
                {
            "attitudes_count" = 0;
            "biz_feature" = 0;
            "bmiddle_pic" = "http://wx3.sinaimg.cn/bmiddle/9603cdd7ly1fhmz6ui42tj20l414a0w7.jpg";
            "comment_manage_info" =             {
                "comment_permission_type" = "-1";
            };
            "comments_count" = 0;
            "created_at" = "Mon Jul 17 16:46:13 +0800 2017";複製程式碼

其實,只有statuses對應的陣列才是我們需要的微博資料,其它的對於我們來說,暫時都是沒有用的。一般的公司開發中,也返回類似的格式,只不過沒有微博這麼複雜罷了。

因此,如果能直接給控制器提供statuses的資料就最好了,controller直接拿到最有用的資料,而且包裝又少了一層。字典轉模型也方便一層。

extension HQNetWorkManager {

    /// 微博資料字典陣列
    ///
    /// - Parameter completion: 微博字典陣列/是否成功
    func statusList(completion: @escaping (_ list: [[String: AnyObject]]?, _ isSuccess: Bool)->()) {

        let urlString = "https://api.weibo.com/2/statuses/home_timeline.json"
        let para = ["access_token": "2.00It5tsGQ6eDJE4ecbf2d825DCpbBD"]

        request(URLString: urlString, parameters: para) { (json, isSuccess) in
            /*
             從`json`中獲取`statuses`字典陣列
             如果`as?`失敗,`result = nil`
             */
            let result = (json as AnyObject)["statuses"] as? [[String: AnyObject]]
            completion(result, isSuccess)
        }
    }
}複製程式碼

注意:

如果你下面這句話這樣寫,像objective-c那樣寫json["statuses"]就會報錯的。

let result = json["statuses"] as? [[String: AnyObject]]複製程式碼

報如下錯誤:

Type 'Any?' has no subscript members複製程式碼

需要改成這樣

let result = (json as AnyObject)["statuses"] as? [[String: AnyObject]]複製程式碼

接下來,控制器中HQAViewController的程式碼就可以簡化成這樣

HQNetWorkManager.shared.statusList { (list, isSuccess) in
    print(list ?? "")
}複製程式碼

至此,HQAViewController中拿到的就是最有用的陣列資料,下一步就直接字典轉模型就可以了。和之前把網路請求urlpara都放在controller相比,是不是,控制器輕鬆了一點呢!

封裝Token

專案中,所有的網路請求,除了登陸以外,基本都需要token,因此,如果我們能將token封裝起來,以後傳引數的時候,不用再考慮token相關的問題就最好了。

HQNetWorkManager中新建一個tokenRequest方法,該方法只是把之前的request方法呼叫一下,同時把token增加到該方法裡。使得在專門處理網路請求的方法裡HQNetWorkManager+Extension不用再去考慮token相關的問題了。

/// token
var accessToken: String? = "2.00It5tsGQ6eDJE4ecbf2d825DCpbBD"

/// 帶`token`的網路請求方法
func tokenRequest(method: HQHTTPMethod = .GET, URLString: String, parameters: [String: AnyObject]?, completion: @escaping (_ json: Any?, _ isSuccess: Bool)->()) {

    guard let token = accessToken else {
        print("沒有 token 需要重新登入")
        completion(nil, false)
        return
    }

    var parameters = parameters

    if parameters == nil {
        parameters = [String: AnyObject]()
    }

    parameters!["access_token"] = token as AnyObject

    request(URLString: URLString, parameters: parameters, completion: completion)
}複製程式碼

這樣封裝以後,在HQNetWorkManager+Extension中不再需要考慮token相關的問題,並且對controller程式碼無侵害。

token 過期處理

因為token存在時效性,因此我們需要對其判斷是否有效,如果token過期需要讓使用者重新登入,或者進行其它頁面的跳轉等操作。

假如token過期,我們仍然向伺服器請求資料,那麼就會報錯

Error Domain=com.alamofire.error.serialization.response Code=-1011 
"Request failed: forbidden (403)"
UserInfo={
    com.alamofire.serialization.response.error.response= 
        { 
            URL: https://api.weibo.com/2/statuses/home_timeline.json?access_token=2.00It5tsGQ6eDJE4ecbf2d825DCpbBD111 

        } 
{ 
    status code: 403, 
        headers {
            "Content-Encoding" = gzip;
            "Content-Type" = "application/json;charset=UTF-8";
            Date = "Tue, 18 Jul 2017 07:54:51 GMT";
            Server = "nginx/1.6.1";
            Vary = "Accept-Encoding";
    }
}, 
NSErrorFailingURLKey=https://api.weibo.com/2/statuses/home_timeline.json?access_token=2.00It5tsGQ6eDJE4ecbf2d825DCpbBD111,
com.alamofire.serialization.response.error.data=<7b226572 22657272="" 63657373="" 65737422="" 72657175="" 74757365="" 726f7222="" 3a22696e="" 76616c69="" 645f6163="" 5f746f6b="" 656e222c="" 6f725f63="" 6f646522="" 3a323133="" 33322c22="" 3a222f32="" 2f737461="" 732f686f="" 6d655f74="" 696d656c="" 696e652e="" 6a736f6e="" 227d="">, 
NSLocalizedDescription=Request failed: forbidden (403)}7b226572>複製程式碼

我們需要在網路請求失敗的時候做個處理

let failure = { (task: URLSessionDataTask?, error: Error)->() in

    if (task?.response as? HTTPURLResponse)?.statusCode == 403 {
        print("token 過期了")

        // FIXME: 傳送通知,提示使用者再次登入
    }

    print("網路請求錯誤 \(error)")
    completion(nil, false)
}複製程式碼

建立微博資料模型

HQStatus.swift中簡單定義兩個屬性

import YYModel

/// 微博資料模型
class HQStatus: NSObject {

    /*
     `Int`型別,在`64`位的機器是`64`位,在`32`位的機器是`32`位
     如果不寫明`Int 64`在 iPad 2 / iPhone 5/5c/4s/4 都無法正常執行
     */
    /// 微博ID
    var id: Int64 = 0

    /// 微博資訊內容
    var text: String?

    override var description: String {

        return yy_modelDescription()
    }
}複製程式碼

建立檢視模型,封裝載入微博資料方法

viewModel的使命

  • 字典轉模型邏輯
  • 上拉 / 下拉資料處理邏輯
  • 下拉重新整理資料數量
  • 本地快取資料處理

初體驗

因為MVVMswift中都是沒有父類的,所以先說下關於父類的選擇問題

  • 如果分類需要使用KVC或者字典轉模型框架設定物件時,類就需要繼承自NSObject
  • 如果類只是包裝一些程式碼邏輯(寫了一些函式),可以不用繼承任何父類,好處: 更加輕量級

HQStatusListViewModel.swift不繼承任何父類

/// 微博資料列表檢視模型
class HQStatusListViewModel {

    lazy var statusList = [HQStatus]()

    func loadStatus(completion: @escaping (_ isSuccess: Bool)->()) {

        HQNetWorkManager.shared.statusList { (list, isSuccess) in

            guard let array = NSArray.yy_modelArray(with: HQStatus.classForCoder(), json: list ?? []) as? [HQStatus] else {

                completion(isSuccess)

                return
            }

            self.statusList += array

            completion(isSuccess)
        }
    }
}複製程式碼

然後HQAViewController中載入資料的程式碼就可以簡化成這樣

fileprivate lazy var listViewModel = HQStatusListViewModel()

/// 載入資料
override func loadData() {

    listViewModel.loadStatus { (isSuccess) in
        self.refreshControl?.endRefreshing()
        self.isPullup = false
        self.tableView?.reloadData()
    }
}複製程式碼

tableViewDataSource中直接呼叫HQStatusListViewModel中資料即可

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return listViewModel.statusList.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

    let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath)
    cell.textLabel?.text = listViewModel.statusList[indexPath.row].text
    return cell
}複製程式碼

接下來執行程式應該能看到這樣的介面,目前由於沒有處理下拉/下拉載入處理,因此只能看到20條微博資料。

DEMO傳送門:HQSwiftMVVM

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

參考:

  1. Swift 3 :Closure use of non-escaping parameter may allow it to escape
  2. Swift 3必看:@noescape走了, @escaping來了

相關文章