Swift-MVVM 簡單演練(四)

宮城良田發表於2017-09-05

Swift-MVVM 簡單演練(一)

Swift-MVVM 簡單演練(二)

Swift-MVVM 簡單演練(三)

前言

這一篇主要寫微博的首頁佈局,及MVVM模式的體會。像微博這種自定義的Cell佈局略顯複雜一些,我們最好將其拆分出來各個不同的模組來處理比較好一些。不要像之前那樣,所有的控制元件都寫在一個cell裡面,那樣不好處理。雖然說總體上來說,是學習MVVM模式,但是架構都是基於專案而設立的。脫離業務談什麼模式本身就不是很好。凡事有法,但法無定式。依個人習慣去延伸就好。沒必要非得說誰的程式碼就一定是錯的。這樣真的不太好。


搭介面、展示微博正文文字

凡事先揀簡單的東西去實現。沒有一蹴而就的事情。先看下接下來我們要實現的目標,見下圖

主要就是將頭部的檢視(頭像、暱稱、會員圖示、時間、來源、認證圖示)微博正文先顯示出來再說。

而且,這裡不是所有的控制元件都直接寫在cell裡面的,那樣太複雜,也不好處理業務邏輯。因此,將每一個cell大致分為四個模組:

  • 頂部檢視(頭像、暱稱、會員圖示、時間、來源、認證圖示)
  • 微博正文
  • 配圖檢視
  • 底部檢視(評論、轉發點贊)

佈局頂部檢視HQACellTopView

class HQACellTopView: UIView {

    fileprivate lazy var carveView: UIView = {
        let view = UIView(frame: CGRect(x: 0, y: 0, width: UIScreen.hq_screenWidth(), height: 8))
        view.backgroundColor = UIColor.hq_color(withHex: 0xF2F2F2)
        return view
    }()
    /// 頭像
    fileprivate lazy var avatarImageView: UIImageView = UIImageView(hq_imageName: "avatar_default_big")
    /// 姓名
    fileprivate lazy var nameLabel: UILabel = UILabel(hq_title: "吳彥祖", fontSize: 14, color: UIColor.hq_color(withHex: 0xFC3E00))
    /// 會員
    fileprivate lazy var memberIconView: UIImageView = UIImageView(hq_imageName: "common_icon_membership_level1")
    /// 時間
    fileprivate lazy var timeLabel: UILabel = UILabel(hq_title: "現在", fontSize: 11, color: UIColor.hq_color(withHex: 0xFF6C00))
    /// 來源
    fileprivate lazy var sourceLabel: UILabel = UILabel(hq_title: "來源", fontSize: 11, color: UIColor.hq_color(withHex: 0x828282))
    /// 認證
    fileprivate lazy var vipIconImageView: UIImageView = UIImageView(hq_imageName: "avatar_vip")

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

        setupUI()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}複製程式碼
// MARK: - UI
extension HQACellTopView {

    fileprivate func setupUI() {

        addSubview(carveView)
        addSubview(avatarImageView)
        addSubview(nameLabel)
        addSubview(memberIconView)
        addSubview(timeLabel)
        addSubview(sourceLabel)
        addSubview(vipIconImageView)

        avatarImageView.snp.makeConstraints { (make) in
            make.top.equalTo(carveView.snp.bottom).offset(margin)
            make.left.equalTo(self).offset(margin)
            make.width.equalTo(AvatarImageViewWidth)
            make.height.equalTo(AvatarImageViewWidth)
        }
        nameLabel.snp.makeConstraints { (make) in
            make.top.equalTo(avatarImageView).offset(4)
            make.left.equalTo(avatarImageView.snp.right).offset(margin - 4)
        }
        memberIconView.snp.makeConstraints { (make) in
            make.left.equalTo(nameLabel.snp.right).offset(margin / 2)
            make.centerY.equalTo(nameLabel)
        }
        timeLabel.snp.makeConstraints { (make) in
            make.left.equalTo(nameLabel)
            make.bottom.equalTo(avatarImageView)
        }
        sourceLabel.snp.makeConstraints { (make) in
            make.left.equalTo(timeLabel.snp.right).offset(margin / 2)
            make.centerY.equalTo(timeLabel)
        }
        vipIconImageView.snp.makeConstraints { (make) in
            make.centerX.equalTo(avatarImageView.snp.right)
            make.centerY.equalTo(avatarImageView.snp.bottom)
        }
    }
}複製程式碼

