ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

帕金森症状追踪应用开发:从数据建模到React Native落地实践

帕金森症状追踪应用开发:从数据建模到React Native落地实践

简介:ParkInsight是一款面向帕金森氏病患者症状追踪的Android移动应用,结合语音与传感器数据,借助机器学习模型按标准UPDRS量表评估病情。该压缩包完整收纳项目源码与部署资料,共126个文件、约6.9MB,涵盖Java与XML的Android原生界面、HTML/CSS/JS前端页面、Python编写的Flask后端、TensorFlow模型及Jupyter Notebook实验脚本,并包含Gradle构建配置与开发环境说明,可支撑从模型训练到端侧集成的完整链路。已有119人学习浏览,适合移动医疗应用开发者、AI初学者及健康物联网方向学生。通过本项目可重点研读语音特征提取、传感器信号处理、Azure机器学习服务与Flask API的衔接方式,以及UPDRS评估逻辑在移动端的落地实现,是一份结合临床需求与工程实践的参考样例。

1. parkinsight:帮助帕金森氏病患者追踪症状,为什么自评数据比问诊回忆更靠谱

帕金森氏病患者在门诊里最常见的状态,是努力回忆“过去两周到底哪天手抖得厉害”。这种回忆高度失真,实际诊疗里医生拿到的常常是“最近好像都还好”。parkinsight 要做的,就是把症状发生变成移动端上一两秒能完成的记录,让患者和医生看到一条连续的、带时间轴的症状曲线。这不是简单的电子日记,而是一套把运动症状、非运动症状和用药时间线对齐的数据采集方案。

对开发者来说,这个方向有两个隐性价值:一是数据模型可以直接复用给其他慢性病追踪应用;二是它迫使你认真面对“弱势输入场景”的设计——按钮要大、流程要短、容错要强。适合人群是移动端开发者和医疗健康方向的独立开发者。下面按“数据建模 → 最小实现 → 踩坑 → 验证 → 主动提醒”这条路径拆到可以照做的程度,也把它不值得做的边界讲清楚。

2. 症状追踪的数据建模:把临床评分变成可持久化的业务对象

帕金森症状追踪的第一性问题不是界面好不好看,而是数据能不能被医生信任。评分标尺、时间语义、药物关系这三件事必须在第一版就定扎实,后面改模型的成本会成倍增加。这一章从临床评分做法迁到代码模型。

2.1 运动症状的量化:把 0-4 分评分表搬进手机

帕金森的运动症状大致分三类:震颤、僵直、运动迟缓,典型表现分别是休息时手抖、肢体僵硬感、动作变慢。临床上常用的评估方式是运动功能评分量表,每一项由医生或者患者本人打 0 到 4 分。parkinsight 不打算做传感器测量,而是把这张评分表搬到手机里,让患者在症状发生的当下自己打分。0 代表没有症状,1 轻微但能感知,2 中度且不影响日常,3 明显影响日常动作,4 最严重、无法忽视。

我一般在代码里用联合类型把症状类型锁死,避免字符串拼写漂移:

export type SymptomType = | 'tremor' | 'rigidity' | 'bradykinesia' | 'gait' | 'freezing' | 'dyskinesia'; export type Severity = 0 | 1 | 2 | 3 | 4; export interface SymptomRecord { id: string; patientId: string; symptomType: SymptomType; severity: Severity; durationMinutes: number; context: 'on' | 'off' | 'unknown'; occurredAt: string; createdAt: string; }

这段结构的重点在context字段,它对应帕金森患者熟悉的“开期 / 关期”:服药后症状缓解是开期,药效消退症状卷土重来是关期。界面上可以用“药效内 / 药效外”这种白话,但数据里必须用一个稳定枚举,因为医生第一眼想看的不是某次震颤是 3 分还是 4 分,而是这个患者一天里有多长时间处于关期。这条信息能直接辅助用药决策,比单次评分重要得多。

