第87天 项目 17 第二部分
科里·豪斯(Cory House)曾说过:“代码就像幽默,需要解释的时候,说明它不够好。”我之前也提到过类似的观点——写出能清晰传达意图的代码是优秀编程的标志,这能为未来节省大量维护和测试的时间。
今天你将学习如何使用苹果的Combine框架监控通知,你会发现这些代码非常简洁,几乎无需额外解释——尽管它能让我们监控各种系统事件。
这并非偶然:苹果会花费大量时间进行“API审查”,在此过程中,跨职能开发团队会共同讨论API的“接口范围”——从终端开发者的角度来看,API接收哪些参数、返回什么结果、如何命名、是否抛出错误,以及在具体场景中如何协同工作等。API审查的难度可能超出你的预期,但最终结果是,我们只需极少的Swift和SwiftUI代码就能实现强大的功能,这对我们来说无疑是巨大的收获!
今天你需要学习三个主题,分别涉及Combine框架、Timer以及特定辅助功能设置的读取。
- 使用计时器重复触发事件
- 如何在SwiftUI应用进入后台时接收通知
- 利用SwiftUI支持特定辅助功能需求
使用计时器重复触发事件
作者:Paul Hudson 2024年2月21日
iOS内置了Timer类,可让我们定期执行代码。它借助了苹果Combine框架中的“发布者(publisher)”机制——Combine框架与SwiftUI同时推出,早在iOS 13时代就已面世,但后来在很大程度上被await等Swift语言特性所取代。
苹果的核心系统库名为Foundation,它为我们提供了Data、Date、SortDescriptor、UserDefaults等多种功能。其中的Timer类不仅能在指定秒数后执行某个函数,还能重复执行代码。Combine框架为Timer添加了扩展,使其可以成为“发布者”——发布者是一种能在值发生变化时发出通知的组件。
创建计时器发布者的代码如下:
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()这段代码同时完成了多项操作:
- 设置计时器每秒触发一次。
- 指定计时器在主线程上运行。
- 指定计时器在“通用运行循环(common run loop)”中运行——这是大多数情况下你会用到的运行循环(运行循环能让iOS在用户进行交互操作,如滚动列表时,依然能正常执行代码)。
- 立即连接计时器,意味着计时器会开始计时。
- 将整个计时器实例赋值给
timer常量,以确保其保持活跃状态。
计时器启动后,会发送变更通知,我们可以在SwiftUI中使用onReceive()修饰符来监控这些通知。该修饰符的第一个参数接收一个发布者,第二个参数接收一个要执行的函数,每当发布者发送变更通知时,都会触发该函数的执行。
以计时器为例,我们可以这样接收它的通知:
Text("Hello, World!")
.onReceive(timer) { time in
print("当前时间为 \(time)")
}这样,每秒都会打印一次当前时间,直到计时器停止。
说到停止计时器,停止我们创建的这个计时器需要一些小技巧。要知道,我们创建的timer属性是一个自动连接的发布者,因此需要找到它的“上游发布者(upstream publisher)”才能获取计时器本身。找到上游发布者后,我们就可以连接到计时器发布者,并请求其取消自身。说实话,如果没有代码补全功能,要找到这个方法并不容易,但具体代码如下:
timer.upstream.connect().cancel()例如,我们可以修改之前的示例,让计时器只触发五次:
struct ContentView: View {
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State private var counter = 0
var body: some View {
Text("Hello, World!")
.onReceive(timer) { time in
if counter == 5 {
timer.upstream.connect().cancel()
} else {
print("当前时间为 \(time)")
}
counter += 1
}
}
}在结束这个主题之前,我还想向你介绍一个关于计时器的重要概念:如果你的计时器可以接受微小的时间偏差,可以为其设置“容差(tolerance)”。这能让iOS进行重要的能耗优化,因为系统可以在计时器的计划触发时间到“计划触发时间+容差”的范围内,任意选择一个时间点触发计时器。
实际上,这意味着系统可以执行“计时器合并”:将你的计时器稍微延后一点,使其与其他一个或多个计时器同时触发,这样就能让CPU更多地处于空闲状态,从而节省电量。
例如,下面的代码为计时器添加了0.5秒的容差:
let timer = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common).autoconnect()如果你需要严格计时,不设置 tolerance参数可以让计时器尽可能保持精确,但请注意,即使不设置容差,Timer类依然是“尽力而为”的——系统无法保证计时器绝对精确地执行。
如何在SwiftUI应用进入后台时接收通知
作者:Paul Hudson 2024年2月21日
SwiftUI能够检测应用何时进入后台(即用户返回主屏幕时)以及何时回到前台,将这两种情况结合起来,我们就能确保应用根据用户当前是否能看到它,来暂停或恢复工作。
实现这一功能需要三个步骤:
- 添加一个新属性,用于监听名为
scenePhase的环境值。 - 使用
onChange()监听scenePhase的变化。 - 对新的场景阶段做出相应处理。
你可能会疑惑,为什么它叫“场景阶段(scenePhase)”,而不是与应用当前状态相关的名称?要知道,在iPad上,用户可以同时运行多个应用实例——他们可以打开多个窗口(称为“场景”),每个窗口都处于不同的状态。
要查看各种场景阶段的实际效果,可以尝试以下代码:
struct ContentView: View {
@Environment(\.scenePhase) var scenePhase
var body: some View {
Text("Hello, world!")
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .active {
print("活跃(Active)")
} else if newPhase == .inactive {
print("非活跃(Inactive)")
} else if newPhase == .background {
print("后台(Background)")
}
}
}
}运行代码后,尝试在模拟器中返回主屏幕、锁定虚拟设备或进行其他常见操作,观察场景阶段的变化。
如你所见,我们需要关注三种场景阶段:
- 活跃(Active)场景:当前正在运行,在iOS上表示对用户可见。在macOS上,即使一个应用的窗口被其他应用的窗口完全遮挡,它仍然被视为活跃状态。
- 非活跃(Inactive)场景:正在运行且可能对用户可见,但用户无法与之交互。例如,当你向下滑动以部分显示控制中心时,下方的应用就处于非活跃状态。
- 后台(Background)场景:对用户不可见,在iOS上,这类场景未来可能会被终止。
利用SwiftUI支持特定辅助功能需求
作者:Paul Hudson 2024年2月21日
SwiftUI提供了多个环境属性,用于描述用户的自定义辅助功能设置,花时间了解并遵循这些设置是很有必要的。
在之前的项目15中,我们学习了辅助功能标签、提示、特性、分组等内容,但这些设置有所不同,因为它们是通过环境提供的。这意味着SwiftUI会自动监控这些设置的变化,一旦其中某个设置发生改变,就会重新调用视图的body属性。
例如,“无颜色区分(Differentiate without color)”是一项辅助功能选项,对每12名男性中就有1名的色盲人群很有帮助。启用此设置后,应用应尝试通过形状、图标和纹理(而非颜色)来让界面更清晰。
要使用这项设置,只需添加如下环境属性:
@Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor该属性的值为布尔类型(true或false),你可以据此调整界面。例如,在下面的代码中,常规布局使用简单的绿色背景,而当“无颜色区分”功能启用时,我们会使用黑色背景并添加一个对勾图标:
struct ContentView: View {
@Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor
var body: some View {
HStack {
if differentiateWithoutColor {
Image(systemName: "checkmark.circle")
}
Text("成功(Success)")
}
.padding()
.background(differentiateWithoutColor ? .black : .green)
.foregroundStyle(.white)
.clipShape(.capsule)
}
}你可以在模拟器中测试此功能:打开“设置”应用,依次选择“辅助功能”>“显示与文字大小”>“无颜色区分”。
另一项常见的选项是“减少动态效果(Reduce Motion)”,在模拟器中,你可以通过“辅助功能”>“动态效果”>“减少动态效果”来找到它。启用此设置后,应用应减少导致界面元素移动的动画数量。例如,iOS的应用切换器会让视图淡入淡出,而不是缩放进出。
在SwiftUI中,这意味着当动画涉及元素移动时,我们应限制withAnimation()的使用,示例如下:
struct ContentView: View {
@Environment(\.accessibilityReduceMotion) var reduceMotion
@State private var scale = 1.0
var body: some View {
Button("Hello, World!") {
if reduceMotion {
scale *= 1.5
} else {
withAnimation {
scale *= 1.5
}
}
}
.scaleEffect(scale)
}
}我个人觉得这样使用起来有些麻烦。幸运的是,我们可以在withAnimation()周围包装一个小函数,该函数直接使用UIKit的UIAccessibility数据,从而自动跳过动画:
func withOptionalAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result {
if UIAccessibility.isReduceMotionEnabled {
return try body()
} else {
return try withAnimation(animation, body)
}
}这样,当“减少动态效果”功能启用时,传入的闭包代码会立即执行;否则,代码会通过withAnimation()执行。其中throws/rethrows是较高级的Swift特性,但这里的函数签名与withAnimation()完全一致,因此两者可以互换使用。
使用方式如下:
struct ContentView: View {
@State private var scale = 1.0
var body: some View {
Button("Hello, World!") {
withOptionalAnimation {
scale *= 1.5
}
}
.scaleEffect(scale)
}
}通过这种方式,你无需每次都重复编写动画相关代码。
最后一项你应该考虑支持的选项是“减少透明度(Reduce Transparency)”,启用此设置后,应用应减少设计中的模糊和半透明效果,以确保所有内容都清晰可见。
例如,下面的代码在“减少透明度”功能启用时使用纯黑色背景,否则使用50%透明度的黑色背景:
struct ContentView: View {
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
var body: some View {
Text("Hello, World!")
.padding()
.background(reduceTransparency ? .black : .black.opacity(0.5))
.foregroundStyle(.white)
.clipShape(.capsule)
}
}以上就是在开始实际项目前,我想让你学习的最后一项技术,请将你的项目恢复到初始状态,为后续开发做好准备。