最近在做 React Native for Harmony 的鸿蒙化改造时,一个绕不开的坎就是评分组件。我们线上项目原本用的是社区 rating 库实现星级评分,结果适配 HarmonyOS NEXT 后,不是首屏白屏,就是星星点了没反应。折腾几天后,我索性基于 React Native 自己写了一套 Rating 评分组件,支持全星、半星、禁用态和自定义样式,最后在鸿蒙真机上稳定跑通。这篇记录就是这次踩坑的全过程,把组件的触摸算法、状态设计、样式体系和联调排障一起写完,给正在做 React Native 鸿蒙适配的朋友一份能直接参考的方案。
1. 社区评分库在 Harmony 适配中的兼容性缺口
1.1 依赖链移植带来的隐性成本
先说结论:不是社区评分库不好,而是它们的依赖链在 Harmony 的 RN 适配层上并不完整。常见的评分库内部往往引用了Pressability、react-native-svg、react-native-animatable等模块;在 Android 和 iOS 上这些都是经过大量验证的基础设施,但到了 React Native for Harmony 这套实现里,原生模块的覆盖度还远达不到双端水平。
举个例子,有些库需要读取屏幕宽度来计算星间距,底层依赖Dimensions;有些库需要手势响应,底层依赖一整套 Touchable 交互链。前者在基础桥接里基本可用,后者也通常能跑。最怕的是依赖了 SVG 原生组件这类的重型模块,在 Harmony 适配初期经常缺少对应的 JSI 实现,最终表现就是星星区域空白、点击无响应,严重时直接把整个 RN 页面带崩。
我这次遇到的情况就非常典型:页面在 Android 上一切正常,切到鸿蒙真机后评分区域整块空白,Log 里报的是某个原生模块找不到。顺着依赖树往上查,问题根本不在“评分”本身,而在评分库底层引用的 SVG 渲染组件并没有在鸿蒙侧完成适配。这种问题不是改一行配置能解决的,只能换实现方案。
1.2 自研方案的三个判断标准
决定自研之前,我给自己列了三条判断标准,如果都满足,就值得动手。
第一,需求边界是否清晰。我们的业务只需要全星、半星、禁用态和自定义样式,不涉及复杂的动画曲线和模糊评分算法。这类需求用纯 JS 完全能覆盖,不需要触碰任何原生代码。
第二,依赖树是否干净。自研组件如果能做到零原生依赖,图标也用自有图片资源,那么整个组件就是一段纯粹的 JavaScript,随 JS bundle 一起加载,天然规避了鸿蒙适配层原生模块缺失的问题。这是最大的安全边际。
第三,维护成本是否可控。评分组件的逻辑并不复杂,一份两百行左右的组件代码,比追着第三方库等 Harmony 支持进度要省心得多。与其每次升级适配层都要重新验证第三方库,不如把这块攥在自己手里。
事实证明这三条判断标准是有效的。自研组件替换上线之后,评分区域白屏和不可点击的问题从根上消失,后续改星星样式也只动一个文件。
2. 全星与半星的触摸换算:核心算法与实现
2.1 落点坐标到分值的换算公式
评分组件的第一个核心问题是:手指按在星星行上,如何换算成具体分值。我的做法是只在星星行容器上做响应,而不是给每颗星单独绑定点击事件。
容器总宽度W = maxStars * starSize + (maxStars - 1) * spacing,然后用触点相对容器左侧的偏移量x计算比例。核心代码如下:
const containerWidth = maxStars * starSize + (maxStars - 1) * spacing; function getValueByX(x: number): number { // x 是触点相对星星行容器左上角的横向偏移 const ratio = Math.max(0, Math.min(1, x / containerWidth)); const raw = ratio * maxStars; if (allowHalfStar) { // 向上取整到最近的 0.5 return Math.ceil(raw * 2) / 2; } // 全星模式:点在第几颗星范围内,就亮第几颗 return Math.min(maxStars, Math.ceil(raw)); }这里有个容易混淆的点:为什么全星用Math.ceil(raw)而不是Math.round(raw)?因为当手指落在第 4 颗星的左半边时,raw可能只有 3.4,如果取四舍五入会得到 3,用户明明点在第四颗星上,结果只亮了第三颗,体感上像慢了半拍。Math.ceil(raw)的语义是“手指落在第 K 颗星的区间内就选中第 K 颗”,配合星星的视觉边界,反馈更直接。
半星模式稍微特殊。Math.ceil(raw * 2) / 2等同于把分值轴切成 0.5 的刻度,然后向上对齐。手指点在某个星星的左半侧,选中半星;点在右半侧或超过右边界,选中整星。这个公式和用户的直觉非常一致,实测下来不需要额外修正。
2.2 星星渲染与半星显示的两种方案
半星显示有两种常见实现。第一种是准备三张图:空星、半星、全星,根据分值在渲染时做判断切换。第二种是我推荐的裁剪叠加方案:用空星打底,上面盖一层宽度按比例裁切的全星图。
function StarItem({ fillRatio, size, spacing, emptyIcon, filledIcon }) { // fillRatio 为 0~1,表示当前这颗星被点亮的比例 return ( <View style={{ width: size, height: size, marginRight: spacing }}> <Image source={emptyIcon} style={{ width: size, height: size }} /> <View style={{ position: 'absolute', left: 0, top: 0, width: size * fillRatio, height: size, overflow: 'hidden', }} > <Image source={filledIcon} style={{ width: size, height: size }} /> </View> </View> ); }父组件渲染时,第index颗星的点亮比例是Math.max(0, Math.min(1, value - index))。比如value = 3.5,那么前 3 颗星的fillRatio都是 1,第 4 颗是 0.5,第 5 颗是 0。
裁剪叠加的优势在于只需要两张图,而且天然支持任意精度——以后业务如果要求 0.1 粒度,连算法都不用改。三图切换的方案在视觉上更保险,不会出现裁剪层在某些真机上边缘发虚的情况,但多维护一张图,灵活性也差一些。我最终选了裁剪叠加,具体原因写在后面的联调章节里。
2.3 滑动手势与连续评分的实现细节
很多评分组件是点哪亮哪,但移动端用户已经习惯了“滑动调分”的交互。为了实现连续滑动,我在星星容器根节点上挂载了响应器,而不是给每颗星挂Touchable:
<View pointerEvents={disabled ? 'none' : 'auto'} onStartShouldSetResponder={() => !disabled} onMoveShouldSetResponder={() => !disabled} onResponderMove={(e) => handleChange(e.nativeEvent.locationX)} onResponderRelease={(e) => handleChange(e.nativeEvent.locationX)} > {renderStars()} </View>这里用的是 RN 最基础的响应器属性,没必要把PanResponder再包一层。评分区域通常只有几十到百来像素,JS 层直接处理事件完全没有性能压力,引入多余封装反而让触摸响应链变复杂。
在 Harmony 真机上,locationX的语义和双端一致,可以直接用。不过建议在初始化时用onLayout缓存一次容器宽度,避免每次触摸都重复计算containerWidth。如果后续遇到locationX偏移问题,备选方案是用pageX - 容器左上角绝对坐标来兜底,但实测鸿蒙适配层没有出现这个偏差。
另外提醒一句:如果应用要支持 RTL 布局,x / containerWidth需要翻转为(containerWidth - x) / containerWidth,否则阿拉伯语等从右往左阅读的语系下评分方向会反过来。
3. 禁用态的三层处理:交互、视觉与无障碍
3.1 交互层的封闭与复原
禁用态最容易想到的实现是“不渲染 Touchable”,但评分组件用的是响应器系统,禁用时不仅要让手势失效,还要让整个星星区域从命中测试里摘除。最稳的做法是给根容器设置pointerEvents="none"。
这个属性会把整个子树从触摸命中树里摘掉,无论是星星行还是外层包着的 View,都不会再响应点击。实测在 React Native for Harmony 适配层里是支持的。如果某些早期适配版本对该属性支持不完整,退化方案是用条件渲染:disabled时渲染一个不挂任何事件处理器的包裹View,本质上就是把交互从事件树上剥掉。
这里有一个细节:禁用状态必须是可逆的。评分组件经常用于“提交后锁定分数”的场景,但也可能用于“编辑已提交的评分”,比如订单评价之后允许追评修改。所以不要在disabled分支里直接卸载组件或清空内部状态,只封闭事件入口,渲染结构保持一致,恢复属性后评分能力立即回归。
3.2 视觉层的灰化与半星正确显示
禁用不等于把组件变成一张死图。如果disabled时直接把整个容器opacity调到 0.3,半星状态下用户会分不清哪个星是半亮的。我的处理方式是:优先把填充色替换成disabledColor,而不是整体降透明度。
具体到代码,就是在渲染星星时判断disabled,如果是禁用态,用disabledColor覆盖filledColor。因为裁剪叠加方案本身是动态算填充比例的,所以半星的形状结构不会被破坏,只是颜色变灰,可读性远好于全容器蒙一层透明度。
如果没有配置disabledColor,再退回opacity: 0.5。另外给容器加上一个不可点击的灰色背景层,也能明显增强禁用态的视觉区分。这个背景层放在星星下方,用绝对定位铺满,注意别挡住星星本身。
3.3 无障碍标签与 Harmony 适配现状
评分控件在无障碍场景里属于典型的可调整组件。iOS 上有increment/decrement动作,Android 上有类似 seek 类的无障碍操作,RN 通过accessibilityActions暴露这些能力。不过 Harmony 适配层对这套动作协议的支持到什么程度,我建议不要做假设。
保守且有效的做法是:把评分容器的accessible设为true,用动态生成的accessibilityLabel描述当前状态,比如“评分 3.5,最高 5 星”,再通过accessibilityHint提示可滑动调整。实测鸿蒙设备上的屏幕朗读对 RN 组件的兼容还在完善中,这部分必须在真机上验证一遍,至少保证 label 能读出来。
如果团队对无障碍要求严格,还可以考虑在禁用态额外报一句“评分已锁定”,帮助使用屏幕朗读的用户区分当前状态。这个成本很低,收益却很明显,别忽略。
4. 自定义样式 API 与渲染链路的落地
4.1 props 如何拆分才不至于臃肿
自定义样式不是简单加几个参数,而是要设计层次感。我最终定义的组件接口大概长这样:
export interface RatingProps { value: number; maxStars?: number; starSize?: number; spacing?: number; allowHalfStar?: boolean; disabled?: boolean; emptyColor?: string; filledColor?: string; disabledColor?: string; emptyIcon?: ImageSourcePropType; filledIcon?: ImageSourcePropType; renderStar?: (params: { index: number; fillRatio: number; disabled: boolean }) => React.ReactNode; onChange?: (next: number) => void; containerStyle?: StyleProp<ViewStyle>; starContainerStyle?: StyleProp<ViewStyle>; }参数按四类划分:数值类(value、maxStars、allowHalfStar)、外观类(尺寸、间距、颜色)、渲染覆盖类(图标图片、自定义渲染函数)、行为类(disabled、onChange)。
不建议暴露二三十个细粒度样式参数,比如“星星圆角半径”“阴影颜色”这种。参数过多会让调用方选择困难,也让组件内部样式判断逻辑变得复杂。高频参数控制在十个以内,再提供一个renderStar完全覆盖入口,剩下的交给调用方自己按需组合。
4.2 三种图标方案的取舍
图标是跨端渲染最容易翻车的地方。我对比过三种方案:
| 方案 | 优点 | 不足 |
|---|---|---|
| 内置 PNG 双图(空星/全星) | 无额外依赖,渲染稳定,尺寸可控 | 需要设计出图,换主题需换资源 |
| 文字字形(★) | 改色方便,零额外资源 | 字体差异大,鸿蒙默认字体下可能显示不一致 |
| SVG 或自定义绘制 | 矢量化,清晰度高 | 依赖 react-native-svg 等库,Harmony 适配风险高 |
最终我选了 PNG 双图方案。首先是因为裁剪叠加只需要空星和全星两张图;其次是零原生依赖,适配风险最低。如果你的设计稿需要特殊形状的星星,比如圆角星、渐变星,直接让设计导出透明底 PNG。
另外强烈建议不要用 emoji 表情做星星。同一颗 emoji 在不同设备字体里渲染差异极大,尤其是鸿蒙默认字体和 iOS 的系统字体,同一个字符可能在两台机器上长得完全不一样,评分组件这种高频视觉元素完全不适合交给字体控制。
4.3 渲染优化与样式优先级
评分组件通常只有 5 颗星,性能瓶颈几乎不存在,但有一个容易忽略的细节:如果renderStar是外部传入的函数,每次手指移动触发重渲染时,5 颗星星的子组件都会重新执行这个函数。外部函数若没有用useCallback包裹,会白白增加不少 diff 开销。建议把每颗星抽成React.memo的StarItem,props 只传index、fillRatio、尺寸、图标这些原始值。
样式优先级也需要提前理清,避免调用方和组件内部各自覆盖时打架。我的顺序是:
renderStar完全接管星星渲染,此时图标和颜色 props 全部失效;- 设置了
emptyIcon/filledIcon时,使用图片渲染; - 否则用纯色渲染,此时
emptyColor/filledColor生效; containerStyle和starContainerStyle在最后合并,外部样式能覆盖默认值,但不会覆盖前三层里已经确定的渲染内容。
提示:合并样式时注意
StyleSheet.compose的传参顺序,外部样式放后边才会覆盖默认样式。这个顺序如果反了,调用方传进来的颜色会被默认值盖掉,排查起来很费时间。
5. 真机联调:从启动白屏到评分组件可用的排查记录
5.1 白屏问题出现时的第一反应清单
React Native for Harmony 集成初期,最常见的现象就是启动白屏:原生壳进来了,页面却一片空白。相信很多朋友都搜过“react native 启动白屏”这类关键词,我这次也中招了。
遇到白屏,先按顺序排除几个最基础的问题:
- Metro 是否在运行。开发调试模式下,RN 页面靠 Metro 提供 bundle,Metro 没起来,一切免谈。
- bundle URL 是否配置正确。调试模式默认从
localhost:8081拉取,真机需要通过端口映射把请求转发到开发机。 - 入口 Ability 是否调用了正确的 RN 加载路径。不同版本的适配层加载 API 名称有差异,拼错一个方法名也能白屏。
- 有没有查看运行日志。白屏不是“没有日志”,而是日志被忽略。
5.2 一步步定位到根因的排查链路
我这边排查白屏的完整链路是这样的。首先是确认端口映射,鸿蒙真机没有 adb,用的是 hdc:
hdc reverse tcp:8081 tcp:8081 hdc shell hilog | grep -i reactnative第一条命令把设备的 8081 端口反向映射到开发机,让真机能访问到本地 Metro;第二条命令实时过滤 React Native 相关日志。
接下来看日志输出。如果看到Loading from Metro: http://localhost:8081/index.bundle?platform=harmony之后一直卡住,说明 bundle 拉取过程出问题,优先查端口映射和局域网连通性。如果日志显示 bundle 已经加载完成,但页面仍然空白,那就是 JS 侧执行时抛了异常。
JS 异常导致的白屏常见于第三方库依赖了鸿蒙适配层尚未实现的原生模块。排查方法用二分注释法:先把评分组件从页面中注释掉,如果页面恢复,再逐步把依赖加回来。我这次就是这么定位到 SVG 原生模块缺失的——不是评分组件本身出错,而是它背后引用的底层库在鸿蒙侧根本无法加载,导致整个渲染进程报错。
定位到根因后,替换成自研组件的收益立刻体现出来:组件不再依赖任何原生模块,白屏问题从源头上消失。如果你的白屏也是第三方库原生依赖引发的,光调端口和配 Metro 没用,必须从依赖链上做减法。
5.3 评分组件联调中的其他兼容问题
组件能显示之后,还有几个和 Harmony 相关的兼容细节值得记录。
首先是裁剪层问题。裁剪叠加方案依赖overflow: 'hidden'做半星裁切,但在某台鸿蒙真机上偶发不裁切的情况,看起来就像半星位置多出一截。实测解决方法是给裁剪层加一个 1px 的透明边框,或者干脆预生成一张半星图片用于禁用态展示,规避裁切不确定性。
其次是图片资源路径。Harmony 的 RN 打包对require('./star.png')的解析方式和 Android 的 res 目录机制不同。模拟器上显示正常不能作为验收标准,一定要在真机上验证一次图标加载。我遇到过一次模拟器正常但真机图标全黑的问题,最后是资源放到打包系统要求的 asset 目录下才解决。
第三是间距边界。spacing设为 0 时星星之间没有间隙,连续滑动评分时坐标计算没有问题,但视觉上贴太近容易误触。默认值我建议给 4,至少保证半星裁剪后相邻星之间还有肉眼可辨的边界。
最后分享一个实际体会:一开始我也想过等社区库更新 Harmony 支持,但评分这种组件逻辑简单、状态模型清晰,自研成本实际上就一两天。把它收进公司公共组件库之后,后续鸿蒙设备适配、深浅色主题、自定义星星造型,都只需要改这一个文件。如果你也在 React Native for Harmony 上被评分组件折腾过,希望这篇记录能帮你少走一段弯路。