设计时还要注意枚举值的别名代价:如果一开始用中文当枚举值,比如在数据库里存“震颤”“僵直”,后续接英文图表库、做多语言、对接外部设备数据时都要做一层映射,不如从第一天就用英文稳定标识,前端展示层再做本地化转换。

2.2 非运动症状与药物时间线:一类容易被低估的数据

帕金森的非运动症状同样很常见,睡眠支离破碎、情绪低落、便秘、注意力难以集中。这些症状对生活质量的影响不小,但在门诊里最容易被遗忘。parkinsight 的做法是把非运动症状和运动症状分开建模,评分压缩到 0 到 3 分,因为让患者给“心情不好”分四档很容易犹豫,三档更顺。

药物记录是另一块拼图。症状曲线脱离用药时间线,分析价值直接腰斩。记录吃药这件事必须尽量轻,只需要药名、剂量、服用时间、是否随餐。

export interface MedicationLog { id: string; patientId: string; medicationName: string; dosageMg: number; takenAt: string; withFood: boolean; } export interface NonMotorRecord { id: string; patientId: string; domain: 'sleep' | 'mood' | 'constipation' | 'cognition'; severity: 0 | 1 | 2 | 3; note?: string; recordedAt: string; }

为什么药物和症状分成两张表而不是塞在一条记录里?因为实际过程是多对多的。一次症状发作可能横跨两次服药,一次服药也可能缓解多种症状。用时间窗口做关联,比用外键硬关联更符合真实病程。withFood这个布尔字段不是形式主义,左旋多巴类药物的吸收受胃排空影响显著,饭后服用可能让起效时间推迟半小时到一小时。不记这个标志,后面分析“为什么今天起效特别慢”时只能靠猜。

2.3 数据模型的四个强制约束

同一个症状评分表,不同开发者的落地方案差异巨大,我梳理出四个在业务层必须卡死的约束。

第一个是时间戳语义。所有时间字段用 ISO 8601 字符串存 UTC,展示层再转本地时区。患者改手机时间、跨时区旅行都不应该污染原始数据。用 UTC 之后,至少能通过occurredAt和createdAt的差值知道某条记录是即时录入还是事后补录。

第二个是记录与录入分离。occurredAt是症状实际发生的时间,createdAt是数据落库时间。两条时间线一旦合并,补录数据就会假装成即时数据,趋势图上会冒出大量“凌晨三点严重震颤”这种伪异常。差值超过 4 小时,分析时该条记录降权处理。

第三个是每一条记录只允许一个主要症状。患者可能一口说“手抖、走路也拖步”,但一次提交只能填一个主诉,其余在备注里写。多选塞进数组表面上友好,实际上让趋势图、统计、导出全都要处理组合爆炸,得不偿失。

第四个是主键用 UUID。本地优先的架构里,患者可能手机和平板交替使用,自增主键同步时几乎必撞车,UUID 从源头避免这个问题。

字段约束原因
时间戳UTC ISO 8601 字符串避免改时间 / 时区导致排序错乱
occurredAt 与 createdAt必须分别存在补录延迟可计算、可标记
单条记录主症状唯一防止多选污染评分与统计
主键UUID多设备同步不冲突

这四个约束是先后两个模拟项目里沉淀下来的。第一个项目在模型层偷了懒,时间字段只存一个,后面做数据清洗时每天都在打补丁。数据模型多花一天设计,不值得省。

3. 用 React Native 实现 parkinsight 的最小可运行闭环

数据模型定下来后,目标是一个能真实跑起来的闭环:记录症状 → 存入本地数据库 → 绘制趋势图 → 导出报告。整个闭环不依赖任何后端服务,所有数据落在设备本地。这样做既保护隐私,也大幅降低初始开发成本。

3.1 工程骨架与依赖选型

parkinsight 的目标用户里有不少年纪偏大,不可能要求患者全部使用同一品牌手机,一个跨平台框架优先于双端原生。常见做法是 React Native 加 Expo。选它不是因为功能最强,而是因为本地 SQLite、本地通知、PDF 导出这三块刚好都有稳定封装。

npx create-expo-app Parkinsight --template blank-typescript cd Parkinsight npx expo install expo-sqlite expo-notifications react-native-svg npm install date-fns

模板使用 blank-typescript,避免官方模板自带的 tab 导航和冗余组件,从第一天就保持依赖干净。expo-sqlite负责存储,expo-notifications做服药提醒,react-native-svg画趋势折线图,date-fns处理时间计算。刻意不引入 Redux 或 MobX,parkinsight 的数据流是单向的:表单页写入数据库,图表页读库渲染,页面间共享状态极少,React 自带的useReducer搭配刷新回调足够。

提示:模拟器里 SQLite 和通知的表现都正常,但真机上通知权限弹出的时机、存储目录的可用性有差异,工程初始化后先在真机上跑一次“写入 + 提醒”的最小路径,能省掉后面大量定位时间。

3.2 症状记录的交互流程与本地存储

核心交互只有一个指标:快。患者手抖发作的时候,撑不过三级页面。记录页设计成三步完成:选症状、选严重度、保存。持续时间和开/关期先填默认值,允许事后编辑,不打断录入流。

存储层用一个轻量 DAO 包住 SQLite 操作,避免业务代码里散落裸 SQL。

import * as SQLite from 'expo-sqlite'; const db = SQLite.openDatabaseSync('parkinsight.db'); export function initDb() { db.execSync(` CREATE TABLE IF NOT EXISTS symptom_records ( id TEXT PRIMARY KEY NOT NULL, patient_id TEXT NOT NULL, symptom_type TEXT NOT NULL, severity INTEGER NOT NULL CHECK(severity BETWEEN 0 AND 4), duration_minutes INTEGER NOT NULL DEFAULT 0, context TEXT NOT NULL DEFAULT 'unknown', occurred_at TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_symptom_time ON symptom_records(occurred_at); `); } export function insertSymptom(record: SymptomRecord) { db.runSync( `INSERT INTO symptom_records (id, patient_id, symptom_type, severity, duration_minutes, context, occurred_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [record.id, record.patientId, record.symptomType, record.severity, record.durationMinutes, record.context, record.occurredAt, record.createdAt] ); }

留意两个细节。CHECK(severity BETWEEN 0 AND 4)是数据库层面的兜底,UI 漏校验时数据库也能拦住脏数据。idx_symptom_time索引直接用occurred_at建,这个表未来最主要的查询都是按时间范围聚合,没有索引到两三个月数据量就会在低端安卓机上出现明显卡顿。插入语句全部使用参数化写法,不拼字符串,既防注入也避免转义问题。

openDatabaseSync创建的是全局单连接。parkinsight 没有并发写需求,单连接够用且简单。如果在日志里看到 “database is locked”,多半是前一次写操作还没结束就切了页面,改成runAsync并在页面卸载前等待写入完成即可。

同步版 API 在数据量小的时候确实简单,但这个应用的记录上限并不低,三年下来可能积累上万条。查询侧建议都走异步版,聚合查询多用索引,UI 线程不会被卡死。

真实使用中还有一个常见情况:患者手边可能只有一只可操作的手,弹出的键盘、二级确认弹窗都会成为障碍。因此提交记录不做二次确认,反而是冒进却正确的选择;误触交给撤销按钮兜底。

3.3 趋势图表:把评分变成医生熟悉的时间线

记录的价值要在图表上兑现。医生要看的不是某一次 3 分,而是最近两周开期占比、日平均严重度、症状加重的时段。用react-native-svg自绘折线,而不是引入完整图表库,是因为需要的定制点正好卡在通用图表库不灵活的位置。

import Svg, { Polyline } from 'react-native-svg'; function SymptomTimeline({ points }: { points: { time: number; value: number }[] }) { const width = 340; const height = 120; const minTime = Math.min(...points.map(p => p.time)); const maxTime = Math.max(...points.map(p => p.time)); const mapped = points.map(p => ({ x: ((p.time - minTime) / (maxTime - minTime)) * width, y: height - (p.value / 4) * height, })); return ( <Svg width={width} height={height}> <Polyline points={mapped.map(p => `${p.x},${p.y}`).join(' ')} fill="none" stroke={mapped.length > 20 ? '#e74c3c' : '#3498db'} strokeWidth={2} /> </Svg> ); }

坐标映射很简单:横轴把时间戳线性映射到画布宽度,纵轴把 0 到 4 分的严重度映射到画布高度,越靠上越严重。stroke颜色按数据量切换,超过 20 条记录用红色高亮,提示医生这段期间数据密集、可信度更高。实际运行时横轴一般按天聚合,因为单次记录往往集中在服药前后,直接画原始点会重叠成一片,看不出趋势。

这里有一个反直觉但重要的决定:不要用平滑曲线。贝塞尔平滑会把“从 0 分直接跳到 4 分”的突变画成缓坡,视觉上暗示症状是逐步加重的,而真实情况可能是突然发作。折线图保留折角,数据诚实度优先于美观。把这一点写进注释,免得后来维护的人改成平滑样式。

4. parkinsight 落地的 5 个高频踩坑点

4.1 记录疲劳:再好的量表也扛不住一天十次提醒

现象:第一版参考健康打卡应用,每天多次推送提醒患者记录,一周后记录量从一天 12 条掉到 2 条,有测试用户直言“不想被手机绑架”。

原因:帕金森患者的日程被服药、复健、休息切得非常碎,频繁打断等于制造焦虑。健康人群习惯的打卡机制,对患者来说是一种额外负担,尤其当记录动作本身需要思考和操作时,提醒反而让患者产生抵触。

解决:把提醒从“定时打卡”改成“药效转折点”:一天只提醒两次,一次在早上首次服药前,一次在下午药效大概率衰退的时间,这两个点正是关期症状高发时段。调整后记录量回到每天 6 到 8 条,而且集中在医生最关心的时段。这个改动没有动任何数据模型,只是把提醒策略从“频率优先”改成“时机优先”。

4.2 时间戳陷阱:补录的日期到底该信谁

现象:趋势图上出现某天凌晨 3 点严重度 4 的记录,医生很紧张,追问后才知道是患者第二天早上补录的,实际发生时间是前晚 9 点。

原因:补录入口直接取了“当前时间”,没有让用户选择“症状发生时间”。这在快速录入场景下是常见的偷懒做法,却让一条完全真实的记录变成了数据里的伪异常。

解决:补录页强制显示发生时间选择器,并把“发生时间”和“录入时间”并排展示,让补录这个行为本身变得可见。更进一步,两者间隔超过 4 小时自动打补录标记,图表上用空心圆渲染。

const isBackfill = occurredAt.getTime() - createdAt.getTime() > 4 * 3600 * 1000;

这条规则保证后续所有时间相关的统计都不会被骗。否则拿到一条曲线,把回忆当成了实况,临床结论就跟着跑偏。

4.3 药物记录和症状评分不能分成两个孤岛

现象:只记录症状不记录药物时,连续三天下午三点症状加重,但分析不出是药效正常消退还是患者当天忘了吃。

原因:第一版把药物记录放在二级页面,入口深,患者嫌麻烦基本不填。症状记录和药物记录之间没有任何视觉关联,患者意识不到这两类数据必须放在一起才有效。

解决:把“最近一次服药时间”展示在症状记录页底部,旁边放一个“吃药后到了 3 小时”的一键标签。药物录入被嵌入到高频的记录动作里,数据模型不变,但录入率从 20% 升到 70% 左右。这个改动没有增加任何新功能,只改了信息架构。

4.4 视觉尺寸要按手抖场景设计

现象:测试用户频繁点错,日志里出现大量“选完症状后 2 秒内退出页面”的误触痕迹,评分和实际严重度经常对不上。

原因:沿用常规 44 像素最小点击区域,对震颤患者来说偏小,按钮间距也不够,手一晃就点到了旁边。常规应用的触控标准在这里完全不适用。

解决:核心按钮点击区域至少做到 60×60 像素,按钮间距留 20 像素以上余量。评分按钮用大色块而不是小圆圈,小圆圈的视觉区域和点击区域不一致,误触率明显更高。底部固定一个撤销按钮,误触后能快速回退。这个设计原则写进组件规范,避免以后新增页面又退回小按钮。

4.5 医疗数据合规模糊地带:权限最小化与本地优先

现象:第一版想做“日志分享给家属”,申请了通讯录权限,应用商店审核被驳回,理由是权限与核心功能无关。

原因:医疗健康类应用申请敏感权限而缺少充分的使用场景说明,是审核红线。当时没有在权限弹窗文案和后台配置里给出足够理由,也没有考虑替代方案。

解决:整体架构改为本地优先,数据默认只存在设备 SQLite 里,分享功能完全由用户手动触发“生成文件 → 系统分享面板”。权限按最小化原则申请:要通知就只申请通知权限,不碰通讯录、地理位置、后台刷新。这一点上没什么玄学,就是把“数据属于患者”落实到权限设计和存储设计上。

注意:健康类应用与临床表现强相关,合规与隐私的本质是“数据属于患者”,权限设计、存储设计都要落实这一点,而不是靠一句空话。

5. 验证症状追踪数据有效性:从评分漂移到双周医生报告

自评数据的第一性质疑是“患者打的分数可不可靠”。parkinsight 的做法不是用算法去猜患者对不对,而是做轻量对照和周期复测,让数据消费者(患者本人和医生)知道哪些分数值得参考,哪些需要打折扣。

5.1 内嵌标准动作引导,给自评评分一个锚点

把 0 到 4 分直接摆在患者面前时,每个人对“中度”的理解千差万别。常见做法是在评分页内嵌一个 10 秒标准动作引导,例如“双手向前平举 10 秒,观察是否有颤抖”。患者先做动作再打分,比盯着文字描述选数字要稳定得多。

动作引导优先用文字加静态示意图,不上视频。原因是门诊和户外场景下,视频加载慢、流量成本高,而且患者手抖时很难精准控制播放。静态图加一句话描述,学习成本最低。

对照校验按月进行。每月指定一天让患者完成四个固定情境的记录,比如“服药后一小时从沙发上站起来”“晚饭后散步十分钟”。这些情境对应的动作是标准化的,同一个人在不同月份做同一组动作,分数差异才具有比较意义。

这里要区分即时记录和对比记录:日常记录允许主观感受波动,月度情境记录要求尽量复现同一状态。两条线分开统计,避免互相污染。

5.2 评分漂移检测:分数变化不能全当病情变化

患者对严重度的标尺会漂移。刚开始觉得 2 分已经很重,几个月后可能觉得 2 分只是常态。这不是患者错,而是参照物变了。如果直接把漂移当成病情恶化,医生会调整一个本来不该调整的方案。

处理办法是给评分系统加一个轻量的漂移检测。每次完成月度情境记录后,将每个情境的分数与上一次同情境分数做差。

export function detectScoreDrift(current: number[], previous: number[]): boolean { const diffs = current.map((v, i) => Math.abs(v - previous[i])); return diffs.some(d => d >= 2); }

diffs.some(d => d >= 2)是触发漂移标记的阈值。单个情境跳动达到 2 分,说明这一个维度的评分稳定性有风险,而不是直接判定病情变化。触发后,趋势图顶部显示黄色提示,不阻断记录,只提示“本次评分可能受主观标准变化影响”。连续两次触发再升级为红色提示,并建议复诊时和医生沟通这个问题。

阈值 2 分怎么定出来的?第一阶段试过 1 分,健康状态下也频繁误报,因为人的感知本来就有波动;试到 2 分才平衡了灵敏度和可用性。这个参数值得每个项目根据自己的受试者重新标定,不要照抄。

5.3 生成给医生用的双周小结报告

数据只有流出 App 才有临床价值。导出报告坚决不用 CSV 甩给医生,医生没有时间拉透视表。实践下来最受用的是 A4 单页 PDF,内容固定为六个模块:近两周平均严重度折线、开期时间占比、症状类型分布、补录记录标记、评分漂移提示、药物记录遗漏率。

报告的数据结构先固定下来,方便接不同的渲染层:

interface ClinicReport { periodStart: string; periodEnd: string; avgSeverityByType: Record<string, number>; onTimeRatio: number; missedMedicationLogs: number; driftFlags: string[]; generatedAt: string; }

periodStart和periodEnd默认取最近 14 天,刚好覆盖两次复诊之间的常规间隔。onTimeRatio是开期时间占比,医生可以通过这个数字快速判断药效覆盖是否充足。missedMedicationLogs用于评估药物记录本身的完整性,如果这个数字过高,说明症状曲线的可信度也要相应下调。

报告模块字段医生用途
严重度趋势avgSeverityByType判断近两周症状整体走向
开期占比onTimeRatio评估药效覆盖是否充足
症状分布symptomTypeCounts判断当前最困扰的症状
补录标记backfillRatio衡量数据整体可信度
漂移提示driftFlags提示自评标尺可能的偏移
药物遗漏率missedMedicationLogs辅助解读症状曲线缺口

expo-print渲染 HTML 模板转 PDF,在双端表现都可控。生成的 PDF 通过系统分享面板交给患者自己发送,应用不直接访问通讯录,和前面的最小权限策略保持一致。文件命名直接写parkinsight-report-日期.pdf,方便医生按日期归档到病历。

6. 从症状日志到主动干预:服药提醒时间窗的前端实现

记录数据积累一段时间后,患者自然会期待应用别只当电子日记。最常见的需求是“到点提醒吃药”。这里的关键不是做成固定闹钟,而是根据最近一次服药时间和药物标准间隔,算出下一次窗口,用本地通知完成。

6.1 本地通知实现动态服药提醒

固定闹钟的问题在于患者作息每天都在变。基于“上次服药时间 + 间隔”计算下一个窗口,才能贴合实际。

import * as Notifications from 'expo-notifications'; export async function scheduleNextDose(takenAt: Date, intervalHours: number) { const next = new Date(takenAt.getTime() + intervalHours * 3600 * 1000); await Notifications.scheduleNotificationAsync({ content: { title: '服药提醒', body: '现在是药效可能开始消退的时间段,如果感觉症状加重,可以记录一条状态', sound: 'default', }, trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: next, }, }); }

trigger使用绝对日期,因为提醒的目的是在具体时间点出现,而不是相对某刻的倒计时。调用时机在患者记录“服药”后立即执行,同时取消掉该药物上一轮未触发的提醒,避免重复。提醒文案只说“可以记录一条状态”,不做更多渲染,保持克制,不给人“App 在指挥用药”的印象。

6.2 症状与服药的联合时间线

提醒的价值需要和症状记录配合才能显现。联合时间线把症状发作区间和药物节点放在同一张图,能直观暴露“吃药后 1.5 小时开始症状加重”这类只在交叉视角里浮现的模式。

实现也简单,两条记录按时间戳合并排序即可:

const entries = [ ...symptoms.map(s => ({ time: s.occurredAt, kind: 'symptom' as const })), ...meds.map(m => ({ time: m.takenAt, kind: 'med' as const })), ].sort((a, b) => a.time.localeCompare(b.time));

localeCompare对 ISO 时间戳的字典序与真实时间序一致,不需要转 Date 再排序,几千条数据也能快速完成。

往远看,这套时间线结构可以平滑容纳可穿戴设备数据,手表传来的运动计数只需要按occurredAt挂进同一条时间轴。做 parkinsight 这类应用,我最后守住的原则是:任何功能改动,先问会不会破坏时间维度。时间线一旦失真,所有统计和结论都会跟着失真。希望这篇梳理能帮准备做慢性病追踪应用的你少走几个弯。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进