1. 项目背景:为什么在鸿蒙环境下做量表评分
1.1 这个需求从哪来,PHQ-9/GAD-7到底是什么
PHQ-9(患者健康问卷抑郁量表)和GAD-7(广泛性焦虑障碍量表)是精神科和临床心理科最常用的两个自评筛查工具。PHQ-9一共9个条目,GAD-7一共7个条目,每个条目的选项都是四级频率描述:“完全没有”“有几天”“一半以上天数”“几乎每天”,对应0到3分。把各条目得分累加,得到总分,再按5分、10分、15分、20分这样的分界值划分严重程度。
我接到这个需求的时候,产品经理给的原话是:“咱们App要内置心理健康自评模块,用户做完题直接出分,后台把每个条目的选项也存下来。量表规则必须按PHQ-9和GAD-7的标准来,不能自己改。另外UI要快,评估能不能用React Native做,顺便把鸿蒙的包也一起出。”
这里有两个关键点:一是打分规则必须严格符合标准化量表的计分逻辑,二是技术栈要跨Android、iOS、鸿蒙三端统一。React Native从0.71版本开始对鸿蒙有了完整的社区支持方案(react-native-oh-tpl),加上我们团队本来就有RN基础,所以最终选了RN + HarmonyOS适配方案。
1.2 为什么选择React Native而不是鸿蒙原生开发
鸿蒙原生用ArkTS + ArkUI,说实话做列表页、表单页效率不低,但问题是团队里没人写过ArkTS,现学成本高。而RN组件化开发速度快,一套业务代码在Android、iOS、鸿蒙三端复用。尤其是这种量表类页面——结构固定、交互简单、逻辑集中在评分计算上——用RN做非常合适。
选择RN还有一层考量:量表的题目和选项可能后续会变。如果用原生写死,每次改量表都要发版;用RN写,可以把量表配置做成JSON下发,热更新就能调整题目标题、选项顺序、甚至评分映射规则。实际做下来,这个设计帮了大忙,后面运营那边改过两次选项文案,前端一行代码没动。
提示:RN鸿蒙适配目前走的是OpenHarmony的React Native适配层(react-native-oh-tpl),本质上是通过鸿蒙的Ability框架承载RN实例。选型时要确认你用的RN版本对应社区维护的鸿蒙适配包版本,别直接拿Android的依赖硬套。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心设计:选项索引映射评分到底是怎么一回事
2.1 量表的评分逻辑拆解
PHQ-9和GAD-7的计分规则非常统一:每个条目的选项都是0到3分,总分就是把所有条目的分值直接相加。所以最简单可靠的实现方式是——“选项索引即分值”。
什么意思?假设我们定义选项数组:
javascript复制const PHQ_OPTIONS = [
{ label: '完全没有', score: 0 },
{ label: '有几天', score: 1 },
{ label: '一半以上天数', score: 2 },
{ label: '几乎每天', score: 3 }
];
用户选中第几个选项,就取对应的score值作为该条目的得分。这个映射关系简单到不能再简单,但你千万别觉得简单就轻视它,真正容易出错的点在“用户没作答的题目怎么处理”“多题共用一套选项时怎么避免状态串了”“总分计算时怎么保证所有题目都已作答”。
PHQ-9的总分范围是0到27分,GAD-7是0到21分。分级标准容易记混,PHQ-9是5/10/15/20四个分界线,GAD-7是5/10/15三个分界线。就是说不光是计算总分,连结果解读也要按量表各自的规则来,不能一刀切。
2.2 索引映射、分值和总分的代码实现
核心逻辑其实不多,把关键部分拆开看:
javascript复制// 题目配置,每个条目的选项都是同一个四级刻度
const QUESTION_CONFIG = Array.from({ length: 9 }, (_, index) => ({
id: `phq9_${index + 1}`,
// 每个条目的题干不同,这里省略具体文案
options: [
{ label: '完全没有', value: 0 },
{ label: '有几天', value: 1 },
{ label: '一半以上天数', value: 2 },
{ label: '几乎每天', value: 3 }
]
}));
// 用户作答状态:answerMap[questionId] = 选中的选项索引
const [answerMap, setAnswerMap] = useState({});
const handleSelect = (questionId, optionIndex) => {
setAnswerMap(prev => ({
...prev,
[questionId]: optionIndex
}));
};
// 计算总分:遍历所有题目,通过索引取分值并累加
const totalScore = QUESTION_CONFIG.reduce((sum, question) => {
const answerIndex = answerMap[question.id];
if (answerIndex === undefined) {
return sum; // 未作答不计分,但UI上需要提示
}
return sum + question.options[answerIndex].value;
}, 0);
这里我把选项的值直接定义成了0到3的数字,所以取分值的代码看起来像“白写”的。但如果你把选项value定义成字符串(比如'0'、'1'、'2'、'3'),累加时就容易出字符串拼接的bug。所以设计评分数据结构的第一个原则就是:分值必须是number类型,索引和分值之间不要有过多的间接层。
用reduce做累加很适合这种场景,但要注意一个细节:QUESTION_CONFIG必须和实际渲染的题目列表是同一个数据源,否则用户看到的第5题跟在计算时遍历的question.id对不上,分数就全乱了。
2.3 为什么选择数组索引而不是Map映射
有人会问:为什么不直接存选项ID,然后再用一个Map把选项ID映射成分值?比如用户选了'rarely',然后scoreMap['rarely'] === 1。
这在业务层其实更“语义化”,因为后续如果选项文案变了,ID可以不变。但我个人的经验是:标准量表这种固定结构,直接用选项索引0/1/2/3作为存储和计算的最小单元,最简单也最不容易错。原因有三点:
第一,PHQ-9和GAD-7的选项顺序是固定的,索引0永远是“完全没有”,索引3永远是“几乎每天”,不存在选项乱序的情况。第二,索引即分值,省掉了Map查找这一步,代码可读性更高。第三,上报后台时索引就是0-3的整数,传输和解析都很轻量。
如果你实在担心语义化问题,折中方案是:前端UI层用选项ID做标识,但在提交计算层统一转换成索引。我这次为了快速上线,直接用了索引,后续如果量表选项支持乱序配置,再改成ID映射也不迟。
3. 鸿蒙适配实战:从JSON定义到UI渲染的完整流程
3.1 定义量表配置与映射规则
在真正写代码之前,我觉得最值得做的事是先想清楚“题目配置放哪”。最理想的做法是放成远程JSON,而不是写死在代码里。我这边是做了一个配置表:
json复制{
"scaleCode": "PHQ9",
"version": "1.0.0",
"questions": [
{
"id": "phq9_1",
"title": "做事时提不起劲或没有兴趣",
"options": [
{ "label": "完全没有", "value": 0 },
{ "label": "有几天", "value": 1 },
{ "label": "一半以上天数", "value": 2 },
{ "label": "几乎每天", "value": 3 }
]
}
],
"scoringRule": {
"type": "sum",
"maxScore": 27,
"levels": [
{ "min": 0, "max": 4, "label": "无抑郁" },
{ "min": 5, "max": 9, "label": "轻度抑郁" },
{ "min": 10, "max": 14, "label": "中度抑郁" },
{ "min": 15, "max": 19, "label": "中重度抑郁" },
{ "min": 20, "max": 27, "label": "重度抑郁" }
]
}
}
这里我把评分规则也放进配置里了。虽然PHQ-9和GAD-7的规则是固定的,但放配置里至少有两个好处:一是客户端可以根据scaleCode动态展示对应的分级文案,二是如果运营要上新的量表比如SAS、SDS,后端加配置就行,前端只需保证渲染和计算逻辑是通用的。
3.2 在RN层实现选项选择与索引回传
UI层我用的是FlatList渲染题目列表,每道题一行选项组。为了避免整页滚动时每个选项都触发大量重渲染,我把每道题抽成了一个QuestionItem组件,用React.memo包裹,只有selectedIndex变化时才重渲染。
javascript复制const QuestionItem = React.memo(({ question, selectedIndex, onSelect }) => {
return (
<View style={styles.questionCard}>
<Text style={styles.questionTitle}>{question.title}</Text>
<View style={styles.optionsContainer}>
{question.options.map((option, index) => {
const isSelected = selectedIndex === index;
return (
<TouchableOpacity
key={option.label}
style={[styles.optionItem, isSelected && styles.optionItemSelected]}
onPress={() => onSelect(question.id, index)}
>
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
});
这里有个小细节值得说一下:onPress={() => onSelect(question.id, index)}这种写法,箭头函数在每次渲染时都会创建新引用,React.memo的浅比较就会失效。不过Flutter、RN在列表规模不大的情况下,性能瓶颈不太明显,我实测9道题、每道4个选项,即使不优化也不会卡。但为了严谨,可以用useCallback包一层。
3.3 针对鸿蒙平台的兼容处理
RN代码跑在鸿蒙上,最典型的几个坑我先列出来,后面“问题排查”部分再展开。
第一个是react-native-netinfo在鸿蒙上不能用,或者需要额外适配。如果你的量表页需要联网加载JSON配置,检测网络状态时就要注意权限申请——鸿蒙的权限模型跟Android不一样,需要在module.json5里声明ohos.permission.GET_NETWORK_INFO,否则可能拿不到网络状态回调。
第二个是启动白屏问题。RN鸿蒙应用启动时会有一段加载JS Bundle的时间,如果Bundle体积大,白屏时间会更明显。我这边做了两个优化:一是把量表页做成懒加载,进入页面时才加载对应模块;二是用SplashScreen控制启动页,让用户感知不到白屏。
第三个是har封装问题。如果你的RN项目里引入了自研的原生模块,需要打包成har供鸿蒙侧调用。我第一次做鸿蒙RN项目时,在打包har时漏了so文件,结果运行到调用原生方法时直接crash,排查了很久才发现是har包里的so没打进去。这个问题后面细说。
4. 常见问题与排查技巧
4.1 启动白屏和netinfo权限问题
启动白屏是RN鸿蒙开发里遇到最多的问题之一。表现是App启动后屏幕一片空白,过一两秒才显示出页面。原因通常是JS Bundle加载慢或者原生端渲染时机晚。我实测几个版本差异挺大:
| 场景 | 表现 | 解决方案 |
|---|---|---|
| Bundle体积大 | 白屏持续2-3秒 | 开启分包加载、按路由懒加载 |
| Debug模式 | 白屏比Release明显 | 属于正常现象,发布版会好很多 |
| 原生so库未打包 | 首屏闪退/白屏+crash | 检查har包内的libs是否完整 |
| 鸿蒙系统权限缺失 | 网络请求失败导致页面空白 | 在module.json5中声明对应权限 |
netinfo的问题比较特殊。RN的@react-native-community/netinfo是社区库,它对鸿蒙的适配是通过react-native-oh-tpl仓库提供的,而且要求应用申请ohos.permission.GET_NETWORK_INFO权限。如果权限没声明,NetInfo.fetch()会一直返回unknown状态,导致你无法判断用户是否在线,加载远程JSON会失败。
注意:鸿蒙的权限声明位置在
entry/src/main/module.json5里的requestPermissions字段,不是AndroidManifest.xml,很多从Android转过来的开发者会找错地方。
4.2 类型与数据的边界问题
量表评分看起来简单,但边界情况往往是最容易出bug的。我踩过的几个典型坑:
第一个是“用户跳题没做”。如果用户把最后一题做完就点了提交,中间漏了一题,你用Object.values(answerMap).reduce累加的话,分数会偏低,而且你根本不知道是漏题还是用户真的选了0分。所以提交前必须校验题目覆盖完整性,遍历QUESTION_CONFIG的每个question.id,确认都存在于answerMap中,否则弹窗提示“还有题目未完成”。
第二个是“JSON配置里的value写成了字符串”。我在联调阶段遇到过接口返回的选项value是字符串'0'、'1',然后reduce累加时变成了字符串拼接,总分直接变成'0123'。解决办法是拿到配置后立即做一次数据清洗,把value用Number()强制转成数字。
javascript复制const sanitizeConfig = (config) => {
return {
...config,
questions: config.questions.map((q) => ({
...q,
options: q.options.map((opt) => ({
...opt,
value: Number(opt.value),
})),
})),
};
};
第三个是“数组越界”。理论上用户只能点击UI上渲染的选项,索引范围是0到3,但如果后端下发配置时options只有3项,而某个逻辑里写死了索引3,就会取到undefined。所以尽量在handleSelect里做防御:if (optionIndex < 0 || optionIndex >= question.options.length) return;。
4.3 React Native鸿蒙开发的其他实操经验
最后分享几个做React Native鸿蒙开发时比较隐性的经验,不一定跟量表评分直接相关,但绝对值得注意:
- 鸿蒙模拟器必须在arm64平台。x86架构的模拟器跑不了RN鸿蒙应用,因为JSVM引擎在x86下的支持不完整。如果开发机上没有ARM架构虚拟机,建议直接拿真机调试。
- 打包真机签名要在build-profile.json5里配置。跟Android的签名配置类似,但文件格式完全不一样,而且鸿蒙的签名证书需要在AppGallery Connect上申请。
- CSS样式兼容性。RN的样式在鸿蒙上大部分都支持,但个别属性比如
boxShadow、borderRadius在某些版本上效果有偏差。如果遇到样式问题,优先检查鸿蒙侧的RN版本和OpenHarmony系统版本是否匹配。 - 热更新要谨慎。RN的CodePush方案在鸿蒙上支持不完美,我曾经在鸿蒙设备上做热更新测试,发现部分设备更新后JS引擎未正确释放,导致内存上涨。如果生产环境要用,一定要做充分的回归测试。
- 调试工具优先使用DevEco Studio+React Native DevTools组合。鸿蒙侧的日志系统是HiLog,和Android的Logcat不一样,初次上手容易找不到关键日志。把
hilog配置好,调试效率会高不少。
4.4 后续扩展建议
如果你跟我一样是第一次在RN + 鸿蒙组合下做这类功能,我的建议是分三步走:
第一步,先把量表配置JSON和本地渲染打通,不依赖网络,保证离线也能做题。第二步,接上远程配置,让后端能动态下发育量表题目和分级标准。第三步,再加用户数据上报,把每个条目的选项索引和总分一起传给后台,方便后续做统计分析。
我个人在实际操作中的体会是:量表评分这类需求,难的不是累加逻辑,而是“把固定规则做成可配置”“把三端行为对齐”“把边界情况处理干净”这三件事。选项索引映射评分本身是个很朴素的思路——索引即分值,但配合上动态配置和鸿蒙适配,就会有一堆小坑等着你踩。花点时间把数据结构和异常处理设计好,后面会省很多事。
最后再分享一个小技巧:如果你需要在多个页面复用这套量表组件,可以把“问卷渲染 + 评分计算”抽成一个自定义Hook,比如useScaleQuestionnaire(config),返回questions、currentAnswers、totalScore、levelLabel和handleSelect。这样不管是做PHQ-9还是GAD-7,甚至以后上线新量表,页面组件完全不用改,真正做到“一套逻辑,多种量表”。
这个内容后续还可以这样扩展:把结果页做得分级图文报告、把多次测评结果做成趋势曲线、把量表数据跟后台的医生端联动。但这些都是量变,核心的选项索引映射评分思路已经能覆盖掉大多数标准化量表场景了。
