Highlight
Apple 演示了用 SwiftUI 和 HealthKit 从零构建 Apple Watch 运动应用,开发者可以采集实时心率、能量、距离数据,保存运动记录,并用 TimelineView 适配 Always On 状态。
核心内容
做一个 Apple Watch 运动应用,难点不在按钮有多少。
真正的麻烦在运动开始之后。用户正在跑步或骑车,手腕会抬起、放下,屏幕会进入 Always On(常亮)低频刷新状态。应用还要持续显示经过时间、能量、心率、距离,并在结束后把记录写入 HealthKit。
以前,如果只把这些逻辑散落在 SwiftUI 视图里,界面、权限、会话状态、数据采集很快会混在一起。暂停按钮要改会话状态,摘要页要等保存完成,实时指标还要根据屏幕刷新频率调整显示。
Apple 在这场 Code-Along 中把问题拆成一条完整路径:先用 SwiftUI 搭出 watchOS 运动应用的三屏结构,再用 WorkoutManager 管理 HealthKit 会话,最后用 TimelineView 让指标页在 Always On 下继续合理刷新。
从一次点击开始运动
用户打开运动应用时,不应该先看到复杂设置。演讲中的起点是一个 StartView:三种运动类型,骑车、跑步、步行。每一项都是一个可以直接进入运动会话的 NavigationLink。
点击后,运动页进入 watchOS 常见的三页结构:左侧控制,中央指标,右侧 Now Playing。默认显示指标页,因为用户开始运动后最先关心的是时间、能量、心率和距离。
用一个对象管理运动生命周期
HealthKit 的运动会话有一套明确流程:请求权限,创建配置,启动 HKWorkoutSession,用 HKLiveWorkoutBuilder 收集数据,结束时保存成 HKWorkout。
这场 session 把这些状态放进 WorkoutManager。SwiftUI 视图通过 @EnvironmentObject 读取它。这样,按钮只负责触发暂停或结束,指标页只负责显示发布出来的数据。
Always On 改变刷新方式
Apple Watch 的 Always On 状态会降低刷新频率。运动应用不能继续按高频方式显示百分秒,否则既不合适,也不符合常亮状态下的信息需求。
解决方法是 TimelineView。它根据 TimelineScheduleMode 决定刷新间隔,并通过 context.cadence 控制经过时间是否显示百分秒。屏幕活跃时显示更细的时间,低频状态下只显示分钟和秒。
详细内容
运动类型列表
(03:25)Apple 先用 HealthKit 的 HKWorkoutActivityType 建模运动类型。为了让它可以直接进入 SwiftUI List,演讲中给这个枚举补上 Identifiable 和显示名称。
import HealthKit
var workoutTypes: [HKWorkoutActivityType] = [.cycling, .running, .walking]
extension HKWorkoutActivityType: Identifiable {
public var id: UInt {
rawValue
}
var name: String {
switch self {
case .running:
return "Run"
case .cycling:
return "Bike"
case .walking:
return "Walk"
default:
return ""
}
}
}
关键点:
import HealthKit引入HKWorkoutActivityType。workoutTypes保存要展示的三种运动。Identifiable让枚举值可以作为List的数据源。id使用rawValue,对应 HealthKit 枚举原始值。name把 HealthKit 类型转成手表界面上的短文案。
(04:22)列表本身使用 carousel 样式。这个样式是 watchOS 上适合纵向滚动列表的视觉效果。
List(workoutTypes) { workoutType in
NavigationLink(
workoutType.name,
destination: Text(workoutType.name)
).padding(
EdgeInsets(top: 15, leading: 5, bottom: 15, trailing: 5)
)
}
.listStyle(.carousel)
.navigationBarTitle("Workouts")
关键点:
List(workoutTypes)为每一种运动生成一行。NavigationLink给每行提供进入下一页的能力。padding增大点击区域,适合运动前快速操作。.listStyle(.carousel)使用 watchOS 的旋转列表效果。.navigationBarTitle("Workouts")给开始页设置标题。
运动中的三页结构
(06:55)运动开始后,用户需要控制按钮、实时指标和媒体控制。演讲用 TabView 做横向分页,并把默认页设为指标页。
@State private var selection: Tab = .metrics
enum Tab {
case controls, metrics, nowPlaying
}
TabView(selection: $selection) {
Text("Controls").tag(Tab.controls)
Text("Metrics").tag(Tab.metrics)
Text("Now Playing").tag(Tab.nowPlaying)
}
关键点:
selection保存当前显示的分页。- 默认值是
.metrics,运动开始后先显示数据。 Tab枚举列出三种页面。TabView(selection:)绑定当前页状态。tag把每个子视图和枚举值对应起来。
(16:09)占位文字随后替换成真实视图。
ControlsView().tag(Tab.controls)
MetricsView().tag(Tab.metrics)
NowPlayingView().tag(Tab.nowPlaying)
关键点:
ControlsView放结束、暂停、恢复按钮。MetricsView显示经过时间、能量、心率、距离。NowPlayingView提供运动中的媒体控制。- 三个页面继续复用同一组
Tab标签。
经过时间格式化
(11:42)经过时间不能直接用普通数字显示。演讲创建了 ElapsedTimeView 和 ElapsedTimeFormatter,用 DateComponentsFormatter 输出分钟和秒,再按需要追加百分秒。
struct ElapsedTimeView: View {
var elapsedTime: TimeInterval = 0
var showSubseconds: Bool = true
@State private var timeFormatter = ElapsedTimeFormatter()
var body: some View {
Text(NSNumber(value: elapsedTime), formatter: timeFormatter)
.fontWeight(.semibold)
.onChange(of: showSubseconds) {
timeFormatter.showSubseconds = $0
}
}
}
class ElapsedTimeFormatter: Formatter {
let componentsFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .second]
formatter.zeroFormattingBehavior = .pad
return formatter
}()
var showSubseconds = true
override func string(for value: Any?) -> String? {
guard let time = value as? TimeInterval else {
return nil
}
guard let formattedString = componentsFormatter.string(from: time) else {
return nil
}
if showSubseconds {
let hundredths = Int((time.truncatingRemainder(dividingBy: 1)) * 100)
let decimalSeparator = Locale.current.decimalSeparator ?? "."
return String(format: "%@%@%0.2d", formattedString, decimalSeparator, hundredths)
}
return formattedString
}
}
关键点:
ElapsedTimeView接收TimeInterval。showSubseconds控制是否显示百分秒。Text(NSNumber(value:), formatter:)让 SwiftUI 使用自定义 formatter。DateComponentsFormatter负责分钟和秒。allowedUnits = [.minute, .second]限定输出单位。zeroFormattingBehavior = .pad保持03:15这种补零格式。truncatingRemainder(dividingBy: 1)取小数部分。Locale.current.decimalSeparator使用当前地区的小数分隔符。
请求 HealthKit 权限
(29:35)运动应用要写入 workout,并读取心率、能量、距离和活动圆环。HealthKit 权限在 WorkoutManager 中集中请求。
// Request authorization to access HealthKit.
func requestAuthorization() {
// The quantity type to write to the health store.
let typesToShare: Set = [
HKQuantityType.workoutType()
]
// The quantity types to read from the health store.
let typesToRead: Set = [
HKQuantityType.quantityType(forIdentifier: .heartRate)!,
HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!,
HKQuantityType.quantityType(forIdentifier: .distanceCycling)!,
HKObjectType.activitySummaryType()
]
// Request authorization for those quantity types.
healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { (success, error) in
// Handle error.
}
}
关键点:
typesToShare声明应用要写入运动记录。HKQuantityType.workoutType()代表 workout 类型。typesToRead声明要读取的 HealthKit 数据。.heartRate用于当前和平均心率。.activeEnergyBurned用于活动能量。.distanceWalkingRunning和.distanceCycling对应步行跑步距离、骑行距离。HKObjectType.activitySummaryType()用于摘要页的活动圆环。requestAuthorization发起系统权限请求。
(30:20)开始页出现时请求授权。
.onAppear {
workoutManager.requestAuthorization()
}
关键点:
.onAppear在开始页显示时执行。workoutManager是注入到 SwiftUI 环境中的共享对象。- 授权流程早于用户开始运动。
启动运动会话
(27:42)HealthKit 的实时运动依赖 HKWorkoutSession 和 HKLiveWorkoutBuilder。前者代表会话状态,后者负责采集实时数据并生成最终 workout。
func startWorkout(workoutType: HKWorkoutActivityType) {
let configuration = HKWorkoutConfiguration()
configuration.activityType = workoutType
configuration.locationType = .outdoor
do {
session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
builder = session?.associatedWorkoutBuilder()
} catch {
// Handle any exceptions.
return
}
builder?.dataSource = HKLiveWorkoutDataSource(
healthStore: healthStore,
workoutConfiguration: configuration
)
// Start the workout session and begin data collection.
let startDate = Date()
session?.startActivity(with: startDate)
builder?.beginCollection(withStart: startDate) { (success, error) in
// The workout has started.
}
}
关键点:
HKWorkoutConfiguration保存这次运动的类型和位置类型。activityType来自用户在开始页选择的运动。locationType = .outdoor设置为户外运动。HKWorkoutSession创建 HealthKit 运动会话。associatedWorkoutBuilder()取得和会话关联的 builder。HKLiveWorkoutDataSource让 builder 获取实时 HealthKit 数据。startDate作为会话和数据采集的共同起点。startActivity(with:)启动运动会话。beginCollection(withStart:)开始收集实时样本。
(29:06)选择运动类型时自动启动会话。
var selectedWorkout: HKWorkoutActivityType? {
didSet {
guard let selectedWorkout = selectedWorkout else { return }
startWorkout(workoutType: selectedWorkout)
}
}
关键点:
selectedWorkout记录用户选中的运动。didSet在选择变化后执行。guard过滤掉空值。startWorkout(workoutType:)把导航选择和 HealthKit 会话连接起来。
暂停、恢复和结束
(33:29)运动控制通过 WorkoutManager 暴露。界面按钮不直接碰 HealthKit session,只调用这些方法。
// MARK: - State Control
// The workout session state.
@Published var running = false
func pause() {
session?.pause()
}
func resume() {
session?.resume()
}
func togglePause() {
if running == true {
pause()
} else {
resume()
}
}
func endWorkout() {
session?.end()
}
关键点:
@Published var running把运行状态发布给 SwiftUI。pause()调用 HealthKit 会话暂停。resume()调用 HealthKit 会话恢复。togglePause()根据当前状态切换动作。endWorkout()结束会话。
(34:11)会话状态变化通过 HKWorkoutSessionDelegate 回传。结束时要先结束数据采集,再完成 workout 保存。
// MARK: - HKWorkoutSessionDelegate
extension WorkoutManager: HKWorkoutSessionDelegate {
func workoutSession(_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date) {
DispatchQueue.main.async {
self.running = toState == .running
}
// Wait for the session to transition states before ending the builder.
if toState == .ended {
builder?.endCollection(withEnd: date) { (success, error) in
self.builder?.finishWorkout { (workout, error) in
}
}
}
}
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
}
}
关键点:
HKWorkoutSessionDelegate接收会话状态变化。didChangeTo提供新状态、旧状态和状态变化时间。DispatchQueue.main.async把running更新放回主线程。toState == .running用来更新暂停按钮状态。toState == .ended表示会话结束。endCollection(withEnd:)停止采集数据。finishWorkout保存并生成HKWorkout。
采集实时指标
(40:25)实时指标用 @Published 暴露给 SwiftUI。
// MARK: - Workout Metrics
@Published var averageHeartRate: Double = 0
@Published var heartRate: Double = 0
@Published var activeEnergy: Double = 0
@Published var distance: Double = 0
关键点:
averageHeartRate保存平均心率。heartRate保存最近一次心率。activeEnergy保存累计活动能量。distance保存累计距离。@Published让指标变化自动刷新绑定的视图。
(41:05)HKLiveWorkoutBuilderDelegate 通知应用哪些样本类型有新数据。
// MARK: - HKLiveWorkoutBuilderDelegate
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
}
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else { return }
let statistics = workoutBuilder.statistics(for: quantityType)
// Update the published values.
updateForStatistics(statistics)
}
}
}
关键点:
HKLiveWorkoutBuilderDelegate接收 builder 采集事件。didCollectDataOf提供本次更新涉及的数据类型。collectedTypes可能包含心率、能量或距离。guard let quantityType只处理数量类型。statistics(for:)从 builder 取出统计结果。updateForStatistics把 HealthKit 统计值转成界面数据。
(42:01)不同指标使用不同单位和统计方法。
func updateForStatistics(_ statistics: HKStatistics?) {
guard let statistics = statistics else { return }
DispatchQueue.main.async {
switch statistics.quantityType {
case HKQuantityType.quantityType(forIdentifier: .heartRate):
let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0
self.averageHeartRate = statistics.averageQuantity()?.doubleValue(for: heartRateUnit) ?? 0
case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned):
let energyUnit = HKUnit.kilocalorie()
self.activeEnergy = statistics.sumQuantity()?.doubleValue(for: energyUnit) ?? 0
case HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), HKQuantityType.quantityType(forIdentifier: .distanceCycling):
let meterUnit = HKUnit.meter()
self.distance = statistics.sumQuantity()?.doubleValue(for: meterUnit) ?? 0
default:
return
}
}
}
关键点:
- 空
statistics直接返回。 - UI 相关状态在主线程更新。
- 心率单位是
count/minute。 mostRecentQuantity()取当前心率。averageQuantity()取平均心率。- 活动能量使用千卡单位。
sumQuantity()取累计能量。- 步行跑步距离和骑行距离都转成米。
- 未处理的类型进入
default。
显示实时指标
(43:35)指标页从 WorkoutManager 读取数值,用 Measurement.formatted 生成适合地区和运动场景的文案。
VStack(alignment: .leading) {
ElapsedTimeView(
elapsedTime: workoutManager.builder?.elapsedTime ?? 0,
showSubseconds: true
).foregroundColor(Color.yellow)
Text(
Measurement(
value: workoutManager.activeEnergy,
unit: UnitEnergy.kilocalories
).formatted(
.measurement(
width: .abbreviated,
usage: .workout,
numberFormat: .numeric(precision: .fractionLength(0))
)
)
)
Text(
workoutManager.heartRate
.formatted(
.number.precision(.fractionLength(0))
)
+ " bpm"
)
Text(
Measurement(
value: workoutManager.distance,
unit: UnitLength.meters
).formatted(
.measurement(
width: .abbreviated,
usage: .road
)
)
)
}
关键点:
ElapsedTimeView显示 builder 的经过时间。?? 0给尚未创建 builder 的状态提供默认值。- 能量用
UnitEnergy.kilocalories。 .measurement(width: .abbreviated, usage: .workout)输出运动场景下的短单位。numberFormat去掉能量的小数。- 心率使用数字格式化,再追加
bpm。 - 距离用
UnitLength.meters作为输入单位。 usage: .road让距离按地区自然显示。
Always On 下的时间线刷新
(45:51)Always On 状态下,运动指标需要降低刷新频率。演讲自定义了 TimelineSchedule。
private struct MetricsTimelineSchedule: TimelineSchedule {
var startDate: Date
init(from startDate: Date) {
self.startDate = startDate
}
func entries(from startDate: Date, mode: TimelineScheduleMode) -> PeriodicTimelineSchedule.Entries {
PeriodicTimelineSchedule(
from: self.startDate,
by: (mode == .lowFrequency ? 1.0 : 1.0 / 30.0)
).entries(
from: startDate,
mode: mode
)
}
}
关键点:
MetricsTimelineSchedule遵守TimelineSchedule。startDate记录运动开始时间。entries(from:mode:)根据系统模式生成刷新时间点。mode == .lowFrequency表示低频更新。- 低频时每 1 秒刷新一次。
- 活跃状态下按
1.0 / 30.0秒刷新。 PeriodicTimelineSchedule负责创建周期性 entries。
(46:38)指标视图放进 TimelineView,并根据 context.cadence 隐藏百分秒。
TimelineView(
MetricsTimelineSchedule(
from: workoutManager.builder?.startDate ?? Date()
)
) { context in
VStack(alignment: .leading) {
ElapsedTimeView(
elapsedTime: workoutManager.builder?.elapsedTime ?? 0,
showSubseconds: context.cadence == .live
).foregroundColor(Color.yellow)
Text(
Measurement(
value: workoutManager.activeEnergy,
unit: UnitEnergy.kilocalories
).formatted(
.measurement(
width: .abbreviated,
usage: .workout,
numberFormat: .numeric(precision: .fractionLength(0))
)
)
)
Text(
workoutManager.heartRate
.formatted(
.number.precision(.fractionLength(0))
)
+ " bpm"
)
Text(
Measurement(
value: workoutManager.distance,
unit: UnitLength.meters
).formatted(
.measurement(
width: .abbreviated,
usage: .road
)
)
)
}
.font(.system(.title, design: .rounded)
.monospacedDigit()
.lowercaseSmallCaps()
)
.frame(maxWidth: .infinity, alignment: .leading)
.ignoresSafeArea(edges: .bottom)
.scenePadding()
}
关键点:
TimelineView按自定义时间线重新计算内容。from使用 builder 的开始时间。context.cadence == .live表示当前是实时刷新状态。- 实时状态显示百分秒。
- 低频状态隐藏百分秒。
- 字体使用 rounded title,适合手表上快速阅读。
monospacedDigit()避免数字跳动造成视觉抖动。.ignoresSafeArea(edges: .bottom)让指标区域利用底部空间。.scenePadding()对齐 watchOS 场景边距。
(51:45)页面层面还可以读取 isLuminanceReduced,在 Always On 低亮度状态隐藏分页指示器,并自动回到指标页。
@Environment(\.isLuminanceReduced) var isLuminanceReduced
.tabViewStyle(
PageTabViewStyle(indexDisplayMode: isLuminanceReduced ? .never : .automatic)
)
.onChange(of: isLuminanceReduced) { _ in
displayMetricsView()
}
关键点:
isLuminanceReduced来自 SwiftUI 环境。- 低亮度状态通常对应 Always On 显示。
PageTabViewStyle控制分页指示器。- 低亮度时用
.never隐藏指示器。 - 正常状态用
.automatic。 onChange监听低亮度状态变化。- 状态变化时调用
displayMetricsView()回到指标页。
运动摘要和活动圆环
(21:00)结束后,摘要页可以显示当天活动圆环。SwiftUI 通过 WKInterfaceObjectRepresentable 包装 WatchKit 的 WKInterfaceActivityRing。
import HealthKit
import SwiftUI
struct ActivityRingsView: WKInterfaceObjectRepresentable {
let healthStore: HKHealthStore
func makeWKInterfaceObject(context: Context) -> some WKInterfaceObject {
let activityRingsObject = WKInterfaceActivityRing()
let calendar = Calendar.current
var components = calendar.dateComponents([.era, .year, .month, .day], from: Date())
components.calendar = calendar
let predicate = HKQuery.predicateForActivitySummary(with: components)
let query = HKActivitySummaryQuery(predicate: predicate) { query, summaries, error in
DispatchQueue.main.async {
activityRingsObject.setActivitySummary(summaries?.first, animated: true)
}
}
healthStore.execute(query)
return activityRingsObject
}
func updateWKInterfaceObject(_ wkInterfaceObject: WKInterfaceObjectType, context: Context) {
}
}
关键点:
WKInterfaceObjectRepresentable把 WatchKit 对象桥接到 SwiftUI。healthStore用来执行 HealthKit 查询。WKInterfaceActivityRing是活动圆环控件。dateComponents取当天日期。predicateForActivitySummary创建当天活动摘要的查询条件。HKActivitySummaryQuery读取活动摘要。setActivitySummary把查询结果设置到圆环控件。DispatchQueue.main.async确保 UI 更新在主线程执行。
(50:43)最终摘要页从保存后的 HKWorkout 读取总时长、总距离和总能量。
SummaryMetricView(
title: "Total Time",
value: durationFormatter
.string(from: workoutManager.workout?.duration ?? 0.0) ?? ""
).accentColor(Color.yellow)
SummaryMetricView(
title: "Total Distance",
value: Measurement(
value: workoutManager.workout?.totalDistance?
.doubleValue(for: .meter()) ?? 0,
unit: UnitLength.meters
).formatted(
.measurement(
width: .abbreviated,
usage: .road
)
)
).accentColor(Color.green)
SummaryMetricView(
title: "Total Energy",
value: Measurement(
value: workoutManager.workout?.totalEnergyBurned?
.doubleValue(for: .kilocalorie()) ?? 0,
unit: UnitEnergy.kilocalories
).formatted(
.measurement(
width: .abbreviated,
usage: .workout,
numberFormat: .numeric(precision: .fractionLength(0))
)
)
).accentColor(Color.pink)
SummaryMetricView(
title: "Avg. Heart Rate",
value: workoutManager.averageHeartRate
.formatted(
.number.precision(.fractionLength(0))
)
+ " bpm"
).accentColor(Color.red)
关键点:
workoutManager.workout是保存完成后的HKWorkout。duration提供总时长。totalDistance提供总距离。doubleValue(for: .meter())转成米。totalEnergyBurned提供总能量。doubleValue(for: .kilocalorie())转成千卡。- 平均心率来自实时采集阶段保存的
averageHeartRate。 - 每个
SummaryMetricView用不同accentColor区分指标。
核心启发
-
做什么:做一个室外步行记录器,只保留开始、暂停、结束和四个核心指标。 为什么值得做:这场 session 已经给出
HKWorkoutSession、HKLiveWorkoutBuilder和指标页结构。 怎么开始:把workoutTypes缩小到.walking,用startWorkout(workoutType:)启动会话,用distanceWalkingRunning和heartRate更新界面。 -
做什么:做一个骑行时的极简心率屏。 为什么值得做:
TimelineView可以在 Always On 下保留可读信息,monospacedDigit()适合运动中快速扫一眼。 怎么开始:复用MetricsTimelineSchedule,只显示heartRate、averageHeartRate和 elapsed time,把context.cadence == .live用于控制时间精度。 -
做什么:做一个运动结束后的复盘摘要页。 为什么值得做:
HKWorkout已经包含总时长、总距离、总能量,活动圆环可以通过HKActivitySummaryQuery读取。 怎么开始:在finishWorkout后保存workout,用SummaryMetricView展示duration、totalDistance、totalEnergyBurned,再嵌入ActivityRingsView。 -
做什么:做一个支持 Apple Watch 媒体控制的跑步应用。 为什么值得做:运动中用户常常需要切歌,session 的三页结构已经把
NowPlayingView放在右侧。 怎么开始:在SessionPagingView中保留NowPlayingView().tag(Tab.nowPlaying),用PageTabViewStyle和isLuminanceReduced控制 Always On 下的分页显示。 -
做什么:做一个只面向某种训练的 watchOS 小工具,比如跑走交替训练。 为什么值得做:
running、pause()、resume()、togglePause()已经把会话控制抽象出来,视图可以专注训练流程。 怎么开始:在WorkoutManager外增加训练阶段状态,阶段切换时更新 SwiftUI 文案,仍然用session?.pause()和session?.resume()控制 HealthKit 会话。
关联 Session
- What’s new in watchOS 8 — 了解 watchOS 8 的系统能力,补齐 Always On 和表盘相关上下文。
- What’s new in SwiftUI — 查看 SwiftUI 在 2021 年的更新,理解 watchOS 界面构建基础。
- Connect Bluetooth devices to Apple Watch — 把外部蓝牙设备数据接入 Apple Watch,适合扩展运动传感器场景。
- Create accessible experiences for watchOS — 改进运动应用在 Apple Watch 上的无障碍体验。
评论
GitHub Issues · utterances