HQACellTopView新增到HQACell

/// 頭像的寬度
let AvatarImageViewWidth: CGFloat = 35

class HQACell: UITableViewCell {

    /// 頂部檢視
    fileprivate lazy var topView: HQACellTopView = HQACellTopView()
    /// 正文
    lazy var contentLabel: UILabel = UILabel(hq_title: "正文", fontSize: 15, color: UIColor.darkGray)

    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        setupUI()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}複製程式碼
// MARK: - UI
extension HQACell {

    fileprivate func setupUI() {

        addSubview(topView)
        addSubview(contentLabel)

        topView.snp.makeConstraints { (make) in
            make.top.equalTo(self)
            make.left.equalTo(self)
            make.right.equalTo(self)
            make.height.equalTo(margin * 2 + AvatarImageViewWidth)
        }
        contentLabel.snp.makeConstraints { (make) in
            make.top.equalTo(topView.snp.bottom).offset(margin / 2)
            make.left.equalTo(self).offset(margin)
            make.right.equalTo(self).offset(0)
            make.bottom.equalTo(self).offset(-margin / 2)
        }
    }
}複製程式碼

在控制器中給微博正文Label賦值

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

    /// 重寫父類的方法
    override func setupTableView() {
        super.setupTableView()

        navItem.leftBarButtonItem = UIBarButtonItem(hq_title: "好友", target: self, action: #selector(showFriends))
        tableView?.register(HQACell.classForCoder(), forCellReuseIdentifier: HQACellId)
        tableView?.rowHeight = UITableViewAutomaticDimension
        tableView?.estimatedRowHeight = 400
        tableView?.separatorStyle = .none

        setupNavTitle()
    }複製程式碼

之前載入資料的程式碼

class HQAViewController: HQBaseViewController {

    fileprivate lazy var listViewModel = HQStatusListViewModel()

    /// 載入資料
    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()
            }
        }
    }複製程式碼

tableView的資料來源方法裡面賦值

// MARK: - tableViewDataSource
extension HQAViewController {

    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: HQACellId, for: indexPath) as! HQACell
        cell.contentLabel.text = listViewModel.statusList[indexPath.row].text
        return cell
    }
}複製程式碼

至此,我們的第一個小目標就完成了。看著有幾分神似了。

完善微博資料模型

好友的頭像、暱稱等資訊是儲存於每條微博資料的一個user屬性當中的。

我們就需要再建立一個專門儲存使用者相關資料的模型HQUser

class HQUser: NSObject {

    // 基本資料型別設定成`Optional` 和 private型別修飾的 不能使用`KVC`設定
    var id: Int64 = 0
    /// 使用者暱稱
    var screen_name: String?
    /// 使用者頭像地址(中圖),50×50畫素
    var profile_image_url: String?
    /// 認證型別,-1:沒有認證,0,認證使用者,2,3,5: 企業認證,220: 達人
    var verified_type: Int = 0
    /// 會員等級 0-6
    var mbrank: Int = 0

    override var description: String {
        return yy_modelDescription()
    }
}複製程式碼

然後在之前的HQStatus模型中增加一個user的屬性

/// 使用者屬性資訊
var user: HQUser?複製程式碼

到此為止,我們就可以拿到我們需要的資訊了,雖然突然了一點,但是這都是基於YYModel的功勞。不管我們的資料巢狀多少層,都可以一句程式碼搞定。

yy_modelArray(with: AnyClass, json: Any)這句程式碼的功勞

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, false)

        return
    }
    print("重新整理到 \(array.count) 條資料 \(array)")複製程式碼

array列印的資訊

 {
    id = 4146112736022810;
    text = "【男子將老人拖行至路邊,只因嫌其走路慢?】8月20日,俄羅斯媒體報導,一名男子因喝醉酒,嫌棄老人過馬路走太慢,竟將其拖行至路邊,遭到網友譴責。不過,也有網友看完視訊後替該男子說話,認為對向車道的汽車沒有要停下的意思,他應該是擔心發生危險,出於好意才上前拉住老人,事件仍在調查中。@微丟...全文: http://m.weibo.cn/1887344341/4146112736022810";
    user =  {
        id = 1887344341;
        mbrank = 5;
        profile_image_url = "http://tva1.sinaimg.cn/crop.0.0.599.599.50/707e96d5gw1f88661z1prj20go0goabq.jpg";
        screen_name = "觀察者網";
        verified_type = 5
    }
}複製程式碼

