Highlight
Apple 展示了 ResearchKit 与 CareKit 的组合用法:开发者可以自定义任务卡片、限制调查编辑、把调查答案绘制成图表,并用 ResearchKit 呈现 3D 教育内容。
核心内容
上一集完成后,Recover 这个康复研究 App 已经能让参与者填写每日调查和活动范围测试。问题出现在真实使用中。
参与者回看前几天的记录时,只能看到“已完成”。他们看不到当天填了多少疼痛分,也看不到睡了几小时。过去的答案还能删除,未来日期的调查也能提前填写。这对研究数据不友好。
医生还想看到趋势。单个答案只能说明一天的状态,无法说明康复有没有进展。Jamie 希望 Insights 标签页里出现两张图:一张对比睡眠和疼痛,一张显示活动范围随时间变化。
Apple 的做法是把 ResearchKit(研究调查框架)和 CareKit(护理计划框架)接在一起。ResearchKit 负责问卷、主动任务和 3D 教育内容。CareKit 负责日程、任务卡片、数据存储和图表。
这场 session 的重点,是把已经收集到的数据变成可读的反馈。参与者完成调查后,卡片直接显示答案;进入 Insights 后,图表自动从 CareKit Store 读取历史结果;点击教育卡片后,ResearchKit 展示说明文字和 3D 膝盖模型。
任务卡片从“完成状态”变成“结果摘要”
以前的卡片只告诉参与者任务是否完成。完成信息对研究人员有用,对参与者帮助有限。参与者真正想知道的是:那天我填了什么。
CareKit 允许开发者通过 View Synchronizer(视图同步器)定制任务卡片。它在 Store 数据变化时更新 UI。这里继承 OCKSurveyTaskViewSynchronizer,在 updateView 中读取 survey outcome,再写入 instructionsLabel。
调查只能在合适的日期编辑
医疗研究数据需要时间边界。过去的康复测试不应被重做,未来的问卷也不应提前填写。
代码里做了两层限制。删除历史 outcome 时,通过 OCKSurveyTaskViewControllerDelegate 返回 false。显示未来日期任务时,直接禁用卡片交互,并降低透明度。
Insights 标签页展示趋势
单次疼痛分数只是一个点。连续数据放进图表后,患者和医生才能看到疼痛、睡眠和活动范围之间的变化。
CareKit 的 OCKCartesianChartViewController 可以直接消费 Store 里的 task outcome。开发者只需要配置数据系列,告诉图表从哪个 task 取数据,以及每一天的 Y 值如何计算。
ResearchKit 也能展示教育内容
医生希望患者理解半月板损伤。这个内容没有日程,不需要存结果,也不属于每日任务。
因此它不进入 CareKit schedule。App 在 Insights 中放一个 OCKFeaturedContentView,用户点击后创建 ORKTaskViewController,运行一个包含说明步骤和 3D 模型步骤的 ResearchKit task。
详细内容
自定义调查卡片,显示已提交答案
(02:27)CareKit 的任务卡片由数据和视图同步器共同驱动。OCKSurveyTaskViewSynchronizer 提供默认样式,子类可以在 updateView 中追加自己的 UI 逻辑。
final class SurveyViewSynchronizer: OCKSurveyTaskViewSynchronizer {
override func updateView(
_ view: OCKInstructionsTaskView,
context: OCKSynchronizationContext<OCKTaskEvents>) {
super.updateView(view, context: context)
if let event = context.viewModel.first?.first, event.outcome != nil {
view.instructionsLabel.isHidden = false
let pain = event.answer(kind: Surveys.checkInPainItemIdentifier)
let sleep = event.answer(kind: Surveys.checkInSleepItemIdentifier)
view.instructionsLabel.text = """
Pain: \(Int(pain))
Sleep: \(Int(sleep)) hours
"""
} else {
view.instructionsLabel.isHidden = true
}
}
}
关键点:
SurveyViewSynchronizer继承OCKSurveyTaskViewSynchronizer,复用 CareKit 默认调查卡片行为。updateView在 Store 数据变化时被调用,适合把 outcome 显示到 UI 上。super.updateView(view, context: context)先保留系统默认更新逻辑。context.viewModel.first?.first取出当前卡片对应的事件。event.outcome != nil表示这个事件已有完成结果。event.answer(kind:)通过 outcome value 的kind读取疼痛和睡眠答案。instructionsLabel.text把答案写回卡片,参与者回看历史日期时能看到具体数值。- 没有 outcome 时隐藏
instructionsLabel,避免显示空白摘要。
(03:12)这里能用 kind 查答案,是因为上一部分保存调查结果时给每个 OCKOutcomeValue 设置了 kind。
static func extractAnswersFromCheckInSurvey(
_ result: ORKTaskResult) -> [OCKOutcomeValue]? {
guard
let response = result.results?
.compactMap({ $0 as? ORKStepResult })
.first(where: { $0.identifier == checkInFormIdentifier }),
let scaleResults = response
.results?.compactMap({ $0 as? ORKScaleQuestionResult }),
let painAnswer = scaleResults
.first(where: { $0.identifier == checkInPainItemIdentifier })?
.scaleAnswer,
let sleepAnswer = scaleResults
.first(where: { $0.identifier == checkInSleepItemIdentifier })?
.scaleAnswer
else {
assertionFailure("Failed to extract answers from check in survey!")
return nil
}
var painValue = OCKOutcomeValue(Double(truncating: painAnswer))
painValue.kind = checkInPainItemIdentifier
var sleepValue = OCKOutcomeValue(Double(truncating: sleepAnswer))
sleepValue.kind = checkInSleepItemIdentifier
return [painValue, sleepValue]
}
关键点:
ORKTaskResult是 ResearchKit 调查完成后的结果对象。ORKStepResult找到 check-in 表单步骤。ORKScaleQuestionResult读取量表题的答案。painAnswer和sleepAnswer分别来自疼痛题和睡眠题。OCKOutcomeValue(Double(truncating:))把 ResearchKit 数值转换成 CareKit outcome value。kind保存字段标识,后续卡片和图表都用它区分疼痛与睡眠。- 返回的数组会被
OCKSurveyTaskViewController写入 CareKit Store。
(03:37)要让自定义同步器生效,需要在创建调查卡片时传入 viewSynchronizer,并设置 surveyDelegate。
let survey = OCKSurveyTaskViewController(
task: task,
eventQuery: OCKEventQuery(for: date),
storeManager: storeManager,
survey: Surveys.checkInSurvey(),
viewSynchronizer: SurveyViewSynchronizer(),
extractOutcome: Surveys.extractAnswersFromCheckInSurvey
)
survey.surveyDelegate = self
关键点:
task是 CareKit Store 中的 check-in task。OCKEventQuery(for: date)限定当前日期的事件。storeManager让任务卡片和 Store 保持同步。survey提供 ResearchKit 问卷。viewSynchronizer替换默认视图同步逻辑。extractOutcome定义 ResearchKit 结果如何转换成 CareKit outcome。surveyDelegate用于后续控制删除权限和完成回调。
禁止删除历史结果,禁止提前填写未来任务
(03:42)CareKit 在删除 outcome 前会询问 delegate。这个方法返回布尔值,决定是否允许删除某个事件的结果。
func surveyTask(
viewController: OCKSurveyTaskViewController,
shouldAllowDeletingOutcomeForEvent event: OCKAnyEvent) -> Bool {
event.scheduleEvent.start >= Calendar.current.startOfDay(for: Date())
}
关键点:
- 方法来自
OCKSurveyTaskViewControllerDelegate。 event.scheduleEvent.start是任务事件的开始时间。Calendar.current.startOfDay(for: Date())得到今天零点。- 事件开始时间早于今天时,表达式返回
false,CareKit 不允许删除。 - 今天及之后的事件返回
true,当前日期仍可重做或删除。
(04:28)未来日期的任务在列表生成时处理。代码先判断列表日期是否晚于今天,再统一修改卡片交互状态。
let isFuture = Calendar.current.compare(
date,
to: Date(),
toGranularity: .day) == .orderedDescending
self.fetchTasks(on: date) { tasks in
tasks.compactMap {
let card = self.taskViewController(for: $0, on: date)
card?.view.isUserInteractionEnabled = !isFuture
card?.view.alpha = isFuture ? 0.4 : 1.0
return card
}.forEach {
listViewController.appendViewController($0, animated: false)
}
}
关键点:
Calendar.current.compare按天比较date和当前时间,忽略小时分钟。isFuture为true表示用户正在查看未来日期。fetchTasks(on:)从 CareKit Store 取出当天有事件的任务。taskViewController(for:on:)为每个任务生成对应卡片。isUserInteractionEnabled = !isFuture禁止未来卡片被点击或填写。alpha = 0.4给用户一个可视反馈:任务能预览,但不能操作。appendViewController把处理后的卡片放入每日列表。
用 CareKit 图表绘制疼痛和睡眠
(05:42)Insights 的第一张图是两组柱状图。它从同一个 check-in task 中取两类答案:疼痛分数和睡眠小时数。
let painSeries = OCKDataSeriesConfiguration(
taskID: TaskIDs.checkIn,
legendTitle: "Pain (1-10)",
gradientStartColor: #colorLiteral(red: 1, green: 0.462745098, blue: 0.368627451, alpha: 1),
gradientEndColor: #colorLiteral(red: 1, green: 0.462745098, blue: 0.368627451, alpha: 1),
markerSize: 10,
eventAggregator: .custom({ events in
events
.first?
.answer(kind: Surveys.checkInPainItemIdentifier)
?? 0
})
)
let sleepSeries = OCKDataSeriesConfiguration(
taskID: TaskIDs.checkIn,
legendTitle: "Sleep (hours)",
gradientStartColor: UIColor.systemBlue,
gradientEndColor: UIColor.systemBlue,
markerSize: 10,
eventAggregator: .custom({ events in
events
.first?
.answer(kind: Surveys.checkInSleepItemIdentifier)
?? 0
})
)
let barChart = OCKCartesianChartViewController(
plotType: .bar,
selectedDate: Date(),
configurations: [painSeries, sleepSeries],
storeManager: storeManager
)
appendViewController(barChart, animated: false)
关键点:
OCKDataSeriesConfiguration描述图表中的一组数据。taskID: TaskIDs.checkIn指定数据来源是每日 check-in 任务。legendTitle显示图例文字。gradientStartColor和gradientEndColor控制柱子的颜色。markerSize: 10控制柱子的宽度。eventAggregator: .custom定义一天内多个事件如何变成一个 Y 值。- 这里每天只有一个 check-in 事件,所以读取
events.first。 - 疼痛系列读取
checkInPainItemIdentifier,睡眠系列读取checkInSleepItemIdentifier。 OCKCartesianChartViewController(plotType: .bar)创建柱状图。storeManager让图表在 Store 数据变化时自动刷新。
用散点图展示活动范围恢复
(07:14)第二张图展示 range of motion(活动范围)测试结果。它只需要一个数据系列,图表类型改成散点图。
let rangeSeries = OCKDataSeriesConfiguration(
taskID: TaskIDs.rangeOfMotionCheck,
legendTitle: "Range of Motion (degrees)",
gradientStartColor: view.tintColor,
gradientEndColor: view.tintColor,
markerSize: 3,
eventAggregator: .custom({ events in
events
.first?
.answer(kind: #keyPath(ORKRangeOfMotionResult.range))
?? 0
})
)
let scatterChart = OCKCartesianChartViewController(
plotType: .scatter,
selectedDate: Date(),
configurations: [rangeSeries],
storeManager: storeManager
)
appendViewController(scatterChart, animated: false)
关键点:
taskID: TaskIDs.rangeOfMotionCheck指向 ResearchKit 的膝盖活动范围任务。legendTitle标明单位是 degrees(角度)。markerSize: 3让散点更小。eventAggregator读取当天第一个 range-of-motion 事件。#keyPath(ORKRangeOfMotionResult.range)与保存 outcome 时使用的kind对齐。plotType: .scatter让数据按散点形式展示。configurations: [rangeSeries]表示这张图只有一组数据。
(08:04)图表连接的是 CareKit Store。演示中删除周二的两个 outcome 后,回到 Insights,周二的数据点会自动消失。
static func extractRangeOfMotionOutcome(
_ result: ORKTaskResult) -> [OCKOutcomeValue]? {
guard let motionResult = result.results?
.compactMap({ $0 as? ORKStepResult })
.compactMap({ $0.results })
.flatMap({ $0 })
.compactMap({ $0 as? ORKRangeOfMotionResult })
.first else {
assertionFailure("Failed to parse range of motion result")
return nil
}
var range = OCKOutcomeValue(motionResult.range)
range.kind = #keyPath(ORKRangeOfMotionResult.range)
return [range]
}
关键点:
ORKRangeOfMotionResult是 ResearchKit 主动任务产生的活动范围结果。motionResult.range是测试得到的角度数值。OCKOutcomeValue(motionResult.range)把结果写成 CareKit 可存储的值。range.kind使用#keyPath(ORKRangeOfMotionResult.range),图表按同一个 key 读取。- 返回数组后,CareKit Store 负责持久化。
用 Featured Content 打开 3D 教育内容
(08:23)教育内容没有固定时间,也没有要保存的结果。它适合放在 Insights 里,用 OCKFeaturedContentView 作为入口。
let kneeModelView = OCKFeaturedContentView(imageOverlayStyle: .dark)
kneeModelView.delegate = self
kneeModelView.label.text = "About the meniscus"
kneeModelView.label.textColor = .systemBackground
kneeModelView.imageView.image = UIImage(named: "knee")
appendView(kneeModelView, animated: true)
关键点:
OCKFeaturedContentView是 CareKit 的内容卡片视图。imageOverlayStyle: .dark给图片加深色遮罩,便于显示文字。delegate = self让控制器接收点击事件。label.text设置卡片标题。imageView.image设置封面图。appendView把卡片放进 Insights 列表。
(09:12)用户点击卡片后,App 创建 ResearchKit task,并用 ORKTaskViewController 展示。
func didTapView(_ view: OCKFeaturedContentView) {
let humanModelTask = Surveys.kneeModel()
let taskViewController = ORKTaskViewController(
task: humanModelTask,
taskRun: nil
)
taskViewController.delegate = self
present(taskViewController, animated: true, completion: nil)
}
func taskViewController(
_ taskViewController: ORKTaskViewController,
didFinishWith reason: ORKTaskViewControllerFinishReason,
error: Error?) {
taskViewController.dismiss(animated: true, completion: nil)
}
关键点:
didTapView来自OCKFeaturedContentViewDelegate。Surveys.kneeModel()返回一个 ResearchKit task。ORKTaskViewController负责运行这个 task。taskRun: nil表示这里不需要指定独立运行标识。delegate = self用于接收完成回调。present以模态方式打开教育内容。didFinishWith中 dismiss,用户看完模型后回到 Insights。
创建 ResearchKit 3D 模型任务
(09:53)3D 教育内容由两个步骤组成:先用说明步骤解释上下文,再进入 3D 模型步骤。
static func kneeModel() -> ORKTask {
let instructionStep = ORKInstructionStep(
identifier: "insights.instructionStep"
)
instructionStep.title = "Your Injury Visualized"
instructionStep.detailText = "A 3D model will be presented to give you better insights on your specific injury."
instructionStep.iconImage = UIImage(systemName: "bandage")
let modelManager = ORKUSDZModelManager(usdzFileName: "toy_robot_vintage")
let kneeModelStep = ORK3DModelStep(
identifier: "insights.kneeModel",
modelManager: modelManager
)
let kneeModelTask = ORKOrderedTask(
identifier: "insights",
steps: [instructionStep, kneeModelStep]
)
return kneeModelTask
}
关键点:
ORKInstructionStep显示标题、说明文字和图标。identifier给步骤一个稳定 ID。ORKUSDZModelManager加载本地 USDZ 模型。- 示例代码先用
toy_robot_vintage验证 3D 模型流程是否可用。 ORK3DModelStep是 ResearchKit 的 3D 模型展示步骤。ORKOrderedTask按顺序组织说明步骤和模型步骤。- session 后半段提到,最终演示把 toy robot 换成 BioDigital 的 model manager,用于显示膝盖和半月板模型。
核心启发
-
做什么:给康复 App 的每日问卷卡片加“当天答案摘要”。 为什么值得做:参与者回看历史日期时能看到疼痛、睡眠等具体答案,减少“只知道完成了,不知道填了什么”的问题。 怎么开始:继承
OCKSurveyTaskViewSynchronizer,在updateView中读取event.answer(kind:),把结果写入instructionsLabel。 -
做什么:禁止用户修改过去的健康测试结果,并禁用未来任务。 为什么值得做:康复、临床研究、症状追踪都依赖按日期记录的数据,历史和未来数据被随意修改会影响趋势判断。 怎么开始:实现
surveyTask(_:shouldAllowDeletingOutcomeForEvent:),用Calendar.current.startOfDay(for:)判断历史事件;生成未来日期卡片时设置isUserInteractionEnabled = false。 -
做什么:在 Insights 页做一张症状和生活习惯的对比图。 为什么值得做:疼痛和睡眠放在同一张柱状图里,参与者更容易看到睡眠不足后疼痛是否升高。 怎么开始:为同一个 task 创建两个
OCKDataSeriesConfiguration,在不同eventAggregator中读取不同的 outcomekind,再传给OCKCartesianChartViewController(plotType: .bar)。 -
做什么:把主动任务结果做成康复进度散点图。 为什么值得做:range of motion 这类测试有天然的时间趋势,散点图能让医生和患者看到恢复轨迹。 怎么开始:从
ORKRangeOfMotionResult.range创建OCKOutcomeValue,设置kind = #keyPath(ORKRangeOfMotionResult.range),再用plotType: .scatter绘制。 -
做什么:给患者加一个 3D 解剖教育入口。 为什么值得做:医生提供的文字说明配合 3D 模型,能帮助患者理解损伤位置和恢复目标。 怎么开始:在 CareKit 的 Insights 中放
OCKFeaturedContentView,点击后 presentORKTaskViewController,task 内包含ORKInstructionStep和ORK3DModelStep。
关联 Session
- Build a research and care app, part 1: Setup onboarding — 用 ResearchKit 和 CareKit 搭建 onboarding、知情同意和权限请求。
- Build a research and care app, part 2: Schedule tasks — 把调查和主动任务加入 CareKit 日程,并保存参与者结果。
- Measure health with motion — 使用 Core Motion 和 HealthKit 的步态、六分钟步行等指标扩展健康监测数据源。
评论
GitHub Issues · utterances