Sommer panage ui swiftly header

构建 Swift 风格的 UI

在本次讲演中,我们将探讨如何利用 Swift 语言的结构体和属性,来让 UI 代码的编写更为简单。我们将看到构建 UI 层时的一些常见陷阱,以及如何使用 Swift 风格的方法来改进它。本次讲演包括了如何用枚举来对视图状态进行建模、如何使用好用的 Swift 第三方库来改善编码,如何通过协议来统一视图层,以及其他更多的内容!


概述

我是 Sommer Panage,我现在是 Chorus Fitness 的首席 iOS 工程师,这是一家位于旧金山的小型初创公司。在为 Chorus Fitness 开发产品的时候,我得以构建自己的应用架构和模式。在这个过程中,我注意到在我编写相同代码的时候,出现了某种问题。

现在,我想要用四个故事来给大家阐述这些问题,以及我是如何用 Swift 语言本身所提供的功能来改善这些问题的,这将是一个很有意思的方式。我将通过一个示例应用来进行解释,它通过爬取 (crawling) Star Wars 的 API 来显示星球大战中这段著名的黄色文字,如下所示。

薛定谔的结果数据

第一个故事,我将其取名为「薛定谔的结果数据」。

译者注:这个典故 (Schrodinger’s Result) 来自于著名的量子力学概念:「薛定谔的猫 (Erwin Schrödinger’s Cat)」。