檢視模型的體會

現在我們的程式碼裡面結構

  • HQAViewController首頁控制器
  • HQStatusListViewModel負責載入資料的檢視模型
  • HQStatus資料模型

控制器HQAViewController通過載入資料的檢視模型HQStatusListViewModel取得資料,但是HQStatusListViewModel載入的還是HQStatus資料模型。

HQStatusListViewModel是引用著HQStatus的,而HQStatusListViewModel又是被HQAViewController引用的。相當於控制器還是在直接使用模型。

為了解決上面的問題,需要將載入資料的檢視模型HQStatusListViewModelHQStatus之間的相互引用打斷。因此,才引入了檢視模型(在這裡指單條微博的檢視模型),用於處理單條微博的所有的業務邏輯。相當於把之前寫在View和部分寫在Controller中的程式碼抽取到這裡,達到ControllerView瘦身的作用。

新增單條微博檢視模型HQStatusViewModel

class HQStatusViewModel {

    var status: HQStatus

    init(model: HQStatus) {
        self.status = model
    }
}複製程式碼

調整HQStatusListViewModel中程式碼

主要目的就是使HQStatusListViewModelHQStatus分離,通過HQStatusViewModel來聯絡之間的關係。

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

    /// 微博檢視模型的懶載入
    lazy var statusList = [HQStatusViewModel]()

    /// 上拉重新整理錯誤次數
    fileprivate var pullupErrorTimes = 0

    /// 載入微博資料字典陣列
    ///
    /// - Parameters:
    ///   - completion: 完成回撥,微博字典陣列/是否成功
    func loadStatus(pullup: Bool, completion: @escaping (_ isSuccess: Bool, _ shouldRefresh: Bool)->()) {

        if pullup && pullupErrorTimes > maxPullupTryTimes {

            completion(true, false)
            print("超出3次 不再走網路請求方法")
            return
        }

        // 取出微博中已經載入的第一條微博(最新的一條微博)的`since_id`進行比較,對下拉重新整理做處理
        let since_id = pullup ? 0 : (statusList.first?.status.id ?? 0)
        // 上拉重新整理,取出陣列的最後一條微博`id`
        let max_id = !pullup ? 0 : (statusList.last?.status.id ?? 0)

        HQNetWorkManager.shared.statusList(since_id: since_id, max_id: max_id) { (list, isSuccess) in

            // 如果網路請求失敗,直接執行完成回撥
            if !isSuccess {

                completion(false, false)
                return
            }

            /*
             遍歷字典陣列,字典轉模型
             模型->檢視模型
             將檢視模型新增到陣列
             */
            var arrayM = [HQStatusViewModel]()

            for dict in list ?? [] {

                // 建立微博模型
                let status = HQStatus()

                // 字典轉模型
                status.yy_modelSet(with: dict)

                // 使用`HQStatus`建立`HQStatusViewModel`
                let viewModel = HQStatusViewModel(model: status)

                // 新增到陣列
                arrayM.append(viewModel)
            }

            print(arrayM)
        }
    }
}複製程式碼

至此,列印輸出arrayMHQStatusViewModel的檢視模型陣列,如下

[
HQSwiftMVVM.HQStatusViewModel,
HQSwiftMVVM.HQStatusViewModel,
。
。
。
HQSwiftMVVM.HQStatusViewModel,
HQSwiftMVVM.HQStatusViewModel
]複製程式碼

程式碼對比

由於控制檯輸出上面的格式,非常不便於我們除錯,這裡再擴充一個小技巧。

如果一個類沒有任何父類,在開發時需要輸出除錯資訊,需要遵守如下規則:

  • 遵守CustomStringConvertible協議
  • 實現description方法
class HQStatusViewModel: CustomStringConvertible {

    var status: HQStatus

    init(model: HQStatus) {
        self.status = model
    }

    var description: String {
        return status.description
    }
}複製程式碼

此時再次執行程式,剛才的列印輸出,就變成如下內容

