Swift 协议的实战应用

Source code

随着 Swift 和 SwiftUI 的发展,协议所扮演的角色越来越重要,面向协议编程(POP)已成为日常开发的常规操作。本文不会细谈协议的基础概念(需要了解基础的同学,可以移步官方文档:Protocols),而是以实际案例来阐述协议的运用,希望读者能有所获益。

1、自定义 Button

我们现在需要自定义一个按钮控件,要求可以快速生成不同样式的按钮,还能方便地维护和扩展。

截屏2021-12-28 下午5.07.38.png

我们首先定义一个协议,该协议只定义了一个方法 config(_:) 来配置按钮的样式:

1
2
3
4
protocol ButtonStyle {

func config(_ button: UIButton)
}

然后,我们定义一个按钮控件 Button,该类暴露了一个 style(_:) 方法来快速生成不同样式的按钮。Buton 的初始化方法里是该控件的一些公共配置,这里使用了笔者自己写的一个小工具来实现链式调用,方法名和系统 api 基本一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import KZRazor

class Button<S: ButtonStyle>: UIButton {

override var isHighlighted: Bool {
didSet {
self.alpha = isHighlighted ? 0.6 : 1.0
}
}

private var style: S?

@discardableResult
func style(_ style: S) -> Self {
self.style = style
style.config(self)
return self
}

override init(frame: CGRect) {
super.init(frame: frame)
self.kz.padding(top: 8, bottom: 8, left: 16, right: 16)
.kz.backgroundColor(.white)
.kz.cornerRadius(8)
.kz.titleColor(.darkText)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

我们现在来实现一个带模糊效果的按钮,定义一个遵循 ButtonStyle 协议的类并实现相应的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class BlurButtonStyle: ButtonStyle {

private var blurStyle: UIBlurEffect.Style

init(blurStyle: UIBlurEffect.Style) {
self.blurStyle = blurStyle
}

func config(_ button: UIButton) {
let blurEffect = UIBlurEffect(style: blurStyle)
let blurEffectView = UIVisualEffectView(effect: blurEffect)
blurEffectView.isUserInteractionEnabled = false
button.kz.addSubview(blurEffectView) { make in
make.edges.equalToSuperview()
}
}
}

然后,我们就可以通过下面的方法来调用了:

1
Button().style(BlurButtonStyle(blurStyle: .systemThinMaterialLight)).kz.title("Hello World!")

但是在写代码的时候不够智能,我们必须知道 ButtonStyle 的具体类型才能实现相应的 style,我们可以通过下面的改进来提供更友好的 api,在调用的时候,直接通过点语法就可以获得编译器的智能提示。

1
2
3
4
5
extension ButtonStyle where Self == BlurButtonStyle {
static func blur(style: UIBlurEffect.Style) -> Self {
Self.init(blurStyle: style)
}
}

上面的调用代码就可以改写成:

1
Button().style(.blur(style: .systemThickMaterialLight)).kz.title("Hello World!")

文章开头的图片还提供了 .automatic.shadow 两种样式,思路与上面一致,参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class DefaultButtonStyle: ButtonStyle {

func config(_ button: UIButton) {}
}
extension ButtonStyle where Self == DefaultButtonStyle {
static var automatic: Self { Self() }
}

class ShadowButtonStyle: ButtonStyle {

func config(_ button: UIButton) {}
}
extension ButtonStyle where Self == ShadowButtonStyle {
static var shadow: Self { Self() }
}

对于 shadow 样式的按钮,为了获取到按钮的真实尺寸并添加阴影效果,我们在 Button 类重写 layoutSubViews 方法:

1
2
3
4
5
6
override func layoutSubviews() {
super.layoutSubviews()
if self.style is ShadowButtonStyle {
self.kz.shadow(radius: 5, opacity: 0.1, color: .black, offset: .zero)
}
}

以上就是一个自定义按钮的实现,如果你熟悉 SwiftUI,就会发现这样的代码结构和 SwiftUI 中的各种视图的 style 实现思路是一致的。在遵循协议的基础上,我们可以放肆地修改和新增功能而将影响范围降至最小,这也意味着我们可以更好地理解我们的代码和进行单元测试。

我们并没有完全摒弃 OOP,而是与 POP 结合起来一起使用,都是为了更好地组织和管理代码。POP 并不能完全取代 OOP 这样的纵向继承方式,但它的横向扩展特性补充了 OOP 的不足。合纵连横,方为上策。

2、一个基础路由的实现

截屏2021-12-29 上午9.50.04.png

如上图,我们现在需要实现一个路由,通过路由来跳转到 A、B 界面,一个不带参数,一个带参数,同时,我们还需要能够在跳转过程中加入自定义转场动画。

首先,我们定义如下协议:

1
2
3
4
public protocol Routable {

var destination: UIViewController { get }
}

Routable 协议只定义一个核心属性,就是路由跳转的控制器。这里我们并没有定义要跳转的方法,因为我们的需求是要在跳转过程中可以加入自定义转场动画,所以将跳转方法交给后文介绍的 Transition 协议来管理。

接着,我们定一个枚举类型,实现该协议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum Router1 {
case pageA
case pageB(params: String)
}

extension Router1: Routable {

var destination: UIViewController {
switch self {
case .pageA:
return AController()
case let .pageB(params):
return BController(params: params)
}
}
}

Router1 这里可以理解为某个功能模块,比如我们还有其它功能模块,可以依样画葫芦,将各个模块的业务实现组织在一起,方便管理和维护。比如,RouterAuth、RouterShop、RouterCart 等。

下一步我们实现一个单例类 Router,该类实现了 openclose 两个方法,分别用于界面的跳转和返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Router {

static let shared = Router()

func open(_ target: Routable, transition: Transition,
fromVc: UIViewController? = nil, completion: (() -> Void)? = nil) {
// open
}

func close(_ viewController: UIViewController?, transition: Transition,
completion: (() -> Void)? = nil) {
// close
}
}

路由的雏形基本出来了,接下来就是让 Transition 实例去实现 open 和 close 方法。根据这个思路,Transiton 协议定义如下:

1
2
3
4
5
6
7
8
9
10
11
protocol Transition {

func open(_ viewController: UIViewController, fromVc: UIViewController?, completion: (() -> Void)?)
func close(_ viewController: UIViewController?, completion: (() -> Void)?)
}

extension Transition {

func open(_ viewController: UIViewController, fromVc: UIViewController?, completion: (() -> Void)?) {}
func close(_ viewController: UIViewController?, completion: (() -> Void)?) {}
}

我们在 Router 中的 open 和 close 方法内加上 Transition 实例的调用方法,更新后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Router {

static let shared = Router()

func open(_ target: Routable, transition: Transition,
fromVc: UIViewController? = nil, completion: (() -> Void)? = nil) {
transition.open(target.destination, fromVc: fromVc, completion: completion)
}

func close(_ viewController: UIViewController?, transition: Transition,
completion: (() -> Void)? = nil) {
transition.close(viewController, completion: completion)
}
}

光有转场,没有动画,是转不起来的,所以我们需要给 Transition 协议新增如下两个属性(其中 animated 是用来标记是否需要动画的,以应对不需要动画的场景):

1
2
var animated: Bool { get set }
var animator: Animator? { get set }

Animator 协议继承自系统的 UIViewControllerAnimatedTransitioning 协议:

1
2
3
4
protocol Animator: UIViewControllerAnimatedTransitioning {

var duration: TimeInterval { get set }
}

接下来,我们来实现一个渐变的 Push。如果你熟悉自定义转场,那么下面的代码就很好理解了。

UIApplication.navigationController 是通过 extension 实现的一个小功能,用来获取 app 当前的导航控制器,方便我们在非控制器界面获取导航控制器,读者不必纠结具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
final class PushTransition: NSObject, Transition {

var animated: Bool = true
var animator: Animator?

private weak var fromVc: UIViewController?

private var navigationController: UINavigationController? {
if fromVc != nil {
return fromVc!.navigationController
}
return UIApplication.navigationController
}

init(animator: Animator?, animated: Bool) {
self.animator = animator
self.animated = animated
}

func open(_ viewController: UIViewController, fromVc: UIViewController?, completion: (() -> Void)?) {
self.fromVc = fromVc
navigationController?.delegate = self
CATransaction.begin()
CATransaction.setCompletionBlock(completion)
navigationController?.pushViewController(viewController, animated: animated)
CATransaction.commit()
}
}

extension Transition where Self == PushTransition {

static func push(animator: Animator?, animated: Bool = true) -> Self {
Self(animator: animator, animated: animated)
}
}

extension PushTransition: UINavigationControllerDelegate {

func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
animator
}
}

我们只差最后一步的动画了,前面我们已经定了 Animator 协议,现在我们实现一个 FadeAnimator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
final class FadeAnimator: NSObject, Animator {

var duration: TimeInterval = 0.25

init(duration: TimeInterval = 0.25) {
self.duration = duration
}

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
duration
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let toView = transitionContext.view(forKey: .to) else { return }
let containerView = transitionContext.containerView
containerView.addSubview(toView)

toView.alpha = 0
UIView.animate(withDuration: duration) {
toView.alpha = 1.0
} completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}

}

代码很简单,除了实现我们自己定义的 duration 属性,还需要实现 UIViewControllerAnimatedTransitioning 的协议方法。

我们的工作已接近尾声,现在尝试调用一下:

1
2
3
4
5
6
7
8
9
Router.shared.open(
Router1.pageB(params: textView.text),
transition: .push(animator: FadeAnimator(duration: 2)), fromVc: nil) {
print("Did push")
}

// animator 传参,这里是直接使用的 FadeAnimator 实例
// 笔者尝试过使用 extenstion Animator 的方式增加点语法调用,比如 .fade(duration: 2)
// 编译器始终无法通过。。。这里作了妥协,如果有更优雅的实现,欢迎交流

结果和预期一致:

Simulator Screen Shot - iPhone 13 Pro Max - 2021-12-29 at 10.58.43.png

同理,对于 pop 操作,我们也可以用同样的思路实现,这里就不展开来说了,有兴趣的读者可以参看源码

至此,一个基础的路由就实现了。如果需要实现更为复杂的功能比如 Universal Links 跳转、加入手势驱动转场等,读者可自行研究和实现。

Source code

您的支持将鼓励我继续创作!
0%