从后台获取数据

	func getFilms(completion: @escaping ([Film]?, APIError? -> Void) {
		let url = SWAPI.baseURL.appendingPathComponent(Endpoint.films.rawValue)
		let task = self.session.dataTask(with: url) { (data, response, error) in
			if let data = data {
				do {
					let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
					if let films = SWAPI.decodeFilms(jsonObject: jsonObject) {
					completion(films, nil)
					} else {
					completion(nil, .decoding)
					}
				} catch {
				completion(nil, .server(originalError: error))
				}
			} else {
			completion(nil, .server(originalError: error))
			}
		}
		task.resume()
	}

Receive news and updates from Realm straight to your inbox

在这里,如果数据得以返回,那么我将会将数据发送回 completion 闭包,否则的话,也就是如果我没法获取到数据或者数据不正确的话,我就会给这个 completion 闭包返回一个 nil 数据以及一个错误信息。

因此,我要么是从服务器中得到一个正常的结果,要么就发生了某种错误。但是如果我们来看下这段 UI 代码,就会发现事实并非如此。我们看到这里出现了四种可能的结果,其中两种结果是完全没有意义的。

	override func viewDidLoad() {
		super.viewDidLoad()

		apiClient.getFilms() { films, error in
			if let films = films{
				// 展示 UI
				if let error = error {
					// 输出警告日志……这看起来很诡异
				}
			} else if let error = error {
				// 展示错误提示 UI
			} else {
				// 一点儿结果都没有得到?我猜应该展示错误提示 UI?
			}
		}
	}

解决方案是针对不同的服务器交互行为进行建模:也就是构建出成功/结果对象,或者是失败/错误对象。

借助 Rob Rix 开发的这款名为 Result 的框架,这样我便可以为这种情况应用这个解决方案。这个框架十分简单,它可以精确捕获到我们正在做的操作。

	public enum Result<T, Error: Swift.Error>: ResultProtocol {
		case success(T)
		case failure(Error)
	}

关于枚举和关联值的注意事项

这里存在两种可能的枚举值:.success 或者 .failure。对于 .success 而言,我们需要传递一个 T 类型的非空结果对象。这也就是我们所获取到的真实数据。对于 .failure 而言,我们需要传递一个非空的错误对象。我们只需要处理这两种情况即可。

	func getFilms(completion: @escaping ([Film]?, APIError? -> Void) {
		let task = self.session
			.dataTask(with: SWAPI.baseURL.appendingPathComponent(Endpoint.films.rawValue)) { (data, response, error) in
				let result = Result(data, failWith: APIError.server(originalError: error!))
					flatMap { data in
						Result<Any, AnyError>(attempt: { try JSONSerialization.jsonObject(with: data, options: []) })
							.mapError { _ in APIError.decoding }
					}
					.flatMap { Result(SWAPI.decodeFilms(jsonObject: $0), failWith: .decoding) }

				completion(result)
			}
		}
		task.resume()
	}

UI 代码则如下所示:

	override func viewDidLoad() {
		super.viewDidLoad()

		apiClient.getFilms() { result in
			switch result {
			case .success(let films): print(films) // 展示正常的 UI!
			case .failure(let error): print(error) // 展示错误提示 U!
			}
		}
	}

借助 Result 枚举,使得我们可以用 .success.failure 这两种状态,从而更准确地为我们服务器的交互进行建模,此外它还简化了我们的视图控制器的代码。

布局引擎做到了

译者注:这个典故 (The Little Layout Engine that Could) 来自于儿童绘本《小火车头做到了 (The Little Engine That Could)》。

Storyboards

我一般不会在产品中使用 Storyboards。首先,就我个人体验而言,在多人团队中使用 Storyboard 实在是太难了。在查看 XML 之间的差异的时候,其他人所做的更改往往很难检阅,更糟糕的是,如果出现了合并冲突,解决问题可能会是一件非常痛苦的事情。

在构建 UI 的时候,您通常会重复使用相同的颜色、字体以及边距。这些值往往应该用常量来表示,而在 Storyboard 中,是没有内置这方面的支持的。

对于 IB (Interface Builder) 文件和代码中的 Outlet 而言,它们之间的关联关系在编译的时候不是强制要求的。如果我在按钮和点击按钮方法之间创建了一个连接,然后将这个方法给重命名了,这个项目仍然能正常构建,但是会在运行的时候发生崩溃。

用代码实现 Auto Layout

如果我选择不使用 Storyboards,那么我会选择用代码来实现 Auto Layout。在我的应用当中,主视图是一个表视图,这个表视图的大小与父视图相同。

我可以使用 iOS 9 推出的 Layout Anchor 来设置该布局。

为了让布局代码更加易读易写,我倾向于使用 Robb Bohnke 的另一个名为 Cartography 的框架。

借助 Cartography,您就可以用漂亮的代码来声明和配置 Auto Layout 约束了。

	init() {
		super.init(frame: .zero)

		addSubview(tableView)

		// Autolayout: 表视图与父视图拥有相同尺寸
		constrain(tableView, self) { table, parent in
			table.edges == parent.edges
		}
	}

下面这个用 Cartography 编写布局的示例要更为复杂。本质上而言,我们可以将 Auto Layout 表示为一组线性方程(linear equations)。

	private let margin: CGFloat = 16
	private let episodeLeftPadding: CGFloat = 8

	override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
		super.init(style: style, reuseIdentifier: reuseIdentifier)

		contentView.addSubview(episodeLabel)
		contentView.addSubview(titleLabel)

		constrain(episodeLabel, titleLabel, contentView) { episode, title, parent in
			episode.leading == parent.leading + margin
			episode.top == parent.top + margin
			episode.bottom == parent.bottom - margin

			title.leading == episode.trailing + episodeLeftPadding
			title.trailing <= parent.trailing - margin
			title.centerY == episode.centerY
		}
	}

对于 episode 而言,它位于 contentView 当中,与边缘有一定的距离,此外 title 也与 episode 有一定的间距,并且两者皆垂直居中。

Cartography 框架借助了 Swift 的一个很强大的功能:运算符重载——这使得用代码实现 Auto Layout 变得轻而易举。

视图状态

通常而言,我们发现需要填充数据的视图至少拥有三种状态:

  • 数据正在加载
  • 数据已加载完毕
  • 数据加载发生了某种类型的错误,那么需要用某个 UI 状态来表示该错误。

下面,您将看到我们是如何处理不同的视图状态的。

	/// MainView.swift

	var isLoading: Bool = false {
		didSet {
			errorView.isHidden = true
			loadingView.isHidden = !isLoading
		}
	}

	var isError: Bool = false {
		didSet {
			errorView.isHidden = !isError
			loadingView.isHidden = true
		}
	}

	var items: [MovieItem]? {
		didSet {
			tableView.reloadData()
		}
	}

标志是用来处理视图状态的一种常见方法。我们可以表示出 isLoading 或者 isError 这两个标志。这个方法虽然有效,但不见得就是最好的。