[
。
。
。
<HQSwiftMVVM.HQStatus: 0x608000272140> {
    id = 4146549921682611;
    text = "【零難度照燒雞腿便當!】開學了,你可別輸在“起跑飯”上@罐頭視訊http://t.cn/RN2e2EF";
    user = <HQSwiftMVVM.HQUser: 0x6080002c3790> {
        id = 1977460817;
        mbrank = 4;
        profile_image_url = "http://tva4.sinaimg.cn/crop.6.5.171.171.50/75dda851jw8ev8xowav75j2050050aa5.jpg";
        screen_name = "網路新聞聯播";
        verified_type = 3
    }
}
]複製程式碼

這樣就非常直觀了,我們就可以愉快的繼續玩耍了。

雖然增加了HQStatusViewModel這個單條微博的檢視模型,並且對負責載入資料的HQStatusListViewModel檢視模型進行了調整,使其和HQStatus直接分離。但是實際上我們在HQAViewController中的程式碼並沒有很大的改動。僅僅是下面賦值的時候稍微改動了一點點而已。

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

    let cell = tableView.dequeueReusableCell(withIdentifier: HQACellId, for: indexPath) as! HQACell

    let viewModel = listViewModel.statusList[indexPath.row]

    cell.contentLabel.text = viewModel.status.text

    return cell複製程式碼

給表格控制元件賦值

以前我們的套路是,在自定義cellmodel屬性的set方法裡賦值。現在仍然延續之前的套路。

在自定義cellviewModel屬性的didSet方法裡賦值。

class HQACell: UITableViewCell {

    var viewModel: HQStatusViewModel? {
        didSet {

            contentLabel.text = viewModel?.status.text
            topView.viewModel = viewModel
        }
    }複製程式碼

因為之前說過,我們是將自定義cell拆分成幾個部分。那麼暱稱和頭像這類的賦值就不能直接在cell中完成,我們只需要將viewModel傳給topView,然後在topView中賦值就好了。

class HQACellTopView: UIView {

    var viewModel: HQStatusViewModel? {
        didSet {
            nameLabel.text = viewModel?.status.user?.screen_name
        }
    }複製程式碼

接下來,我們要做的就是在控制器中將viewModel傳到cell中就可以了。

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

    let cell = tableView.dequeueReusableCell(withIdentifier: HQACellId, for: indexPath) as! HQACell

    let viewModel = listViewModel.statusList[indexPath.row]

    cell.viewModel = viewModel複製程式碼

到此,我們實現的效果是正文和暱稱可以正常顯示了

到這裡其實就應該多多少少能體會到檢視模型的一點點好處了。

  • 有專門負責載入資料的檢視模型
  • 有專門處理業務邏輯的檢視模型
  • 控制器和模型之間可以解除耦合
  • 檢視可以進一步拆分,各處耦合性都不是很大,而且又比較容易處理邏輯問題

但是現在為止,還沒有完全發揮出檢視模型的最大功能,繼續往下看!

設定會員圖示

這裡就能展示出檢視模型的優點了,會員分不同的等級對應不同的圖示,我們要根據返回的mbrank的值,來給會員圖示的ImageView設定影象。如果是以前,我們就需要在celldidSet方法中去寫判斷,大概程式碼是這樣的

class HQACell: UITableViewCell {

    var viewModel: HQStatusViewModel? {
        didSet {

            contentLabel.text = viewModel?.status.text

            // 會員等級
            if (viewModel?.status.user?.mbrank)! > 0 && (viewModel?.status.user?.mbrank)! < 7 {
                let imageName = "common_icon_membership_level\(viewModel?.status.user?.mbrank ?? 1)"
                memberIconView.image = UIImage(named: imageName)
            }
        }
    }複製程式碼

可能你會感覺沒什麼,平時就這麼寫的啊。但是這麼小的一個控制元件都要這幾行程式碼塞在這裡。每一條微博有那麼多控制元件,都在這裡一個一個判斷嗎?

而且這個控制元件的邏輯判斷算是簡單的,如果邏輯判斷複雜的就不是4行程式碼的事情了。

試著把程式碼這部分程式碼放到viewModel中嘗試一下。

在單條檢視模型HQStatusViewModel裡定義一個會員圖示的屬性,並且在檢視模型裡面處理不同等級顯示不同圖示的業務邏輯

class HQStatusViewModel: CustomStringConvertible {

