WWDC Quick Look 💓 By SwiftGGTeam
Build a research and care app, part 3: Visualize progress

Build a research and care app, part 3: Visualize progress

观看原视频

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 读取量表题的答案。
  • painAnswersleepAnswer 分别来自疼痛题和睡眠题。
  • 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 和当前时间,忽略小时分钟。
  • isFuturetrue 表示用户正在查看未来日期。
  • 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 显示图例文字。
  • gradientStartColorgradientEndColor 控制柱子的颜色。
  • 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 负责持久化。

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,用于显示膝盖和半月板模型。

核心启发

  1. 做什么:给康复 App 的每日问卷卡片加“当天答案摘要”。 为什么值得做:参与者回看历史日期时能看到疼痛、睡眠等具体答案,减少“只知道完成了,不知道填了什么”的问题。 怎么开始:继承 OCKSurveyTaskViewSynchronizer,在 updateView 中读取 event.answer(kind:),把结果写入 instructionsLabel

  2. 做什么:禁止用户修改过去的健康测试结果,并禁用未来任务。 为什么值得做:康复、临床研究、症状追踪都依赖按日期记录的数据,历史和未来数据被随意修改会影响趋势判断。 怎么开始:实现 surveyTask(_:shouldAllowDeletingOutcomeForEvent:),用 Calendar.current.startOfDay(for:) 判断历史事件;生成未来日期卡片时设置 isUserInteractionEnabled = false

  3. 做什么:在 Insights 页做一张症状和生活习惯的对比图。 为什么值得做:疼痛和睡眠放在同一张柱状图里,参与者更容易看到睡眠不足后疼痛是否升高。 怎么开始:为同一个 task 创建两个 OCKDataSeriesConfiguration,在不同 eventAggregator 中读取不同的 outcome kind,再传给 OCKCartesianChartViewController(plotType: .bar)

  4. 做什么:把主动任务结果做成康复进度散点图。 为什么值得做:range of motion 这类测试有天然的时间趋势,散点图能让医生和患者看到恢复轨迹。 怎么开始:从 ORKRangeOfMotionResult.range 创建 OCKOutcomeValue,设置 kind = #keyPath(ORKRangeOfMotionResult.range),再用 plotType: .scatter 绘制。

  5. 做什么:给患者加一个 3D 解剖教育入口。 为什么值得做:医生提供的文字说明配合 3D 模型,能帮助患者理解损伤位置和恢复目标。 怎么开始:在 CareKit 的 Insights 中放 OCKFeaturedContentView,点击后 present ORKTaskViewController,task 内包含 ORKInstructionStepORK3DModelStep

关联 Session

评论

GitHub Issues · utterances