Swift-MVVM 简单演练(一)

丁志勇
2023-12-01

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来了

转载于:https://juejin.im/post/5972d59d51882578fe4fe7d4

 类似资料: