Swift 实践篇之链式 UI 代码

| categories: iOS Swift | tags: iOS Swift

前言


本篇博客主要介绍 Swift 实践方面的一个技巧,链式 UI 代码。链式代码在 Swift 中有着比 Objective-C 天然的优势。而且通过 Swift 语言本身强大的特性,只需要很少的代码就可以让自己的 Swift 工程具有编写链式 UI 代码的能力。

缘由


先来回答一个问题,为什么要用链式代码来写 UI?答案就是,提高代码可读性。代码可读性的重要性在软件工程的意义不必多说。而代码可读性可以从许多方面优化,比如,合适的缩进、正确的命名、清晰有逻辑的结构等等。链式 UI 代码正是从清晰有逻辑的结构这点出发,提高代码可读性的一种方案。

在 iOS 开发中,我们每天都要 UI 代码打交道。而在 UI 的实现方面,用 Storyboad 实现还是代码实现,是 iOS 开发领域中一个永不停息的争论。两者各有优势,关于这个的讨论也不在本篇博客范围内。不过无论是用何种形式写 UI, 代码或者 Storyboard, 如果整体的 UI 布局没有一个清晰的分层和结构,那么维护起来都会吃力。本篇的重点在于介绍一种代码风格,一个使用链式代码来写 UI 布局的代码风格,而不在于讲解如何将 UI 布局做得结构清晰。

前置条件


有一些前置条件需要交代一下。接下来的介绍中,UI 布局通过代码来完成,而且使用的是 AutoLayout 而非绝对布局。相信没有人会用原生的 Autolayout API 来写全部的 UI 吧,一般会选用一个封装库。博客下面的介绍以最常见的 Autolayout 封装库 SnapKit 来举例,如果你的工程使用了别的库,根据情况来修改相关的代码即可。

核心代码实现


先来分析一下 UI 布局代码的结构。翻出一段使用代码完成的 UI 布局代码看一看,你会发现其中的一些特定的结构。

第一步肯定是会初始化一个 view,接下来通过一系列步骤完成需求:添加到父 view、添加视图约束、配置属性。在这接下来的三个步骤里,添加到父视图这个步骤一定要在添加视图约束这个步骤之前。至于配置 view 属性这个步骤,在这两个步骤之前或之后都没有问题。

除了初始化 view 这个步骤,将其后的三个步骤抽取出来,做成三个通用的链式布局函数,将这三个函数使用 extension 机制扩展到 UIView 中。实现代码如下:

import UIKit
import SnapKit

protocol ViewChainable {}
extension ViewChainable where Self: UIView {
    @discardableResult
    func config(_ config: (Self) -> Void) -> Self {
        config(self)
        return self
    }
}
extension UIView: ViewChainable {
    func adhere(toSuperView: UIView) -> Self {
        toSuperView.addSubview(self)
        return self
    }
    @discardableResult
    func layout(snapKitMaker: (ConstraintMaker) -> Void) -> Self {
        self.snp.makeConstraints { (make) in
            snapKitMaker(make)
        }
        return self
    }
}

以上就是完成链式 UI 布局的所有代码。仅仅 20 多行代码就完成了整个核心功能,是不是很棒?不需要引用任何第三方库,只要将这段代码拷贝到 Swift 工程中,你的工程马上就有了写链式 UI 代码的能力。

来个样例说明用法,比如,我们要在一个 view 中添加一个 label,这个 label 水平居中,它的 top 与父 view 的 top 相距 80。代码如下:

let label = UILabel()
    .adhere(toSuperView: view)
    .layout { (make) in
        make.top.equalToSuperview().offset(80)
        make.centerX.equalToSuperview()
    }
    .config { (label) in
        label.backgroundColor = UIColor.clear
        label.font = UIFont.systemFont(ofSize: 20)
        label.textColor = UIColor.darkGray
        label.text = "Label"
    }

那么这段样例代码有什么好处呢?首先,从这段代码中,可以明显看到 UI 布局的步骤,而且添加约束和配置 view 的具体逻辑代码,被不同的 Closure 封装起来。代码的结构要比之前的写法,代码都用同一个缩进的代码结构要更清晰一些。当定位到 UI 的问题后,可以根据问题是布局错误还是配置问题,直接去分析对应的代码逻辑。

这样的代码还有一些别的好处,那就是 UI 配置相关代码的复用性提高。来,针对上面的代码的 config 部分做如下的修改:

let labelConfiger = { (label: UILabel) in
    label.backgroundColor = UIColor.clear
    label.font = UIFont.systemFont(ofSize: 20)
    label.textColor = UIColor.darkGray
    label.text = "Label"
}
label.config(labelConfiger)

完成与之前同样功能的代码,但是这里定义了一个类型为 (UILabel) -> Void 的 Closure,并将其作为参数直接传递给了链式函数 config。想想,如果接下来新建一个名为 label2 的 UILabel,配置跟 label 一样,那个它的 config 函数直接接收这个 labelConfiger 的 Closure 作为参数就可以完成配置。你可以能会问,如果这两个 label 的文本值不一样的时候怎么办呢。这种情况,只需要将代码改动如下:

label.config(labelConfiger)
    .config { (label) in
        label.text = "Label1"
    }

label2.config(labelConfiger)
    .config { (label) in
        label.text = "Label2"
    }

没错。作为链式代码,config 函数可以多次对自身调用。不过需要注意的是,针对视图的同一个属性,后面调用的 config 函数会覆盖掉前面的 config 函数里设置的状态。

在 Swift 中,Closure 是可以被当做变量的,而且就连函数其实也是一种 Closure。所以,接下来就可以自由发挥啦~~

分析实现


来讲解一下链式核心代码的实现。 链式函数的关键就在于返回值类型与自身相同。

由于是 UI 相关,那么首先是对 UIView 这个视图父类做扩展。adhere 这个函数没啥可说的,就是讲自身添加到通过参数传入的父视图上。接下来是 layout 这个布局函数实现,由于是使用 SnapKit,而且具体的布局逻辑不同的视图也不一样。所以将使用 SnapKit 布局时的关键逻辑代码以 Closure 闭包参数的形式传入。这里比较难理解的是 config 函数的实现,可以看到这里定义了一个名为 ViewChainable 的协议,然后对这个协议做了一个扩展,并让 UIView 实现了这个协议。这么做的原因在于,为了让 config 函数的闭包参数在实际使用中,闭包的第一个参数类型可以具体到 UIView 的不同子类上。拿上面的例子来说,UILabel 的实例在调用 config 函数时,所需的闭包参数类型就是 (UILabel) -> Void 而不是 (UIView) -> Void

优化


不知不觉文章已经很长了,不过还是希望你读下去。接下来是对上面的链式核心代码的优化。

由于上面的实现是直接针对 UIView 做了方法扩展,这在实际的工程中会碰到一个问题:命名冲突。举例来说,如果下一个版本的 SDK 更新后,UIKit 给 UIView 新增了一个 adhere 函数,参数和返回值类型和上面的定义完全一样,那如果发生这种情况,就只能修改自己之前的代码了。

Objective-C 时代的一个通行做法是对系统库的扩展方法加一个至少三个字符的前缀,这样函数名字就变成了这样:xxx_adhere 。当然你可以按照这种形式,给之前的链式 UI 函数名字都加上一个前缀,这么做是没有任何问题的。可是在 Swift 的世界里,有着更 Swifty 的实现形式,我在之前的博客文章 Swift 命名空间形式扩展的实现 中具体介绍这种形式的实现方式。那篇文章里提到的 HandOfTheKing 这个工程,里面已经包含了命名空间形式的链式 UI 代码实现。需要注意的是,在最近的一次更新里,我把这个项目的命名空间前缀由之前的 hk 改为了 hand,hk 用起来总感觉跟香港有关。哎,谁让命名是编程世界的一大难题呢~~下面贴出具体实现的代码(这里不包含命名空间的实现)

import UIKit
import SnapKit

extension UIView: NamespaceWrappable { }
extension NamespaceWrapper where T: UIView {
    public func adhere(toSuperView: UIView) -> T {
        toSuperView.addSubview(wrappedValue)
        return wrappedValue
    }

    @discardableResult
    public func layout(snapKitMaker: (ConstraintMaker) -> Void) -> T {
        wrappedValue.snp.makeConstraints { (make) in
            snapKitMaker(make)
        }
        return wrappedValue
    }

    @discardableResult
    public func config(_ config: (T) -> Void) -> T {
        config(wrappedValue)
        return wrappedValue
    }
}

改成这个之后,之前的 label 的代码就变成了下面的风格:

let label = UILabel()
    .hand.adhere(toSuperView: view)
    .hand.layout { (make) in
        make.top.equalToSuperview().offset(80)
        make.centerX.equalToSuperview()
    }
    .hand.config(labelConfiger)

进阶


如果你对响应式编程感兴趣,你会发现 RxSwift 这个框架和上面的链式布局代码简直是绝配。RxSwift 这个库本身也是支持很多链式调用的。关于 RxSwift,这又是另外一个大坑了,本篇就不多讲了。

结尾


昨天在 V2EX 上看到一个帖子问 2017 年的 iOS 开发是用 Objective-C 还是 Swift 的。对于习惯了 Swift 的我而言,已经不想再回头写 Objective-C 了。当然在最后,还是要继续吐槽一下 Xcode 这个不争气的 IDE。配上一声长叹,来结束本篇博客,哎……




Previous     Next

Published under (CC) BY-NC-SA