我们有时候可能需要表示更多的状态:如果 isErrorisLoading 不小心都被设置成了 true,那么这个时候我们就不知道现在该处于何种状态。我们的视图实际上只有三种状态,其中两个状态需要某些关联的信息。

解决方法就是使用带有关联值的枚举。

	final class MainView: UIView {
	
		enum State {
			case loading
			case loaded(items: [MovieItem])
			case error(message: String)
		}

		init(state: State) { ... }

		// 类的剩余部分……
	}

请注意,我们也可以用所需的确切状态来对我们的视图进行初始化。初始化之前,我们的视图将永远处于一个状态之下。

我们所有的视图控制都是在这里进行的,其他地方是没有任何控制的。在调用 getFilms 之前,我们将 ViewState 设置为 loading,然后根据所得到的结果再将其设置为 loaded 或者 error

皮特猫:重复代码

译者注:这个典故 (Pete and the Repeated Code) 似乎来自与儿童绘本《皮特猫 (Pete the Cat)》系列。

这是我们的第二个视图控制器,它展示了这段著名的黄色文本,这个视图似乎也有完全相同的三个视图状态。这也就是我们的第四个也是最后一个故事:

假设我们有一组行为模式,但是我们不希望将它们分享给其他不相关的对象。在本例当中,所谓的不相关对象指的是主视图控制器和爬虫视图控制器。我们可以使用协议来减轻我们的负担。

协议当中定义了一组方法、属性以及其他相关的要求,从而让实现该协议的对象能够实现某个任务或者功能。协议可以给类、结构体或者枚举使用,并且对于这些要求,协议还可以提供实际的实现。

在本例当中,我们要表示这些与这三个视图状态相关联的行为。

我们希望能够将数据加载到视图当中,特别是数据已加载和加载失败这两个状态。为了实现这一点,我们的视图当中需要拥有一个 ViewState 枚举;此外它还需要两个表示加载中、发生错误的视图,当状态发生变更时,我们还需要调用某种 update() 方法。

protocol DataLoading {
	associatedtype DataLoading
	
	var state: ViewState<Data> { get set }
	var loadingView: loadingView { get }
	var errorView: ErrorView { get }
	
	func update()
}

enum ViewState<Content> {
	case loading
	case loaded(data: Content)
	case error(message: String)
}

我们将这些东西全部放到协议当中——这组行为定义了我们的加载模式,这样我们就可以使用 ViewState 枚举,并加载任何它所需要的数据。

// 默认的协议实现
extension DataLoading where Self: UIView {
	func update() {
		switch state {
		case .loading:
			loadingView.isHidden = false
			errorView.isHidden = true
		case .error(let error):
			loadingView.isHidden = true
			errorView.isHidden = false
			Log.error(error)
		case .loaded:
				loadingView.isHidden = true
				errorView.isHidden = true
		}
	}
}

通过将不给不相关对象分享的功能分解为协议,便可以帮助我们避免写出重复的代码,还能够将逻辑整合到一起。

// DataLoading in Main View
final class MainView: UIView, DataLoading {
	let loadingView = LoadingView()
	let errorView = ErrorView()
	
	var state: ViewState<[MovieItem]> {
		didSet {
			update()
			tableView.reloadData()
		}
	}
}

// DataLoading in Crawl View
class CrawlView: UIView, DataLoading {
	let loadingView = LoadingView()
	let errorView = ErrorView()
	
	var state: ViewState<String> {
		didSet {
			update()
			crawlLabel.text = state.data
		}
	}
}

About the content

This talk was delivered live in March 2017 at try! Swift Tokyo. The video was recorded, produced, and transcribed by Realm, and is published here with the permission of the conference organizers.

Sommer Panage

Sommer Panage is currently a mobile software developer at Chorus, and circus artist. She worked previously as the lead for Mobile Accessibility on iOS and Android at Twitter. Before moving into this role, she worked on various iOS projects such as DMs and Anti-spam. Prior to Twitter, Sommer worked on the iOS team at Apple. She earned her BA in Psychology and MS in Computer Science at Stanford University.

4 design patterns for a RESTless mobile integration »

close