在构建应用程序和设计系统时,最困难的事情之一是决定如何建模和处理状态。当我们的应用程序的一部分最终没有符合我们的预期时,管理状态的代码是一个非常常见的 bug 来源。
本周,让我们来看看一些技术,这些技术可以让我们更容易地编写处理和反应状态变化的代码——使其更健壮,更不容易出错。我不会在这篇文章中讨论具体的框架或更大的、整个应用程序的架构变化(如RxSwift、ReSwift或使用ELM启发的架构)—— 相反,我想把重点放在我发现非常有用的小型技巧、窍门和模式。
在对各种状态进行建模时,一个很好的核心原则是尽可能地坚持 "单一数据来源"。一个简单的方法是,你要做到不需要检查多个条件来确定你处于什么状态。让我们来看一个例子。
假设我们正在构建一个游戏,其中的敌人有一定的血量,还有一个标志来确定他们是否在游戏中。我们可以用敌人类的这两个属性来建立模型,像这样:
class Enemy {
var health = 10
var isInPlay = false
}
虽然上述内容看起来很直接,但它很容易让我们陷入有多个数据来源的情况。比方说,一旦敌人的血量达到零,它就应该被淘汰出局。所以在我们代码的某个地方,我们有一些逻辑来处理这个问题:
func enemyDidTakeDamage() {
if enemy.health <= 0 {
enemy.isInPlay = false
}
}
当我们引入新的代码路径时,问题就出现了,我们忘记了执行上述检查。例如,我们可能会给我们的玩家一个特殊的攻击,将所有敌人的血量瞬间设置为零:
func performSpecialAttack() {
for enemy in allEnemies {
enemy.health = 0
}
}
正如你在上面看到的,我们更新了所有敌人的血量属性,但我们忘记了更新isInPlay
。这很可能会导致 bug 和我们最终处于未定义状态的情况。
在这样的情况下,可能会通过添加多个检查来解决问题,比如这样:
if enemy.isInPlay && enemy.health > 0 {
// Enemy is *really* in play
} else {
// Enemy is *really* defeated
}
虽然上述方法可能作为一个临时的 "创可贴 "解决方案,但它很快就会导致更难读的代码,当我们添加更多的条件和更复杂的状态时,就会很容易被破坏。如果你想一想,像上面那样做有点像不相信我们自己的API,因为我们必须对它们进行防御性编码😕。
解决这个问题的一个方法是,为了确保我们有一个单一的数据来源,在Enemy
类中自动更新isInPlay
属性,使用health
属性的didSet
:
class Enemy {
var health = 10 {
didSet { putOutOfPlayIfNeeded() }
}
// 设置其为只读是非常重要的
private(set) var isInPlay = true
private func putOutOfPlayIfNeeded() {
guard health <= 0 else {
return
}
isInPlay = false
remove()
}
}
这样,我们现在只需要更新敌人的血量,而且我们确信isInPlay
属性会一直保持同步👍。
上面的 "敌人 "例子非常简单,所以让我们看看另一个例子,我们处理更复杂的状态,每个状态都有相关的值,我们需要相应地渲染和反应。
比方说,我们正在构建一个视频播放器,它可以让我们从某个URL下载并观看一个视频。为了给视频建模,我们可以使用一个结构题,就像这样:
struct Video {
let url: URL
var downloadTask: Task?
var file: File?
var isPlaying = false
var progress: Double = 0
}
上述方式的问题是,我们最终会有很多选项,而且我们不能仅仅通过阅读我们的模型代码就真正知道一个视频可以处于什么状态。我们通常最终不得不写出复杂的处理方法,包括理想情况下不应该进入的代码路径:
if let downloadTask = video.downloadTask {
// 处理下载
} else if let file = video.file {
// 调用播放
} else {
// Uhm... 这里该干啥? 🤔
}
我常用的解决方法是使用一个枚举来定义非常明确的、排他性的状态,就像这样:
struct Video {
enum State {
case willDownload(from: URL)
case downloading(task: Task)
case playing(file: File, progress: Double)
case paused(file: File, progress: Double)
}
var state: State
}
正如你在上面看到的,我们已经去掉了所有的选项,所有特定状态的值现在都被纳入了它们将被使用的状态中。我们可以通过为回放信息引入另一个层次的状态来进一步摆脱一些重复的东西:
extension Video {
struct PlaybackState {
let file: File
var progress: Double
}
}
然后我们可以在playing
和paused
的情况下使用它:
case playing(PlaybackState)
case paused(PlaybackState)
然而,如果你开始像上面那样对你的状态进行建模,却一直在写强制性的状态处理代码(像上面那样使用多个if/else
语句),事情就会变得很难看。因为我们需要的所有信息都 "隐藏 "在各种情况下,我们需要做大量的switch
或if case let
语句来 "把它拿出来"。
我们需要将我们的状态枚举与反应式状态处理代码结合起来。作为一个例子,让我们来看看我们如何编写代码来更新视频播放器视图控制器中的一个动作按钮:
class VideoPlayerViewController: UIViewController {
var video: Video {
// Every time the video changes, we re-render
didSet { render() }
}
fileprivate lazy var actionButton = UIButton()
private func render() {
renderActionButton()
}
private func renderActionButton() {
let actionButtonImage = resolveActionButtonImage()
actionButton.setImage(actionButtonImage, for: .normal)
}
private func resolveActionButtonImage() -> UIImage {
// 动作按钮的图像是通过声明的方式解决的
// 直接来自视频状态
switch video.state {
// 我们可以很容易地丢弃我们不需要的关联值
case .willDownload:
return .wait
case .downloading:
return .cancel
case .playing:
return .pause
case .paused:
return .play
}
}
}
现在,每当我们的视频状态改变时,我们的用户界面就会自动更新。我们有一个单一的数据源,而且没有未定义的状态 🎉 我们可以扩展我们的渲染方法,以便在我们的状态变化时自动执行所有的UI更新:
func render() {
renderActionButton()
renderVideoSurface()
renderNavigationBarButtonItems()
...
}
渲染是一种情况,但通常在状态改变时我们还需要触发某种形式的逻辑。我们可能想过渡到另一个状态,或者开始一个操作。好消息是,我们也可以使用与渲染完全相同的模式来执行这样的逻辑。
让我们写一个handleStateChange
方法,也从video
属性的 didSet 中调用,根据我们当前所处的状态运行各种逻辑:
private extension VideoPlayerViewController {
func handleStateChange() {
switch video.state {
case .willDownload(let url):
// 开始下载任务,并进入'downloading' 状态
let task = Task.download(url: url)
task.start()
video.state = .downloading(task: task)
case .downloading(let task):
// 如果下载任务完成了,进入播放
switch task.state {
case .inProgress:
break
case .finished(let file):
let playbackState = Video.PlaybackState(file: file, progress: 0)
video.state = .playing(playbackState)
}
case .playing:
player.play()
case .paused:
player.pause()
}
}
}
到目前为止,我们一直使用switch
语句来执行我们所有的渲染和状态处理。这里有一个很好的理由——它 "迫使 "我们考虑所有的状态和所有的情况,并为每一个状态和情况编写适当的逻辑。它还可以让我们利用编译器,在我们没有处理的新状态出现时,给我们带来错误提示。
然而,有时你需要做一些非常具体的事情,只影响到某个状态。比方说,我们想确保在我们的视图控制器离开屏幕时取消任何正在进行的下载任务。
extension VideoPlayerViewController {
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// 理想情况下,我们希望有一个这样的API,让我们取消
// 任何正在进行的下载任务,而不需要写一个巨大的switch语句
video.downloadTask?.cancel()
}
}
能够像上面那样访问某些属性是非常好的,可以帮助我们摆脱大量的模板,如果我们选择总是使用switch
语句来处理状态,我们就必须写这些模板。
所以,让我们来实现这个目标吧! 要做到这一点,我们只需在Video
上创建一个扩展,使用Swift的guard case let
模式匹配语法来提取任何正在进行的下载任务。
extension Video {
var downloadTask: Task? {
guard case let .downloading(task) = state else {
return nil
}
return task
}
}
虽然在状态处理方面没有灵丹妙药,但以一种消除模糊性和强制执行明确定义的状态的方式来模拟你的状态,通常会创造更健壮的代码。
拥有单一的数据来源,并以反应式的方式处理状态变化,通常也能让你写出更容易阅读和推理的代码,也更容易扩展和重构(只要增加或删除一个case
,编译器就会告诉你需要更新哪些代码)。
我在这篇文章中提到的解决方案和技巧肯定是有取舍的,它们确实需要你写更多的模板代码,而且为你的状态枚举实现Equatable
有时会有点麻烦(我们会在以后的文章中看看如何通过代码生成和脚本使之更容易)。
谢谢你的阅读! 🚀
译自 John Sundell 的 Modelling state in Swift
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有