在前文中,我们设计了一种基于 UIStackView 的界面布局方案,实现了样式声明与事件响应的分离。我们首先使用 UIStackView 来创建了一个登录界面,通过对代码进行重构,实现了界面声明与实现的分离,这不仅提高了代码的解耦性,还增加了代码的可复用性。最后,我们在新的架构上更进一步,增加了一个界面样式,使得界面更加丰富多样。在本文中,我们将引入面向协议编程范式和依赖注入设计模式,进一步优化代码架构。
能用但不好用
在现有的代码中,我们已经实现了一些接口。在 View Controller 中,我们利用这些接口完成界面的构建,包括控件的添加、样式的设定和事件的绑定等。而在 generator 中 ,我们实现这些接口以完成具体功能,例如绘制 UI、处理用户输入、响应事件等。
为了评估接口的可用性,我们在同一界面展示了两个“登录 / 注册”模块,并修改代码以检查:
- 在构建两个相同功能的界面时是否存在代码冗余?
- 使用我们的接口构建“登录 / 注册”界面是否足够方便?
新增一种类型的 generator,为了与原有界面有所区别,我们会在界面样式上做一点点修改[1]。
// FILE: AnotherElementGenerator.swift
struct AnotherElementGenerator {
private(set) weak var containerView: UIStackView?
func elementView(from element: ElementType) -> UIView {
switch element {
case let .centeredText(title: title):
return createSingleLineText(title)
// ....
}
}
func addArrangedElements(_ elements: [ElementType]) {
for element in elements {
let subview = elementView(from: element)
containerView.addArrangedSubview(subview)
configureView(subview, for: element)
}
}
func configureView(_ view: UIView, for element: ElementType) {
switch element {
case let .spacer(height: height):
view.snp.makeConstraints { make in
make.height.equalTo(height)
}
default: break
}
}
}
private extension AnotherElementGenerator {
func createSingleLineText(_ title: String) -> UILabel {
// ....
}
// ....
}
此时 View Controller 中的调用方式如下:
// FILE: StackViewController.swift
class StackViewController: UIViewController {
lazy var stackView = {
// ....
return stackView
}()
lazy var generator: ConcreteElementGenerator = {
return ConcreteElementGenerator(base: stackView)
}()
// 1
lazy var anotherStackView = {
// ....
return stackView
}()
lazy var anotherGenerator: AnotherElementGenerator = {
return AnotherElementGenerator(base: anotherStackView)
}()
override func viewDidLoad() {
// ....
// 2
view.addSubview(stackView)
view.addSubview(anotherStackView)
}
func loginElementList()-> [EType] {
return [
.segment(items: ["登录", "注册"], defaultIndex: 0, onTapped: nil),
.spacer(height: 15),
.commonInput(label: "User Name:", placeHolder: "Email/Phone/ID", onTextChanged: { text in
print("User Name: \(String(describing: text))")
}),
.spacer(height: 15),
.commonInput(label: "Password:", placeHolder: "Password", onTextChanged: { text in
print("Password: \(String(describing: text))")
}),
.spacer(height: 10),
.checker(title: "记住用户名", checked: false, onTapped: { checked in
print("checked: \(checked)")
}),
.spacer(height: 10),
.button(title: "登录", onTapped: nil)
]
}
func setupSubviews() {
let elementList = loginElementList()
generator.addArrangedElements(elementList)
// 3
anotherGenerator.addArrangedElements(elementList)
}
}
- 新增一个 UIStackView 及其使用的 generator
- 添加 UIStackView 到界面
- 为新增的 UIStackView 添加子控件
代码运行结果如下:
那么问题来了。
接口实现是否存在冗余
两个 generator 代码结构几乎一模一样,大概可以分为两部分,第一部分 struct AnotherElementGenerator
主要用来添加子控件,第二部分 extension AnotherElementGenerator
中定义了子控件的实际创建过程。
在第一部分中,addArrangedElements
函数没有直接使用 ElementType
类型的枚举值,因此可以抽出公共代码。
在第二部分的代码中,为了方便,我们直接复制了 ConcreteElementGenerator
。在实际需求中,不同的 generator 可能会生成非常不同的子控件,因此这部分的代码几乎没有冗余。即使有部分子控件的样式相同,我们也可以通过抽象出工厂方法来解决。
调用接口是否足够方便
在 View Controller 中,generator 的 addArrangedElements
函数与 UIStackView 的 addArrangedSubview
函数功能和参数相似,因此我们可以考虑将 addArrangedElements
函数迁移到 UIStackView。这样,通过调用 UIStackView 的 addArrangedElements
函数来添加子控件,更符合接口使用者的习惯。
为了创建两个登录界面,我们需要声明两个 view 和两个 generator,这在只创建一个登录界面时不明显,但在多个界面时显得繁琐。因此,我们需要在 View Controller 的属性选择中做出决定,是保留 view 还是 generator?考虑到 generator 的接口只在调用 addArrangedElements
时使用,并且我们已经决定将 addArrangedElements
函数迁移到 view,View Controller 将不再直接调用 generator 的函数,除了创建 generator。同时,View Controller 持有 view 是合理的,因为无论如何,View Controller 都需要写 addSubview
。
总的来说,我们的代码修改将主要关注 view 和 generator 之间的依赖关系。当前的实现是 generator 弱引用 view,但在接下来的重构中,我们将改为 view 引用 generator。
我们的目标是将接口的调用方式改为:
// FILE: StackViewController.swift
class StackViewController: UIViewController {
lazy var stackView = {
// ....
return stackView
}()
override func viewDidLoad() {
// ....
setupSubviews()
}
func setupSubviews() {
// ....
stackView.addArrangedElements(elementList)
}
}
而 view 如何引用 generator 将放在下一个章节讨论。
解决问题
使用协议
在对比两个 generator 的代码后,我们发现了一些共同的逻辑。我们可以通过定义协议来描述这些共性的代码执行逻辑,并为该协议添加默认实现。
// FILE: ElementGenerator.swift
// 1
protocol ElementGenerator {
/// 向 stack view 添加子控件
/// - Parameters:
/// - elements: 子控件列表
/// - stackView: stack view
func addArrangedElements(_ elements: [ElementType], to stackView: UIStackView)
/// 根据子控件类型描述生成子控件
/// - Parameter element: 子控件类型描述
/// - Returns: 子控件
func elementView(from element: ElementType) -> UIView
/// 在子控件添加到 stack view 之后,继续设置子控件的属性
/// - Parameters:
/// - view: 子控件
/// - element: 子控件描述
func configureView(_ view: UIView, for element: ElementType)
}
extension ElementGenerator {
// 2
func addArrangedElements(_ elements: [ElementType], to stackView: UIStackView) -> Void {
for element in elements {
let subview = elementView(from: element)
stackView.addArrangedSubview(subview)
configureView(subview, for: element)
}
}
}
-
ElementGenerator
声明了生成并添加子控件的流程,整个流程分为三个函数。 -
ElementGenerator
的扩展中定义了addArrangedElements
的默认实现。因为addArrangedElements
函数没有直接使用ElementType
的具体值,且内部调用了elementView(from:) -> UIView
和configureView(_:for:)
函数,因此可以在协议中直接写成默认实现。
观察协议代码,我们发现 ElementGenerator
协议和 ElementType
仍然紧密耦合。在实际需求中,generator 和子控件类型通常没有如此紧密的联系,generator 生成其他类型的子控件也是合理的。比如,我们在 demo 中实现了一个登录界面,下一个需求可能是实现一个信息流列表页,所使用的子控件类型会有很大的不同。
当协议 ElementGenerator
和 ElementType
紧密相关时,它们之间的耦合性较高,这意味着更改其中一个可能会影响到另一个。在实际开发中,generator 应该具有更高的灵活性,能够生成各种类型的子控件,而不仅仅限于一种特定的 ElementType
。为了降低耦合性,可以使用泛型来设计 ElementGenerator
协议。这样,生成器可以指定生成任意类型的子控件。Swift 支持在协议中使用关联类型(associated types)来实现类似泛型的功能[2]。
// FILE: ElementGenerator.swift
protocol ElementGenerator {
// 1
associatedtype EType
/// 向 stack view 添加子控件
/// - Parameters:
/// - elements: 子控件列表
/// - stackView: stack view
func addArrangedElements(_ elements: [EType], to stackView: UIStackView)
/// 根据子控件类型描述生成子控件
/// - Parameter element: 子控件类型描述
/// - Returns: 子控件
func elementView(from element: EType) -> UIView
/// 在子控件添加到 stack view 之后,继续设置子控件的属性
/// - Parameters:
/// - view: 子控件
/// - element: 子控件描述
func configureView(_ view: UIView, for element: EType)
}
extension ElementGenerator {
func addArrangedElements(_ elements: [EType], to stackView: UIStackView) -> Void {
for element in elements {
let subview = elementView(from: element)
stackView.addArrangedSubview(subview)
configureView(subview, for: element)
}
}
}
- 通过引入关联类型,可以定义一个可以使用任意类型元素
EType
的ElementGenerator
协议。
此时只需对 ConcreteElementGenerator
和 AnotherElementGenerator
稍加改造,使其遵守 ElementGenerator
协议,即可自动获得 addArrangedElements
函数。
// FILE: ConcreteElementGenerator.swift
struct ConcreteElementGenerator: ElementGenerator {
// 1
typealias EType = ElementType
// ....
//2
func elementView(from element: EType) -> UIView {
switch element {
// ....
}
}
}
- 设置类型别名,用
EType
表示ElementType
。 - 实现协议方法时就可以直接使用
EType
,此时类型推断系统会正确地把协议中的关联类型EType
推断为ElementType
。
通过这种方式,ElementGenerator
协议就不再与任何特定的 ElementType
耦合,提高了代码的可维护性和可扩展性。进而确保 ElementGenerator
协议的通用性和灵活性,使其能够适应不同的开发需求,同时减少了代码间的依赖,便于后续的扩展和维护。
使用泛型
接下来考虑 UIStackView 的改造。前面章节已提到,我们要让 UIStackView 持有一个 generator,并且为 UIStackView 添加一个函数 addArrangedElements
。我们添加这个函数的目的是使得添加子控件更符合开发者的习惯,因为直接通过 view 添加子控件,比通过 generator 完成相同功能,更易于开发者理解。
func addArrangedElements<E>(_ elements: [E]) -> Void where E == EType {
elementGenerator.addArrangedElements(elements, to: self)
}
其中:
-
EType
表示子控件类型,对应 demo 中的ElementType
。 -
E
表示函数入参的类型。这里给函数加了约束,入参的子控件类型(E
)必须与EType
相同。
接下来,我们需要考虑如何让 UIStackView 持有一个 generator。新建一个 ElementStackView
继承自 UIStackView
,并增加一个 elementGenerator 属性。
// FILE: StackViewExtention.swift
class ElementStackView<T: ElementGenerator>: UIStackView {
// 1
typealias EType = T.EType
// 2
let elementGenerator: T
// 3
init(elementGenerator: T) {
self.elementGenerator = elementGenerator
super.init(frame: CGRectZero)
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func addArrangedElements<E>(_ elements: [E]) -> Void where E == EType {
elementGenerator.addArrangedElements(elements, to: self)
}
}
在 View Controller 中使用如下方式调用 ElementStackView
:
// FILE: StackViewController.swift
class StackViewController: UIViewController {
lazy var stackView = {
// 4
let stackView = ElementStackView<ConcreteElementGenerator>(elementGenerator: ConcreteElementGenerator())
stackView.axis = .vertical
stackView.distribution = .equalSpacing
stackView.alignment = .fill
stackView.backgroundColor = .lightGray.withAlphaComponent(0.1)
return stackView
}()
// ....
func setupSubviews() {
let elementList = loginElementList()
// 5
stackView.addArrangedElements(elementList)
}
}
-
EType
表示子控件类型,对应 demo 中的ElementType
。 - 新增的 generator 属性,这里使用泛型指定 generator 的类型。
-
ElementStackView
的构造器。 - 生成 stackView。
- 调用
ElementStackView
的函数添加子控件。
我们注意到,在创建 stackView
的代码中,我们指定了 ConcreteElementGenerator
泛型类型并创建了一个此类型的结构体,这种使用方式较为繁琐。理想情况下,ConcreteElementGenerator
应只出现一次,既声明了泛型类型,又能生成此类型的结构体。这里有两种实现方式:一种是将 2
处改为 lazy
属性,根据传入的泛型类型创建 generator 结构体;另一种是在 3
处的构造器上加入默认参数,创建一个 generator 结构体。无论选择哪种方案,我们都需要为 ElementGenerator
协议添加一个构造器 [3]。
// FILE: ElementGenerator.swift
protocol ElementGenerator {
associatedtype EType
init()
/// 向 stack view 添加子控件
/// - Parameters:
/// - elements: 子控件列表
/// - stackView: stack view
func addArrangedElements(_ elements: [EType], to stackView: UIStackView)
/// 根据子控件类型描述生成子控件
/// - Parameter element: 子控件类型描述
/// - Returns: 子控件
func elementView(from element: EType) -> UIView
/// 在子控件添加到 stack view 之后,继续设置子控件的属性
/// - Parameters:
/// - view: 子控件
/// - element: 子控件描述
func configureView(_ view: UIView, for element: EType)
}
在 ElementStackView
中使用 lazy 属性创建 elementGenerator
:
// FILE: StackViewExtention.swift
class ElementStackView<T: ElementGenerator>: UIStackView {
typealias EType = T.EType
lazy var elementGenerator: T = T()
func addArrangedElements<E>(_ elements: [E]) -> Void where E == EType {
elementGenerator.addArrangedElements(elements, to: self)
}
}
或给 ElementStackView
的构造器加上默认参数:
class ElementStackView<T: ElementGenerator>: UIStackView {
typealias EType = T.EType
let elementGenerator: T
init(elementGenerator: T = T()) {
self.elementGenerator = elementGenerator
super.init(frame: CGRectZero)
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func addArrangedElements<E>(_ elements: [E]) -> Void where E == EType {
elementGenerator.addArrangedElements(elements, to: self)
}
}
通过对比很容易发现,使用 lazy 属性创建 elementGenerator
这种实现方式更加简洁。因为它避免了初始化时的复杂设置,将创建逻辑保持在属性的访问逻辑中。同时 lazy 属性可以提高性能,因为它们仅在需要时才创建,这避免了不必要的计算和内存使用。然而,开发者需要根据具体场景考虑是否需要立即初始化属性,以及多线程环境下的线程安全问题。在那些不需要立即使用属性或者初始化开销较大的场景中,lazy 属性是一个很好的选择。
使用依赖注入(Dependency Injection)
在上一章节中,我们为 ElementGenerator
协议添加了一个构造器,以满足 ElementStackView
中创建 generator 的需求。但在协议中声明一个构造器显得有些突兀,我们希望找到一种方案,能移除协议中的构造器,同时不影响 ElementStackView
使用 generator。
因此,我们将使用依赖注入(Dependency Injection)技术来继续完善解决方案。依赖注入是一种设计模式,它允许将依赖(如服务或对象)传递给使用它们的组件,而不是让组件自己构建依赖。这样做的好处是可以增加组件的可测试性、可维护性和模块化。在 ElementGenerator
协议中声明构造器可能会导致实现该协议的类型必须实现特定的构造器,这限制了类型的灵活性。通过使用依赖注入,我们可以移除协议中的构造器声明,而是在需要使用 ElementGenerator
的地方(如 ElementStackView
)将其作为参数传递进去。
Resolver 是一个轻量级的依赖注入框架,它为 Swift 应用程序提供了服务定位和依赖注入的功能。使用 Resolver 可以帮助开发者管理对象的生命周期和依赖关系[4]:
// FILE: StackViewController.swift
import Resolver
extension Resolver: ResolverRegistering {
public static func registerAllServices() {
// 1
register {
ConcreteElementGenerator()
}.scope(.application)
}
}
// FILE: StackViewExtension.swift
class ElementStackView<T: ElementGenerator>: UIStackView {
typealias EType = T.EType
// 2
@LazyInjected var elementGenerator: T
func addArrangedElements<E>(_ elements: [E]) -> Void where E == EType {
elementGenerator.addArrangedElements(elements, to: self)
}
}
- 将
ConcreteElementGenerator
的实现类注册到 Resolver 的容器中。 - 通过 Resolver 来解析依赖的 generator
利用 Resolver 库,我们可以方便地将依赖注入到需要它们的对象中,而不必在协议中定义构造器。这种方法简化了 ElementStackView 和 ElementGenerator 的实现,使得它们之间的关系更加灵活和松耦合。通过 Resolver,开发者可以更容易地管理和配置依赖关系,同时保持代码的清晰和可维护性,同时也便于单元测试,因为可以很容易地为 ElementStackView 提供模拟的 ElementGenerator 实现。使用依赖注入库如 Resolver,可以让我们的应用架构更加模块化,易于测试和扩展。
总结
优化基于UIStackView的界面布局方案
在本文中我们继续对基于 UIStackView 的界面布局方案进行优化,引入了面向协议编程范式和依赖注入设计模式。
-
引入面向协议编程范式:面向协议编程(Protocol-Oriented Programming, POP)是 Swift 语言的核心范式之一,它强调了在设计接口和交互时使用协议来定义蓝图,并通过扩展来提供默认实现。在布局方案中应用POP可以使得各个组件的职责更加明确,同时增加了代码的复用性和可维护性。
-
应用依赖注入设计模式:依赖注入(Dependency Injection, DI)是一种设计模式,用于减少代码之间的耦合关系。通过这种方式,一个对象的依赖关系是由外部传入,而不是由对象自己创建。在布局方案中,通过依赖注入可以动态地向
UIStackView
中的ElementStackView
提供ElementGenerator
实例,这样做可以降低模块间的直接依赖,提高模块的可测试性和灵活性。 -
移除冗余代码:利用依赖注入和面向协议的范式,我们能够移除一些在
ElementStackView
和ElementGenerator
之间硬编码的构造器调用,从而减少冗余代码。这种优化使得代码更加简洁,降低了出错的可能性,并且使得未来的维护和扩展变得更加容易。 -
降低模块间耦合:通过将
ElementGenerator
的创建和配置从ElementStackView
中分离出来,我们降低了这两个模块之间的耦合度。使用 Resolver 库作为依赖注入的工具,进一步抽象了对象创建的过程,使得ElementStackView
不再依赖于具体的ElementGenerator
实现,而是依赖于一个能够产生ElementGenerator
的抽象。 -
提升接口的易用性:在这个优化过程中,
ElementStackView
的使用者不再需要关心如何创建ElementGenerator
,只需要关注如何使用它。通过简化接口,使得其他开发者能够更加容易地使用和集成ElementStackView
,无需深入了解其内部实现细节。
结论
- 通过引入面向协议编程范式和依赖注入设计模式,我们优化了基于
UIStackView
的界面布局方案。 - 这些改进不仅减少了代码的冗余,还降低了模块间的耦合,同时提高了整体代码的易用性和可维护性。
- 这种灵活的设计使得界面组件更加通用和可配置,为后续的功能扩展和维护奠定了良好的基础。
衡量模块“好用”性的判断标准
在衡量一个模块是否“好用”的过程中,我们提出了两点判断标准,即在完成两个相同功能时模块内部是否存在代码冗余,以及使用模块对外公开的接口是否足够方便。
代码冗余程度
- 代码冗余是指在模块内部进行功能开发时,相同或相似的代码被重复编写的情况。这不仅会导致项目体积增大,还会增加维护成本和出错的风险。
- 一个“好用”的模块应该最大限度地减少代码冗余。通过函数复用、面向协议编程、设计模式等技术手段,可以有效地避免冗余代码的产生。
接口的便利性
- 接口的便利性涉及到模块对外提供的 API 是否简洁明了,是否能够让使用者容易理解和使用,以及是否能够方便地与其他模块或系统集成。
- 一个“好用”的模块应该提供清晰、文档化良好的公共接口,隐藏内部实现细节,减少使用者的学习成本,使得接口的使用直观且容易。
结论
- 衡量模块“好用”性的两个重要指标是内部的代码冗余程度和对外公开接口的便利性。
- 优秀的模块设计应该力求在这两个方面都做到最优,以提供高效、简洁、易于维护和扩展的代码,确保模块的高可用性和良好的用户体验。
网友评论