构建研究与护理 App(三):可视化进度
Build a research and care app, part 3: Visualize progress
2021年6月9日
一句话判断
健康数据的可视化不只是画图表,关键在于把「你看你走了多少步」变成「你看你的努力有了什么变化」——这个 Session 教你怎么用 SwiftUI 做到这一点。
这场 Session 讲了什么
这是「Build a research and care app」系列的第三部分,聚焦在如何用 SwiftUI 将 HealthKit 数据以有意义的方式呈现给用户。前两部分讲了数据采集和存储,这一部分解决的是「最后一公里」的问题:让用户看懂自己的健康数据。
Session 以一个示例 App 为线索,展示了从 HealthKit 读取步数、心率等数据后,如何用 SwiftUI 的图表组件(ChartView)和自定义 Shape 来绘制趋势图。重点不是教你画折线图的技术细节,而是如何选择正确的可视化方式:时间跨度用折线图、分类对比用柱状图、目标达成用环形进度。Session 还讲了如何处理数据稀疏的情况——用户可能连续几天没有数据,你的可视化不能在空数据处画一条直线假装没事发生。
另外,Session 讨论了健康数据的隐私边界:呈现数据时要尊重用户的敏感信息,比如不要在小组件(Widget)上显示具体的心率数值,而是用「在目标范围内」这样的模糊表述。
值得深挖的点
SwiftUI Charts 的数据聚合策略。 当你从 HealthKit 读取大量数据点时(比如一年的每日步数),直接全部绘制会导致图表过于密集无法阅读。Session 展示了一个分层聚合的方法:在日视图显示每小时数据,周视图显示每日数据,月视图显示每周数据。通过 Calendar.current.dateComponents 做日期分组,然后用 Dictionary(grouping:) 聚合,最后取每组的平均值或总和。这种「根据时间粒度自动聚合」的模式在健康类 App 中几乎无处不在。
数据缺失的处理哲学。 健康数据天然会有空缺——用户忘了戴手表、传感器断连、或者纯粹是某天没运动。Session 的建议是:在图表中用虚线或透明色段明确标识缺失区间,而不是插值或跳过。这不仅是视觉设计问题,更关乎用户信任——如果你在空缺处画了插值线,用户会质疑其他数据的真实性。HealthKit 的 statisticsCollection API 返回的结果包含了数据区间信息,你可以据此判断哪些日期确实没有数据。
代码片段
import HealthKit
// 从 HealthKit 查询步数统计数据
func fetchStepData(for date: Date) async throws -> [Date: Double] {
let healthStore = HKHealthStore()
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
let predicate = HKQuery.predicateForSamples(
withStart: date.startOfMonth(),
end: date.endOfMonth(),
options: .strictStartDate
)
var results: [Date: Double] = []
let query = HKStatisticsCollectionQuery(
quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: date.startOfMonth(),
intervalComponents: DateComponents(day: 1)
)
// 每日聚合数据
query.initialResultsHandler = { _, collection, error in
collection?.enumerateStatistics(from: date.startOfMonth(),
to: date.endOfMonth()) { stats, _ in
if let sum = stats.sumQuantity() {
results[stats.startDate] = sum.doubleValue(for: .count())
}
}
}
return results
}
// SwiftUI 绘制简单的步数趋势图
struct StepChartView: View {
let data: [(date: Date, steps: Double)]
var body: some View {
VStack {
// 用 Path 绘制折线
GeometryReader { geo in
Path { path in
let maxSteps = data.map(\.steps).max() ?? 1
for (index, point) in data.enumerated() {
let x = geo.size.width * CGFloat(index) / CGFloat(data.count)
let y = geo.size.height * (1 - point.steps / maxSteps)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(Color.blue, lineWidth: 2)
}
.frame(height: 200)
}
}
}
// 环形进度视图——展示目标完成度
struct RingProgressView: View {
let progress: Double // 0.0 到 1.0
var body: some View {
ZStack {
// 背景环
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: 12)
// 进度环
Circle()
.trim(from: 0, to: progress)
.stroke(Color.green, style: StrokeStyle(lineWidth: 12, lineCap: .round))
.rotationEffect(.degrees(-90))
// 中间显示百分比
Text("\(Int(progress * 100))%")
.font(.title2.bold())
}
.frame(width: 100, height: 100)
}
}
最佳实践
- 健康数据的可视化应该突出「趋势」而不是「绝对值」——用户更关心「我在变好还是变差」而不是「今天走了 8432 步」
- 在 Widget 上展示健康数据时使用模糊化表述(「在目标范围内」「比昨天多了」),避免暴露敏感的具体数值
- 数据加载期间显示骨架屏或上次的缓存数据,不要显示空的图表区域——空白会让用户以为 App 坏了
- 给用户提供时间范围选择器(7 天 / 30 天 / 90 天 / 1 年),不同时间粒度下的数据聚合逻辑要一致
还有什么值得关注
- SwiftUI Charts 框架在后续 WWDC 版本中有大幅增强,iOS 16 引入了原生
Chart组件 - HealthKit 的
HKStatisticsCollectionQuery支持设置增量更新 handler,可以实时响应新数据写入 - 考虑为色盲用户提供图案或形状来区分数据系列,不要完全依赖颜色