Swift-MVVM 简单演练(二) Swift-MVVM 简单演练(三) Swift-MVVM 简单演练(四)
最近在学习swift
和MVVM
架构模式,目的只是将自己的学习笔记记录下来,方便自己日后查找,仅此而已!!!
本来打算一篇全部搞定的,但是简书每篇文章只能写大约不超过15000字的内容,因此只能分开写了。
如果有任何问题,欢迎和我一起讨论。当然如果有什么存在的问题,欢迎批评指正,我会积极改造的!
NavgationBar
NSLayoutConstraint
VFL
布局(VisualFormatLanguage)
tabBar
的标题和图片样式如有需要,请移步下面两篇文章
ViewController.swift
、Main.storyboard
和LaunchScreen.storyboard
APPIcon
和LaunchImage
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
方法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)
}
UIBarButtonItem
方法不能方便的满足我们创建所需的leftBarButtonItem
或rightBarButtonItem
一般自定义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))
便利构造函数的作用:简化控件的创建
leftBarButtonItem
及title
的字体有渐融的问题,我们又想解决这样的问题。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)
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
这里如果能直接改好了就最好小技巧:
右键
->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.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
里面
在基类设置datasource
和delegate
,这样子类就可以直接实现方法就可以了,不用每个tableView
的页面都去设置tableView?.dataSource = self
和tableView?.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
里面实现了tableView
的tableViewDataSource
和tableViewDelegate
以及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
的后面,底部位置也有问题主要在HQBaseViewController
里,重新设置tableView
的ContentInsets
/*
取消自动缩进,当导航栏遇到`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
添加监听方法,监听refreshControl
的valueChange
事件loadData
方法loadData
方法,因此不用在去子类重写此方法// 设置刷新控件
refreshControl = UIRefreshControl()
tableView?.addSubview(refreshControl!)
refreshControl?.addTarget(self, action: #selector(loadData), for: .valueChanged)
swift
和objective-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))
H
水平方向V
竖直方向|
边界[]
包含控件的名称字符串,对应关系在views
字典中定义()
定义控件的宽/高,可以在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)
有几点需要注意的
tiValue
的M_PI
在swift 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)
}
将之前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
}
在之前的代码中,json
的反序列化的时候,我们遇到了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!
并且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...
避免程序崩溃。
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
开发中,编译器自动添加retain
、release
、autorelease
,如果用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
。
在这里HQVistorView
和HQBaseViewController
是紧耦合的关系,HQVistorView
可以看成是从属于HQBaseViewController
。基本不会被在其它地方被用到。虽然是紧耦合,但是添加监听方法特别简单。是否需要解耦需要根据实际情况判断,没必要为了解耦而解耦,为了模式而模式。
总结
TableView
addTarget
的方式为该视图中的按钮添加监听方法如果单纯的在setupVistorView
中设置leftBarButtonItem
和rightBarButtonItem
,那么在首页就会出现左侧的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";
AFNetworking
的GET
和POST
请求注意:
如果你的闭包是这样的写法
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
中的网络请求方法虽然进行了一些封装,但是还是要在控制器中填写urlString
和para
,如果能把这些也直接封装到一个便于管理的地方,就更好了。这样,当我们偶一个网络接口的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
中拿到的就是最有用的数组数据,下一步就直接字典转模型就可以了。和之前把网络请求url
和para
都放在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
过期,我们仍然向服务器请求数据,那么就会报错
Error Domain=com.alamofire.error.serialization.response Code=-1011
"Request failed: forbidden (403)"
UserInfo={
com.alamofire.serialization.response.error.response=<NSHTTPURLResponse: 0x608000225bc0>
{
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 726f7222 3a22696e 76616c69 645f6163 63657373 5f746f6b 656e222c 22657272 6f725f63 6f646522 3a323133 33322c22 72657175 65737422 3a222f32 2f737461 74757365 732f686f 6d655f74 696d656c 696e652e 6a736f6e 227d>,
NSLocalizedDescription=Request failed: forbidden (403)}
我们需要在网络请求失败的时候做个处理
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
的使命
初体验
因为MVVM
在swift
中都是没有父类的,所以先说下关于父类的选择问题
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
参考: