本文是对 iOS 项目模版 演示部分的解释说明
可以拉取 tag 4.1 的代码以保证本文与演示内容一致。
登录注册 UI 实现
项目跑起来后首先看到的是常规登录注册流程,基本可以直接用于生产环境,只是演示用的接口数据是静态的。
这几个页面都在 Scene/Login/LoginVCs.swift 里,可以看到五个页面的业务代码不过两百多行,而且大部分代码都是在请求接口。
你可能注意到表单是有验证的,输入不满足要求提交按钮不可点,还有提示,而且输入框是可以切换到下一个字段的:
UI 定义在 storyboard 中很正常,那表单验证和跳转是在哪儿实现的呢?让我们打开 Login.storyboard,可以看到每个表单有一个 Form Field Verify Control 对象,它连接了表单中的输入框和提交按钮:
经过这样的关联后,当输入框文字变化时,这个控件会向关联的输入框们询问是不是都验证通过,如果通过提交按钮可用,否则禁用。
输入框上也有不少逻辑,首先定义了一组字符串枚举,用来定义当前输入框应该输入的是什么内容:
enum TextFieldContentType: String {
case mobile // 手机号
case code // 验证码
case password // 密码,不验证长度,只验证非空
case password2 // 密码验证,严格验证
case userName // 用户名
case email // 电子邮箱
case name // 姓名
case required // 非空,为空时用 placeholder 提示
}
之后根据不同的内容做验证,验证方法是这样定义的:
class TextField: MBTextField {
/// 自动验证、提示并返回合法值
///
/// - Parameters:
/// - noticeWhenInvaild: 内容非法时弹出报错提示
/// - becomeFirstResponderWhenInvaild: 内容非法时获取键盘焦点
/// - Returns: 合法值
func vaildFieldText(noticeWhenInvaild: Bool = true, becomeFirstResponderWhenInvaild: Bool = true) -> String? {
...
}
}
区分内容除了做验证,还设置了键盘类型、UITextContentType。输入框间的切换是通过 nextField 属性关联的,如果输入框的 nextField 指向其他输入框,则回车时把焦点切换到下一个输入框;如果指向的是按钮,则指向按钮点击事件。
相关实现详见 General/Text/TextField.swift 和 MBAppKit/MBTextField。
你可能也注意到表单的 UI 都是定义在另外的 UITableViewController 中的,这点在代码里有解释:Scene/Login/LoginForms.swift。
修改密码分两步,第一步通过验证码验证身份,获取到一个一次性 token,第二步用这个 token 设置新密码。但两个页面的代码里是找不到传这个 token 的代码的,segue 跳转有个默认的传值方式,参见框架介绍 - 界面间传值方式。
开始登入
登入成功后接口的响应很简单,拿到用户信息、token 之类的信息后创建用户对象,最后设置 Account.current 就完成了,相关代码:
// LoginVCs.swift Line: 65
// 登入请求
API.requestName("SignInUp") { c in
c.parameters = ["mobile": mobile, "code": code]
...
c.success { _, rsp in
guard let item = rsp as? LoginResponseEntity else { fatalError() }
item.setAsCurrent()
}
}
// LoginResponseEntity.swift Line: 41
/// 收到服务器登入信息,设置当前用户
func setAsCurrent() {
guard let info = info, let token = token else {
AppHUD().showErrorStatus("服务器返回信息缺失")
return
}
let user = Account(id: info.uid as String)
user?.token = token
Account.current = user
}
新手经常会在请求成功这里写很多东西:界面跳转、信息保存、对若干服务进行设置。好一点会抽到用户模块的一个方法中,这种情况在用户模块中应该有两个方法:登入时做一些事,登出时做与之相反的一些事。但不论是在 vc,还是在用户模块中,都直接引入了很多其他模块的代码。随着项目变得复杂,会导致一些问题:
- 模块的代码有部分写在了外部,当业务复杂、变动多时容易遗漏写在外面的这部分代码,产生 bug;
- 模块生命会有周期问题,在登入方法引用其他模块难免需要创建它们,即便并不是立即需要这些功能;另一个实际发生的 bug 是应用启动后用户模块正在初始化,判断登入状态并设置其他模块,但在其他模块中又引入了用户模块,导致用户模块创建死循环,有些可以通过延迟设置避免,但不能避免有些是需要立即设置的。
还有些相关问题都是关于模块的,比如模块引用原则问题——基础模块不应引用更高层级的模块,这点之后补在项目 wiki 里。
这里提供的机制是,外部模块可以向 Account 注册账户变化事件,当 Account.current 变化时,再通知这些外部模块。比如导航的注册:
class NavigationController: MBNavigationController {
override func viewDidLoad() {
super.viewDidLoad()
...
// initial 为 true,在调用时就执行 closure
Account.addCurrentUserChangeObserver(self, initial: true) { [weak self] user in
if user != nil {
// 跳转主页
self?.onLogin()
} else {
// 跳转用户登入页
self?.onLogout()
}
}
}
}
导航
登入后我们进入到应用主界面了,底部是 tab 栏,但这个 tab 不是用 UITabBarController 实现的,而是自定义 view,被 NavigationController 持有并管理:
其显隐由导航控制,vc 可以通过属性声明自己是否需要显示 tab。另见框架介绍 - 唯一导航。
帖子部分
列表布局
Demo 的主要页面均支持 Dynamic Type 和 Dark Mode,列表 cell 的布局使用了点技巧,图片和文字可以看成两栏,cell 的高度以两者最高的为准,同时点赞和时间一栏始终是相对 cell 底部的。布局主要靠 stack view。
列表代码组织
帖子列表定义在 TopicListDisplayer,这也是个 UITableViewController,具体列表页通过嵌入的方式显示,为了 UI 复用。
详情布局
详情帖子内容部分利用 MBTableHeaderFooterView 做自适应高度,底部的按钮区域用了 MBBottomLayoutView 支持 iPhone X 等有着非规整矩形屏幕的设备,自动调整位置并裁切圆角:
Model 更新后的刷新
模型实例是跨页面传递的,在任一处点赞操作,变更会反应在其他地方而没有重新向后台请求。这是通过一对多代理实现的,比 KVO 和 Notification 用起来更舒服。
帖子模型切换点赞的方法简化如下:
class TopicEntity: MBModel {
...
@objc private(set) var isLiked: Bool = false
private weak var likeTask: RFAPITask?
/// 切换点赞状态
func toggleLike() {
let positiveAPI = "TopicLikedAdd"
let negativeAPI = "TopicLikedRemove"
if let task = likeTask {
task.cancel()
likeTask = nil
return
}
let shouldLike = !isLiked
isLiked = shouldLike
delegates.invoke { $0.topicLikedChanged?(self) }
likeTask = API.requestName(shouldLike ? positiveAPI : negativeAPI, context: { c in
c.parameters = ["tid": self.uid]
c.complation { task, _, _ in
if task?.isSuccess == false {
self.isLiked = !shouldLike
self.delegates.invoke { $0.topicLikedChanged?(self) }
}
}
})
}
lazy var delegates = MulticastDelegate<TopicEntityUpdating>()
}
// 状态更新协议
// 需要可选实现,需要标记成 @objc
@objc protocol TopicEntityUpdating {
@objc optional func topicLikedChanged(_ item: TopicEntity)
...
}
需要关注模型变化的地方添加监听,无需手动移除,对象释放会自动解除关联,但列表 cell 会复用,需要移除。帖子列表 cell 实现简化如下:
/// 帖子列表 cell
class TopicListCell: UITableViewCell, TopicEntityUpdating {
@objc var item: TopicEntity! {
didSet {
if let old = oldValue {
old.delegates.remove(self)
}
item.delegates.add(self)
... // 其他 UI 设置
topicLikedChanged(item)
}
}
@IBOutlet private weak var likeButton: UIButton!
@IBAction private func onLikeButtonTapped(_ sender: Any) {
item.toggleLike()
}
func topicLikedChanged(_ item: TopicEntity) {
likeButton.isSelected = item.isLiked
likeButton.text = (item.isLiked ? "已赞" : "点赞") + " \(item.likeCount)"
}
}