    var status: HQStatus

    /// 會員圖示
    var memberIcon: UIImage?

    init(model: HQStatus) {
        self.status = model

        // 會員等級
        if (model.user?.mbrank)! > 0 && (model.user?.mbrank)! < 7 {
            let imageName = "common_icon_membership_level\(model.user?.mbrank ?? 1)"
            memberIcon = UIImage(named: imageName)
        }
    }複製程式碼

然後再回到自定義的HQACellTopView中設定會員圖示

class HQACellTopView: UIView {

    var viewModel: HQStatusViewModel? {
        didSet {
            memberIconView.image = viewModel?.memberIcon
        }
    }複製程式碼

而且HQACell中的程式碼我們一點都沒有改動,還是原來的樣子

class HQACell: UITableViewCell {

    var viewModel: HQStatusViewModel? {
        didSet {

            contentLabel.text = viewModel?.status.text
            topView.viewModel = viewModel
        }
    }複製程式碼

到這裡是不是有點感覺了。漸漸的體會到檢視模型的好處了吧。不僅是為控制器瘦身,連View的程式碼都比之前更少更清晰了。

關於效能的一點探討

之前在didSet方法中設定時,如果是表格,每次滾出螢幕再滾動回來的時候都要重新執行didSet方法,重新計算。不斷的消耗CPU。一定會多多少少影響一點效能的。

而在ViewModel中的我們自定義的memberIcon是一個儲存型屬性,在init建構函式中,直接計算出該是哪個會員圖示。計算好以後,下次就可以直接使用,不再需要計算了。這樣會比較耗記憶體,但是記憶體得到警告的話,我們可以去釋放記憶體。但是CPU消耗的多了,就會直接造成表格的卡頓。

關於表格效能的優化:

  • 儘量少計算,所有需要的素材提前計算好。
  • 控制元件上不要設定圓角半徑,所有影象渲染的屬性都要注意。
  • 不要動態建立控制元件,所有需要的控制元件,都要提前建立好,根據需要來隱藏/顯示
  • 所有的目的都是為了減少CPU的消耗,用記憶體來換CPU

設定認證圖示

按照設定會員圖示的思路來設定認證圖示

  • HQStatusViewModel中定義一個認證圖示的圖片屬性
class HQStatusViewModel: CustomStringConvertible {

    /// 認證圖示(-1:沒有認證, 0:認證使用者, 2,3,5:企業認證, 220:達人)
    var vipIcon: UIImage?複製程式碼
  • HQStatusViewModel中根據返回資料verified_type型別來設定vipIcon該顯示哪張圖示
class HQStatusViewModel: CustomStringConvertible {

    init(model: HQStatus) {
        self.status = model

        // 認證圖示
        switch model.user?.verified_type ?? -1 {
        case 0:
            vipIcon = UIImage(named: "avatar_vip")
        case 2, 3, 5:
            vipIcon = UIImage(named: "avatar_enterprise_vip")
        case 220:
            vipIcon = UIImage(named: "avatar_grassroot")
        default:
            break
        }
    }複製程式碼
  • HQACellTopViewviewModeldidSet方法中為vipIconImageView設定影象
class HQACellTopView: UIView {

    var viewModel: HQStatusViewModel? {
        didSet {
            vipIconImageView.image = viewModel?.vipIcon
        }
    }複製程式碼

這樣設定的時候,就不用再像之前那樣,好多的邏輯判斷都放在viewviewModeldidSet方法裡面去判斷了。我們設定的時候,只需要將檢視模型的屬性直接賦值到相應的控制元件就好。是不是方便了很多。簡化了程式碼。


隔離SDWebImage,設定頭像

隔離SDWebImage

在專案中,我們經常會用到各種第三方框架,除了一些比較知名的框架以外,其它框架都存在這不穩定的因素,就算是知名的框架,也是總在更新的。為了以防萬一,我們最好是能將第三方框架隔離出來。這樣日後更換的時候也會省了不少的麻煩。

建立一個UIImageViewExtension,即HQImageView

SDWebImage的設定影象的方法封裝起來

import UIKit
import SDWebImage

// MARK: - 隔離`SDWebImage框架`
extension UIImageView {

    /// 隔離`SDWebImage`設定影象函式
    ///
    /// - Parameters:
    ///   - urlString: urlString
    ///   - placeholderImage: placeholderImage
    ///   - isAvatar: 是否是頭像(圓角)
    func hq_setImage(urlString: String?, placeholderImage: UIImage?, isAvatar: Bool = false) {

        guard let urlString = urlString,
            let url = URL(string: urlString)
            else {

                image = placeholderImage
                return
        }

        sd_setImage(with: url, placeholderImage: placeholderImage, options: []) { [weak self] (image, _, _, _) in

            if isAvatar {
                self?.image = image?.hq_avatarImage(size: self?.bounds.size)
            } else {
                self?.image = image?.hq_rectImage(size: self?.bounds.size)
            }
        }
    }
}複製程式碼

設定頭像

class HQACellTopView: UIView {

    var viewModel: HQStatusViewModel? {
        didSet {
            avatarImageView.hq_setImage(urlString: viewModel?.status.user?.profile_image_url, placeholderImage: UIImage(named: "avatar_default_big"), isAvatar: true)
            memberIconView.image = viewModel?.memberIcon?.hq_rectImage(size: memberIconView.bounds.size)
        }
    }複製程式碼

Color Blended Layers效果如下

Color Misaligned Images效果如下

可以看到,經過程式碼設定以後,頭像vip等級圖示已經完全沒有問題了。

但是,頭像右下角的認證圖示還是存在問題的。而我並沒有去處理它,因為,如果像處理vip等級圖示那樣處理的話,認證圖示周圍四個角,會有白色的背景顯示,會遮擋頭像,效果非常不好,而我暫時也並沒有太好的辦法去處理,暫時就不對其做處理了。

如果用程式碼處理是這樣的

class HQACellTopView: UIView {

    var viewModel: HQStatusViewModel? {
        didSet {
//            vipIconImageView.image = viewModel?.vipIcon?.hq_rectImage(size: vipIconImageView.bounds.size)
            vipIconImageView.image = viewModel?.vipIcon?.hq_rectImage(size: CGSize(width: 30, height: 30))
        }
    }複製程式碼

效果是這樣的

雖然在Color Blended Layers模式下,不會有紅色的問題,但是這裡真的不能那樣做

補充:

如果設定hq_rectImage控制檯會列印error,下面這句程式碼

memberIconView.image = viewModel?.memberIcon?.hq_rectImage(size: memberIconView.bounds.size)複製程式碼

雖然控制檯列印輸出error,但是並沒有影響程式的執行。報錯如下

: CGContextSetFillColorWithColor: invalid context 0x0. If you want to see the backtrace, please set CG_CONTEXT_SHOW_BACKTRACE environmental variable.
: CGContextGetCompositeOperation: invalid context 0x0. If you want to see the backtrace, please set CG_CONTEXT_SHOW_BACKTRACE environmental variable.
: CGContextSetCompositeOperation: invalid context 0x0. If you want to see the backtrace, please set CG_CONTEXT_SHOW_BACKTRACE environmental variable.
: CGContextFillRects: invalid context 0x0. If you want to see the backtrace, please set CG_CONTEXT_SHOW_BACKTRACE environmental variable.複製程式碼

原因是因為在cell佈局的時候,有時memberIconView.bounds.size的值為(0.0, 0.0)

class HQACellTopView: UIView {

    var viewModel: HQStatusViewModel? {
        didSet {
            print("memberIconView.bounds.size = \(memberIconView.bounds.size)")
            memberIconView.image = viewModel?.memberIcon?.hq_rectImage(size: memberIconView.bounds.size)複製程式碼

輸出結果

memberIconView.bounds.size = (0.0, 0.0)複製程式碼

解決辦法

目前我還沒有想到什麼比較好的解決辦法,只是設定size的時候,給定了固定一個值

memberIconView.image = viewModel?.memberIcon?.hq_rectImage(size: CGSize(width: 17, height: 17))複製程式碼

這樣控制檯就不會再輸出error

佈局底部檢視

按照之前的邏輯,將底部檢視HQACellBottomView也拆分出來,方便邏輯的處理。

我先根據需要自定義封裝了一個快速建立ButtonExtension

extension UIButton {

    /// 標題 + 字號 + 文字顏色 + 圖片 + 背景圖片
    ///
    /// - Parameters:
    ///   - hq_title: title
    ///   - fontSize: fontSize
    ///   - color: color
    ///   - imageName: 圖片
    ///   - backImage: 背景圖片
    ///   - titleEdge: 圖片和文字間距
    convenience init(hq_title: String, fontSize: CGFloat, color: UIColor, imageName: String, backImage: String, titleEdge: CGFloat) {
        self.init()

        setTitle(hq_title, for: .normal)
        titleLabel?.font = UIFont.systemFont(ofSize: fontSize)
        setTitleColor(color, for: .normal)
        setImage(UIImage(named: imageName), for: .normal)

        setBackgroundImage(UIImage(named: backImage), for: .normal)

        titleEdgeInsets = UIEdgeInsetsMake(0, titleEdge, 0, -titleEdge)

        sizeToFit()
    }複製程式碼

然後進行佈局

class HQACellBottomView: UIView {

    /// 轉發
    fileprivate lazy var retweetedButton: UIButton = UIButton(hq_title: " 轉發", fontSize: 12, color: UIColor.darkGray, imageName: "timeline_icon_retweet", backImage: "timeline_card_bottom_background", titleEdge: 5)
    /// 評論
    fileprivate lazy var commentButton: UIButton = UIButton(hq_title: " 評論", fontSize: 12, color: UIColor.darkGray, imageName: "timeline_icon_comment", backImage: "timeline_card_bottom_background", titleEdge: 5)
    /// 贊
    fileprivate lazy var likeButton: UIButton = UIButton(hq_title: " 贊", fontSize: 12, color: UIColor.darkGray, imageName: "timeline_icon_unlike", backImage: "timeline_card_bottom_background", titleEdge: 5)
    /// 分割線
    fileprivate lazy var sepView01: UIImageView = UIImageView(hq_imageName: "timeline_card_bottom_line_highlighted")
    /// 分割線
    fileprivate lazy var sepView02: UIImageView = UIImageView(hq_imageName: "timeline_card_bottom_line_highlighted")

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

        setupUI()
    }

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

// MARK: - UI
extension HQACellBottomView {

    fileprivate func setupUI() {

        backgroundColor = UIColor(white: 0.9, alpha: 1.0)

        addSubview(retweetedButton)
        addSubview(commentButton)
        addSubview(likeButton)
        addSubview(sepView01)
        addSubview(sepView02)

        retweetedButton.snp.makeConstraints { (make) in
            make.top.equalTo(self)
            make.left.equalTo(self)
            make.bottom.equalTo(self)
        }
        commentButton.snp.makeConstraints { (make) in
            make.top.equalTo(retweetedButton)
            make.left.equalTo(retweetedButton.snp.right)
            make.width.equalTo(retweetedButton)
            make.height.equalTo(retweetedButton)
        }
        likeButton.snp.makeConstraints { (make) in
            make.top.equalTo(commentButton)
            make.left.equalTo(commentButton.snp.right)
            make.width.equalTo(commentButton)
            make.height.equalTo(commentButton)
            make.right.equalTo(self)
        }
        sepView01.snp.makeConstraints { (make) in
            make.right.equalTo(retweetedButton)
            make.centerY.equalTo(retweetedButton)
        }
        sepView02.snp.makeConstraints { (make) in
            make.right.equalTo(commentButton)
            make.centerY.equalTo(commentButton)
        }
    }
}複製程式碼

然後將bottomView新增到cell的上

class HQACell: UITableViewCell {

    /// 底部檢視
    fileprivate lazy var bottomView: HQACellBottomView = HQACellBottomView()複製程式碼
// MARK: - UI
extension HQACell {

    fileprivate func setupUI() {

        addSubview(bottomView)

        bottomView.snp.makeConstraints { (make) in
            make.top.equalTo(contentLabel.snp.bottom).offset(margin)
            make.left.equalTo(self)
            make.right.equalTo(self)
            make.height.equalTo(44)
            make.bottom.equalTo(self)
        }複製程式碼

顯示效果如下所示

CellBottomView賦值

bottomView的每個Button上面都是如果有轉發評論都是顯示對應的數量,否則只顯示漢字。

先擴充套件模型,增加相應欄位

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

    /// 轉發數
    var reposts_count: Int = 0
    /// 評論數
    var comments_count: Int = 0
    /// 表態數
    var attitudes_count: Int = 0複製程式碼

bottomView中賦值

class HQACellBottomView: UIView {

    var viewModel: HQStatusViewModel? {
        didSet {
            retweetedButton.setTitle("\(viewModel?.status.reposts_count)", for: .normal)
            commentButton.setTitle("\(viewModel?.status.comments_count)", for: .normal)
            likeButton.setTitle("\(viewModel?.status.attitudes_count)", for: .normal)
        }
    }複製程式碼

viewModel傳到bottomViewviewModel

class HQACell: UITableViewCell {

    var viewModel: HQStatusViewModel? {
        didSet {

            bottomView.viewModel = viewModel
        }
    }複製程式碼

效果如下所示

因為這裡需要對返回資料進行處理,並且不同情況有不同的顯示情況

  • 如果數量 == 0, 顯示預設標題
  • 如果數量 >= 10000,顯示 x.xx 萬
  • 如果數量 < 10000, 顯示實際數字

而這些邏輯當然都要交給ViewModel來處理了

首先定義對應的字串變數

class HQStatusViewModel: CustomStringConvertible {

    /// 轉發
    var retweetString: String?
    /// 評論
    var commentString: String?
    /// 贊
    var likeSting: String?複製程式碼

接下來,自定義一個方法,根據返回的資料,及我們的需求建立出不同字串的方法

class HQStatusViewModel: CustomStringConvertible {

    /// 給定一個數字,返回對應的描述結果
    ///
    /// - Parameters:
    ///   - count: 數字
    ///   - defaultString: 預設字串(轉發、評論、贊)
    fileprivate func countString(count: Int, defaultString: String) -> String {

        if count == 0 {
            return defaultString
        }

        if count < 10000 {
            return count.description
        }

        return String(format: "%0.2f 萬", CGFloat(count)  / 10000)
    }複製程式碼

然後在檢視模型的構造方法裡面設定值

class HQStatusViewModel: CustomStringConvertible {

    init(model: HQStatus) {

        // 轉發、評論、贊
        retweetString = countString(count: model.reposts_count, defaultString: "轉發")
        commentString = countString(count: model.comments_count, defaultString: "評論")
        likeSting = countString(count: model.attitudes_count, defaultString: "贊")複製程式碼

最後一步,在HQACellBottomView中賦值

class HQACellBottomView: UIView {

    var viewModel: HQStatusViewModel? {
        didSet {
            retweetedButton.setTitle(viewModel?.retweetString, for: .normal)
            commentButton.setTitle(viewModel?.commentString, for: .normal)
            likeButton.setTitle(viewModel?.likeSting, for: .normal)
        }
    }複製程式碼

效果如下


測試

開發中,任何一個可能的情況我們都要儘可能 的測試到,否則過了很久以後再發現問題,很可能就找不到有問題的地方了。

這裡,我們還缺少數量超過10000的情況,所以我們需要自己造資料測試一下

因為是檢視模型處理業務邏輯,因此,測試的時候,我們直接在檢視模型裡面處理就好。這樣會對ViewController做盡可能少的侵害。

class HQStatusViewModel: CustomStringConvertible {

    init(model: HQStatus) {
        self.status = model

        // 測試數量超過`10000`的情況
        model.reposts_count = Int(arc4random_uniform(100000))
        // 轉發、評論、贊
        retweetString = countString(count: model.reposts_count, defaultString: "轉發")
        commentString = countString(count: model.comments_count, defaultString: "評論")
        likeSting = countString(count: model.attitudes_count, defaultString: "贊")複製程式碼

效果如下


小結

檢視模型的作用

  • 把要計算的業務邏輯全部抽取出去
  • 在檢視中,需要什麼,直接去檢視模型中取相關的屬性
  • 檢視裡面不再需要考慮計算相關的問題

DEMO傳送門:HQSwiftMVVM

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


最後,發個求職廣告。小弟最近在求職,現工作在北京,準備去杭州發展,有願意幫忙推薦、介紹、或者丟擲橄欖枝的,在下感激不盡!

聯絡方式

郵箱:

  • 13120010341@163.com

微信

相關文章