iOS 项目模板 demo 注解

862 阅读6分钟

本文是对 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.swiftMBAppKit/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,还是在用户模块中,都直接引入了很多其他模块的代码。随着项目变得复杂,会导致一些问题:

  1. 模块的代码有部分写在了外部,当业务复杂、变动多时容易遗漏写在外面的这部分代码,产生 bug;
  2. 模块生命会有周期问题,在登入方法引用其他模块难免需要创建它们,即便并不是立即需要这些功能;另一个实际发生的 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 持有并管理:

Tab 导航及实现

其显隐由导航控制,vc 可以通过属性声明自己是否需要显示 tab。另见框架介绍 - 唯一导航

帖子部分

列表布局

列表的响应式布局

Demo 的主要页面均支持 Dynamic Type 和 Dark Mode,列表 cell 的布局使用了点技巧,图片和文字可以看成两栏,cell 的高度以两者最高的为准,同时点赞和时间一栏始终是相对 cell 底部的。布局主要靠 stack view。

列表 cell 布局分析

列表代码组织

帖子列表定义在 TopicListDisplayer,这也是个 UITableViewController,具体列表页通过嵌入的方式显示,为了 UI 复用。

详情布局

详情帖子内容部分利用 MBTableHeaderFooterView 做自适应高度,底部的按钮区域用了 MBBottomLayoutView 支持 iPhone X 等有着非规整矩形屏幕的设备,自动调整位置并裁切圆角:

MBBottomLayoutView 在不同设备上的效果

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)"
    }
}